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