Skip to main content

retroglyph_widgets/widget/
button.rs

1//! [`Button`]: a clickable label, styled from an already-resolved [`Response`].
2use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::Widget;
5use crate::Response;
6use crate::Theme;
7use crate::draw::fill_rect;
8use crate::text::truncate as truncate_to_cols;
9
10/// A filled, centered `label`, styled by a [`Response`] the caller already resolved via
11/// [`Interaction::interact`](crate::Interaction::interact).
12///
13/// `Button` is pure presentation, not a new source of truth: it never calls `interact` itself and
14/// has no `Id` type parameter, unlike `Interaction<Id>`. The app still owns the `Interaction<Id>`
15/// context and decides the button's id/[`Sense`](crate::Sense) -- the same division of labor as
16/// every other widget here (state lives outside; the widget only reads it), applied to the
17/// `interact` module's own doctest pattern ("draw the button, using `response.hovered()`/
18/// `focused()` to pick a style") instead of leaving every call site to hand-roll it:
19///
20/// ```
21/// use retroglyph_core::{Backend, Headless, Rect, Terminal};
22/// use retroglyph_widgets::{Button, Interaction, Sense, Widget};
23///
24/// #[derive(Clone, Copy, PartialEq, Eq)]
25/// enum Id {
26///     Save,
27/// }
28///
29/// let mut term = Terminal::new(Headless::new(20, 10));
30/// let mut interaction = Interaction::<Id>::new();
31/// interaction.begin_frame();
32/// let area = Rect::new(0, 0, 10, 1);
33/// let response = interaction.interact(area, Id::Save, Sense::click());
34/// Button::new("Save", response).render(area, &mut term);
35/// interaction.end_frame();
36/// ```
37///
38/// Precedence when more than one [`Response`] flag is set at once:
39/// [`pressed`](Response::pressed) &gt; [`hovered`](Response::hovered) &gt;
40/// [`focused`](Response::focused) &gt; the default `style` -- matching the conventional
41/// `:active` &gt; `:hover` &gt; `:focus` ordering, so a press always reads as pressed even while
42/// still hovered, and a keyboard-focused-but-not-hovered button still shows something distinct
43/// from idle.
44///
45/// `style`, `hovered_style`, `pressed_style`, and `focused_style` each default to a fixed
46/// palette; set them with [`Button::style`]/[`Button::hovered_style`]/[`Button::pressed_style`]/
47/// [`Button::focused_style`].
48#[derive(Clone, Copy, Debug)]
49pub struct Button<'a> {
50    label: &'a str,
51    response: Response,
52    style: Style,
53    hovered_style: Style,
54    pressed_style: Style,
55    focused_style: Style,
56}
57
58impl<'a> Button<'a> {
59    /// A button labeled `label`, styled from `response`.
60    #[must_use]
61    pub fn new(label: &'a str, response: Response) -> Self {
62        Self {
63            label,
64            response,
65            style: Style::new()
66                .fg(Color::Rgb {
67                    r: 170,
68                    g: 175,
69                    b: 190,
70                })
71                .bg(Color::Rgb {
72                    r: 45,
73                    g: 48,
74                    b: 58,
75                }),
76            hovered_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
77                r: 60,
78                g: 65,
79                b: 80,
80            }),
81            pressed_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
82                r: 40,
83                g: 60,
84                b: 90,
85            }),
86            focused_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
87                r: 55,
88                g: 55,
89                b: 70,
90            }),
91        }
92    }
93
94    /// Set the default (idle) style.
95    #[must_use]
96    pub const fn style(mut self, style: Style) -> Self {
97        self.style = style;
98        self
99    }
100
101    /// Set the style used while [`Response::hovered`] is `true`.
102    #[must_use]
103    pub const fn hovered_style(mut self, style: Style) -> Self {
104        self.hovered_style = style;
105        self
106    }
107
108    /// Set the style used while [`Response::pressed`] is `true`.
109    #[must_use]
110    pub const fn pressed_style(mut self, style: Style) -> Self {
111        self.pressed_style = style;
112        self
113    }
114
115    /// Set the style used while [`Response::focused`] is `true` (and neither pressed nor
116    /// hovered).
117    #[must_use]
118    pub const fn focused_style(mut self, style: Style) -> Self {
119        self.focused_style = style;
120        self
121    }
122
123    /// Applies `theme`'s named roles to all four of this button's states: idle becomes
124    /// `theme.fg` on `theme.panel_bg`; hovered/pressed swap in `theme.hover_bg`/`theme.press_bg`
125    /// for the background; focused becomes `theme.accent` on `theme.panel_bg`. The same mapping
126    /// `09_widgets_dashboard`'s "Ping" button hand-threads today.
127    ///
128    /// Call before any manual `_style` override you want to keep.
129    #[must_use]
130    pub fn theme(self, theme: Theme) -> Self {
131        self.theme_on(theme, theme.panel_bg)
132    }
133
134    /// Same as [`Button::theme`], but the idle and focused states are drawn on `bg` instead of
135    /// `theme.panel_bg` (`hovered_style`/`pressed_style` still use `theme.hover_bg`/
136    /// `theme.press_bg`, unaffected by `bg`) -- for a button drawn directly on a backdrop other
137    /// than a themed [`super::Panel`]/[`super::Modal`]'s fill. [`Button::theme`] is exactly
138    /// `theme_on(theme, theme.panel_bg)`.
139    #[must_use]
140    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
141        self.style = Style::new().fg(theme.fg).bg(bg);
142        self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
143        self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
144        self.focused_style = Style::new().fg(theme.accent).bg(bg);
145        self
146    }
147
148    /// The style this button draws with this frame, per the
149    /// pressed &gt; hovered &gt; focused &gt; default precedence documented on [`Button`].
150    const fn resolved_style(&self) -> Style {
151        if self.response.pressed() {
152            self.pressed_style
153        } else if self.response.hovered() {
154            self.hovered_style
155        } else if self.response.focused() {
156            self.focused_style
157        } else {
158            self.style
159        }
160    }
161}
162
163impl<B: Backend> Widget<B> for Button<'_> {
164    fn render(self, area: Rect, term: &mut Terminal<B>) {
165        if area.width() == 0 || area.height() == 0 {
166            return;
167        }
168
169        let style = self.resolved_style();
170        fill_rect(term, area, ' ', style);
171
172        let text = truncate_to_cols(self.label, area.width_usize());
173        let text_width = text.chars().count() as u16;
174        let x = area.left() + (area.width().saturating_sub(text_width)) / 2;
175        let y = area.top() + area.height() / 2;
176
177        term.reset_style()
178            .fg(style.foreground())
179            .bg(style.background());
180        term.print(x, y, text);
181        term.reset_style();
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use retroglyph_core::{
188        Event, Headless, 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 term = Terminal::new(Headless::new(7, 1));
203        Button::new("Go", Response::default()).render(area, &mut term);
204
205        // "Go" (2 cols) centered in width 7 starts at column (7-2)/2 = 2.
206        assert_eq!(term.grid().get(2, 0).glyph(), 'G');
207        assert_eq!(term.grid().get(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 term = Terminal::new(Headless::new(7, 1));
214        Button::new("Go", Response::default()).render(area, &mut term);
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!(term.grid().get(0, 0).style().background(), idle_bg);
229        assert_eq!(term.grid().get(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 term = Terminal::new(Headless::new(7, 1));
332        button.render(area, &mut term);
333    }
334
335    #[test]
336    fn zero_size_is_a_no_op() {
337        let area = Rect::new(0, 0, 0, 1);
338        let mut term = Terminal::new(Headless::new(1, 1));
339        Button::new("Go", Response::default()).render(area, &mut term);
340        assert_eq!(term.grid().get(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}