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