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
@@ -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]new file mode 100644@@ -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.@@ -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 {new file mode 100644@@ -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 }]+ );+ }+ }+}