Skip to main content

retroglyph_ui/widget/
button.rs

1//! [`Button`]: a clickable label, styled from an already-resolved [`Response`].
2use retroglyph_core::color::{Color, Style};
3
4use super::{InteractiveWidget, Widget};
5use crate::Align;
6use crate::Response;
7use crate::Sense;
8use crate::Surface;
9use crate::Theme;
10use crate::draw::fill_rect;
11use crate::text::draw_clipped;
12
13/// A filled, centered `label`, styled by a [`Response`] the caller resolves via
14/// [`Interaction::interact`](crate::Interaction::interact) (or, through [`InteractiveWidget`],
15/// has resolved automatically).
16///
17/// `Button` is pure presentation, not a new source of truth: it never calls `interact` itself and
18/// has no `Id` type parameter, unlike `Interaction<Id>`. The app still owns the `Interaction<Id>`
19/// context and decides the button's id: the same division of labor as every other widget here
20/// (state lives outside; the widget only reads it). [`InteractiveWidget::sense`] fixes the
21/// [`Sense`](crate::Sense) this button needs ([`Sense::click`](crate::Sense::click)), so a call
22/// site can't mismatch it:
23///
24/// ```
25/// use retroglyph_core::grid::{Grid, Rect};
26/// use retroglyph_ui::{Button, InteractiveWidget, Interaction, Surface};
27///
28/// #[derive(Clone, Copy, PartialEq, Eq)]
29/// enum Id {
30///     Save,
31/// }
32///
33/// let mut grid = Grid::new(20, 10);
34/// let mut interaction = Interaction::<Id>::new();
35/// interaction.begin_frame();
36/// let area = Rect::new(0, 0, 10, 1);
37/// let button = Button::new("Save");
38/// let response = interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
39/// InteractiveWidget::render(&button, &mut Surface::new(&mut grid, area, 0), &mut (), response);
40/// interaction.end_frame();
41/// ```
42///
43/// Precedence when more than one [`Response`] flag is set at once:
44/// [`disabled`](Response::disabled) > [`pressed`](Response::pressed) >
45/// [`hovered`](Response::hovered) > [`focused`](Response::focused) > the default `style`:
46/// matching the conventional `:disabled` > `:active` > `:hover` > `:focus` ordering, so a
47/// disabled button always reads as muted regardless of a stale hover/press, a press always reads
48/// as pressed even while still hovered, and a keyboard-focused-but-not-hovered button still shows
49/// something distinct from idle.
50///
51/// `style`, `hovered_style`, `pressed_style`, `focused_style`, and `disabled_style` default to
52/// [`Theme::DARK`], as if [`Button::theme`] had been called; set them with
53/// [`Button::style`]/[`Button::hovered_style`]/[`Button::pressed_style`]/
54/// [`Button::focused_style`]/[`Button::disabled_style`].
55#[derive(Clone, Copy, Debug)]
56pub struct Button<'a> {
57    label: &'a str,
58    style: Style,
59    hovered_style: Style,
60    pressed_style: Style,
61    focused_style: Style,
62    disabled_style: Style,
63}
64
65impl<'a> Button<'a> {
66    /// A button labeled `label`, styled from [`Theme::DARK`] (as if [`Button::theme`] had been
67    /// called); set [`Button::theme`]/[`Button::theme_on`] for a different [`Theme`] or one of the
68    /// `_style` setters for a one-off override.
69    #[must_use]
70    pub fn new(label: &'a str) -> Self {
71        Self {
72            label,
73            style: Style::new(),
74            hovered_style: Style::new(),
75            pressed_style: Style::new(),
76            focused_style: Style::new(),
77            disabled_style: Style::new(),
78        }
79        .theme(Theme::DARK)
80    }
81
82    /// Set the default (idle) style.
83    #[must_use]
84    pub const fn style(mut self, style: Style) -> Self {
85        self.style = style;
86        self
87    }
88
89    /// Set the style used while [`Response::hovered`] is `true`.
90    #[must_use]
91    pub const fn hovered_style(mut self, style: Style) -> Self {
92        self.hovered_style = style;
93        self
94    }
95
96    /// Set the style used while [`Response::pressed`] is `true`.
97    #[must_use]
98    pub const fn pressed_style(mut self, style: Style) -> Self {
99        self.pressed_style = style;
100        self
101    }
102
103    /// Set the style used while [`Response::focused`] is `true` (and neither pressed nor
104    /// hovered).
105    #[must_use]
106    pub const fn focused_style(mut self, style: Style) -> Self {
107        self.focused_style = style;
108        self
109    }
110
111    /// Set the style used while [`Response::disabled`] is `true`, regardless of any other
112    /// [`Response`] flag.
113    #[must_use]
114    pub const fn disabled_style(mut self, style: Style) -> Self {
115        self.disabled_style = style;
116        self
117    }
118
119    /// Applies `theme`'s named roles to all four of this button's states: idle becomes
120    /// `theme.fg` on `theme.panel_bg`; hovered/pressed swap in `theme.hover_bg`/`theme.press_bg`
121    /// for the background; focused becomes `theme.accent` on `theme.panel_bg`. The same mapping
122    /// `09_widgets_dashboard`'s "Ping" button hand-threads today.
123    ///
124    /// Call before any manual `_style` override you want to keep.
125    #[must_use]
126    pub fn theme(self, theme: Theme) -> Self {
127        self.theme_on(theme, theme.panel_bg)
128    }
129
130    // `theme`/`theme_on` leave `disabled_style` untouched: `Theme` has one `dim`
131    // role, already used for de-emphasized text elsewhere, and this button's default
132    // `disabled_style` (set in `new`) already matches it. A themed button that wants a different
133    // disabled treatment can still call `disabled_style` after `theme`/`theme_on`, same as any
134    // other override.
135
136    /// Same as [`Button::theme`], but the idle and focused states are drawn on `bg` instead of
137    /// `theme.panel_bg` (`hovered_style`/`pressed_style` still use `theme.hover_bg`/
138    /// `theme.press_bg`, unaffected by `bg`): for a button drawn directly on a backdrop other
139    /// than a themed [`super::Panel`]/[`super::Modal`]'s fill. [`Button::theme`] is exactly
140    /// `theme_on(theme, theme.panel_bg)`.
141    #[must_use]
142    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
143        self.style = Style::new().fg(theme.fg).bg(bg);
144        self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
145        self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
146        self.focused_style = Style::new().fg(theme.accent).bg(bg);
147        self.disabled_style = Style::new().fg(theme.dim).bg(bg);
148        self
149    }
150
151    /// The style this button draws with this frame, per the disabled > pressed > hovered
152    /// > focused > default precedence documented on [`Button`], given `response`.
153    const fn resolved_style<Id>(&self, response: &Response<Id>) -> Style {
154        if response.disabled() {
155            self.disabled_style
156        } else if response.pressed() {
157            self.pressed_style
158        } else if response.hovered() {
159            self.hovered_style
160        } else if response.focused() {
161            self.focused_style
162        } else {
163            self.style
164        }
165    }
166}
167
168impl<Id> InteractiveWidget<Id> for Button<'_> {
169    type State = ();
170
171    fn sense(&self) -> Sense {
172        Sense::click()
173    }
174
175    fn render(&self, surface: &mut Surface<'_>, (): &mut Self::State, response: Response<Id>) {
176        let (width, height) = (surface.width(), surface.height());
177        if width == 0 || height == 0 {
178            return;
179        }
180
181        let style = self.resolved_style(&response);
182        let local_area = surface.area().at_origin();
183        fill_rect(surface, local_area, ' ', style);
184
185        // Center row, biased toward the bottom for even heights (integer division floors).
186        let y = height / 2;
187        let _ = draw_clipped(surface, (0, y), width, self.label, Align::Center, style);
188    }
189}
190
191impl Widget for Button<'_> {
192    /// Draws this button in its idle style: the non-interactive counterpart to
193    /// [`InteractiveWidget::render`], sharing the same drawing routine with
194    /// [`Response::default`] standing in for "nothing happened".
195    fn render(&self, surface: &mut Surface<'_>) {
196        InteractiveWidget::<()>::render(self, surface, &mut (), Response::default());
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use retroglyph_core::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
203    use retroglyph_core::grid::{Grid, Pos, Rect};
204
205    use super::*;
206    use crate::Interaction;
207
208    #[derive(Clone, Copy, PartialEq, Eq)]
209    enum Id {
210        Save,
211    }
212
213    #[test]
214    fn draws_the_label_centered_in_the_idle_style() {
215        let area = Rect::new(0, 0, 7, 1);
216        let mut grid = Grid::new(7, 1);
217        Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
218
219        // "Go" (2 cols) centered in width 7 starts at column (7-2)/2 = 2.
220        assert_eq!(grid[Pos::new(2, 0)].glyph(), 'G');
221        assert_eq!(grid[Pos::new(3, 0)].glyph(), 'o');
222    }
223
224    #[test]
225    fn fills_the_whole_area_with_the_background() {
226        let area = Rect::new(0, 0, 7, 1);
227        let mut grid = Grid::new(7, 1);
228        Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
229
230        let idle_bg = Theme::DARK.panel_bg;
231        assert_eq!(grid[Pos::new(0, 0)].style().background(), idle_bg);
232        assert_eq!(grid[Pos::new(6, 0)].style().background(), idle_bg);
233    }
234
235    #[test]
236    fn pressed_takes_precedence_over_hovered() {
237        let response: Response<()> = Response {
238            hovered: true,
239            pressed: true,
240            ..Response::default()
241        };
242        let button = Button::new("Go");
243        assert_eq!(
244            button.resolved_style(&response).background(),
245            button.pressed_style.background()
246        );
247    }
248
249    #[test]
250    fn hovered_takes_precedence_over_focused() {
251        let response: Response<()> = Response {
252            hovered: true,
253            focused: true,
254            ..Response::default()
255        };
256        let button = Button::new("Go");
257        assert_eq!(
258            button.resolved_style(&response).background(),
259            button.hovered_style.background()
260        );
261    }
262
263    #[test]
264    fn focused_only_shows_when_not_pressed_or_hovered() {
265        let response: Response<()> = Response {
266            focused: true,
267            ..Response::default()
268        };
269        let button = Button::new("Go");
270        assert_eq!(
271            button.resolved_style(&response).background(),
272            button.focused_style.background()
273        );
274    }
275
276    #[test]
277    fn idle_by_default() {
278        let button = Button::new("Go");
279        assert_eq!(
280            button
281                .resolved_style(&Response::<()>::default())
282                .background(),
283            button.style.background()
284        );
285    }
286
287    #[test]
288    fn style_knobs_can_be_overridden() {
289        let custom = Style::new().fg(Color::RED).bg(Color::GREEN);
290        let response: Response<()> = Response {
291            pressed: true,
292            ..Response::default()
293        };
294        let button = Button::new("Go").pressed_style(custom);
295        assert_eq!(button.resolved_style(&response).background(), Color::GREEN);
296    }
297
298    #[test]
299    fn integrates_with_interaction_and_reflects_a_real_click() {
300        let mut interaction = Interaction::<Id>::new();
301        let area = Rect::new(0, 0, 7, 1);
302        let button = Button::new("Go");
303
304        interaction.begin_frame();
305        let _ = interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
306        interaction.end_frame();
307
308        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
309            MouseEventKind::Down(MouseButton::Left),
310            Pos::new(2, 0),
311            KeyModifiers::NONE,
312        )));
313        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
314            MouseEventKind::Up(MouseButton::Left),
315            Pos::new(2, 0),
316            KeyModifiers::NONE,
317        )));
318
319        interaction.begin_frame();
320        let response =
321            interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
322        interaction.end_frame();
323        assert!(response.clicked());
324
325        // The synthetic down+up pair above lands in one `handle_event` batch (see
326        // `Interaction`'s doc comment on this exact edge case), so `pressed` is still `true` on
327        // the same frame `clicked` resolves: `Button` renders with `pressed_style` here, not
328        // idle. Confirms end-to-end wiring (a real click drives a real style pick), not just that
329        // `resolved_style` matches its own precedence rules in isolation (the other tests above).
330        assert_eq!(
331            button.resolved_style(&response).background(),
332            button.pressed_style.background()
333        );
334
335        let mut grid = Grid::new(7, 1);
336        InteractiveWidget::render(
337            &button,
338            &mut Surface::new(&mut grid, area, 0),
339            &mut (),
340            response,
341        );
342    }
343
344    #[test]
345    fn scoped_into_a_narrower_clip_still_centers_against_the_full_area() {
346        let mut grid = Grid::new(10, 1);
347        let full = Rect::new(0, 0, 10, 1);
348        let mut surface = Surface::new(&mut grid, full, 0);
349        // Clip to the right-hand half before scoping: mirrors a caller drawing this button
350        // inside an already-clipped ancestor (e.g. a scrolled panel), then handing it a
351        // sub-surface via `scope` for its own (unclipped-by-that-call) area.
352        let mut clipped = surface.clip(Rect::new(6, 0, 4, 1));
353        Widget::render(&Button::new("Save"), &mut clipped.scope(full));
354
355        // "Save" (4 cols) centered in the full 10-col area starts at column 3, so only its
356        // last column (6) falls inside the narrower clip. A widget that recentered itself
357        // against the clip instead of `area` would draw the whole label starting at column
358        // 6, showing 'S' there instead.
359        assert_eq!(grid[Pos::new(6, 0)].glyph(), 'e');
360        assert_eq!(grid[Pos::new(7, 0)].glyph(), ' ');
361    }
362
363    #[test]
364    fn disabled_style_takes_precedence_over_pressed_and_hovered() {
365        let response: Response<()> = Response {
366            hovered: true,
367            pressed: true,
368            disabled: true,
369            ..Response::default()
370        };
371        let button = Button::new("Go");
372        assert_eq!(button.resolved_style(&response), button.disabled_style);
373    }
374
375    #[test]
376    fn theme_on_maps_dim_onto_disabled_style() {
377        use crate::Theme;
378
379        let button = Button::new("Go").theme_on(Theme::DARK, Color::Default);
380        assert_eq!(button.disabled_style.foreground(), Theme::DARK.dim);
381        assert_eq!(button.disabled_style.background(), Color::Default);
382    }
383
384    #[test]
385    fn zero_size_is_a_no_op() {
386        let area = Rect::new(0, 0, 0, 1);
387        let mut grid = Grid::new(1, 1);
388        Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
389        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
390    }
391
392    #[test]
393    fn theme_maps_named_roles_onto_every_state() {
394        use crate::Theme;
395
396        let response: Response<()> = Response {
397            hovered: true,
398            ..Response::default()
399        };
400        let button = Button::new("Go").theme(Theme::DARK);
401
402        assert_eq!(button.style.foreground(), Theme::DARK.fg);
403        assert_eq!(button.style.background(), Theme::DARK.panel_bg);
404        assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
405        assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
406        assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
407        assert_eq!(
408            button.resolved_style(&response).background(),
409            Theme::DARK.hover_bg
410        );
411    }
412
413    #[test]
414    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
415        use crate::Theme;
416
417        let button = Button::new("Go").theme_on(Theme::DARK, Color::Default);
418
419        assert_eq!(button.style.foreground(), Theme::DARK.fg);
420        assert_eq!(button.style.background(), Color::Default);
421        assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
422        assert_eq!(button.focused_style.background(), Color::Default);
423        // Unaffected by `bg`.
424        assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
425        assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
426    }
427
428    #[test]
429    fn button_wide_label_draws_outside_its_own_area() {
430        let area = Rect::new(0, 0, 4, 1);
431        let mut grid = Grid::new(5, 1);
432        Widget::render(&Button::new("保存"), &mut Surface::new(&mut grid, area, 0));
433
434        // "保存" is exactly 4 columns; the button's own area is the full width, so the label
435        // starts at column 0 and its last continuation cell must land at column 3, not spill
436        // into column 4 (outside the button, clobbering whatever's drawn next to it).
437        assert_eq!(grid[Pos::new(0, 0)].glyph(), '保');
438        assert_eq!(grid[Pos::new(2, 0)].glyph(), '存');
439        assert_eq!(grid[Pos::new(4, 0)].glyph(), ' ');
440    }
441
442    #[test]
443    fn button_centers_a_wide_label_by_display_width() {
444        let area = Rect::new(0, 0, 8, 1);
445        let mut grid = Grid::new(8, 1);
446        Widget::render(&Button::new("保存"), &mut Surface::new(&mut grid, area, 0));
447
448        // "保存" (4 cols) centered in width 8 starts at column (8-4)/2 = 2.
449        assert_eq!(grid[Pos::new(2, 0)].glyph(), '保');
450        assert_eq!(grid[Pos::new(4, 0)].glyph(), '存');
451    }
452}