Skip to main content

agg_gui/widgets/
button.rs

1//! `Button` — a clickable button with hover, pressed, and focus states.
2//!
3//! # Composition
4//!
5//! Button is fully compositional: it always has exactly one child widget, a
6//! [`Label`], which is responsible for rendering the button's text.  The
7//! [`paint_subtree`] machinery handles the Label automatically after
8//! [`Button::paint`] draws the background.
9//!
10//! ```text
11//! Button (background + focus ring)
12//!   └── Label (text, tight bounds, centred within button)
13//! ```
14//!
15//! `Label::layout` returns tight text bounds.  `Button::layout` centres the
16//! label within the button area.  Because [`Label::hit_test`] returns `false`,
17//! the Label is invisible to the hit-test and event-routing system; the Button
18//! retains full ownership of focus and click events.
19
20use std::rc::Rc;
21use std::sync::Arc;
22
23use crate::color::Color;
24use crate::draw_ctx::DrawCtx;
25use crate::event::{Event, EventResult, MouseButton};
26use crate::geometry::{Rect, Size};
27use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
28use crate::text::Font;
29use crate::widget::Widget;
30use crate::widgets::label::{Label, LabelAlign};
31
32/// A theme for [`Button`] visual states.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ButtonTheme {
35    pub background: Color,
36    pub background_hovered: Color,
37    pub background_pressed: Color,
38    pub label_color: Color,
39    pub border_radius: f64,
40    pub focus_ring_color: Color,
41    pub focus_ring_width: f64,
42}
43
44impl Default for ButtonTheme {
45    fn default() -> Self {
46        Self {
47            background: Color::rgb(0.22, 0.45, 0.88),
48            background_hovered: Color::rgb(0.30, 0.52, 0.92),
49            background_pressed: Color::rgb(0.16, 0.36, 0.72),
50            label_color: Color::white(),
51            border_radius: 6.0,
52            focus_ring_color: Color::rgba(0.22, 0.45, 0.88, 0.55),
53            focus_ring_width: 2.5,
54        }
55    }
56}
57
58/// A clickable button.
59///
60/// Build with [`Button::new`] and optionally chain builder methods.
61pub struct Button {
62    bounds: Rect,
63    /// Always exactly one child: the `Label` for the button's text.
64    children: Vec<Box<dyn Widget>>,
65    base: WidgetBase,
66    /// Source of truth for the label text, kept so `build_label` can rebuild.
67    label_text: String,
68    font: Arc<Font>,
69    font_size: f64,
70    pub theme: ButtonTheme,
71    on_click: Option<Box<dyn FnMut()>>,
72    /// Optional gate: when `Some`, the button is enabled only while the
73    /// closure returns `true`.  Queried each paint / event so the caller
74    /// can base it on live state (e.g. "only enable Relaunch when the
75    /// selected MSAA differs from the running one") without rebuilding
76    /// the widget tree.  `None` = always enabled.
77    enabled_fn: Option<Rc<dyn Fn() -> bool>>,
78
79    hovered: bool,
80    pressed: bool,
81    focused: bool,
82}
83
84impl Button {
85    /// Create a button with the given label.
86    pub fn new(label: impl Into<String>, font: Arc<Font>) -> Self {
87        let label_text: String = label.into();
88        let font_size = 14.0;
89        let theme = ButtonTheme::default();
90        let child = Self::build_label(&label_text, &font, font_size, &theme);
91        Self {
92            bounds: Rect::default(),
93            children: vec![child],
94            base: WidgetBase::new(),
95            label_text,
96            font,
97            font_size,
98            theme,
99            on_click: None,
100            enabled_fn: None,
101            hovered: false,
102            pressed: false,
103            focused: false,
104        }
105    }
106
107    pub fn with_font_size(mut self, size: f64) -> Self {
108        self.font_size = size;
109        self.children[0] = Self::build_label(&self.label_text, &self.font, size, &self.theme);
110        self
111    }
112
113    pub fn with_theme(mut self, theme: ButtonTheme) -> Self {
114        self.theme = theme;
115        self.children[0] =
116            Self::build_label(&self.label_text, &self.font, self.font_size, &self.theme);
117        self
118    }
119
120    pub fn on_click(mut self, cb: impl FnMut() + 'static) -> Self {
121        self.on_click = Some(Box::new(cb));
122        self
123    }
124
125    /// Gate the button on a live predicate.  Returned-`false` frames paint
126    /// the button in its disabled style and ignore mouse / keyboard input.
127    pub fn with_enabled_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
128        self.enabled_fn = Some(Rc::new(f));
129        self
130    }
131
132    fn is_enabled(&self) -> bool {
133        self.enabled_fn.as_ref().map(|f| f()).unwrap_or(true)
134    }
135
136    pub fn with_margin(mut self, m: Insets) -> Self {
137        self.base.margin = m;
138        self
139    }
140    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
141        self.base.h_anchor = h;
142        self
143    }
144    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
145        self.base.v_anchor = v;
146        self
147    }
148    pub fn with_min_size(mut self, s: Size) -> Self {
149        self.base.min_size = s;
150        self
151    }
152    pub fn with_max_size(mut self, s: Size) -> Self {
153        self.base.max_size = s;
154        self
155    }
156
157    fn fire_click(&mut self) {
158        if let Some(cb) = self.on_click.as_mut() {
159            cb();
160        }
161    }
162
163    fn disabled_colors(v: &crate::theme::Visuals) -> (Color, Color, Color) {
164        let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
165        if luma < 0.5 {
166            (
167                v.window_fill,
168                Color::rgba(1.0, 1.0, 1.0, 0.22),
169                v.text_dim.with_alpha(0.42),
170            )
171        } else {
172            (v.track_bg, v.widget_stroke.with_alpha(0.45), v.text_dim)
173        }
174    }
175
176    /// Construct a label child from the button's current state.
177    ///
178    /// Called from `new()`, `with_theme()`, and `with_font_size()` so the
179    /// child always reflects the button's configuration.
180    fn build_label(
181        text: &str,
182        font: &Arc<Font>,
183        font_size: f64,
184        theme: &ButtonTheme,
185    ) -> Box<dyn Widget> {
186        Box::new(
187            Label::new(text, Arc::clone(font))
188                .with_font_size(font_size)
189                .with_color(theme.label_color)
190                .with_align(LabelAlign::Center),
191        )
192    }
193}
194
195impl Widget for Button {
196    fn type_name(&self) -> &'static str {
197        "Button"
198    }
199    fn bounds(&self) -> Rect {
200        self.bounds
201    }
202    fn set_bounds(&mut self, bounds: Rect) {
203        self.bounds = bounds;
204    }
205
206    fn children(&self) -> &[Box<dyn Widget>] {
207        &self.children
208    }
209    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
210        &mut self.children
211    }
212
213    fn is_focusable(&self) -> bool {
214        self.is_enabled()
215    }
216
217    fn margin(&self) -> Insets {
218        self.base.margin
219    }
220    fn h_anchor(&self) -> HAnchor {
221        self.base.h_anchor
222    }
223    fn v_anchor(&self) -> VAnchor {
224        self.base.v_anchor
225    }
226    fn min_size(&self) -> Size {
227        self.base.min_size
228    }
229    fn max_size(&self) -> Size {
230        self.base.max_size
231    }
232
233    fn layout(&mut self, available: Size) -> Size {
234        let height = (self.font_size * 1.7).max(24.0);
235        // Measure the label first so we can report a "fit" width — label
236        // width plus horizontal padding — instead of stretching to the whole
237        // available width.  This makes Buttons share horizontal space
238        // politely when placed inside a `FlexRow` next to other widgets.
239        // Parents that want a full-width button should wrap in a `SizedBox`
240        // with an explicit width, or set `HAnchor::FILL` — handled by the
241        // flex layout before this method is called.
242        let pad_h = self.font_size * 1.2;
243        let label_size = self.children[0].layout(Size::new(available.width, height));
244        let natural_w = (label_size.width + pad_h).max(48.0);
245        let width = natural_w.min(available.width);
246        let size = Size::new(width, height);
247        let label_x = ((size.width - label_size.width) * 0.5).max(0.0);
248        let label_y = ((size.height - label_size.height) * 0.5).max(0.0);
249        self.children[0].set_bounds(Rect::new(
250            label_x,
251            label_y,
252            label_size.width,
253            label_size.height,
254        ));
255        size
256    }
257
258    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
259        let w = self.bounds.width;
260        let h = self.bounds.height;
261        let r = self.theme.border_radius;
262        let enabled = self.is_enabled();
263        let v = ctx.visuals();
264        let use_visuals = self.theme == ButtonTheme::default();
265
266        // Focus ring (behind the button surface) — skipped when disabled
267        // because the disabled button never actually holds focus.
268        if enabled && self.focused {
269            let ring = self.theme.focus_ring_width;
270            let focus_ring = if use_visuals {
271                v.accent_focus
272            } else {
273                self.theme.focus_ring_color
274            };
275            ctx.set_stroke_color(focus_ring);
276            ctx.set_line_width(ring);
277            ctx.begin_path();
278            ctx.rounded_rect(-ring * 0.5, -ring * 0.5, w + ring, h + ring, r + ring * 0.5);
279            ctx.stroke();
280        }
281
282        // Background — color depends on interaction state. Disabled buttons
283        // use neutral widget colors instead of a washed-out accent, so they
284        // don't look like secondary active actions.
285        let base_bg = if use_visuals && self.pressed {
286            v.accent_pressed
287        } else if use_visuals && self.hovered {
288            v.accent_hovered
289        } else if use_visuals {
290            v.accent
291        } else if self.pressed {
292            self.theme.background_pressed
293        } else if self.hovered {
294            self.theme.background_hovered
295        } else {
296            self.theme.background
297        };
298        let (disabled_bg, disabled_stroke, _) = Self::disabled_colors(&v);
299        let bg = if enabled { base_bg } else { disabled_bg };
300        ctx.set_fill_color(bg);
301        ctx.begin_path();
302        ctx.rounded_rect(0.0, 0.0, w, h, r);
303        ctx.fill();
304
305        if !enabled {
306            ctx.set_stroke_color(disabled_stroke);
307            ctx.set_line_width(1.0);
308            ctx.begin_path();
309            ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
310            ctx.stroke();
311        }
312
313        // Text is NOT drawn here. `paint_subtree` recurses into the Label
314        // child automatically after this method returns.
315    }
316
317    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
318        if self.is_enabled() {
319            return;
320        }
321
322        // The normal child Label was built for the enabled foreground color.
323        // Cover it and repaint the label with the disabled text color.
324        let w = self.bounds.width;
325        let h = self.bounds.height;
326        let r = self.theme.border_radius;
327        let v = ctx.visuals();
328        let (disabled_bg, disabled_stroke, disabled_text) = Self::disabled_colors(&v);
329
330        ctx.set_fill_color(disabled_bg);
331        ctx.begin_path();
332        ctx.rounded_rect(0.0, 0.0, w, h, r);
333        ctx.fill();
334
335        ctx.set_stroke_color(disabled_stroke);
336        ctx.set_line_width(1.0);
337        ctx.begin_path();
338        ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
339        ctx.stroke();
340
341        let font =
342            crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font));
343        ctx.set_font(font);
344        ctx.set_font_size(self.font_size * crate::font_settings::current_font_size_scale());
345        ctx.set_fill_color(disabled_text);
346        if let Some(m) = ctx.measure_text(&self.label_text) {
347            let tx = ((w - m.width) * 0.5).max(0.0);
348            let ty = m.centered_baseline_y(h).max(0.0);
349            ctx.fill_text(&self.label_text, tx, ty);
350        }
351    }
352
353    fn on_event(&mut self, event: &Event) -> EventResult {
354        if !self.is_enabled() {
355            // Clear any lingering hover / pressed state so the button
356            // looks idle the instant it's disabled mid-interaction.
357            self.hovered = false;
358            self.pressed = false;
359            return EventResult::Ignored;
360        }
361        match event {
362            Event::MouseMove { pos } => {
363                let was_hovered = self.hovered;
364                let was_pressed = self.pressed;
365                self.hovered = self.hit_test(*pos);
366                if !self.hovered {
367                    self.pressed = false;
368                }
369                if was_hovered != self.hovered || was_pressed != self.pressed {
370                    crate::animation::request_draw();
371                    return EventResult::Consumed;
372                }
373                EventResult::Ignored
374            }
375            Event::MouseDown {
376                button: MouseButton::Left,
377                ..
378            } => {
379                if !self.pressed {
380                    crate::animation::request_draw();
381                }
382                self.pressed = true;
383                EventResult::Consumed
384            }
385            Event::MouseUp {
386                button: MouseButton::Left,
387                ..
388            } => {
389                let was_pressed = self.pressed;
390                self.pressed = false;
391                if was_pressed {
392                    crate::animation::request_draw();
393                }
394                if was_pressed && self.hovered {
395                    self.fire_click();
396                    // Click handler almost always mutates app state that
397                    // affects the next paint; request one so the handler's
398                    // side-effects are visible.
399                    crate::animation::request_draw();
400                }
401                EventResult::Consumed
402            }
403            Event::KeyDown { key, .. } => {
404                use crate::event::Key;
405                match key {
406                    Key::Enter | Key::Char(' ') => {
407                        self.fire_click();
408                        crate::animation::request_draw();
409                        EventResult::Consumed
410                    }
411                    _ => EventResult::Ignored,
412                }
413            }
414            Event::FocusGained => {
415                let was = self.focused;
416                self.focused = true;
417                if !was {
418                    crate::animation::request_draw();
419                    EventResult::Consumed
420                } else {
421                    EventResult::Ignored
422                }
423            }
424            Event::FocusLost => {
425                let was_focused = self.focused;
426                let was_pressed = self.pressed;
427                self.focused = false;
428                self.pressed = false;
429                if was_focused || was_pressed {
430                    crate::animation::request_draw();
431                    EventResult::Consumed
432                } else {
433                    EventResult::Ignored
434                }
435            }
436            _ => EventResult::Ignored,
437        }
438    }
439
440    fn properties(&self) -> Vec<(&'static str, String)> {
441        vec![
442            ("label", self.label_text.clone()),
443            ("font_size", format!("{:.1}", self.font_size)),
444        ]
445    }
446}