collective

Browse / github.com/storytold/craft-libs / patch

Track contextual tab entry and exit

Track contextual tab entry and exit Handle entering, departing and reappearing tabs with stable IDs and explicit paint-only ghosts. Makes the reusable part of [WordCraft #3131](https://github.com/storytold/wordcraft/pull/3131) available to other Craft apps. Includes API documentation, integration guidance and regression coverage. Consumer adoption remains separate; applications retain their themes, localization and command adapters. Validation on Windows: workspace formatting, strict Clippy and tests; craft-ui Clippy/tests with all features; craft-ui WASM check with all features. Upstream CI results are tracked on this PR. In

Status
open
Score
0.00
Adopters
0 other operators
Author
fluorine (imported from pull request #276, open)
Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10
Size
+166 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
8bb7722ec133792c7eb1c4aa

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/tests.rs crates/ui/docs/contextual-tabs.md crates/ui/src/tabs/mod.rs crates/ui/src/tabs/presence.rs

diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs--- a/crates/fonts-build/src/tests.rs+++ b/crates/fonts-build/src/tests.rs@@ -376,7 +376,10 @@ fn for_ui_embeds_cjk_faces_only_as_cut_interface_faces() {     assert_eq!(warnings(&r), Vec::<String>::new());     let r = run(Embed::documents(&["Noto Sans TC UI", "Noto Sans TC"]), Some(&dir), Some("1"));     assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular", "Noto Sans TC UI Regular"]);-    assert!(r.table.contains("fonts/1.ttf"), "{}", r.table);+    // Generated Rust escapes native separators (and Windows' canonical path prefix).+    // Match the selected file's complete include expression on every platform.+    let interface_face = dir.join("fonts/1.ttf").canonicalize().unwrap();+    assert!(r.table.contains(&format!("include_bytes!({interface_face:?})")), "{}", r.table); }  #[test]diff --git a/crates/ui/docs/contextual-tabs.md b/crates/ui/docs/contextual-tabs.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/contextual-tabs.md@@ -0,0 +1,18 @@+# Contextual tab entry and exit++`tabs::TabPresence` shares the contextual tab lifecycle from+[WordCraft #3131](https://github.com/storytold/wordcraft/pull/3131). Each app still decides which+tabs apply to its current selection. Supply stable IDs and strip positions to `update`, along+with monotonic seconds, duration (for example 0.35) and the host's motion setting.++The returned frames provide eased visibility, position and whether the tab is present. Initial+tabs appear immediately. New tabs enter from zero visibility; removed tabs remain as outgoing+paint-only ghosts until their fade completes. Reappearing tabs reverse from their current+visibility without duplicate copies. Invalid time/duration, rollback and motion-off settle+current tabs and discard ghosts immediately. Duplicate IDs use their first position.++Use visibility for opacity and optionally `(1.0 - visibility) * distance` for an entry offset.+Request repaint while any frame is moving. Keep label/rectangle data needed for outgoing+painting in the host. **Only present tabs may register input, commands or keytips**. Selection+fallback, overflow, localization, clipping and document semantics remain app-owned. This helper+does not make a disappearing selected tab applicable to the document.diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs--- a/crates/ui/src/tabs/mod.rs+++ b/crates/ui/src/tabs/mod.rs@@ -6,6 +6,9 @@  use egui::{Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; +mod presence;+pub use presence::{TabPresence, TabPresenceFrame};+ /// Tabs visible on a strip and tabs that need an overflow menu, all in original order. #[derive(Clone, Debug, Default, PartialEq)] pub struct TabFit {diff --git a/crates/ui/src/tabs/presence.rs b/crates/ui/src/tabs/presence.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/tabs/presence.rs@@ -0,0 +1,141 @@+use egui::Id;+use std::collections::{HashMap, HashSet};++/// One present tab or outgoing paint-only ghost. Use `visibility` for opacity/entry offset.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct TabPresenceFrame {+    pub id: Id,+    /// The caller's current strip position, or the last known position for a departing tab.+    pub position: usize,+    pub visibility: f32,+    /// False for outgoing ghosts: do not register tab input, commands or keytips for them.+    pub present: bool,+    /// Request a repaint while true.+    pub moving: bool,+}++/// Tracks contextual tab entry/exit independently of any document model or egui global memory.+/// First-frame tabs appear immediately. Keep one instance per strip and supply stable IDs.+/// Removed tabs remain as outgoing ghosts until their fade completes. Reappearing tabs reverse+/// from their current visibility rather than flashing or retaining a second outgoing copy.+#[derive(Clone, Debug, Default)]+pub struct TabPresence {+    initialized: bool,+    last_time: f64,+    entries: HashMap<Id, Entry>,+}++#[derive(Clone, Debug)]+struct Entry {+    position: usize,+    present: bool,+    from: f32,+    start: f64,+    duration: f64,+}++impl Entry {+    fn visibility(&self, now: f64) -> f32 {+        let p = if self.duration > 0.0 { ((now - self.start) / self.duration).clamp(0.0, 1.0) as f32 } else { 1.0 };+        let to = if self.present { 1.0 } else { 0.0 };+        self.from + (to - self.from) * (1.0 - (1.0 - p).powi(3))+    }+}++impl TabPresence {+    /// Advance the strip using `(stable ID, position)` pairs and monotonic seconds. Duration is+    /// seconds, capped at ten. Motion-off, invalid time/duration and clock rollback settle current+    /// tabs and discard ghosts immediately. Duplicate IDs use their first supplied position.+    ///+    /// Results preserve input order, followed by outgoing ghosts in last-position/ID order.+    /// The caller owns labels/rectangles needed to paint ghosts, overflow, selection and input.+    /// A disappearing selected tab still requires the app to choose an applicable replacement.+    pub fn update(&mut self, shown: &[(Id, usize)], now: f64, duration: f32, motion: bool) -> Vec<TabPresenceFrame> {+        let animate = self.initialized && motion && now.is_finite() && now >= self.last_time && duration.is_finite() && duration > 0.0;+        let time = if now.is_finite() { now } else { 0.0 };+        let duration = if animate { f64::from(duration.min(10.0)) } else { 0.0 };+        let mut seen = HashSet::with_capacity(shown.len());+        let mut frames = Vec::with_capacity(shown.len());+        for &(id, position) in shown {+            if !seen.insert(id) {+                continue;+            }+            let entry =+                self.entries.entry(id).or_insert(Entry { position, present: true, from: if animate { 0.0 } else { 1.0 }, start: time, duration });+            if !entry.present || !animate {+                let from = if animate { entry.visibility(time) } else { 1.0 };+                *entry = Entry { position, present: true, from, start: time, duration };+            }+            entry.position = position;+            let visibility = entry.visibility(time);+            frames.push(TabPresenceFrame { id, position, visibility, present: true, moving: visibility < 1.0 });+        }+        let mut outgoing = Vec::new();+        self.entries.retain(|&id, entry| {+            if seen.contains(&id) {+                return true;+            }+            if !animate {+                return false;+            }+            if entry.present {+                let from = entry.visibility(time);+                *entry = Entry { position: entry.position, present: false, from, start: time, duration };+            }+            let visibility = entry.visibility(time);+            if visibility <= 0.0 {+                return false;+            }+            outgoing.push(TabPresenceFrame { id, position: entry.position, visibility, present: false, moving: true });+            true+        });+        outgoing.sort_by_key(|frame| (frame.position, frame.id.value()));+        frames.extend(outgoing);+        self.initialized = true;+        self.last_time = time;+        frames+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn outgoing_tabs_reverse_without_duplicates_and_keep_their_last_position() {+        let mut presence = TabPresence::default();+        let a = Id::new("table");+        let b = Id::new("chart");+        assert_eq!(presence.update(&[(a, 2)], 0.0, 1.0, true)[0].visibility, 1.0);+        let frames = presence.update(&[(b, 3)], 1.0, 1.0, true);+        assert_eq!(frames.len(), 2);+        assert_eq!(frames[0].visibility, 0.0);+        assert_eq!(frames[1].position, 2);+        assert!(!frames[1].present);+        let frames = presence.update(&[(b, 3)], 1.5, 1.0, true);+        let outgoing = frames[1].visibility;+        assert!(outgoing > 0.0 && outgoing < 1.0);+        let frames = presence.update(&[(a, 4), (b, 3), (a, 9)], 1.5, 1.0, true);+        assert_eq!(frames.len(), 2);+        assert_eq!(frames[0].visibility, outgoing);+        assert!(frames[0].present);+        assert_eq!(frames[0].position, 4);+        assert!(presence.update(&[(a, 4)], 4.0, 1.0, true).iter().all(|f| f.visibility.is_finite()));+        presence.update(&[], 5.0, 1.0, true);+        let frames = presence.update(&[], 6.0, 1.0, true);+        assert!(frames.is_empty());+    }+    #[test]+    fn motion_off_invalid_parameters_and_rollback_drop_ghosts_and_settle() {+        let a = Id::new("a");+        let b = Id::new("b");+        for (now, duration, motion) in [(1.2, 1.0, false), (f64::NAN, 1.0, true), (1.2, f32::NAN, true), (1.2, -1.0, true), (0.5, 1.0, true)] {+            let mut presence = TabPresence::default();+            presence.update(&[(a, 0)], 0.0, 1.0, true);+            presence.update(&[(b, 1)], 1.0, 1.0, true);+            assert_eq!(+                presence.update(&[(b, 1)], now, duration, motion),+                vec![TabPresenceFrame { id: b, position: 1, visibility: 1.0, present: true, moving: false }]+            );+        }+    }+}