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    /// Paint an icon glyph at the leading edge of the label.
215    /// `icon_font` carries the glyph (e.g. a Font Awesome face);
216    /// the label text continues to render in the button's main
217    /// font, so callers can pair a Latin text font with an
218    /// icon-only font without merging them.
219    ///
220    /// Defaults `font_size` to the button's current `font_size`.
221    /// Use [`with_icon_sized`](Self::with_icon_sized) to scale the
222    /// icon independently.
223    pub fn with_icon(mut self, glyph: char, icon_font: Arc<Font>) -> Self {
224        let font_size = self.font_size;
225        self.icon = Some(ButtonIcon {
226            glyph,
227            font: icon_font,
228            font_size,
229        });
230        self
231    }
232
233    /// Like [`with_icon`](Self::with_icon) but with an explicit
234    /// icon font size — useful when the icon font's glyphs read
235    /// larger or smaller than the text at the same point size.
236    pub fn with_icon_sized(mut self, glyph: char, icon_font: Arc<Font>, font_size: f64) -> Self {
237        self.icon = Some(ButtonIcon {
238            glyph,
239            font: icon_font,
240            font_size,
241        });
242        self
243    }
244
245    /// Use a transparent inactive background + faint text-coloured
246    /// hover/pressed overlay instead of the muted `widget_bg` fill.
247    /// Implies [`with_subtle`] (theme text colour, accent on active).
248    /// Right for sidebar list rows where the inactive state should
249    /// blend with the panel.
250    pub fn with_ghost(mut self) -> Self {
251        self.subtle = true;
252        self.ghost = true;
253        let theme_text = crate::theme::current_visuals().text_color;
254        self.children[0] =
255            Self::build_label_with_color(&self.label_text, &self.font, self.font_size, theme_text);
256        self
257    }
258
259    /// Switch to the muted (secondary) visual style: theme `widget_bg`
260    /// fill, theme `text_color` label.  Pair with [`with_active_fn`] to
261    /// build segmented controls — inactive segments paint subtle, the
262    /// selected segment flips to the accent surface.
263    /// Draw a 1-px `widget_stroke` outline around the button while inactive.
264    /// Combined with [`Self::with_subtle`] this gives top-bar segmented
265    /// controls a defined edge so they don't visually bleed into a parent
266    /// that shares the same `widget_bg` colour.  Active state already paints
267    /// a high-contrast accent fill and skips the stroke.
268    pub fn with_outlined(mut self) -> Self {
269        self.outlined = true;
270        self
271    }
272
273    pub fn with_subtle(mut self) -> Self {
274        self.subtle = true;
275        // Subtle buttons use the theme's text colour, not the white-on-accent
276        // default.  Rebuild the label with the active visuals' text colour
277        // (the paint pass also retints each frame, so this just gives a
278        // sensible first-paint colour before the visuals are queried).
279        let theme_text = crate::theme::current_visuals().text_color;
280        self.children[0] =
281            Self::build_label_with_color(&self.label_text, &self.font, self.font_size, theme_text);
282        self
283    }
284
285    fn is_enabled(&self) -> bool {
286        self.enabled_fn.as_ref().map(|f| f()).unwrap_or(true)
287    }
288
289    fn is_active(&self) -> bool {
290        self.active_fn.as_ref().map(|f| f()).unwrap_or(true)
291    }
292
293    /// Spacing reserved between the leading icon glyph and the label.
294    /// Collapses to zero for icon-only buttons (empty label) so the glyph
295    /// centres in the button instead of being shoved left by a gap that
296    /// precedes no text.
297    fn icon_gap(&self) -> f64 {
298        if self.label_text.is_empty() {
299            0.0
300        } else {
301            ICON_GAP
302        }
303    }
304
305    pub fn with_margin(mut self, m: Insets) -> Self {
306        self.base.margin = m;
307        self
308    }
309    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
310        self.base.h_anchor = h;
311        self
312    }
313    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
314        self.base.v_anchor = v;
315        self
316    }
317    pub fn with_min_size(mut self, s: Size) -> Self {
318        self.base.min_size = s;
319        self
320    }
321    pub fn with_max_size(mut self, s: Size) -> Self {
322        self.base.max_size = s;
323        self
324    }
325
326    fn fire_click(&mut self) {
327        if let Some(cb) = self.on_click.as_mut() {
328            cb();
329        }
330    }
331
332    fn position_label(&mut self, size: Size, label_size: Size) {
333        // Width contributed by the leading icon glyph (icon advance
334        // + spacing gap). Zero when no icon is configured.
335        let icon_block_w = self
336            .icon
337            .as_ref()
338            .map(|i| measure_advance(&i.font, &i.glyph.to_string(), i.font_size) + self.icon_gap())
339            .unwrap_or(0.0);
340        // The (icon + gap + label) group is positioned as a unit;
341        // align uses the COMBINED width so the icon stays directly
342        // left of the label for any alignment mode.
343        let group_w = label_size.width + icon_block_w;
344        let group_x = match self.label_align {
345            LabelAlign::Left => self.label_pad_h.min(size.width),
346            LabelAlign::Right => (size.width - group_w - self.label_pad_h).max(0.0),
347            LabelAlign::Center => ((size.width - group_w) * 0.5).max(0.0),
348        };
349        let label_x = group_x + icon_block_w;
350        let label_y = ((size.height - label_size.height) * 0.5).max(0.0);
351        self.children[0].set_bounds(Rect::new(
352            label_x,
353            label_y,
354            label_size.width,
355            label_size.height,
356        ));
357    }
358
359    fn disabled_colors(v: &crate::theme::Visuals) -> (Color, Color, Color) {
360        let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
361        if luma < 0.5 {
362            (
363                v.window_fill,
364                Color::rgba(1.0, 1.0, 1.0, 0.22),
365                v.text_dim.with_alpha(0.42),
366            )
367        } else {
368            (v.track_bg, v.widget_stroke.with_alpha(0.45), v.text_dim)
369        }
370    }
371
372    /// Construct a label child from the button's current state.
373    ///
374    /// Called from `new()`, `with_theme()`, and `with_font_size()` so the
375    /// child always reflects the button's configuration.
376    fn build_label(
377        text: &str,
378        font: &Arc<Font>,
379        font_size: f64,
380        theme: &ButtonTheme,
381    ) -> Box<dyn Widget> {
382        Self::build_label_with_color(text, font, font_size, theme.label_color)
383    }
384
385    fn build_label_with_color(
386        text: &str,
387        font: &Arc<Font>,
388        font_size: f64,
389        color: Color,
390    ) -> Box<dyn Widget> {
391        Box::new(
392            Label::new(text, Arc::clone(font))
393                .with_font_size(font_size)
394                .with_color(color)
395                .with_align(LabelAlign::Center),
396        )
397    }
398
399    /// Render the configured icon glyph centred vertically in the
400    /// button using the glyph's *actual* outline bounding box — not
401    /// the font's worst-case ascender/descender. Icon fonts (Font
402    /// Awesome especially) place each glyph in a sub-rectangle of
403    /// the design space; centring by the font metric leaves the glyph
404    /// visibly high on the button (the "icons floating to the top"
405    /// regression we've hit repeatedly). With the per-glyph bbox we
406    /// solve for the baseline that puts the glyph's vertical midpoint
407    /// at `button_h / 2`.
408    fn paint_icon(
409        ctx: &mut dyn DrawCtx,
410        icon: &Option<ButtonIcon>,
411        _label_font: &Arc<Font>,
412        _label_font_size: f64,
413        x: f64,
414        button_h: f64,
415        color: Color,
416    ) {
417        let Some(icon) = icon else { return };
418        // (y_min, y_max) is the glyph's actual extent in pixels
419        // relative to baseline, Y-up. y_min is usually negative
420        // (descender region) or ~0, y_max is the cap-height of the
421        // glyph. Pick the baseline so that
422        //   baseline + (y_min + y_max) / 2  ==  button_h / 2
423        // i.e. the glyph's midpoint sits at the button's midpoint.
424        // Fall back to the font metric only if the glyph has no
425        // outline (e.g. a space or a missing glyph).
426        let baseline_y = match icon.font.glyph_visual_bounds(icon.glyph, icon.font_size) {
427            Some((y_min, y_max)) => (button_h * 0.5 - (y_min + y_max) * 0.5).max(0.0),
428            None => ((button_h - icon.font_size) * 0.5).max(0.0),
429        };
430        ctx.set_font(Arc::clone(&icon.font));
431        ctx.set_font_size(icon.font_size);
432        ctx.set_fill_color(color);
433        ctx.fill_text(&icon.glyph.to_string(), x, baseline_y);
434    }
435}
436
437impl Widget for Button {
438    fn type_name(&self) -> &'static str {
439        "Button"
440    }
441    fn bounds(&self) -> Rect {
442        self.bounds
443    }
444    fn set_bounds(&mut self, bounds: Rect) {
445        self.bounds = bounds;
446    }
447
448    fn children(&self) -> &[Box<dyn Widget>] {
449        &self.children
450    }
451    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
452        &mut self.children
453    }
454
455    fn is_focusable(&self) -> bool {
456        self.is_enabled()
457    }
458
459    fn margin(&self) -> Insets {
460        self.base.margin
461    }
462    fn widget_base(&self) -> Option<&WidgetBase> {
463        Some(&self.base)
464    }
465    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
466        Some(&mut self.base)
467    }
468    fn h_anchor(&self) -> HAnchor {
469        self.base.h_anchor
470    }
471    fn v_anchor(&self) -> VAnchor {
472        self.base.v_anchor
473    }
474    fn min_size(&self) -> Size {
475        self.base.min_size
476    }
477    fn max_size(&self) -> Size {
478        self.base.max_size
479    }
480
481    fn layout(&mut self, available: Size) -> Size {
482        // Honour an explicit `min_size.height` floor (symmetric with the
483        // `min_size.width` handling below) so callers can force uniform
484        // square icon buttons. Defaults to `Size::ZERO`, so unset buttons
485        // keep the font-derived natural height.
486        let natural_height = (self.font_size * 1.7)
487            .max(24.0)
488            .max(self.base.min_size.height);
489        let height = if available.height > 0.0 {
490            natural_height.min(available.height)
491        } else {
492            natural_height
493        };
494        // Measure the label first so we can report a "fit" width — label
495        // width plus horizontal padding — instead of stretching to the
496        // whole available width.  This keeps Buttons polite siblings in a
497        // `FlexRow`.  Parents that want a full-width button can:
498        //   - wrap it in a `SizedBox` with an explicit width, or
499        //   - apply `HAnchor::STRETCH`, or
500        //   - set `with_min_size(Size::new(width, _))` for a width floor.
501        // Compact mode tightens the horizontal pad and drops the
502        // 48 px touch-target floor — icon-only toolbar buttons that
503        // need to sit next to each other on a narrow bar would
504        // otherwise eat all the row width.
505        let pad_h = if self.compact {
506            self.font_size * 0.7
507        } else {
508            self.font_size * 1.2
509        };
510        let label_size = self.children[0].layout(Size::new(available.width, height));
511        let icon_block_w = self
512            .icon
513            .as_ref()
514            .map(|i| measure_advance(&i.font, &i.glyph.to_string(), i.font_size) + self.icon_gap())
515            .unwrap_or(0.0);
516        let min_w = if self.compact { 0.0 } else { 48.0 };
517        let natural_w = (label_size.width + icon_block_w + pad_h)
518            .max(min_w)
519            .max(self.base.min_size.width);
520        let width = if self.base.h_anchor.is_stretch() {
521            available.width.max(natural_w)
522        } else {
523            natural_w
524        }
525        .min(available.width);
526        let size = Size::new(width, height);
527        self.position_label(size, label_size);
528        size
529    }
530
531    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
532        let w = self.bounds.width;
533        let h = self.bounds.height;
534        let r = self.theme.border_radius;
535        let enabled = self.is_enabled();
536        let v = ctx.visuals();
537        let use_visuals = self.theme == ButtonTheme::default();
538        let active = self.is_active();
539        // A subtle button paints in muted theme colours when inactive, and
540        // flips to the accent surface (white text on accent fill) when its
541        // `active_fn` returns true.  Plain (non-subtle) buttons always use
542        // the accent surface — that's the existing primary-button look.
543        let muted = self.subtle && !active;
544
545        // Focus ring — drawn JUST INSIDE the button bounds so the parent's
546        // `clip_children_rect` (defaults to widget bounds) doesn't chop
547        // the leftmost stroke pixel when the button sits flush against
548        // a container edge.  Painting outside-bounds with negative
549        // coordinates was the long-standing cause of "the left edge of
550        // my button looks clipped" reports.
551        if enabled && self.focused {
552            let ring = self.theme.focus_ring_width;
553            let focus_ring = if use_visuals {
554                v.accent_focus
555            } else {
556                self.theme.focus_ring_color
557            };
558            ctx.set_stroke_color(focus_ring);
559            ctx.set_line_width(ring);
560            ctx.begin_path();
561            let inset = ring * 0.5;
562            ctx.rounded_rect(
563                inset,
564                inset,
565                (w - ring).max(0.0),
566                (h - ring).max(0.0),
567                (r - inset).max(0.0),
568            );
569            ctx.stroke();
570        }
571
572        // Background — color depends on interaction state. Disabled buttons
573        // use neutral widget colors instead of a washed-out accent, so they
574        // don't look like secondary active actions.
575        let base_bg = if muted && self.ghost && self.pressed {
576            // Ghost (transparent-inactive) buttons paint a faint
577            // text-coloured overlay on hover / press instead of the
578            // widget_bg shade.  Matches the egui-style sidebar row
579            // look the demo's `ToggleButton` had before refactor.
580            Color::rgba(v.text_color.r, v.text_color.g, v.text_color.b, 0.16)
581        } else if muted && self.ghost && self.hovered {
582            Color::rgba(v.text_color.r, v.text_color.g, v.text_color.b, 0.10)
583        } else if muted && self.ghost {
584            // Fully transparent when the user isn't interacting.
585            Color::rgba(0.0, 0.0, 0.0, 0.0)
586        } else if muted && (self.pressed || self.hovered) {
587            v.widget_bg_hovered
588        } else if muted {
589            v.widget_bg
590        } else if use_visuals && self.pressed {
591            v.accent_pressed
592        } else if use_visuals && self.hovered {
593            v.accent_hovered
594        } else if use_visuals {
595            v.accent
596        } else if self.pressed {
597            self.theme.background_pressed
598        } else if self.hovered {
599            self.theme.background_hovered
600        } else {
601            self.theme.background
602        };
603        let (disabled_bg, disabled_stroke, _) = Self::disabled_colors(&v);
604        let bg = if enabled { base_bg } else { disabled_bg };
605        ctx.set_fill_color(bg);
606        ctx.begin_path();
607        ctx.rounded_rect(0.0, 0.0, w, h, r);
608        ctx.fill();
609
610        // Optional outline — opt-in via `with_outlined()` for inactive
611        // segmented buttons that want a defined edge against a same-colour
612        // parent (e.g. top-bar tabs).  Active state already has a
613        // high-contrast accent fill and skips this so the selected segment
614        // visually pops.
615        if enabled && self.outlined && !active {
616            ctx.set_stroke_color(v.widget_stroke);
617            ctx.set_line_width(1.0);
618            ctx.begin_path();
619            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
620            ctx.stroke();
621        }
622
623        // Retint the child label so subtle / active states show the right
624        // foreground colour without rebuilding the Label widget.  Calling
625        // through the dyn Widget keeps Button agnostic of the concrete
626        // Label type — `set_label_color` is a default no-op that Label
627        // overrides, see `Widget::set_label_color`.
628        let label_color = if muted {
629            v.text_color
630        } else {
631            self.theme.label_color
632        };
633        if let Some(child) = self.children.get_mut(0) {
634            child.set_label_color(label_color);
635        }
636
637        if !enabled {
638            ctx.set_stroke_color(disabled_stroke);
639            ctx.set_line_width(1.0);
640            ctx.begin_path();
641            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
642            ctx.stroke();
643        }
644
645        // Text is NOT drawn here. `paint_subtree` recurses into the Label
646        // child automatically after this method returns.
647    }
648
649    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
650        let enabled = self.is_enabled();
651        let w = self.bounds.width;
652        let h = self.bounds.height;
653        let r = self.theme.border_radius;
654        let v = ctx.visuals();
655
656        if !enabled {
657            // The normal child Label was built for the enabled foreground
658            // colour. Cover it and repaint the label with the disabled
659            // text colour. Icon (if any) renders in the same disabled
660            // text colour at the same group_x as layout positioned it.
661            let (disabled_bg, disabled_stroke, disabled_text) = Self::disabled_colors(&v);
662
663            ctx.set_fill_color(disabled_bg);
664            ctx.begin_path();
665            ctx.rounded_rect(0.0, 0.0, w, h, r);
666            ctx.fill();
667
668            ctx.set_stroke_color(disabled_stroke);
669            ctx.set_line_width(1.0);
670            ctx.begin_path();
671            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
672            ctx.stroke();
673
674            let font = crate::font_settings::current_system_font()
675                .unwrap_or_else(|| Arc::clone(&self.font));
676            let icon_block_w = self
677                .icon
678                .as_ref()
679                .map(|i| measure_advance(&i.font, &i.glyph.to_string(), i.font_size) + self.icon_gap())
680                .unwrap_or(0.0);
681            ctx.set_font(font);
682            ctx.set_font_size(self.font_size * crate::font_settings::current_font_size_scale());
683            ctx.set_fill_color(disabled_text);
684            if let Some(m) = ctx.measure_text(&self.label_text) {
685                let group_w = m.width + icon_block_w;
686                let group_x = ((w - group_w) * 0.5).max(0.0);
687                let tx = group_x + icon_block_w;
688                let ty = m.centered_baseline_y(h).max(0.0);
689                ctx.fill_text(&self.label_text, tx, ty);
690                Self::paint_icon(
691                    ctx,
692                    &self.icon,
693                    &self.font,
694                    self.font_size,
695                    group_x,
696                    h,
697                    disabled_text,
698                );
699            }
700            return;
701        }
702
703        // Enabled state — only paint the icon (label has already been
704        // drawn by the framework via the child Label's paint).
705        if let Some(icon) = self.icon.clone() {
706            let active = self.is_active();
707            let muted = self.subtle && !active;
708            let label_color = if muted {
709                v.text_color
710            } else {
711                self.theme.label_color
712            };
713            let label_x = self
714                .children
715                .first()
716                .map(|c| c.bounds().x)
717                .unwrap_or_default();
718            let icon_block_w =
719                measure_advance(&icon.font, &icon.glyph.to_string(), icon.font_size) + self.icon_gap();
720            let group_x = (label_x - icon_block_w).max(0.0);
721            Self::paint_icon(
722                ctx,
723                &Some(icon),
724                &self.font,
725                self.font_size,
726                group_x,
727                h,
728                label_color,
729            );
730        }
731    }
732
733    fn on_event(&mut self, event: &Event) -> EventResult {
734        self.handle_event(event)
735    }
736
737    fn properties(&self) -> Vec<(&'static str, String)> {
738        vec![
739            ("label", self.label_text.clone()),
740            ("font_size", format!("{:.1}", self.font_size)),
741        ]
742    }
743}