Skip to main content

agg_gui/widgets/
button.rs

1//! `Button` — a clickable button with hover, pressed, and focus states.
2//!
3//! # Composition
4//!
5//! Button is fully compositional: it always has exactly one child widget, a
6//! [`Label`], which is responsible for rendering the button's text.  The
7//! [`paint_subtree`] machinery handles the Label automatically after
8//! [`Button::paint`] draws the background.
9//!
10//! ```text
11//! Button (background + focus ring)
12//!   └── Label (text, tight bounds, centred within button)
13//! ```
14//!
15//! `Label::layout` returns tight text bounds.  `Button::layout` centres the
16//! label within the button area.  Because [`Label::hit_test`] returns `false`,
17//! the Label is invisible to the hit-test and event-routing system; the Button
18//! retains full ownership of focus and click events.
19
20use std::rc::Rc;
21use std::sync::Arc;
22
23use crate::color::Color;
24use crate::draw_ctx::DrawCtx;
25use crate::event::{Event, EventResult, MouseButton};
26use crate::geometry::{Rect, Size};
27use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
28use crate::text::Font;
29use crate::widget::Widget;
30use crate::widgets::label::{Label, LabelAlign};
31
32/// Default horizontal padding used to inset a left- or right-aligned label
33/// from the button edge.  Center-aligned labels ignore this and centre
34/// inside the button bounds.
35const LEFT_LABEL_PAD: f64 = 8.0;
36
37/// A theme for [`Button`] visual states.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct ButtonTheme {
40    pub background: Color,
41    pub background_hovered: Color,
42    pub background_pressed: Color,
43    pub label_color: Color,
44    pub border_radius: f64,
45    pub focus_ring_color: Color,
46    pub focus_ring_width: f64,
47}
48
49impl Default for ButtonTheme {
50    fn default() -> Self {
51        Self {
52            background: Color::rgb(0.22, 0.45, 0.88),
53            background_hovered: Color::rgb(0.30, 0.52, 0.92),
54            background_pressed: Color::rgb(0.16, 0.36, 0.72),
55            label_color: Color::white(),
56            border_radius: 6.0,
57            focus_ring_color: Color::rgba(0.22, 0.45, 0.88, 0.55),
58            focus_ring_width: 2.5,
59        }
60    }
61}
62
63/// A clickable button.
64///
65/// Build with [`Button::new`] and optionally chain builder methods.
66pub struct Button {
67    bounds: Rect,
68    /// Always exactly one child: the `Label` for the button's text.
69    children: Vec<Box<dyn Widget>>,
70    base: WidgetBase,
71    /// Source of truth for the label text, kept so `build_label` can rebuild.
72    label_text: String,
73    font: Arc<Font>,
74    font_size: f64,
75    pub theme: ButtonTheme,
76    on_click: Option<Box<dyn FnMut()>>,
77    /// Optional gate: when `Some`, the button is enabled only while the
78    /// closure returns `true`.  Queried each paint / event so the caller
79    /// can base it on live state (e.g. "only enable Relaunch when the
80    /// selected MSAA differs from the running one") without rebuilding
81    /// the widget tree.  `None` = always enabled.
82    enabled_fn: Option<Rc<dyn Fn() -> bool>>,
83    /// Optional toggle: when `Some` and the closure returns `true`, the
84    /// button paints with the accent / selected appearance regardless of
85    /// hover / press state.  When the closure returns `false`, an active-
86    /// aware button uses the subtle (`widget_bg`) variant so segmented
87    /// selectors look right.  `None` = legacy behaviour: always painted as
88    /// the accent button.
89    active_fn: Option<Rc<dyn Fn() -> bool>>,
90    /// `true` selects the muted "secondary" visual style (theme widget_bg
91    /// + theme text colour) instead of the accent appearance.  Combined
92    /// with `active_fn`, this drives segmented toggles: each segment is a
93    /// subtle button that flips to the accent look when its `active_fn`
94    /// returns true.
95    subtle: bool,
96    /// When `true` AND in the inactive state, the inactive background
97    /// is fully transparent (no fill) so the button reads as part of
98    /// its parent — sidebar list rows want this.  Hovered / pressed
99    /// inactive states paint a faint text-coloured overlay instead of
100    /// the `widget_bg` shade.  Active state is unaffected.
101    ghost: bool,
102    /// When `true`, draw a 1-px stroke around the button rect using the
103    /// theme's `widget_stroke` colour while inactive — gives subtle
104    /// segmented buttons a defined edge so they don't visually bleed
105    /// into a parent that has the same `widget_bg` shade.  Active state
106    /// already has a high-contrast accent fill and skips the stroke.
107    outlined: bool,
108    /// How the child label is positioned inside the button rect.
109    /// `Center` (default) centres horizontally; `Left` insets by
110    /// [`LEFT_LABEL_PAD`] and is the right choice for full-width
111    /// sidebar rows where the label hugs the leading edge.
112    label_align: LabelAlign,
113    /// Custom horizontal inset applied when `label_align` is `Left` or
114    /// `Right`.  Defaults to [`LEFT_LABEL_PAD`]; sidebar entries with
115    /// indent > 0 set this to push the label past a group-marker
116    /// triangle.
117    label_pad_h: f64,
118
119    hovered: bool,
120    pressed: bool,
121    focused: bool,
122}
123
124impl Button {
125    /// Create a button with the given label.
126    pub fn new(label: impl Into<String>, font: Arc<Font>) -> Self {
127        let label_text: String = label.into();
128        let font_size = 14.0;
129        let theme = ButtonTheme::default();
130        let child = Self::build_label(&label_text, &font, font_size, &theme);
131        Self {
132            bounds: Rect::default(),
133            children: vec![child],
134            base: WidgetBase::new(),
135            label_text,
136            font,
137            font_size,
138            theme,
139            on_click: None,
140            enabled_fn: None,
141            active_fn: None,
142            subtle: false,
143            ghost: false,
144            outlined: false,
145            label_align: LabelAlign::Center,
146            label_pad_h: LEFT_LABEL_PAD,
147            hovered: false,
148            pressed: false,
149            focused: false,
150        }
151    }
152
153    pub fn with_font_size(mut self, size: f64) -> Self {
154        self.font_size = size;
155        self.children[0] = Self::build_label(&self.label_text, &self.font, size, &self.theme);
156        self
157    }
158
159    pub fn with_theme(mut self, theme: ButtonTheme) -> Self {
160        self.theme = theme;
161        self.children[0] =
162            Self::build_label(&self.label_text, &self.font, self.font_size, &self.theme);
163        self
164    }
165
166    pub fn on_click(mut self, cb: impl FnMut() + 'static) -> Self {
167        self.on_click = Some(Box::new(cb));
168        self
169    }
170
171    /// Gate the button on a live predicate.  Returned-`false` frames paint
172    /// the button in its disabled style and ignore mouse / keyboard input.
173    pub fn with_enabled_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
174        self.enabled_fn = Some(Rc::new(f));
175        self
176    }
177
178    /// Bind the button's "selected" state to a live predicate.  When the
179    /// closure returns `true`, the button paints with the accent surface
180    /// regardless of hover / press; when it returns `false`, an
181    /// active-aware button (i.e. `with_subtle()` is also set) reverts to
182    /// the muted `widget_bg` appearance.  Used to compose segmented
183    /// toggles out of plain `Button`s without hand-rolled paint code.
184    pub fn with_active_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
185        self.active_fn = Some(Rc::new(f));
186        self
187    }
188
189    /// Override how the child label is aligned inside the button rect.
190    /// Defaults to [`LabelAlign::Center`].  Use [`LabelAlign::Left`] for
191    /// full-width sidebar rows where the label hugs the leading edge.
192    /// Also rebuilds the child Label so its own internal alignment matches.
193    pub fn with_label_align(mut self, align: LabelAlign) -> Self {
194        self.label_align = align;
195        self.children[0] = Box::new(
196            Label::new(&self.label_text, Arc::clone(&self.font))
197                .with_font_size(self.font_size)
198                .with_color(self.theme.label_color)
199                .with_align(align),
200        );
201        self
202    }
203
204    /// Override the horizontal padding used when `label_align` is `Left`
205    /// or `Right`.  Defaults to a small visual gutter; bump it up to indent
206    /// the label past a group-marker triangle in sidebar rows.
207    pub fn with_label_pad_h(mut self, pad: f64) -> Self {
208        self.label_pad_h = pad;
209        self
210    }
211
212    /// Use a transparent inactive background + faint text-coloured
213    /// hover/pressed overlay instead of the muted `widget_bg` fill.
214    /// Implies [`with_subtle`] (theme text colour, accent on active).
215    /// Right for sidebar list rows where the inactive state should
216    /// blend with the panel.
217    pub fn with_ghost(mut self) -> Self {
218        self.subtle = true;
219        self.ghost = true;
220        let theme_text = crate::theme::current_visuals().text_color;
221        self.children[0] = Self::build_label_with_color(
222            &self.label_text,
223            &self.font,
224            self.font_size,
225            theme_text,
226        );
227        self
228    }
229
230    /// Switch to the muted (secondary) visual style: theme `widget_bg`
231    /// fill, theme `text_color` label.  Pair with [`with_active_fn`] to
232    /// build segmented controls — inactive segments paint subtle, the
233    /// selected segment flips to the accent surface.
234    /// Draw a 1-px `widget_stroke` outline around the button while inactive.
235    /// Combined with [`Self::with_subtle`] this gives top-bar segmented
236    /// controls a defined edge so they don't visually bleed into a parent
237    /// that shares the same `widget_bg` colour.  Active state already paints
238    /// a high-contrast accent fill and skips the stroke.
239    pub fn with_outlined(mut self) -> Self {
240        self.outlined = true;
241        self
242    }
243
244    pub fn with_subtle(mut self) -> Self {
245        self.subtle = true;
246        // Subtle buttons use the theme's text colour, not the white-on-accent
247        // default.  Rebuild the label with the active visuals' text colour
248        // (the paint pass also retints each frame, so this just gives a
249        // sensible first-paint colour before the visuals are queried).
250        let theme_text = crate::theme::current_visuals().text_color;
251        self.children[0] = Self::build_label_with_color(
252            &self.label_text,
253            &self.font,
254            self.font_size,
255            theme_text,
256        );
257        self
258    }
259
260    fn is_enabled(&self) -> bool {
261        self.enabled_fn.as_ref().map(|f| f()).unwrap_or(true)
262    }
263
264    fn is_active(&self) -> bool {
265        self.active_fn.as_ref().map(|f| f()).unwrap_or(true)
266    }
267
268    pub fn with_margin(mut self, m: Insets) -> Self {
269        self.base.margin = m;
270        self
271    }
272    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
273        self.base.h_anchor = h;
274        self
275    }
276    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
277        self.base.v_anchor = v;
278        self
279    }
280    pub fn with_min_size(mut self, s: Size) -> Self {
281        self.base.min_size = s;
282        self
283    }
284    pub fn with_max_size(mut self, s: Size) -> Self {
285        self.base.max_size = s;
286        self
287    }
288
289    fn fire_click(&mut self) {
290        if let Some(cb) = self.on_click.as_mut() {
291            cb();
292        }
293    }
294
295    fn disabled_colors(v: &crate::theme::Visuals) -> (Color, Color, Color) {
296        let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
297        if luma < 0.5 {
298            (
299                v.window_fill,
300                Color::rgba(1.0, 1.0, 1.0, 0.22),
301                v.text_dim.with_alpha(0.42),
302            )
303        } else {
304            (v.track_bg, v.widget_stroke.with_alpha(0.45), v.text_dim)
305        }
306    }
307
308    /// Construct a label child from the button's current state.
309    ///
310    /// Called from `new()`, `with_theme()`, and `with_font_size()` so the
311    /// child always reflects the button's configuration.
312    fn build_label(
313        text: &str,
314        font: &Arc<Font>,
315        font_size: f64,
316        theme: &ButtonTheme,
317    ) -> Box<dyn Widget> {
318        Self::build_label_with_color(text, font, font_size, theme.label_color)
319    }
320
321    fn build_label_with_color(
322        text: &str,
323        font: &Arc<Font>,
324        font_size: f64,
325        color: Color,
326    ) -> Box<dyn Widget> {
327        Box::new(
328            Label::new(text, Arc::clone(font))
329                .with_font_size(font_size)
330                .with_color(color)
331                .with_align(LabelAlign::Center),
332        )
333    }
334}
335
336impl Widget for Button {
337    fn type_name(&self) -> &'static str {
338        "Button"
339    }
340    fn bounds(&self) -> Rect {
341        self.bounds
342    }
343    fn set_bounds(&mut self, bounds: Rect) {
344        self.bounds = bounds;
345    }
346
347    fn children(&self) -> &[Box<dyn Widget>] {
348        &self.children
349    }
350    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
351        &mut self.children
352    }
353
354    fn is_focusable(&self) -> bool {
355        self.is_enabled()
356    }
357
358    fn margin(&self) -> Insets {
359        self.base.margin
360    }
361    fn widget_base(&self) -> Option<&WidgetBase> {
362        Some(&self.base)
363    }
364    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
365        Some(&mut self.base)
366    }
367    fn h_anchor(&self) -> HAnchor {
368        self.base.h_anchor
369    }
370    fn v_anchor(&self) -> VAnchor {
371        self.base.v_anchor
372    }
373    fn min_size(&self) -> Size {
374        self.base.min_size
375    }
376    fn max_size(&self) -> Size {
377        self.base.max_size
378    }
379
380    fn layout(&mut self, available: Size) -> Size {
381        let height = (self.font_size * 1.7).max(24.0);
382        // Measure the label first so we can report a "fit" width — label
383        // width plus horizontal padding — instead of stretching to the
384        // whole available width.  This keeps Buttons polite siblings in a
385        // `FlexRow`.  Parents that want a full-width button can:
386        //   - wrap it in a `SizedBox` with an explicit width, or
387        //   - apply `HAnchor::STRETCH`, or
388        //   - set `with_min_size(Size::new(width, _))` for a width floor.
389        let pad_h = self.font_size * 1.2;
390        let label_size = self.children[0].layout(Size::new(available.width, height));
391        let natural_w = (label_size.width + pad_h)
392            .max(48.0)
393            .max(self.base.min_size.width);
394        let width = if self.base.h_anchor.is_stretch() {
395            available.width.max(natural_w)
396        } else {
397            natural_w
398        }
399        .min(available.width);
400        let size = Size::new(width, height);
401        let label_x = match self.label_align {
402            LabelAlign::Left => self.label_pad_h.min(size.width),
403            LabelAlign::Right => {
404                (size.width - label_size.width - self.label_pad_h).max(0.0)
405            }
406            LabelAlign::Center => ((size.width - label_size.width) * 0.5).max(0.0),
407        };
408        let label_y = ((size.height - label_size.height) * 0.5).max(0.0);
409        self.children[0].set_bounds(Rect::new(
410            label_x,
411            label_y,
412            label_size.width,
413            label_size.height,
414        ));
415        size
416    }
417
418    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
419        let w = self.bounds.width;
420        let h = self.bounds.height;
421        let r = self.theme.border_radius;
422        let enabled = self.is_enabled();
423        let v = ctx.visuals();
424        let use_visuals = self.theme == ButtonTheme::default();
425        let active = self.is_active();
426        // A subtle button paints in muted theme colours when inactive, and
427        // flips to the accent surface (white text on accent fill) when its
428        // `active_fn` returns true.  Plain (non-subtle) buttons always use
429        // the accent surface — that's the existing primary-button look.
430        let muted = self.subtle && !active;
431
432        // Focus ring — drawn JUST INSIDE the button bounds so the parent's
433        // `clip_children_rect` (defaults to widget bounds) doesn't chop
434        // the leftmost stroke pixel when the button sits flush against
435        // a container edge.  Painting outside-bounds with negative
436        // coordinates was the long-standing cause of "the left edge of
437        // my button looks clipped" reports.
438        if enabled && self.focused {
439            let ring = self.theme.focus_ring_width;
440            let focus_ring = if use_visuals {
441                v.accent_focus
442            } else {
443                self.theme.focus_ring_color
444            };
445            ctx.set_stroke_color(focus_ring);
446            ctx.set_line_width(ring);
447            ctx.begin_path();
448            let inset = ring * 0.5;
449            ctx.rounded_rect(
450                inset,
451                inset,
452                (w - ring).max(0.0),
453                (h - ring).max(0.0),
454                (r - inset).max(0.0),
455            );
456            ctx.stroke();
457        }
458
459        // Background — color depends on interaction state. Disabled buttons
460        // use neutral widget colors instead of a washed-out accent, so they
461        // don't look like secondary active actions.
462        let base_bg = if muted && self.ghost && self.pressed {
463            // Ghost (transparent-inactive) buttons paint a faint
464            // text-coloured overlay on hover / press instead of the
465            // widget_bg shade.  Matches the egui-style sidebar row
466            // look the demo's `ToggleButton` had before refactor.
467            Color::rgba(v.text_color.r, v.text_color.g, v.text_color.b, 0.16)
468        } else if muted && self.ghost && self.hovered {
469            Color::rgba(v.text_color.r, v.text_color.g, v.text_color.b, 0.10)
470        } else if muted && self.ghost {
471            // Fully transparent when the user isn't interacting.
472            Color::rgba(0.0, 0.0, 0.0, 0.0)
473        } else if muted && (self.pressed || self.hovered) {
474            v.widget_bg_hovered
475        } else if muted {
476            v.widget_bg
477        } else if use_visuals && self.pressed {
478            v.accent_pressed
479        } else if use_visuals && self.hovered {
480            v.accent_hovered
481        } else if use_visuals {
482            v.accent
483        } else if self.pressed {
484            self.theme.background_pressed
485        } else if self.hovered {
486            self.theme.background_hovered
487        } else {
488            self.theme.background
489        };
490        let (disabled_bg, disabled_stroke, _) = Self::disabled_colors(&v);
491        let bg = if enabled { base_bg } else { disabled_bg };
492        ctx.set_fill_color(bg);
493        ctx.begin_path();
494        ctx.rounded_rect(0.0, 0.0, w, h, r);
495        ctx.fill();
496
497        // Optional outline — opt-in via `with_outlined()` for inactive
498        // segmented buttons that want a defined edge against a same-colour
499        // parent (e.g. top-bar tabs).  Active state already has a
500        // high-contrast accent fill and skips this so the selected segment
501        // visually pops.
502        if enabled && self.outlined && !active {
503            ctx.set_stroke_color(v.widget_stroke);
504            ctx.set_line_width(1.0);
505            ctx.begin_path();
506            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
507            ctx.stroke();
508        }
509
510        // Retint the child label so subtle / active states show the right
511        // foreground colour without rebuilding the Label widget.  Calling
512        // through the dyn Widget keeps Button agnostic of the concrete
513        // Label type — `set_label_color` is a default no-op that Label
514        // overrides, see `Widget::set_label_color`.
515        let label_color = if muted { v.text_color } else { self.theme.label_color };
516        if let Some(child) = self.children.get_mut(0) {
517            child.set_label_color(label_color);
518        }
519
520        if !enabled {
521            ctx.set_stroke_color(disabled_stroke);
522            ctx.set_line_width(1.0);
523            ctx.begin_path();
524            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
525            ctx.stroke();
526        }
527
528        // Text is NOT drawn here. `paint_subtree` recurses into the Label
529        // child automatically after this method returns.
530    }
531
532    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
533        if self.is_enabled() {
534            return;
535        }
536
537        // The normal child Label was built for the enabled foreground color.
538        // Cover it and repaint the label with the disabled text color.
539        let w = self.bounds.width;
540        let h = self.bounds.height;
541        let r = self.theme.border_radius;
542        let v = ctx.visuals();
543        let (disabled_bg, disabled_stroke, disabled_text) = Self::disabled_colors(&v);
544
545        ctx.set_fill_color(disabled_bg);
546        ctx.begin_path();
547        ctx.rounded_rect(0.0, 0.0, w, h, r);
548        ctx.fill();
549
550        ctx.set_stroke_color(disabled_stroke);
551        ctx.set_line_width(1.0);
552        ctx.begin_path();
553        ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
554        ctx.stroke();
555
556        let font =
557            crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font));
558        ctx.set_font(font);
559        ctx.set_font_size(self.font_size * crate::font_settings::current_font_size_scale());
560        ctx.set_fill_color(disabled_text);
561        if let Some(m) = ctx.measure_text(&self.label_text) {
562            let tx = ((w - m.width) * 0.5).max(0.0);
563            let ty = m.centered_baseline_y(h).max(0.0);
564            ctx.fill_text(&self.label_text, tx, ty);
565        }
566    }
567
568    fn on_event(&mut self, event: &Event) -> EventResult {
569        if !self.is_enabled() {
570            // Clear any lingering hover / pressed state so the button
571            // looks idle the instant it's disabled mid-interaction.
572            self.hovered = false;
573            self.pressed = false;
574            return EventResult::Ignored;
575        }
576        match event {
577            Event::MouseMove { pos } => {
578                let was_hovered = self.hovered;
579                let was_pressed = self.pressed;
580                self.hovered = self.hit_test(*pos);
581                if !self.hovered {
582                    self.pressed = false;
583                }
584                if was_hovered != self.hovered || was_pressed != self.pressed {
585                    crate::animation::request_draw();
586                    return EventResult::Consumed;
587                }
588                EventResult::Ignored
589            }
590            Event::MouseDown {
591                button: MouseButton::Left,
592                ..
593            } => {
594                if !self.pressed {
595                    crate::animation::request_draw();
596                }
597                self.pressed = true;
598                EventResult::Consumed
599            }
600            Event::MouseUp {
601                button: MouseButton::Left,
602                ..
603            } => {
604                let was_pressed = self.pressed;
605                self.pressed = false;
606                if was_pressed {
607                    crate::animation::request_draw();
608                }
609                if was_pressed && self.hovered {
610                    self.fire_click();
611                    // Clear the focus ring after a mouse click — the ring is a
612                    // keyboard-navigation aid and should not persist after a
613                    // pointer interaction.
614                    self.focused = false;
615                    // Click handler almost always mutates app state that
616                    // affects the next paint; request one so the handler's
617                    // side-effects are visible.
618                    crate::animation::request_draw();
619                }
620                EventResult::Consumed
621            }
622            Event::KeyDown { key, .. } => {
623                use crate::event::Key;
624                match key {
625                    Key::Enter | Key::Char(' ') => {
626                        self.fire_click();
627                        crate::animation::request_draw();
628                        EventResult::Consumed
629                    }
630                    _ => EventResult::Ignored,
631                }
632            }
633            Event::FocusGained => {
634                let was = self.focused;
635                self.focused = true;
636                if !was {
637                    crate::animation::request_draw();
638                    EventResult::Consumed
639                } else {
640                    EventResult::Ignored
641                }
642            }
643            Event::FocusLost => {
644                let was_focused = self.focused;
645                let was_pressed = self.pressed;
646                self.focused = false;
647                self.pressed = false;
648                if was_focused || was_pressed {
649                    crate::animation::request_draw();
650                    EventResult::Consumed
651                } else {
652                    EventResult::Ignored
653                }
654            }
655            _ => EventResult::Ignored,
656        }
657    }
658
659    fn properties(&self) -> Vec<(&'static str, String)> {
660        vec![
661            ("label", self.label_text.clone()),
662            ("font_size", format!("{:.1}", self.font_size)),
663        ]
664    }
665}