From 9dd2450ce26b20f56118fbdc73c53b4a92399539 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 22:04:08 +0200 Subject: [PATCH] UI: hover and pressed feedback for the drawn icon buttons (follow-up to #305) (#314) #305 replaced symbols the interface fonts lack with drawn icons, but the new buttons switched state instantly and the pane close button had no pressed state at all. - widgets::drawn_state: the shared look for drawn buttons. Hover fades in over egui's animation time and the pressed state blends in while the pointer is down. Framed buttons take egui's widget visuals (fill, stroke, text colour, expansion) so they match ui.button; frameless ones take the ribbon's hover/pressed tokens, like the ribbon buttons. - widgets::icon_button: a frameless icon button built on it; the panes' close button uses it. - icon_text_button (Editing/Reviewing, Comments, New Comment) uses it too. widgets::tests::drawn_buttons_fade_in_on_hover_and_darken_while_pressed drives both kinds with real pointer events at 60 fps and checks the idle, mid-fade, hovered and pressed fills. Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panes.rs | 8 +-- crates/ui-egui/src/widgets.rs | 113 ++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 70267b9..bbb0f16 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -45,13 +45,7 @@ fn header(ui: &mut Ui, title: &str) -> bool { ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Drawn, not the "✕" character: the interface fonts have no glyph for it. - let t = crate::theme::Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); - } - crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 14.0)), "close", t.icon, t.accent); - if resp.on_hover_text(tl!("Close")).clicked() { + if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() { close = true; } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5ae4fc3..d9faefc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -390,17 +390,68 @@ pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], prev out } +/// How a drawn button looks this frame. Hover fades in over egui's animation time and the +/// pressed state blends in while the pointer is down, as on egui's own buttons. Framed buttons +/// take egui's widget visuals (fill, stroke, text colour, expansion) so they match `ui.button`; +/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`]. +pub struct DrawnState { + pub fill: Color32, + pub stroke: Stroke, + pub text: Color32, + pub expansion: f32, +} + +pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState { + let pressed = resp.is_pointer_button_down_on(); + let h = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() || pressed); + let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, 0.06); + if framed { + let w = &ui.visuals().widgets; + let mix = |a: Color32, b: Color32, c: Color32| a.lerp_to_gamma(b, h).lerp_to_gamma(c, p); + let num = |a: f32, b: f32, c: f32| egui::lerp(egui::lerp(a..=b, h)..=c, p); + DrawnState { + fill: mix(w.inactive.weak_bg_fill, w.hovered.weak_bg_fill, w.active.weak_bg_fill), + stroke: Stroke::new( + num(w.inactive.bg_stroke.width, w.hovered.bg_stroke.width, w.active.bg_stroke.width), + mix(w.inactive.bg_stroke.color, w.hovered.bg_stroke.color, w.active.bg_stroke.color), + ), + text: mix(w.inactive.fg_stroke.color, w.hovered.fg_stroke.color, w.active.fg_stroke.color), + expansion: num(w.inactive.expansion, w.hovered.expansion, w.active.expansion), + } + } else { + let t = Tokens::get(ui.ctx()); + DrawnState { + fill: Color32::TRANSPARENT.lerp_to_gamma(t.hover, h).lerp_to_gamma(t.pressed, p), + stroke: Stroke::NONE, + text: t.text, + expansion: 0.0, + } + } +} + +/// A frameless 22 px icon button (pane close buttons): ribbon-style hover and press feedback. +pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + let st = drawn_state(ui, &resp, false); + ui.painter().rect_filled(r, CornerRadius::same(4), st.fill); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, t.icon, t.accent); + resp.on_hover_text(tip) +} + /// A text button with a drawn icon in front. Symbols such as ✎ or 💬 aren't in the interface /// fonts and would draw as empty boxes, so icons are painted (`icons.rs`) rather than typed. +/// It looks and reacts like `ui.button` (see [`drawn_state`]). pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), font, t.text); + let galley = ui.painter().layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER); let pad = ui.spacing().button_padding; let icon_w = 16.0; let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let v = ui.style().interact(&resp); - ui.painter().rect(r, v.corner_radius, v.weak_bg_fill, v.bg_stroke, egui::StrokeKind::Inside); + let st = drawn_state(ui, &resp, true); + let r = r.expand(st.expansion); + ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside); icons::paint( ui.painter(), Rect::from_center_size(pos2(r.min.x + pad.x + icon_w / 2.0, r.center().y), vec2(icon_w, icon_w)), @@ -408,7 +459,7 @@ pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) t.icon, t.accent, ); - ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, t.text); + ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, st.text); resp } @@ -416,6 +467,60 @@ pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) mod tests { use super::*; + #[test] + fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() { + // #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons. + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, &Tokens::light()); + let clock = std::cell::Cell::new(0u32); + let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER)); + // One 60 fps frame; returns both buttons' rects and fills (framed, frameless). + let frame = |events: Vec| { + clock.set(clock.get() + 1); + let input = egui::RawInput { time: Some(f64::from(clock.get()) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + ctx.run_ui(input, |ui| { + let framed = icon_text_button(ui, "comment", "Comments", regular(12.0)); + let frameless = icon_button(ui, "close", "Close"); + let (a, b) = (drawn_state(ui, &framed, true).fill, drawn_state(ui, &frameless, false).fill); + seen.set((framed.rect, frameless.rect, a, b)); + }) + .drop_without_applying_deltas(); + seen.get() + }; + // Half a second of frames, so animations finish; returns the last one. + let settle = || { + for _ in 0..29 { + frame(vec![]); + } + frame(vec![]) + }; + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let w = ctx.global_style().visuals.widgets.clone(); + let t = Tokens::light(); + + let (framed, frameless, idle, idle_bare) = settle(); + assert_eq!(idle, w.inactive.weak_bg_fill); + assert_eq!(idle_bare, Color32::TRANSPARENT); + + // Hover fades in over a few frames rather than jumping, then settles on egui's hovered fill. + let first = frame(vec![egui::Event::PointerMoved(framed.center())]).2; + let between = |c: Color32, a: Color32, b: Color32| a.r().min(b.r()) < c.r() && c.r() < a.r().max(b.r()); + assert!(between(first, idle, w.hovered.weak_bg_fill), "hover should animate: {idle:?} → {first:?} → {:?}", w.hovered.weak_bg_fill); + assert_eq!(settle().2, w.hovered.weak_bg_fill); + // Held down: blends to the pressed (active) fill. + frame(vec![press(framed.center(), true)]); + assert_eq!(settle().2, w.active.weak_bg_fill); + frame(vec![press(framed.center(), false)]); + + // The frameless close button uses the ribbon's hover and pressed tokens; the other fades out. + frame(vec![egui::Event::PointerMoved(frameless.center())]); + let after = settle(); + assert_eq!(after.3, t.hover); + assert_eq!(after.2, w.inactive.weak_bg_fill); + frame(vec![press(frameless.center(), true)]); + assert_eq!(settle().3, t.pressed); + } + #[test] fn a_long_combo_list_builds_only_the_rows_in_view() { // #121: the font menu built (and so loaded the font of) every installed family.