collective

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

Animate tab selection underlines

Animate tab selection underlines Retarget selection markers from their current position when users switch tabs quickly. 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. Includes th

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

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/tests.rs crates/ui/docs/sliding-underline.md crates/ui/src/tabs/mod.rs crates/ui/src/tabs/underline.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/sliding-underline.md b/crates/ui/docs/sliding-underline.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/sliding-underline.md@@ -0,0 +1,18 @@+# Sliding selection underlines++`tabs::SlidingUnderline` extracts the selection-marker motion from+[WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781) for ribbons, settings tabs and+inspector strips. Keep one instance per strip and supply stable tab IDs, target geometry,+monotonic seconds, duration and the host's motion preference.++```rust+if let Some(frame) = underline.update(selected_id, target_rect, now, 0.2, animations_enabled) {+    ui.painter().rect_filled(frame.rect, 2.0, accent);+    if frame.moving { ui.ctx().request_repaint(); }+}+```++Interrupted selection changes start from the marker's current position. Initial appearance and+same-selection layout changes snap. Motion-off and invalid/backwards clocks discard movement;+invalid rectangles clear the state. Color, shape, strip identity, input and selection remain in+the caller. No page or document model is required. Consumer migration is separate.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 underline;+pub use underline::{SlidingUnderline, UnderlineFrame};+ /// 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/underline.rs b/crates/ui/src/tabs/underline.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/tabs/underline.rs@@ -0,0 +1,108 @@+//! Retargetable selection underline geometry, with caller-owned painting and time.++use egui::{Id, Rect};++/// Geometry to paint this frame. Request another egui repaint while `moving` is true.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct UnderlineFrame {+    pub rect: Rect,+    pub moving: bool,+}++/// One strip's underline. Keep a separate instance per strip; identity is independent of labels.+///+/// The first target appears immediately. A selection change slides from the currently displayed+/// rectangle, including when interrupted. Layout changes for the same selection snap to the new+/// rectangle so window resizing does not leave the marker behind its tab. This owns no egui+/// memory, theme, selected page or widget interaction.+#[derive(Clone, Debug, Default)]+pub struct SlidingUnderline {+    state: Option<Segment>,+}++#[derive(Clone, Debug)]+struct Segment {+    selected: Id,+    from: Rect,+    to: Rect,+    start: f64,+    last_time: f64,+    duration: f64,+}++impl SlidingUnderline {+    /// Sample and advance the underline. `now` is monotonic seconds (e.g. egui input time).+    /// `duration` is seconds, capped at ten. Motion-off, non-positive/invalid durations, invalid+    /// time and a backwards clock snap to the target and discard any in-flight movement.+    /// Invalid, empty or excessive rectangles clear the state and return `None`.+    pub fn update(&mut self, selected: Id, target: Rect, now: f64, duration: f32, motion: bool) -> Option<UnderlineFrame> {+        if !target.is_finite() || target.width() <= 0.0 || target.height() <= 0.0 || target.size().max_elem() > 1_000_000.0 {+            self.state = None;+            return None;+        }+        let duration = if motion && now.is_finite() && duration.is_finite() { f64::from(duration.clamp(0.0, 10.0)) } else { 0.0 };+        let time = if now.is_finite() { now } else { 0.0 };+        let snap = Segment { selected, from: target, to: target, start: time, last_time: time, duration };+        let next = match self.state.as_ref() {+            Some(previous) if duration > 0.0 && time >= previous.last_time && previous.selected != selected => {+                Segment { from: previous.sample(time).rect, ..snap }+            }+            Some(previous) if duration > 0.0 && time >= previous.last_time && previous.to == target && previous.duration == duration => {+                Segment { last_time: time, ..previous.clone() }+            }+            _ => snap,+        };+        let frame = next.sample(time);+        self.state = Some(next);+        Some(frame)+    }+}++impl Segment {+    fn sample(&self, now: f64) -> UnderlineFrame {+        let progress = if self.duration > 0.0 { ((now - self.start) / self.duration).clamp(0.0, 1.0) as f32 } else { 1.0 };+        let eased = 1.0 - (1.0 - progress).powi(3);+        // f64 intermediates avoid overflow for finite rectangles on opposite ends of f32 space.+        let lerp = |a: f32, b: f32| (f64::from(a) * (1.0 - f64::from(eased)) + f64::from(b) * f64::from(eased)) as f32;+        let rect = Rect::from_min_max(+            egui::pos2(lerp(self.from.min.x, self.to.min.x), lerp(self.from.min.y, self.to.min.y)),+            egui::pos2(lerp(self.from.max.x, self.to.max.x), lerp(self.from.max.y, self.to.max.y)),+        );+        UnderlineFrame { rect, moving: self.from != self.to && progress < 1.0 }+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    fn rect(x: f32) -> Rect {+        Rect::from_min_size(egui::pos2(x, 30.0), egui::vec2(40.0, 2.0))+    }+    fn frame(state: &mut SlidingUnderline, id: &str, x: f32, now: f64, motion: bool) -> UnderlineFrame {+        let result = state.update(Id::new(id), rect(x), now, 1.0, motion);+        assert!(result.is_some(), "valid target rejected");+        result.unwrap_or(UnderlineFrame { rect: Rect::NOTHING, moving: false })+    }+    #[test]+    fn interrupted_selection_starts_at_the_displayed_marker_and_eventually_settles() {+        let mut state = SlidingUnderline::default();+        assert_eq!(frame(&mut state, "a", 0.0, 0.0, true).rect, rect(0.0));+        assert_eq!(frame(&mut state, "b", 100.0, 1.0, true).rect, rect(0.0));+        let midway = frame(&mut state, "b", 100.0, 1.5, true);+        assert!(midway.moving && midway.rect.left() > 0.0 && midway.rect.left() < 100.0);+        assert_eq!(frame(&mut state, "c", 200.0, 1.5, true).rect, midway.rect);+        assert_eq!(frame(&mut state, "c", 200.0, 3.0, true), UnderlineFrame { rect: rect(200.0), moving: false });+    }+    #[test]+    fn resize_motion_off_and_clock_reset_snap_without_leaving_stale_motion() {+        let mut state = SlidingUnderline::default();+        frame(&mut state, "a", 0.0, 0.0, true);+        frame(&mut state, "b", 100.0, 1.0, true);+        assert!(!frame(&mut state, "b", 120.0, 1.1, true).moving);+        assert_eq!(frame(&mut state, "c", 200.0, 1.2, false).rect, rect(200.0));+        assert!(!frame(&mut state, "d", 300.0, 0.0, true).moving);+        assert!(!frame(&mut state, "e", 400.0, f64::NAN, true).moving);+        assert!(state.update(Id::new("bad"), Rect::NOTHING, 1.0, 1.0, true).is_none());+        assert!(!frame(&mut state, "f", 500.0, 2.0, true).moving);+    }+}