Skip to main content

agg_gui/widgets/
button.rs

1//! `Button` — a clickable, compositional button with a `Label` child.
2
3// `handle_event` lives in `button_events.rs` as a child module so it
4// has direct access to Button's private fields/methods, and lifts the
5// 90-line event-dispatch block out of this file to keep it under the
6// 800-line cap.
7#[path = "button_events.rs"]
8mod events;
9
10use std::rc::Rc;
11use std::sync::Arc;
12
13use crate::color::Color;
14use crate::draw_ctx::DrawCtx;
15use crate::event::{Event, EventResult};
16use crate::geometry::{Rect, Size};
17use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
18use crate::text::{measure_advance, Font};
19use crate::widget::Widget;
20use crate::widgets::label::{Label, LabelAlign};
21
22/// Icon glyph drawn at the leading edge of a [`Button`]'s label.
23/// The glyph is rendered with a separate font so callers can pair
24/// e.g. a Font Awesome glyph with a Latin-only text font.
25#[derive(Clone)]
26pub struct ButtonIcon {
27    pub glyph: char,
28    pub font: Arc<Font>,
29    pub font_size: f64,
30}
31
32/// Spacing between the icon glyph and the label text, in pixels.
33const ICON_GAP: f64 = 8.0;
34
35/// Default horizontal padding used to inset a left- or right-aligned label
36/// from the button edge.  Center-aligned labels ignore this and centre
37/// inside the button bounds.
38const LEFT_LABEL_PAD: f64 = 8.0;
39
40pub use super::button_theme::ButtonTheme;
41
42/// A clickable button.
43///
44/// Build with [`Button::new`] and optionally chain builder methods.
45pub struct Button {
46    bounds: Rect,
47    /// Always exactly one child: the `Label` for the button's text.
48    children: Vec<Box<dyn Widget>>,
49    base: WidgetBase,
50    /// Source of truth for the label text, kept so `build_label` can rebuild.
51    label_text: String,
52    font: Arc<Font>,
53    font_size: f64,
54    pub theme: ButtonTheme,
55    on_click: Option<Box<dyn FnMut()>>,
56    /// Optional gate: when `Some`, the button is enabled only while the
57    /// closure returns `true`.  Queried each paint / event so the caller
58    /// can base it on live state (e.g. "only enable Relaunch when the
59    /// selected MSAA differs from the running one") without rebuilding
60    /// the widget tree.  `None` = always enabled.
61    enabled_fn: Option<Rc<dyn Fn() -> bool>>,
62    /// Optional toggle: when `Some` and the closure returns `true`, the
63    /// button paints with the accent / selected appearance regardless of
64    /// hover / press state.  When the closure returns `false`, an active-
65    /// aware button uses the subtle (`widget_bg`) variant so segmented
66    /// selectors look right.  `None` = legacy behaviour: always painted as
67    /// the accent button.
68    active_fn: Option<Rc<dyn Fn() -> bool>>,
69    /// `true` selects the muted "secondary" visual style (theme widget_bg
70    /// + theme text colour) instead of the accent appearance.  Combined
71    /// with `active_fn`, this drives segmented toggles: each segment is a
72    /// subtle button that flips to the accent look when its `active_fn`
73    /// returns true.
74    subtle: bool,
75    /// When `true` AND in the inactive state, the inactive background
76    /// is fully transparent (no fill) so the button reads as part of
77    /// its parent — sidebar list rows want this.  Hovered / pressed
78    /// inactive states paint a faint text-coloured overlay instead of
79    /// the `widget_bg` shade.  Active state is unaffected.
80    ghost: bool,
81    /// When `true`, draw a 1-px stroke around the button rect using the
82    /// theme's `widget_stroke` colour while inactive — gives subtle
83    /// segmented buttons a defined edge so they don't visually bleed
84    /// into a parent that has the same `widget_bg` shade.  Active state
85    /// already has a high-contrast accent fill and skips the stroke.
86    outlined: bool,
87    /// How the child label is positioned inside the button rect.
88    /// `Center` (default) centres horizontally; `Left` insets by
89    /// [`LEFT_LABEL_PAD`] and is the right choice for full-width
90    /// sidebar rows where the label hugs the leading edge.
91    label_align: LabelAlign,
92    /// Custom horizontal inset applied when `label_align` is `Left` or
93    /// `Right`.  Defaults to [`LEFT_LABEL_PAD`]; sidebar entries with
94    /// indent > 0 set this to push the label past a group-marker
95    /// triangle.
96    label_pad_h: f64,
97
98    /// Optional icon glyph painted at the leading edge of the label.
99    /// See [`with_icon`](Self::with_icon).
100    icon: Option<ButtonIcon>,
101
102    /// When true, drop the 48 px touch-target width floor and shrink
103    /// the horizontal padding. Right for icon-only toolbar buttons
104    /// that want to sit tightly next to each other; defaults false
105    /// so regular buttons keep the comfortable touch target.
106    compact: bool,
107
108    hovered: bool,
109    pressed: bool,
110    focused: bool,
111}
112
113impl Button {
114    /// Create a button with the given label.
115    pub fn new(label: impl Into<String>, font: Arc<Font>) -> Self {
116        let label_text: String = label.into();
117        let font_size = 14.0;
118        let theme = ButtonTheme::default();
119        let child = Self::build_label(&label_text, &font, font_size, &theme);
120        Self {
121            bounds: Rect::default(),
122            children: vec![child],
123            base: WidgetBase::new(),
124            label_text,
125            font,
126            font_size,
127            theme,
128            on_click: None,
129            enabled_fn: None,
130            active_fn: None,
131            subtle: false,
132            ghost: false,
133            outlined: false,
134            label_align: LabelAlign::Center,
135            label_pad_h: LEFT_LABEL_PAD,
136            icon: None,
137            compact: false,
138            hovered: false,
139            pressed: false,
140            focused: false,
141        }
142    }
143
144    pub fn with_font_size(mut self, size: f64) -> Self {
145        self.font_size = size;
146        self.children[0] = Self::build_label(&self.label_text, &self.font, size, &self.theme);
147        self
148    }
149
150    pub fn with_theme(mut self, theme: ButtonTheme) -> Self {
151        self.theme = theme;
152        self.children[0] =
153            Self::build_label(&self.label_text, &self.font, self.font_size, &self.theme);
154        self
155    }
156
157    pub fn on_click(mut self, cb: impl FnMut() + 'static) -> Self {
158        self.on_click = Some(Box::new(cb));
159        self
160    }
161
162    /// Gate the button on a live predicate.  Returned-`false` frames paint
163    /// the button in its disabled style and ignore mouse / keyboard input.
164    pub fn with_enabled_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
165        self.enabled_fn = Some(Rc::new(f));
166        self
167    }
168
169    /// Bind the button's "selected" state to a live predicate.  When the
170    /// closure returns `true`, the button paints with the accent surface
171    /// regardless of hover / press; when it returns `false`, an
172    /// active-aware button (i.e. `with_subtle()` is also set) reverts to
173    /// the muted `widget_bg` appearance.  Used to compose segmented
174    /// toggles out of plain `Button`s without hand-rolled paint code.
175    pub fn with_active_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
176        self.active_fn = Some(Rc::new(f));
177        self
178    }
179
180    /// Override how the child label is aligned inside the button rect.
181    /// Defaults to [`LabelAlign::Center`].  Use [`LabelAlign::Left`] for
182    /// full-width sidebar rows where the label hugs the leading edge.
183    /// Also rebuilds the child Label so its own internal alignment matches.
184    pub fn with_label_align(mut self, align: LabelAlign) -> Self {
185        self.label_align = align;
186        self.children[0] = Box::new(
187            Label::new(&self.label_text, Arc::clone(&self.font))
188                .with_font_size(self.font_size)
189                .with_color(self.theme.label_color)
190                .with_align(align),
191        );
192        self
193    }
194
195    /// Override the horizontal padding used when `label_align` is `Left`
196    /// or `Right`.  Defaults to a small visual gutter; bump it up to indent
197    /// the label past a group-marker triangle in sidebar rows.
198    pub fn with_label_pad_h(mut self, pad: f64) -> Self {
199        self.label_pad_h = pad;
200        self
201    }
202
203    /// Compact mode: drop the 48 px width floor and use a tighter
204    /// horizontal padding. Use for icon-only toolbar buttons where
205    /// you want them packed close to the glyph; the 48 px touch-
206    /// target default is right for stand-alone buttons but wastes
207    /// horizontal space when 5+ icon buttons need to sit next to
208    /// each other on a narrow mobile bar.
209    pub fn with_compact(mut self) -> Self {
210        self.compact = true;
211        self
212    }
213
214    /// Make this the default action of the nearest scope: the enclosing
215    /// [`ModalSheet`](crate::widgets::ModalSheet) (or the `App` root when
216    /// no modal is showing) fires this button's `on_click` when Return /
217    /// Enter arrives unconsumed — i.e. focus is not on a text input.
218    /// SwiftUI: `.keyboardShortcut(.defaultAction)`. Disabled buttons
219    /// decline the key. The first such button in paint order wins. Stored
220    /// on the [`WidgetBase`] (`default_action`) like tooltips are.
221    pub fn with_default_action(mut self) -> Self {
222        self.base.default_action = true;
223        self
224    }
225
226    /// Make this the cancel action of the nearest scope: Escape fires
227    /// this button's `on_click` instead of the sheet's plain close, so
228    /// the handler owns dismissal. SwiftUI: `.keyboardShortcut(.cancelAction)`.
229    pub fn with_cancel_action(mut self) -> Self {
230        self.base.cancel_action = true;
231        self
232    }
233
234    /// Paint an icon glyph at the leading edge of the label.
235    /// `icon_font` carries the glyph (e.g. a Font Awesome face);
236    /// the label text continues to render in the button's main
237    /// font, so callers can pair a Latin text font with an
238    /// icon-only font without merging them.
239    ///
240    /// Defaults `font_size` to the button's current `font_size`.
241    /// Use [`with_icon_sized`](Self::with_icon_sized) to scale the
242    /// icon independently.
243    pub fn with_icon(mut self, glyph: char, icon_font: Arc<Font>) -> Self {
244        let font_size = self.font_size;
245        self.icon = Some(ButtonIcon {
246            glyph,
247            font: icon_font,
248            font_size,
249        });
250        self
251    }
252
253    /// Like [`with_icon`](Self::with_icon) but with an explicit
254    /// icon font size — useful when the icon font's glyphs read
255    /// larger or smaller than the text at the same point size.
256    pub fn with_icon_sized(mut self, glyph: char, icon_font: Arc<Font>, font_size: f64) -> Self {
257        self.icon = Some(ButtonIcon {
258            glyph,
259            font: icon_font,
260            font_size,
261        });
262        self
263    }
264
265    /// Use a transparent inactive background + faint text-coloured
266    /// hover/pressed overlay instead of the muted `widget_bg` fill.
267    /// Implies [`with_subtle`] (theme text colour, accent on active).
268    /// Right for sidebar list rows where the inactive state should
269    /// blend with the panel.
270    pub fn with_ghost(mut self) -> Self {
271        self.subtle = true;
272        self.ghost = true;
273        let theme_text = crate::theme::current_visuals().text_color;
274        self.children[0] =
275            Self::build_label_with_color(&self.label_text, &self.font, self.font_size, theme_text);
276        self
277    }
278
279    /// Switch to the muted (secondary) visual style: theme `widget_bg`
280    /// fill, theme `text_color` label.  Pair with [`with_active_fn`] to
281    /// build segmented controls — inactive segments paint subtle, the
282    /// selected segment flips to the accent surface.
283    /// Draw a 1-px `widget_stroke` outline around the button while inactive.
284    /// Combined with [`Self::with_subtle`] this gives top-bar segmented
285    /// controls a defined edge so they don't visually bleed into a parent
286    /// that shares the same `widget_bg` colour.  Active state already paints
287    /// a high-contrast accent fill and skips the stroke.
288    pub fn with_outlined(mut self) -> Self {
289        self.outlined = true;
290        self
291    }
292
293    pub fn with_subtle(mut self) -> Self {
294        self.subtle = true;
295        // Subtle buttons use the theme's text colour, not the white-on-accent
296        // default.  Rebuild the label with the active visuals' text colour
297        // (the paint pass also retints each frame, so this just gives a
298        // sensible first-paint colour before the visuals are queried).
299        let theme_text = crate::theme::current_visuals().text_color;
300        self.children[0] =
301            Self::build_label_with_color(&self.label_text, &self.font, self.font_size, theme_text);
302        self
303    }
304
305    fn is_enabled(&self) -> bool {
306        self.enabled_fn.as_ref().map(|f| f()).unwrap_or(true)
307    }
308
309    fn is_active(&self) -> bool {
310        self.active_fn.as_ref().map(|f| f()).unwrap_or(true)
311    }
312
313    /// Spacing reserved between the leading icon glyph and the label.
314    /// Collapses to zero for icon-only buttons (empty label) so the glyph
315    /// centres in the button instead of being shoved left by a gap that
316    /// precedes no text.
317    fn icon_gap(&self) -> f64 {
318        if self.label_text.is_empty() {
319            0.0
320        } else {
321            ICON_GAP
322        }
323    }
324
325    pub fn with_margin(mut self, m: Insets) -> Self {
326        self.base.margin = m;
327        self
328    }
329    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
330        self.base.h_anchor = h;
331        self
332    }
333    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
334        self.base.v_anchor = v;
335        self
336    }
337    pub fn with_min_size(mut self, s: Size) -> Self {
338        self.base.min_size = s;
339        self
340    }
341    pub fn with_max_size(mut self, s: Size) -> Self {
342        self.base.max_size = s;
343        self
344    }
345
346    fn fire_click(&mut self) {
347        if let Some(cb) = self.on_click.as_mut() {
348            cb();
349        }
350    }
351
352    fn position_label(&mut self, size: Size, label_size: Size) {
353        // Width contributed by the leading icon glyph (icon advance
354        // + spacing gap). Zero when no icon is configured.
355        let icon_block_w = self
356            .icon
357            .as_ref()
358            .map(|i| measure_advance(&i.font, &i.glyph.to_string(), i.font_size) + self.icon_gap())
359            .unwrap_or(0.0);
360        // The (icon + gap + label) group is positioned as a unit;
361        // align uses the COMBINED width so the icon stays directly
362        // left of the label for any alignment mode.
363        let group_w = label_size.width + icon_block_w;
364        let group_x = match self.label_align {
365            LabelAlign::Left => self.label_pad_h.min(size.width),
366            LabelAlign::Right => (size.width - group_w - self.label_pad_h).max(0.0),
367            LabelAlign::Center => ((size.width - group_w) * 0.5).max(0.0),
368        };
369        let label_x = group_x + icon_block_w;
370        let label_y = ((size.height - label_size.height) * 0.5).max(0.0);
371        self.children[0].set_bounds(Rect::new(
372            label_x,
373            label_y,
374            label_size.width,
375            label_size.height,
376        ));
377    }
378
379    fn disabled_colors(v: &crate::theme::Visuals) -> (Color, Color, Color) {
380        let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
381        if luma < 0.5 {
382            (
383                v.window_fill,
384                Color::rgba(1.0, 1.0, 1.0, 0.22),
385                v.text_dim.with_alpha(0.42),
386            )
387        } else {
388            (v.track_bg, v.widget_stroke.with_alpha(0.45), v.text_dim)
389        }
390    }
391
392    /// Construct a label child from the button's current state.
393    ///
394    /// Called from `new()`, `with_theme()`, and `with_font_size()` so the
395    /// child always reflects the button's configuration.
396    fn build_label(
397        text: &str,
398        font: &Arc<Font>,
399        font_size: f64,
400        theme: &ButtonTheme,
401    ) -> Box<dyn Widget> {
402        Self::build_label_with_color(text, font, font_size, theme.label_color)
403    }
404
405    fn build_label_with_color(
406        text: &str,
407        font: &Arc<Font>,
408        font_size: f64,
409        color: Color,
410    ) -> Box<dyn Widget> {
411        Box::new(
412            Label::new(text, Arc::clone(font))
413                .with_font_size(font_size)
414                .with_color(color)
415                .with_align(LabelAlign::Center),
416        )
417    }
418
419    /// Render the configured icon glyph centred vertically in the
420    /// button using the glyph's *actual* outline bounding box — not
421    /// the font's worst-case ascender/descender. Icon fonts (Font
422    /// Awesome especially) place each glyph in a sub-rectangle of
423    /// the design space; centring by the font metric leaves the glyph
424    /// visibly high on the button (the "icons floating to the top"
425    /// regression we've hit repeatedly). With the per-glyph bbox we
426    /// solve for the baseline that puts the glyph's vertical midpoint
427    /// at `button_h / 2`.
428    fn paint_icon(
429        ctx: &mut dyn DrawCtx,
430        icon: &Option<ButtonIcon>,
431        _label_font: &Arc<Font>,
432        _label_font_size: f64,
433        x: f64,
434        button_h: f64,
435        color: Color,
436    ) {
437        let Some(icon) = icon else { return };
438        // (y_min, y_max) is the glyph's actual extent in pixels
439        // relative to baseline, Y-up. y_min is usually negative
440        // (descender region) or ~0, y_max is the cap-height of the
441        // glyph. Pick the baseline so that
442        //   baseline + (y_min + y_max) / 2  ==  button_h / 2
443        // i.e. the glyph's midpoint sits at the button's midpoint.
444        // Fall back to the font metric only if the glyph has no
445        // outline (e.g. a space or a missing glyph).
446        let baseline_y = match icon.font.glyph_visual_bounds(icon.glyph, icon.font_size) {
447            Some((y_min, y_max)) => (button_h * 0.5 - (y_min + y_max) * 0.5).max(0.0),
448            None => ((button_h - icon.font_size) * 0.5).max(0.0),
449        };
450        ctx.set_font(Arc::clone(&icon.font));
451        ctx.set_font_size(icon.font_size);
452        ctx.set_fill_color(color);
453        ctx.fill_text(&icon.glyph.to_string(), x, baseline_y);
454    }
455}
456
457impl Widget for Button {
458    fn type_name(&self) -> &'static str {
459        "Button"
460    }
461    fn bounds(&self) -> Rect {
462        self.bounds
463    }
464    fn set_bounds(&mut self, bounds: Rect) {
465        self.bounds = bounds;
466    }
467
468    fn children(&self) -> &[Box<dyn Widget>] {
469        &self.children
470    }
471    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
472        &mut self.children
473    }
474
475    fn is_focusable(&self) -> bool {
476        self.is_enabled()
477    }
478
479    fn margin(&self) -> Insets {
480        self.base.margin
481    }
482    fn widget_base(&self) -> Option<&WidgetBase> {
483        Some(&self.base)
484    }
485    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
486        Some(&mut self.base)
487    }
488    fn h_anchor(&self) -> HAnchor {
489        self.base.h_anchor
490    }
491    fn v_anchor(&self) -> VAnchor {
492        self.base.v_anchor
493    }
494    fn min_size(&self) -> Size {
495        self.base.min_size
496    }
497    fn max_size(&self) -> Size {
498        self.base.max_size
499    }
500
501    fn layout(&mut self, available: Size) -> Size {
502        // Honour an explicit `min_size.height` floor (symmetric with the
503        // `min_size.width` handling below) so callers can force uniform
504        // square icon buttons. Defaults to `Size::ZERO`, so unset buttons
505        // keep the font-derived natural height.
506        let natural_height = (self.font_size * 1.7)
507            .max(24.0)
508            .max(self.base.min_size.height);
509        let height = if available.height > 0.0 {
510            natural_height.min(available.height)
511        } else {
512            natural_height
513        };
514        // Measure the label first so we can report a "fit" width — label
515        // width plus horizontal padding — instead of stretching to the
516        // whole available width.  This keeps Buttons polite siblings in a
517        // `FlexRow`.  Parents that want a full-width button can:
518        //   - wrap it in a `SizedBox` with an explicit width, or
519        //   - apply `HAnchor::STRETCH`, or
520        //   - set `with_min_size(Size::new(width, _))` for a width floor.
521        // Compact mode tightens the horizontal pad and drops the
522        // 48 px touch-target floor — icon-only toolbar buttons that
523        // need to sit next to each other on a narrow bar would
524        // otherwise eat all the row width.
525        let pad_h = if self.compact {
526            self.font_size * 0.7
527        } else {
528            self.font_size * 1.2
529        };
530        let label_size = self.children[0].layout(Size::new(available.width, height));
531        let icon_block_w = self
532            .icon
533            .as_ref()
534            .map(|i| measure_advance(&i.font, &i.glyph.to_string(), i.font_size) + self.icon_gap())
535            .unwrap_or(0.0);
536        let min_w = if self.compact { 0.0 } else { 48.0 };
537        let natural_w = (label_size.width + icon_block_w + pad_h)
538            .max(min_w)
539            .max(self.base.min_size.width);
540        let width = if self.base.h_anchor.is_stretch() {
541            available.width.max(natural_w)
542        } else {
543            natural_w
544        }
545        .min(available.width);
546        let size = Size::new(width, height);
547        self.position_label(size, label_size);
548        size
549    }
550
551    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
552        let w = self.bounds.width;
553        let h = self.bounds.height;
554        let r = self.theme.border_radius;
555        let enabled = self.is_enabled();
556        let v = ctx.visuals();
557        let use_visuals = self.theme == ButtonTheme::default();
558        let active = self.is_active();
559        // A subtle button paints in muted theme colours when inactive, and
560        // flips to the accent surface (white text on accent fill) when its
561        // `active_fn` returns true.  Plain (non-subtle) buttons always use
562        // the accent surface — that's the existing primary-button look.
563        let muted = self.subtle && !active;
564
565        // Focus ring — drawn JUST INSIDE the button bounds so the parent's
566        // `clip_children_rect` (defaults to widget bounds) doesn't chop
567        // the leftmost stroke pixel when the button sits flush against
568        // a container edge.  Painting outside-bounds with negative
569        // coordinates was the long-standing cause of "the left edge of
570        // my button looks clipped" reports.
571        if enabled && self.focused {
572            let ring = self.theme.focus_ring_width;
573            let focus_ring = if use_visuals {
574                v.accent_focus
575            } else {
576                self.theme.focus_ring_color
577            };
578            ctx.set_stroke_color(focus_ring);
579            ctx.set_line_width(ring);
580            ctx.begin_path();
581            let inset = ring * 0.5;
582            ctx.rounded_rect(
583                inset,
584                inset,
585                (w - ring).max(0.0),
586                (h - ring).max(0.0),
587                (r - inset).max(0.0),
588            );
589            ctx.stroke();
590        }
591
592        // Background — color depends on interaction state. Disabled buttons
593        // use neutral widget colors instead of a washed-out accent, so they
594        // don't look like secondary active actions.
595        let base_bg = if muted && self.ghost && self.pressed {
596            // Ghost (transparent-inactive) buttons paint a faint
597            // text-coloured overlay on hover / press instead of the
598            // widget_bg shade.  Matches the egui-style sidebar row
599            // look the demo's `ToggleButton` had before refactor.
600            Color::rgba(v.text_color.r, v.text_color.g, v.text_color.b, 0.16)
601        } else if muted && self.ghost && self.hovered {
602            Color::rgba(v.text_color.r, v.text_color.g, v.text_color.b, 0.10)
603        } else if muted && self.ghost {
604            // Fully transparent when the user isn't interacting.
605            Color::rgba(0.0, 0.0, 0.0, 0.0)
606        } else if muted && (self.pressed || self.hovered) {
607            v.widget_bg_hovered
608        } else if muted {
609            v.widget_bg
610        } else if use_visuals && self.pressed {
611            v.accent_pressed
612        } else if use_visuals && self.hovered {
613            v.accent_hovered
614        } else if use_visuals {
615            v.accent
616        } else if self.pressed {
617            self.theme.background_pressed
618        } else if self.hovered {
619            self.theme.background_hovered
620        } else {
621            self.theme.background
622        };
623        let (disabled_bg, disabled_stroke, _) = Self::disabled_colors(&v);
624        let bg = if enabled { base_bg } else { disabled_bg };
625        ctx.set_fill_color(bg);
626        ctx.begin_path();
627        ctx.rounded_rect(0.0, 0.0, w, h, r);
628        ctx.fill();
629
630        // Optional outline — opt-in via `with_outlined()` for inactive
631        // segmented buttons that want a defined edge against a same-colour
632        // parent (e.g. top-bar tabs).  Active state already has a
633        // high-contrast accent fill and skips this so the selected segment
634        // visually pops.
635        if enabled && self.outlined && !active {
636            ctx.set_stroke_color(v.widget_stroke);
637            ctx.set_line_width(1.0);
638            ctx.begin_path();
639            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
640            ctx.stroke();
641        }
642
643        // Retint the child label so subtle / active states show the right
644        // foreground colour without rebuilding the Label widget.  Calling
645        // through the dyn Widget keeps Button agnostic of the concrete
646        // Label type — `set_label_color` is a default no-op that Label
647        // overrides, see `Widget::set_label_color`.
648        let label_color = if muted {
649            v.text_color
650        } else {
651            self.theme.label_color
652        };
653        if let Some(child) = self.children.get_mut(0) {
654            child.set_label_color(label_color);
655        }
656
657        if !enabled {
658            ctx.set_stroke_color(disabled_stroke);
659            ctx.set_line_width(1.0);
660            ctx.begin_path();
661            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
662            ctx.stroke();
663        }
664
665        // Text is NOT drawn here. `paint_subtree` recurses into the Label
666        // child automatically after this method returns.
667    }
668
669    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
670        let enabled = self.is_enabled();
671        let w = self.bounds.width;
672        let h = self.bounds.height;
673        let r = self.theme.border_radius;
674        let v = ctx.visuals();
675
676        if !enabled {
677            // The normal child Label was built for the enabled foreground
678            // colour. Cover it and repaint the label with the disabled
679            // text colour. Icon (if any) renders in the same disabled
680            // text colour at the same group_x as layout positioned it.
681            let (disabled_bg, disabled_stroke, disabled_text) = Self::disabled_colors(&v);
682
683            ctx.set_fill_color(disabled_bg);
684            ctx.begin_path();
685            ctx.rounded_rect(0.0, 0.0, w, h, r);
686            ctx.fill();
687
688            ctx.set_stroke_color(disabled_stroke);
689            ctx.set_line_width(1.0);
690            ctx.begin_path();
691            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
692            ctx.stroke();
693
694            let font = crate::font_settings::current_system_font()
695                .unwrap_or_else(|| Arc::clone(&self.font));
696            let icon_block_w = self
697                .icon
698                .as_ref()
699                .map(|i| {
700                    measure_advance(&i.font, &i.glyph.to_string(), i.font_size) + self.icon_gap()
701                })
702                .unwrap_or(0.0);
703            ctx.set_font(font);
704            ctx.set_font_size(self.font_size * crate::font_settings::current_font_size_scale());
705            ctx.set_fill_color(disabled_text);
706            if let Some(m) = ctx.measure_text(&self.label_text) {
707                let group_w = m.width + icon_block_w;
708                let group_x = ((w - group_w) * 0.5).max(0.0);
709                let tx = group_x + icon_block_w;
710                let ty = m.centered_baseline_y(h).max(0.0);
711                ctx.fill_text(&self.label_text, tx, ty);
712                Self::paint_icon(
713                    ctx,
714                    &self.icon,
715                    &self.font,
716                    self.font_size,
717                    group_x,
718                    h,
719                    disabled_text,
720                );
721            }
722            return;
723        }
724
725        // Enabled state — only paint the icon (label has already been
726        // drawn by the framework via the child Label's paint).
727        if let Some(icon) = self.icon.clone() {
728            let active = self.is_active();
729            let muted = self.subtle && !active;
730            let label_color = if muted {
731                v.text_color
732            } else {
733                self.theme.label_color
734            };
735            let label_x = self
736                .children
737                .first()
738                .map(|c| c.bounds().x)
739                .unwrap_or_default();
740            let icon_block_w = measure_advance(&icon.font, &icon.glyph.to_string(), icon.font_size)
741                + self.icon_gap();
742            let group_x = (label_x - icon_block_w).max(0.0);
743            Self::paint_icon(
744                ctx,
745                &Some(icon),
746                &self.font,
747                self.font_size,
748                group_x,
749                h,
750                label_color,
751            );
752        }
753    }
754
755    fn on_event(&mut self, event: &Event) -> EventResult {
756        self.handle_event(event)
757    }
758
759    fn properties(&self) -> Vec<(&'static str, String)> {
760        vec![
761            ("label", self.label_text.clone()),
762            ("font_size", format!("{:.1}", self.font_size)),
763        ]
764    }
765}