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::event::{Event, EventResult, MouseButton};
25use crate::geometry::{Rect, Size};
26use crate::draw_ctx::DrawCtx;
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.
33pub struct ButtonTheme {
34    pub background:         Color,
35    pub background_hovered: Color,
36    pub background_pressed: Color,
37    pub label_color:        Color,
38    pub border_radius:      f64,
39    pub focus_ring_color:   Color,
40    pub focus_ring_width:   f64,
41}
42
43impl Default for ButtonTheme {
44    fn default() -> Self {
45        Self {
46            background:         Color::rgb(0.22, 0.45, 0.88),
47            background_hovered: Color::rgb(0.30, 0.52, 0.92),
48            background_pressed: Color::rgb(0.16, 0.36, 0.72),
49            label_color:        Color::white(),
50            border_radius:      6.0,
51            focus_ring_color:   Color::rgba(0.22, 0.45, 0.88, 0.55),
52            focus_ring_width:   2.5,
53        }
54    }
55}
56
57/// A clickable button.
58///
59/// Build with [`Button::new`] and optionally chain builder methods.
60pub struct Button {
61    bounds: Rect,
62    /// Always exactly one child: the `Label` for the button's text.
63    children: Vec<Box<dyn Widget>>,
64    base: WidgetBase,
65    /// Source of truth for the label text, kept so `build_label` can rebuild.
66    label_text: String,
67    font: Arc<Font>,
68    font_size: f64,
69    pub theme: ButtonTheme,
70    on_click: Option<Box<dyn FnMut()>>,
71    /// Optional gate: when `Some`, the button is enabled only while the
72    /// closure returns `true`.  Queried each paint / event so the caller
73    /// can base it on live state (e.g. "only enable Relaunch when the
74    /// selected MSAA differs from the running one") without rebuilding
75    /// the widget tree.  `None` = always enabled.
76    enabled_fn: Option<Rc<dyn Fn() -> bool>>,
77
78    hovered: bool,
79    pressed: bool,
80    focused: bool,
81}
82
83impl Button {
84    /// Create a button with the given label.
85    pub fn new(label: impl Into<String>, font: Arc<Font>) -> Self {
86        let label_text: String = label.into();
87        let font_size = 14.0;
88        let theme = ButtonTheme::default();
89        let child = Self::build_label(&label_text, &font, font_size, &theme);
90        Self {
91            bounds: Rect::default(),
92            children: vec![child],
93            base: WidgetBase::new(),
94            label_text,
95            font,
96            font_size,
97            theme,
98            on_click: None,
99            enabled_fn: None,
100            hovered: false,
101            pressed: false,
102            focused: false,
103        }
104    }
105
106    pub fn with_font_size(mut self, size: f64) -> Self {
107        self.font_size = size;
108        self.children[0] = Self::build_label(&self.label_text, &self.font, size, &self.theme);
109        self
110    }
111
112    pub fn with_theme(mut self, theme: ButtonTheme) -> Self {
113        self.theme = theme;
114        self.children[0] = Self::build_label(&self.label_text, &self.font, self.font_size, &self.theme);
115        self
116    }
117
118    pub fn on_click(mut self, cb: impl FnMut() + 'static) -> Self {
119        self.on_click = Some(Box::new(cb));
120        self
121    }
122
123    /// Gate the button on a live predicate.  Returned-`false` frames paint
124    /// the button in its disabled style and ignore mouse / keyboard input.
125    pub fn with_enabled_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
126        self.enabled_fn = Some(Rc::new(f));
127        self
128    }
129
130    fn is_enabled(&self) -> bool {
131        self.enabled_fn.as_ref().map(|f| f()).unwrap_or(true)
132    }
133
134    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
135    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
136    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
137    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
138    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
139
140    fn fire_click(&mut self) {
141        if let Some(cb) = self.on_click.as_mut() {
142            cb();
143        }
144    }
145
146    /// Construct a label child from the button's current state.
147    ///
148    /// Called from `new()`, `with_theme()`, and `with_font_size()` so the
149    /// child always reflects the button's configuration.
150    fn build_label(
151        text:      &str,
152        font:      &Arc<Font>,
153        font_size: f64,
154        theme:     &ButtonTheme,
155    ) -> Box<dyn Widget> {
156        Box::new(
157            Label::new(text, Arc::clone(font))
158                .with_font_size(font_size)
159                .with_color(theme.label_color)
160                .with_align(LabelAlign::Center),
161        )
162    }
163}
164
165impl Widget for Button {
166    fn type_name(&self) -> &'static str { "Button" }
167    fn bounds(&self) -> Rect { self.bounds }
168    fn set_bounds(&mut self, bounds: Rect) { self.bounds = bounds; }
169
170    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
171    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
172
173    fn is_focusable(&self) -> bool { self.is_enabled() }
174
175    fn margin(&self)   -> Insets  { self.base.margin }
176    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
177    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
178    fn min_size(&self) -> Size    { self.base.min_size }
179    fn max_size(&self) -> Size    { self.base.max_size }
180
181    fn layout(&mut self, available: Size) -> Size {
182        let height = (self.font_size * 2.4).max(28.0);
183        // Measure the label first so we can report a "fit" width — label
184        // width plus horizontal padding — instead of stretching to the whole
185        // available width.  This makes Buttons share horizontal space
186        // politely when placed inside a `FlexRow` next to other widgets.
187        // Parents that want a full-width button should wrap in a `SizedBox`
188        // with an explicit width, or set `HAnchor::FILL` — handled by the
189        // flex layout before this method is called.
190        let pad_h = self.font_size * 1.4;
191        let label_size = self.children[0].layout(Size::new(available.width, height));
192        let natural_w = (label_size.width + pad_h).max(48.0);
193        let width = natural_w.min(available.width);
194        let size = Size::new(width, height);
195        let label_x = ((size.width  - label_size.width)  * 0.5).max(0.0);
196        let label_y = ((size.height - label_size.height) * 0.5).max(0.0);
197        self.children[0].set_bounds(Rect::new(label_x, label_y, label_size.width, label_size.height));
198        size
199    }
200
201    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
202        let w = self.bounds.width;
203        let h = self.bounds.height;
204        let r = self.theme.border_radius;
205        let enabled = self.is_enabled();
206
207        // Focus ring (behind the button surface) — skipped when disabled
208        // because the disabled button never actually holds focus.
209        if enabled && self.focused {
210            let ring = self.theme.focus_ring_width;
211            ctx.set_stroke_color(self.theme.focus_ring_color);
212            ctx.set_line_width(ring);
213            ctx.begin_path();
214            ctx.rounded_rect(-ring * 0.5, -ring * 0.5, w + ring, h + ring, r + ring * 0.5);
215            ctx.stroke();
216        }
217
218        // Background — color depends on interaction state.  Disabled state
219        // desaturates the theme colour toward mid-grey so the button still
220        // looks like a button, just clearly inactive.
221        let base_bg = if self.pressed {
222            self.theme.background_pressed
223        } else if self.hovered {
224            self.theme.background_hovered
225        } else {
226            self.theme.background
227        };
228        let bg = if enabled {
229            base_bg
230        } else {
231            let k = 0.45;
232            Color::rgba(
233                base_bg.r * k + 0.5 * (1.0 - k),
234                base_bg.g * k + 0.5 * (1.0 - k),
235                base_bg.b * k + 0.5 * (1.0 - k),
236                base_bg.a,
237            )
238        };
239        ctx.set_fill_color(bg);
240        ctx.begin_path();
241        ctx.rounded_rect(0.0, 0.0, w, h, r);
242        ctx.fill();
243
244        // Text is NOT drawn here. `paint_subtree` recurses into the Label
245        // child automatically after this method returns.
246    }
247
248    fn on_event(&mut self, event: &Event) -> EventResult {
249        if !self.is_enabled() {
250            // Clear any lingering hover / pressed state so the button
251            // looks idle the instant it's disabled mid-interaction.
252            self.hovered = false;
253            self.pressed = false;
254            return EventResult::Ignored;
255        }
256        match event {
257            Event::MouseMove { pos } => {
258                let was_hovered = self.hovered;
259                let was_pressed = self.pressed;
260                self.hovered = self.hit_test(*pos);
261                if !self.hovered {
262                    self.pressed = false;
263                }
264                if was_hovered != self.hovered || was_pressed != self.pressed {
265                    crate::animation::request_tick();
266                }
267                EventResult::Ignored
268            }
269            Event::MouseDown { button: MouseButton::Left, .. } => {
270                if !self.pressed { crate::animation::request_tick(); }
271                self.pressed = true;
272                EventResult::Consumed
273            }
274            Event::MouseUp { button: MouseButton::Left, .. } => {
275                let was_pressed = self.pressed;
276                self.pressed = false;
277                if was_pressed { crate::animation::request_tick(); }
278                if was_pressed && self.hovered {
279                    self.fire_click();
280                    // Click handler almost always mutates app state that
281                    // affects the next paint; request one so the handler's
282                    // side-effects are visible.
283                    crate::animation::request_tick();
284                }
285                EventResult::Consumed
286            }
287            Event::KeyDown { key, .. } => {
288                use crate::event::Key;
289                match key {
290                    Key::Enter | Key::Char(' ') => {
291                        self.fire_click();
292                        crate::animation::request_tick();
293                        EventResult::Consumed
294                    }
295                    _ => EventResult::Ignored,
296                }
297            }
298            Event::FocusGained => {
299                self.focused = true;
300                crate::animation::request_tick();
301                EventResult::Ignored
302            }
303            Event::FocusLost => {
304                self.focused = false;
305                self.pressed = false;
306                crate::animation::request_tick();
307                EventResult::Ignored
308            }
309            _ => EventResult::Ignored,
310        }
311    }
312
313    fn properties(&self) -> Vec<(&'static str, String)> {
314        vec![
315            ("label",     self.label_text.clone()),
316            ("font_size", format!("{:.1}", self.font_size)),
317        ]
318    }
319}