collective

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

Add opt-in icon button hover fades

Add opt-in icon button hover fades Smooth toolbar feedback while preserving immediate selection/press and motion-off behavior. 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. Includ

Status
open
Score
0.00
Adopters
0 other operators
Author
fluorine (imported from pull request #272, open)
Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10
Size
+141 −11 in 5 files
Review
pending
Shared
2026-10-28
Id
59303fdf18c18c5972ac5dcc

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/tests.rs crates/ui/README.md crates/ui/docs/hover-fades.md crates/ui/src/buttons/mod.rs crates/ui/src/buttons/tests.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/README.md b/crates/ui/README.md--- a/crates/ui/README.md+++ b/crates/ui/README.md@@ -55,6 +55,8 @@ remain the responsibility of the existing loader.  ## Icon buttons +Buttons can opt into [hover fades](docs/hover-fades.md) without changing existing styles or interaction.+ `buttons::IconButton` accepts a label, size and `IconButtonStyle`, and returns `egui::Response`. `ButtonVisuals` describes fill, border and icon tint for normal, hovered, pressed and selected states. Selection wins over press and hover. `pressed: None` keeps ordinary hover/normal styling;diff --git a/crates/ui/docs/hover-fades.md b/crates/ui/docs/hover-fades.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/hover-fades.md@@ -0,0 +1,19 @@+# Opt-in icon button hover fades++`IconButton::animated_hover(true)` smoothly blends normal/hover fill, border and icon tint using+egui's animation clock. It carries the hover-feedback concern from+[WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781) into the existing shared control.+It works for ribbons, toolbars and inspectors without importing an application theme.++```rust+craft_ui::buttons::IconButton::new(localized_label, size, &style)+    .animated_hover(true)+    .show(ui, paint_icon);+```++Existing buttons remain instantaneous by default. Duration follows global `Style::animation_time`;+the host maps its motion preference to zero to disable animations. Invalid/negative durations+snap; positive durations are capped at ten seconds. Disabled-state changes snap immediately.+Selection and an explicit pressed style take precedence over the fade. egui requests repaint+while animating. Focus, accessibility, hit geometry, clipping and command dispatch are unchanged.+Stable UI scopes keep each animation attached to its button when labels are translated.diff --git a/crates/ui/src/buttons/mod.rs b/crates/ui/src/buttons/mod.rs--- a/crates/ui/src/buttons/mod.rs+++ b/crates/ui/src/buttons/mod.rs@@ -51,11 +51,12 @@ pub struct IconButton<'a> {     selected: bool,     style: &'a IconButtonStyle,     single_shape_frame: bool,+    animated_hover: bool, }  impl<'a> IconButton<'a> {     pub fn new(label: &'a str, size: Vec2, style: &'a IconButtonStyle) -> Self {-        Self { label, size, selected: false, style, single_shape_frame: false }+        Self { label, size, selected: false, style, single_shape_frame: false, animated_hover: false }     }      pub fn selected(mut self, selected: bool) -> Self {@@ -63,6 +64,16 @@ impl<'a> IconButton<'a> {         self     } +    /// Opt into smooth transitions between the normal and hovered visuals. The default is+    /// instantaneous, preserving existing consumers. Duration comes from egui's global+    /// `Style::animation_time`; set it to zero for the application's motion-off preference.+    /// Selected/pressed visuals and disabled-state changes remain immediate. This affects+    /// painting only: the hit rectangle, focus outline and click response never animate.+    pub fn animated_hover(mut self, enabled: bool) -> Self {+        self.animated_hover = enabled;+        self+    }+     /// Paint fill and border as one egui rectangle instead of separate shapes.     ///     /// This preserves the edge tessellation of controls previously painted with `Painter::rect`,@@ -86,7 +97,23 @@ impl<'a> IconButton<'a> {             return response;         } -        let visuals = self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on());+        let visuals = if self.animated_hover {+            let duration = ui.ctx().global_style().animation_time;+            let duration = if ui.is_enabled() && duration.is_finite() { duration.clamp(0.0, 10.0) } else { 0.0 };+            let hover = ui.ctx().animate_bool_with_time_and_easing(+                response.id.with("craft_icon_button_hover"),+                response.hovered(),+                duration,+                egui::emath::easing::cubic_out,+            );+            if self.selected || (response.is_pointer_button_down_on() && self.style.pressed.is_some()) {+                self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on())+            } else {+                blend(self.style.normal, self.style.hovered, hover)+            }+        } else {+            self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on())+        };         let stroke = bounded_stroke(visuals.stroke, rect);         if self.single_shape_frame {             ui.painter().rect(rect, self.style.corner_radius, visuals.fill, stroke, StrokeKind::Inside);@@ -110,6 +137,19 @@ impl<'a> IconButton<'a> {     } } +fn blend(from: ButtonVisuals, to: ButtonVisuals, progress: f32) -> ButtonVisuals {+    // Sanitize before interpolation: NaN * zero is still NaN at an animation endpoint.+    let width = |value: f32| if value.is_finite() { value.clamp(0.0, 4096.0) } else { 0.0 };+    ButtonVisuals {+        fill: from.fill.lerp_to_gamma(to.fill, progress),+        icon: from.icon.lerp_to_gamma(to.icon, progress),+        stroke: Stroke::new(+            egui::lerp(width(from.stroke.width)..=width(to.stroke.width), progress),+            from.stroke.color.lerp_to_gamma(to.stroke.color, progress),+        ),+    }+}+ fn bounded_stroke(stroke: Stroke, rect: Rect) -> Stroke {     if stroke.width.is_finite() && stroke.width > 0.0 {         Stroke::new(stroke.width.min(rect.width().min(rect.height()) * 0.5), stroke.color)diff --git a/crates/ui/src/buttons/tests.rs b/crates/ui/src/buttons/tests.rs--- a/crates/ui/src/buttons/tests.rs+++ b/crates/ui/src/buttons/tests.rs@@ -22,6 +22,8 @@ struct ButtonTest {     size: Vec2,     clicks: usize,     tint: Option<Color32>,+    animated_hover: bool,+    time: f64, }  impl ButtonTest {@@ -29,26 +31,43 @@ impl ButtonTest {         let ctx = Context::default();         ctx.enable_accesskit();         ctx.global_style_mut(|s| s.animation_time = 0.0);-        Self { ctx, style: style(), enabled: true, selected: false, label: "Inspect layers".into(), size: vec2(32.0, 32.0), clicks: 0, tint: None }+        Self {+            ctx,+            style: style(),+            enabled: true,+            selected: false,+            label: "Inspect layers".into(),+            size: vec2(32.0, 32.0),+            clicks: 0,+            tint: None,+            animated_hover: false,+            time: 0.0,+        }     }      fn frame(&mut self, events: Vec<Event>) -> (Response, FullOutput) {         let ctx = self.ctx.clone();         let mut response = None;         self.tint = None;-        let mut output =-            ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }, |ui| {+        self.time += 1.0 / 60.0;+        let mut output = ctx.run_ui(+            RawInput { time: Some(self.time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() },+            |ui| {                 let r = ui                     .add_enabled_ui(self.enabled, |ui| {-                        IconButton::new(&self.label, self.size, &self.style).selected(self.selected).show(ui, |ui, rect, tint| {-                            self.tint = Some(tint);-                            ui.painter().circle_filled(rect.center(), 3.0, tint);-                        })+                        IconButton::new(&self.label, self.size, &self.style).selected(self.selected).animated_hover(self.animated_hover).show(+                            ui,+                            |ui, rect, tint| {+                                self.tint = Some(tint);+                                ui.painter().circle_filled(rect.center(), 3.0, tint);+                            },+                        )                     })                     .inner;                 self.clicks += usize::from(r.clicked());                 response = Some(r);-            });+            },+        );         output.textures_delta.clear();         (response.unwrap(), output)     }@@ -59,6 +78,53 @@ impl ButtonTest {     } } +#[test]+fn opt_in_hover_fades_both_ways_but_press_and_selection_are_immediate() {+    let mut h = ButtonTest::new();+    h.animated_hover = true;+    h.ctx.global_style_mut(|s| s.animation_time = 0.3);+    let inside = h.settle().rect.center();+    h.frame(vec![Event::PointerMoved(inside)]);+    assert_ne!(h.tint, Some(Color32::GREEN), "hover must not jump directly to its target");+    for _ in 0..24 {+        h.frame(vec![]);+    }+    assert_eq!(h.tint, Some(Color32::GREEN));+    h.frame(vec![pointer(inside, true)]);+    assert_eq!(h.tint, Some(Color32::RED));+    h.selected = true;+    h.frame(vec![]);+    assert_eq!(h.tint, Some(Color32::YELLOW));+    h.selected = false;+    h.frame(vec![pointer(inside, false), Event::PointerMoved(pos2(300.0, 150.0))]);+    assert_ne!(h.tint, Some(Color32::WHITE), "leaving should fade instead of jumping");+    for _ in 0..24 {+        h.frame(vec![]);+    }+    assert_eq!(h.tint, Some(Color32::WHITE));+}++#[test]+fn motion_off_and_disabling_snap_even_in_the_middle_of_a_hover() {+    let mut h = ButtonTest::new();+    h.animated_hover = true;+    h.ctx.global_style_mut(|s| s.animation_time = 0.3);+    let inside = h.settle().rect.center();+    h.frame(vec![Event::PointerMoved(inside)]);+    h.enabled = false;+    h.frame(vec![]);+    assert_eq!(h.tint, Some(Color32::WHITE));+    h.enabled = true;+    // egui's hover hit testing uses the previous pass: register the re-enabled target first.+    h.frame(vec![]);+    h.ctx.global_style_mut(|s| s.animation_time = 0.0);+    let (response, _) = h.frame(vec![Event::PointerMoved(inside)]);+    assert!(response.hovered());+    assert_eq!(h.tint, Some(Color32::GREEN));+    h.frame(vec![Event::PointerMoved(pos2(300.0, 150.0))]);+    assert_eq!(h.tint, Some(Color32::WHITE));+}+ fn pointer(pos: Pos2, pressed: bool) -> Event {     Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } }