Skip to main content

retroglyph_widgets/widget/
button.rs

1//! [`Button`]: a clickable label, styled from an already-resolved [`Response`].
2use retroglyph_core::{Color, Rect, Style};
3
4use super::Widget;
5use crate::Response;
6use crate::Surface;
7use crate::Theme;
8use crate::draw::fill_rect;
9use crate::text::truncate as truncate_to_cols;
10
11/// A filled, centered `label`, styled by a [`Response`] the caller already resolved via
12/// [`Interaction::interact`](crate::Interaction::interact).
13///
14/// `Button` is pure presentation, not a new source of truth: it never calls `interact` itself and
15/// has no `Id` type parameter, unlike `Interaction<Id>`. The app still owns the `Interaction<Id>`
16/// context and decides the button's id/[`Sense`](crate::Sense): the same division of labor as
17/// every other widget here (state lives outside; the widget only reads it), applied to the
18/// `interact` module's own doctest pattern ("draw the button, using `response.hovered()`/
19/// `focused()` to pick a style") instead of leaving every call site to hand-roll it:
20///
21/// ```
22/// use retroglyph_core::{Grid, Rect};
23/// use retroglyph_widgets::{Button, Interaction, Sense, Surface, Widget};
24///
25/// #[derive(Clone, Copy, PartialEq, Eq)]
26/// enum Id {
27///     Save,
28/// }
29///
30/// let mut grid = Grid::new(20, 10);
31/// let mut interaction = Interaction::<Id>::new();
32/// interaction.begin_frame();
33/// let area = Rect::new(0, 0, 10, 1);
34/// let response = interaction.interact(area, Id::Save, Sense::click());
35/// Button::new("Save", response).render(area, &mut Surface::new(&mut grid, area, 0));
36/// interaction.end_frame();
37/// ```
38///
39/// Precedence when more than one [`Response`] flag is set at once:
40/// [`pressed`](Response::pressed) &gt; [`hovered`](Response::hovered) &gt;
41/// [`focused`](Response::focused) &gt; the default `style`: matching the conventional
42/// `:active` &gt; `:hover` &gt; `:focus` ordering, so a press always reads as pressed even while
43/// still hovered, and a keyboard-focused-but-not-hovered button still shows something distinct
44/// from idle.
45///
46/// `style`, `hovered_style`, `pressed_style`, and `focused_style` each default to a fixed
47/// palette; set them with [`Button::style`]/[`Button::hovered_style`]/[`Button::pressed_style`]/
48/// [`Button::focused_style`].
49#[derive(Clone, Copy, Debug)]
50pub struct Button<'a> {
51    label: &'a str,
52    response: Response,
53    style: Style,
54    hovered_style: Style,
55    pressed_style: Style,
56    focused_style: Style,
57}
58
59impl<'a> Button<'a> {
60    /// A button labeled `label`, styled from `response`.
61    #[must_use]
62    pub fn new(label: &'a str, response: Response) -> Self {
63        Self {
64            label,
65            response,
66            style: Style::new()
67                .fg(Color::Rgb {
68                    r: 170,
69                    g: 175,
70                    b: 190,
71                })
72                .bg(Color::Rgb {
73                    r: 45,
74                    g: 48,
75                    b: 58,
76                }),
77            hovered_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
78                r: 60,
79                g: 65,
80                b: 80,
81            }),
82            pressed_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
83                r: 40,
84                g: 60,
85                b: 90,
86            }),
87            focused_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
88                r: 55,
89                g: 55,
90                b: 70,
91            }),
92        }
93    }
94
95    /// Set the default (idle) style.
96    #[must_use]
97    pub const fn style(mut self, style: Style) -> Self {
98        self.style = style;
99        self
100    }
101
102    /// Set the style used while [`Response::hovered`] is `true`.
103    #[must_use]
104    pub const fn hovered_style(mut self, style: Style) -> Self {
105        self.hovered_style = style;
106        self
107    }
108
109    /// Set the style used while [`Response::pressed`] is `true`.
110    #[must_use]
111    pub const fn pressed_style(mut self, style: Style) -> Self {
112        self.pressed_style = style;
113        self
114    }
115
116    /// Set the style used while [`Response::focused`] is `true` (and neither pressed nor
117    /// hovered).
118    #[must_use]
119    pub const fn focused_style(mut self, style: Style) -> Self {
120        self.focused_style = style;
121        self
122    }
123
124    /// Applies `theme`'s named roles to all four of this button's states: idle becomes
125    /// `theme.fg` on `theme.panel_bg`; hovered/pressed swap in `theme.hover_bg`/`theme.press_bg`
126    /// for the background; focused becomes `theme.accent` on `theme.panel_bg`. The same mapping
127    /// `09_widgets_dashboard`'s "Ping" button hand-threads today.
128    ///
129    /// Call before any manual `_style` override you want to keep.
130    #[must_use]
131    pub fn theme(self, theme: Theme) -> Self {
132        self.theme_on(theme, theme.panel_bg)
133    }
134
135    /// Same as [`Button::theme`], but the idle and focused states are drawn on `bg` instead of
136    /// `theme.panel_bg` (`hovered_style`/`pressed_style` still use `theme.hover_bg`/
137    /// `theme.press_bg`, unaffected by `bg`): for a button drawn directly on a backdrop other
138    /// than a themed [`super::Panel`]/[`super::Modal`]'s fill. [`Button::theme`] is exactly
139    /// `theme_on(theme, theme.panel_bg)`.
140    #[must_use]
141    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
142        self.style = Style::new().fg(theme.fg).bg(bg);
143        self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
144        self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
145        self.focused_style = Style::new().fg(theme.accent).bg(bg);
146        self
147    }
148
149    /// The style this button draws with this frame, per the
150    /// pressed &gt; hovered &gt; focused &gt; default precedence documented on [`Button`].
151    const fn resolved_style(&self) -> Style {
152        if self.response.pressed() {
153            self.pressed_style
154        } else if self.response.hovered() {
155            self.hovered_style
156        } else if self.response.focused() {
157            self.focused_style
158        } else {
159            self.style
160        }
161    }
162}
163
164impl Widget for Button<'_> {
165    fn render(&self, area: Rect, surface: &mut Surface<'_>) {
166        if area.width() == 0 || area.height() == 0 {
167            return;
168        }
169
170        let style = self.resolved_style();
171        fill_rect(surface, area, ' ', style);
172
173        let text = truncate_to_cols(self.label, area.width_usize());
174        // `truncate_to_cols` bounds `text` to `area.width_usize()` columns, which is itself a
175        // `u16` widened by `.width_usize()`, so narrowing the count back is always exact.
176        #[allow(clippy::cast_possible_truncation)]
177        let text_width = text.chars().count() as u16;
178        let x = area.left() + (area.width().saturating_sub(text_width)) / 2;
179        let y = area.top() + area.height() / 2;
180
181        surface.print((x, y), text, style);
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use retroglyph_core::{
188        Event, Grid, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, Pos,
189    };
190
191    use super::*;
192    use crate::{Interaction, Sense};
193
194    #[derive(Clone, Copy, PartialEq, Eq)]
195    enum Id {
196        Save,
197    }
198
199    #[test]
200    fn draws_the_label_centered_in_the_idle_style() {
201        let area = Rect::new(0, 0, 7, 1);
202        let mut grid = Grid::new(7, 1);
203        Button::new("Go", Response::default()).render(area, &mut Surface::new(&mut grid, area, 0));
204
205        // "Go" (2 cols) centered in width 7 starts at column (7-2)/2 = 2.
206        assert_eq!(grid[Pos::new(2, 0)].glyph(), 'G');
207        assert_eq!(grid[Pos::new(3, 0)].glyph(), 'o');
208    }
209
210    #[test]
211    fn fills_the_whole_area_with_the_background() {
212        let area = Rect::new(0, 0, 7, 1);
213        let mut grid = Grid::new(7, 1);
214        Button::new("Go", Response::default()).render(area, &mut Surface::new(&mut grid, area, 0));
215
216        let idle_bg = Style::new()
217            .fg(Color::Rgb {
218                r: 170,
219                g: 175,
220                b: 190,
221            })
222            .bg(Color::Rgb {
223                r: 45,
224                g: 48,
225                b: 58,
226            })
227            .background();
228        assert_eq!(grid[Pos::new(0, 0)].style().background(), idle_bg);
229        assert_eq!(grid[Pos::new(6, 0)].style().background(), idle_bg);
230    }
231
232    #[test]
233    fn pressed_takes_precedence_over_hovered() {
234        let response = Response {
235            hovered: true,
236            pressed: true,
237            ..Response::default()
238        };
239        let button = Button::new("Go", response);
240        assert_eq!(
241            button.resolved_style().background(),
242            button.pressed_style.background()
243        );
244    }
245
246    #[test]
247    fn hovered_takes_precedence_over_focused() {
248        let response = Response {
249            hovered: true,
250            focused: true,
251            ..Response::default()
252        };
253        let button = Button::new("Go", response);
254        assert_eq!(
255            button.resolved_style().background(),
256            button.hovered_style.background()
257        );
258    }
259
260    #[test]
261    fn focused_only_shows_when_not_pressed_or_hovered() {
262        let response = Response {
263            focused: true,
264            ..Response::default()
265        };
266        let button = Button::new("Go", response);
267        assert_eq!(
268            button.resolved_style().background(),
269            button.focused_style.background()
270        );
271    }
272
273    #[test]
274    fn idle_by_default() {
275        let button = Button::new("Go", Response::default());
276        assert_eq!(
277            button.resolved_style().background(),
278            button.style.background()
279        );
280    }
281
282    #[test]
283    fn style_knobs_can_be_overridden() {
284        let custom = Style::new().fg(Color::RED).bg(Color::GREEN);
285        let response = Response {
286            pressed: true,
287            ..Response::default()
288        };
289        let button = Button::new("Go", response).pressed_style(custom);
290        assert_eq!(button.resolved_style().background(), Color::GREEN);
291    }
292
293    #[test]
294    fn integrates_with_interaction_and_reflects_a_real_click() {
295        let mut interaction = Interaction::<Id>::new();
296        let area = Rect::new(0, 0, 7, 1);
297
298        interaction.begin_frame();
299        let _ = interaction.interact(area, Id::Save, Sense::click());
300        interaction.end_frame();
301
302        interaction.handle_event(&Event::Mouse(MouseEvent {
303            kind: MouseEventKind::Down(MouseButton::Left),
304            position: Pos::new(2, 0),
305            pixel_position: None,
306            modifiers: KeyModifiers::NONE,
307        }));
308        interaction.handle_event(&Event::Mouse(MouseEvent {
309            kind: MouseEventKind::Up(MouseButton::Left),
310            position: Pos::new(2, 0),
311            pixel_position: None,
312            modifiers: KeyModifiers::NONE,
313        }));
314
315        interaction.begin_frame();
316        let response = interaction.interact(area, Id::Save, Sense::click());
317        interaction.end_frame();
318        assert!(response.clicked());
319
320        // The synthetic down+up pair above lands in one `handle_event` batch (see
321        // `Interaction`'s doc comment on this exact edge case), so `pressed` is still `true` on
322        // the same frame `clicked` resolves: `Button` renders with `pressed_style` here, not
323        // idle. Confirms end-to-end wiring (a real click drives a real style pick), not just that
324        // `resolved_style` matches its own precedence rules in isolation (the other tests above).
325        let button = Button::new("Go", response);
326        assert_eq!(
327            button.resolved_style().background(),
328            button.pressed_style.background()
329        );
330
331        let mut grid = Grid::new(7, 1);
332        button.render(area, &mut Surface::new(&mut grid, area, 0));
333    }
334
335    #[test]
336    fn zero_size_is_a_no_op() {
337        let area = Rect::new(0, 0, 0, 1);
338        let mut grid = Grid::new(1, 1);
339        Button::new("Go", Response::default()).render(area, &mut Surface::new(&mut grid, area, 0));
340        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
341    }
342
343    #[test]
344    fn theme_maps_named_roles_onto_every_state() {
345        use crate::Theme;
346
347        let response = Response {
348            hovered: true,
349            ..Response::default()
350        };
351        let button = Button::new("Go", response).theme(Theme::DARK);
352
353        assert_eq!(button.style.foreground(), Theme::DARK.fg);
354        assert_eq!(button.style.background(), Theme::DARK.panel_bg);
355        assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
356        assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
357        assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
358        assert_eq!(button.resolved_style().background(), Theme::DARK.hover_bg);
359    }
360
361    #[test]
362    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
363        use crate::Theme;
364
365        let button = Button::new("Go", Response::default()).theme_on(Theme::DARK, Color::Default);
366
367        assert_eq!(button.style.foreground(), Theme::DARK.fg);
368        assert_eq!(button.style.background(), Color::Default);
369        assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
370        assert_eq!(button.focused_style.background(), Color::Default);
371        // Unaffected by `bg`.
372        assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
373        assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
374    }
375}