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}