collective

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

Share directional tab content entry

Share directional tab content entry Provide reusable entry progress and direction without owning page content or input policy. Makes the reusable part of [WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781) 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. Include

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

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/tests.rs crates/ui/docs/content-entry.md crates/ui/src/tabs/content.rs crates/ui/src/tabs/mod.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/content-entry.md b/crates/ui/docs/content-entry.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/content-entry.md@@ -0,0 +1,19 @@+# Tab content entry++`tabs::ContentEntry` shares the directional content transition from+[WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781). It tracks one region's selected+tab, stable identity, order and entry clock without retaining or rendering application content.++```rust+let frame = entry.update(selected_id, tab_index, now, 0.16, animations_enabled);+let offset = egui::vec2(f32::from(frame.direction) * (1.0 - frame.progress) * 14.0, 0.0);+let opacity = 0.2 + 0.8 * frame.progress;+// Apply offset and opacity to the caller's clipped child UI containing the new page.+if frame.moving { ui.ctx().request_repaint(); }+```++Initial appearance, same-index page replacement, motion-off, invalid parameters and backwards+clocks settle immediately. Rapid selection changes restart the new page's entry. Reordering the+same page does not start another animation. Host-owned layout decides direction for RTL strips.+The host keeps focus and command policy, disabled state, clipping and page contents, and decides+whether input should be suppressed during entry. A fading page is not automatically disabled.diff --git a/crates/ui/src/tabs/content.rs b/crates/ui/src/tabs/content.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/tabs/content.rs@@ -0,0 +1,105 @@+use egui::Id;++/// The new page's entry progress. Applications translate/clip their child UI and set its opacity+/// using this value. It describes only the new content; no outgoing content is retained.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct ContentEntryFrame {+    /// Eased 0..1, with 1 meaning fully settled.+    pub progress: f32,+    /// +1 enters from the right, -1 from the left, 0 means no movement. Order is caller-owned.+    pub direction: i8,+    /// Request an egui repaint while true.+    pub moving: bool,+}++impl ContentEntryFrame {+    const SETTLED: Self = Self { progress: 1.0, direction: 0, moving: false };+}++/// Directional entry when switching tabs, reusable for ribbons, inspectors and settings pages.+/// Keep one instance per content region. IDs must not depend on translated labels.+/// The first page appears immediately; a new page enters from the side indicated by its order+/// relative to the previous one. Reordering the same tab alone never starts an animation.+#[derive(Clone, Debug, Default)]+pub struct ContentEntry {+    state: Option<State>,+}++#[derive(Clone, Debug)]+struct State {+    selected: Id,+    order: usize,+    start: f64,+    last_time: f64,+    direction: i8,+}++impl ContentEntry {+    /// `now` is monotonic seconds, `duration` seconds (bounded to ten). Motion-off, invalid time,+    /// invalid/non-positive durations and a backwards clock settle immediately, without replay+    /// when motion is re-enabled. A second selection change starts the *new* page's entry again;+    /// the host owns page replacement, focus, enabled state and any input suppression.+    pub fn update(&mut self, selected: Id, order: usize, now: f64, duration: f32, motion: bool) -> ContentEntryFrame {+        let active = motion && now.is_finite() && duration.is_finite() && duration > 0.0;+        let time = if now.is_finite() { now } else { 0.0 };+        let mut direction = 0;+        let mut start = time;+        if let Some(previous) = self.state.as_ref().filter(|p| active && time >= p.last_time) {+            if previous.selected == selected {+                direction = previous.direction;+                start = previous.start;+            } else {+                direction = match order.cmp(&previous.order) {+                    std::cmp::Ordering::Greater => 1,+                    std::cmp::Ordering::Less => -1,+                    std::cmp::Ordering::Equal => 0,+                };+            }+        }+        let progress = if active { ((time - start) / f64::from(duration.min(10.0))).clamp(0.0, 1.0) as f32 } else { 1.0 };+        if progress >= 1.0 {+            direction = 0;+        }+        self.state = Some(State { selected, order, start, last_time: time, direction });+        if direction == 0 {+            ContentEntryFrame::SETTLED+        } else {+            ContentEntryFrame { progress: 1.0 - (1.0 - progress).powi(3), direction, moving: true }+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn selection_direction_interruption_and_initial_appearance() {+        let mut entry = ContentEntry::default();+        let a = Id::new("a");+        let b = Id::new("b");+        let c = Id::new("c");+        assert_eq!(entry.update(a, 0, 0.0, 1.0, true), ContentEntryFrame::SETTLED);+        assert_eq!(entry.update(b, 1, 1.0, 1.0, true), ContentEntryFrame { progress: 0.0, direction: 1, moving: true });+        let mid = entry.update(b, 1, 1.5, 1.0, true);+        assert!(mid.progress > 0.0 && mid.progress < 1.0);+        assert_eq!(entry.update(c, 0, 1.5, 1.0, true).direction, -1);+        assert_eq!(entry.update(c, 0, 3.0, 1.0, true), ContentEntryFrame::SETTLED);+        assert_eq!(entry.update(c, 20, 4.0, 1.0, true), ContentEntryFrame::SETTLED);+    }+    #[test]+    fn motion_off_and_hostile_clocks_settle_without_replay() {+        let mut entry = ContentEntry::default();+        let a = Id::new("a");+        let b = Id::new("b");+        entry.update(a, 0, 0.0, 1.0, true);+        entry.update(b, 1, 1.0, 1.0, true);+        assert_eq!(entry.update(b, 1, 1.1, 1.0, false), ContentEntryFrame::SETTLED);+        assert_eq!(entry.update(b, 1, 1.2, 1.0, true), ContentEntryFrame::SETTLED);+        for duration in [f32::NAN, f32::INFINITY, -1.0, 0.0] {+            assert_eq!(entry.update(a, 0, 2.0, duration, true), ContentEntryFrame::SETTLED);+        }+        assert_eq!(entry.update(b, 1, f64::NAN, 1.0, true), ContentEntryFrame::SETTLED);+        entry.update(a, 0, 2.0, 1.0, true);+        assert_eq!(entry.update(b, 1, 1.0, 1.0, true), ContentEntryFrame::SETTLED);+    }+}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 content;+pub use content::{ContentEntry, ContentEntryFrame};+ /// Tabs visible on a strip and tabs that need an overflow menu, all in original order. #[derive(Clone, Debug, Default, PartialEq)] pub struct TabFit {