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