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