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}