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(mut self, theme: Theme) -> Self {
131        self.style = Style::new().fg(theme.fg).bg(theme.panel_bg);
132        self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
133        self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
134        self.focused_style = Style::new().fg(theme.accent).bg(theme.panel_bg);
135        self
136    }
137
138    /// The style this button draws with this frame, per the
139    /// pressed &gt; hovered &gt; focused &gt; default precedence documented on [`Button`].
140    const fn resolved_style(&self) -> Style {
141        if self.response.pressed() {
142            self.pressed_style
143        } else if self.response.hovered() {
144            self.hovered_style
145        } else if self.response.focused() {
146            self.focused_style
147        } else {
148            self.style
149        }
150    }
151}
152
153impl<B: Backend> Widget<B> for Button<'_> {
154    fn render(self, area: Rect, term: &mut Terminal<B>) {
155        if area.width() == 0 || area.height() == 0 {
156            return;
157        }
158
159        let style = self.resolved_style();
160        fill_rect(term, area, ' ', style);
161
162        let text = truncate_to_cols(self.label, area.width_usize());
163        let text_width = text.chars().count() as u16;
164        let x = area.left() + (area.width().saturating_sub(text_width)) / 2;
165        let y = area.top() + area.height() / 2;
166
167        term.reset_style()
168            .fg(style.foreground())
169            .bg(style.background());
170        term.print(x, y, &text);
171        term.reset_style();
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use retroglyph_core::{
178        Event, Headless, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, Pos,
179    };
180
181    use super::*;
182    use crate::{Interaction, Sense};
183
184    #[derive(Clone, Copy, PartialEq, Eq)]
185    enum Id {
186        Save,
187    }
188
189    #[test]
190    fn draws_the_label_centered_in_the_idle_style() {
191        let area = Rect::new(0, 0, 7, 1);
192        let mut term = Terminal::new(Headless::new(7, 1));
193        Button::new("Go", Response::default()).render(area, &mut term);
194
195        // "Go" (2 cols) centered in width 7 starts at column (7-2)/2 = 2.
196        assert_eq!(term.grid().get(2, 0).glyph(), 'G');
197        assert_eq!(term.grid().get(3, 0).glyph(), 'o');
198    }
199
200    #[test]
201    fn fills_the_whole_area_with_the_background() {
202        let area = Rect::new(0, 0, 7, 1);
203        let mut term = Terminal::new(Headless::new(7, 1));
204        Button::new("Go", Response::default()).render(area, &mut term);
205
206        let idle_bg = Style::new()
207            .fg(Color::Rgb {
208                r: 170,
209                g: 175,
210                b: 190,
211            })
212            .bg(Color::Rgb {
213                r: 45,
214                g: 48,
215                b: 58,
216            })
217            .background();
218        assert_eq!(term.grid().get(0, 0).style().background(), idle_bg);
219        assert_eq!(term.grid().get(6, 0).style().background(), idle_bg);
220    }
221
222    #[test]
223    fn pressed_takes_precedence_over_hovered() {
224        let response = Response {
225            hovered: true,
226            pressed: true,
227            ..Response::default()
228        };
229        let button = Button::new("Go", response);
230        assert_eq!(
231            button.resolved_style().background(),
232            button.pressed_style.background()
233        );
234    }
235
236    #[test]
237    fn hovered_takes_precedence_over_focused() {
238        let response = Response {
239            hovered: true,
240            focused: true,
241            ..Response::default()
242        };
243        let button = Button::new("Go", response);
244        assert_eq!(
245            button.resolved_style().background(),
246            button.hovered_style.background()
247        );
248    }
249
250    #[test]
251    fn focused_only_shows_when_not_pressed_or_hovered() {
252        let response = Response {
253            focused: true,
254            ..Response::default()
255        };
256        let button = Button::new("Go", response);
257        assert_eq!(
258            button.resolved_style().background(),
259            button.focused_style.background()
260        );
261    }
262
263    #[test]
264    fn idle_by_default() {
265        let button = Button::new("Go", Response::default());
266        assert_eq!(
267            button.resolved_style().background(),
268            button.style.background()
269        );
270    }
271
272    #[test]
273    fn style_knobs_can_be_overridden() {
274        let custom = Style::new().fg(Color::RED).bg(Color::GREEN);
275        let response = Response {
276            pressed: true,
277            ..Response::default()
278        };
279        let button = Button::new("Go", response).pressed_style(custom);
280        assert_eq!(button.resolved_style().background(), Color::GREEN);
281    }
282
283    #[test]
284    fn integrates_with_interaction_and_reflects_a_real_click() {
285        let mut interaction = Interaction::<Id>::new();
286        let area = Rect::new(0, 0, 7, 1);
287
288        interaction.begin_frame();
289        let _ = interaction.interact(area, Id::Save, Sense::click());
290        interaction.end_frame();
291
292        interaction.handle_event(&Event::Mouse(MouseEvent {
293            kind: MouseEventKind::Down(MouseButton::Left),
294            position: Pos::new(2, 0),
295            pixel_position: None,
296            modifiers: KeyModifiers::NONE,
297        }));
298        interaction.handle_event(&Event::Mouse(MouseEvent {
299            kind: MouseEventKind::Up(MouseButton::Left),
300            position: Pos::new(2, 0),
301            pixel_position: None,
302            modifiers: KeyModifiers::NONE,
303        }));
304
305        interaction.begin_frame();
306        let response = interaction.interact(area, Id::Save, Sense::click());
307        interaction.end_frame();
308        assert!(response.clicked());
309
310        // The synthetic down+up pair above lands in one `handle_event` batch (see
311        // `Interaction`'s doc comment on this exact edge case), so `pressed` is still `true` on
312        // the same frame `clicked` resolves -- `Button` renders with `pressed_style` here, not
313        // idle. Confirms end-to-end wiring (a real click drives a real style pick), not just that
314        // `resolved_style` matches its own precedence rules in isolation (the other tests above).
315        let button = Button::new("Go", response);
316        assert_eq!(
317            button.resolved_style().background(),
318            button.pressed_style.background()
319        );
320
321        let mut term = Terminal::new(Headless::new(7, 1));
322        button.render(area, &mut term);
323    }
324
325    #[test]
326    fn zero_size_is_a_no_op() {
327        let area = Rect::new(0, 0, 0, 1);
328        let mut term = Terminal::new(Headless::new(1, 1));
329        Button::new("Go", Response::default()).render(area, &mut term);
330        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
331    }
332
333    #[test]
334    fn theme_maps_named_roles_onto_every_state() {
335        use crate::Theme;
336
337        let response = Response {
338            hovered: true,
339            ..Response::default()
340        };
341        let button = Button::new("Go", response).theme(Theme::DARK);
342
343        assert_eq!(button.style.foreground(), Theme::DARK.fg);
344        assert_eq!(button.style.background(), Theme::DARK.panel_bg);
345        assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
346        assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
347        assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
348        assert_eq!(button.resolved_style().background(), Theme::DARK.hover_bg);
349    }
350}