Skip to main content

qframe/widgets/
button.rs

1//! Buttons.
2
3use super::cells;
4use super::press::{self, Press};
5use crate::event::Event;
6use crate::geometry::{Rect, Size};
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11/// A button: the shape is its surface colour, never brackets.
12///
13/// Sends its message on Enter or Space while focused, or on a click released over it, and
14/// flashes one tone brighter to confirm. Hovered and focused buttons show the pillar in their very
15/// first cell, before the shortcut segment and the icon (`▌ ⏎ Save`); the cell is reserved at rest,
16/// so nothing moves when the pillar appears. Like every widget that is not a list, buttons never
17/// slide. A theme padding of zero columns leaves no room for the pillar and draws none. Style keys
18/// (`pillar` sets the pillar colour): `button`, `button.<variant>`, states `hover`, `focus`,
19/// `pressed`, `disabled`; the shortcut segment uses `button-key` and `button-key.<variant>`.
20pub struct Button<Msg> {
21    label: String,
22    icon: Option<String>,
23    shortcut: Option<String>,
24    variant: Option<String>,
25    disabled: bool,
26    loading: bool,
27    selected: Option<bool>,
28    on_press: Option<Msg>,
29}
30
31impl<Msg> Button<Msg> {
32    /// A button labelled `label`.
33    #[must_use]
34    pub fn new(label: impl Into<String>) -> Self {
35        Self {
36            label: label.into(),
37            icon: None,
38            shortcut: None,
39            variant: None,
40            disabled: false,
41            loading: false,
42            selected: None,
43            on_press: None,
44        }
45    }
46
47    /// The message sent when the button is pressed.
48    #[must_use]
49    pub fn on_press(mut self, message: Msg) -> Self {
50        self.on_press = Some(message);
51        self
52    }
53
54    /// Theme variant, e.g. `"primary"` or `"danger"`.
55    #[must_use]
56    pub fn variant(mut self, variant: impl Into<String>) -> Self {
57        self.variant = Some(variant.into());
58        self
59    }
60
61    /// Icon key drawn before the label.
62    #[must_use]
63    pub fn icon(mut self, key: impl Into<String>) -> Self {
64        self.icon = Some(key.into());
65        self
66    }
67
68    /// Key label drawn in a darker segment on the left, e.g. `"⏎"` or `"ctrl s"`. The segment
69    /// starts with the button's pillar cell, so the pillar stays the leftmost mark.
70    #[must_use]
71    pub fn shortcut(mut self, label: impl Into<String>) -> Self {
72        self.shortcut = Some(label.into());
73        self
74    }
75
76    /// Greys the button out; it cannot be focused or pressed.
77    #[must_use]
78    pub fn disabled(mut self, disabled: bool) -> Self {
79        self.disabled = disabled;
80        self
81    }
82
83    /// Shows a spinner in the default spinner style instead of the icon and ignores presses.
84    #[must_use]
85    pub fn loading(mut self, loading: bool) -> Self {
86        self.loading = loading;
87        self
88    }
89
90    /// Makes this a choice button, such as one of a few view modes: `selected` shows it chosen,
91    /// raised with a steady pillar. Choosing is its own feedback, so a choice button does not
92    /// flash when pressed.
93    #[must_use]
94    pub fn selected(mut self, selected: bool) -> Self {
95        self.selected = Some(selected);
96        self
97    }
98
99    fn active(&self) -> bool {
100        !self.disabled && !self.loading && self.on_press.is_some()
101    }
102}
103
104/// Width of the shortcut segment for `key`: the pillar cell when the padding leaves room for a
105/// pillar, then the key with a space on each side.
106fn key_width(key: &str, horizontal_padding: u16) -> u16 {
107    cells::sum([u16::from(horizontal_padding >= 1), text::width(key), 2])
108}
109
110impl<Msg: Clone + 'static> Button<Msg> {
111    fn press(&self, cx: &mut EventCx<'_, Msg>) {
112        if let Some(message) = &self.on_press {
113            if self.selected.is_none() {
114                cx.flash();
115            }
116            cx.emit(message.clone());
117        }
118    }
119}
120
121impl<Msg: Clone + 'static> Widget<Msg> for Button<Msg> {
122    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
123        let theme = cx.env().theme();
124        let style = theme.style("button", self.variant.as_deref(), &[]);
125        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 2));
126        let icon_width = self
127            .icon
128            .as_deref()
129            .map_or(0, |key| text::width(&cx.env().icons().glyph(key)) + 1)
130            .max(if self.loading { 2 } else { 0 });
131        let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, horizontal));
132        let width = cells::sum([shortcut_width, horizontal.saturating_mul(2), icon_width, text::width(&self.label)]);
133        Size::new(width, vertical.saturating_mul(2).saturating_add(1)).min(available)
134    }
135
136    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
137        // A loading button ignores presses but keeps its hover and focus look, so a short job does
138        // not make it blink.
139        let interactive = !self.disabled && self.on_press.is_some();
140        // A clicked button stays calm under the pointer; its focus breathes only when it was
141        // reached with the keyboard.
142        let mut states = if interactive { cx.pressable_states() } else { Vec::new() };
143        if self.selected == Some(true) {
144            states.push(State::Selected);
145        }
146        if self.disabled {
147            states.push(State::Disabled);
148        }
149        let variant = self.variant.as_deref();
150        let style = cx.style("button", variant, &states);
151        let surface = style.text();
152        let background = surface.bg.unwrap_or_else(|| cx.color("raised"));
153        cx.clear(area, background);
154        if interactive {
155            cx.register_hit(area);
156        }
157
158        let padding = style.padding();
159        let y = area.y + i32::from(padding.top);
160        let mut x = area.x;
161        let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, padding.left));
162        if let Some(shortcut) = &self.shortcut {
163            let key_style = cx.style("button-key", variant, &states).text();
164            cx.clear(Rect::new(x, area.y, shortcut_width, area.height), key_style.bg.unwrap_or(background));
165            let label = format!(" {shortcut} ");
166            let label_width = text::width(&label);
167            cx.text(x + i32::from(shortcut_width - label_width), y, &label, key_style, label_width);
168        }
169        // Hover and focus raise the pillar in the button's first cell: the leftmost mark of the
170        // raised thing, before the shortcut segment. Buttons never slide: only list structures
171        // do, so every button behaves the same whatever its width.
172        let pillar = style.color("pillar").filter(|_| padding.left >= 1);
173        if let Some(color) = pillar {
174            cx.pillar(x, y, color);
175        }
176        x += i32::from(shortcut_width);
177        x += i32::from(padding.left);
178        let right = area.right() - i32::from(padding.right);
179        let budget = |x: i32| crate::geometry::clamp_u16(right - x);
180
181        if self.loading {
182            // The same turning arc as a plain `Spinner`, so busy states look alike everywhere.
183            let spinner = super::SpinnerStyle::default().animation();
184            let cell = cx.animation(spinner, surface, Some(std::time::Duration::ZERO));
185            x += i32::from(cx.text(x, y, &cell.glyph, cell.style, budget(x).min(1)));
186            x += 1;
187        } else if let Some(icon) = &self.icon {
188            let glyph = cx.env().icons().glyph(icon).into_owned();
189            x += i32::from(cx.text(x, y, &glyph, surface, budget(x)));
190            x += 1;
191        }
192        let label = text::truncate(&self.label, budget(x)).into_owned();
193        cx.text(x, y, &label, surface, budget(x));
194    }
195
196    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
197        if !self.active() {
198            return false;
199        }
200        match press::read(cx, event) {
201            Press::Ignored => false,
202            Press::Used => true,
203            Press::Key | Press::Click(..) => {
204                self.press(cx);
205                true
206            }
207        }
208    }
209
210    fn focusable(&self) -> bool {
211        self.active()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::event::{MouseButton, MouseKind};
219    use crate::runtime::{App, Command, Harness};
220    use crate::widget::View;
221    use std::time::Duration;
222
223    #[derive(Default)]
224    struct Demo {
225        presses: u32,
226        disabled: bool,
227        loading: bool,
228    }
229
230    impl App for Demo {
231        type Msg = ();
232        fn update(&mut self, _: ()) -> Command<()> {
233            self.presses += 1;
234            Command::none()
235        }
236        fn view(&self, ui: &mut View<'_, ()>) {
237            ui.row(|ui| {
238                ui.add(Button::new("Save").variant("primary").shortcut("⏎").on_press(()).disabled(self.disabled))
239                    .id("save");
240                ui.add(Button::new("Wait").loading(self.loading).on_press(()));
241            })
242            .gap(1);
243        }
244    }
245
246    #[test]
247    fn draws_without_brackets() {
248        let h = Harness::new(Demo::default(), 24, 1);
249        assert_eq!(h.screen(), "  ⏎   Save     Wait\n");
250        let theme = h.env().theme();
251        assert_ne!(h.bg(6, 0), theme.color("accent"), "a resting primary button is a tint, not the full fill");
252        assert_eq!(h.fg(6, 0), theme.color("accent"));
253    }
254
255    /// The sum of a colour's channels, to compare how bright two tones are.
256    fn brightness(color: Option<crate::color::Rgb>) -> u32 {
257        color.map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b))
258    }
259
260    #[test]
261    fn tones_climb_from_rest_to_hover_to_press_and_never_invert() {
262        let mut h = Harness::new(Demo::default(), 24, 1);
263        let rest = h.bg(6, 0);
264        h.hover(6, 0);
265        let hover = h.bg(6, 0);
266        h.mouse(MouseKind::Down(MouseButton::Left), 6, 0).mouse(MouseKind::Up(MouseButton::Left), 6, 0);
267        let pressed = h.bg(6, 0);
268        assert!(brightness(rest) < brightness(hover) && brightness(hover) < brightness(pressed));
269        assert_eq!(h.fg(6, 0), h.env().theme().color("accent"), "the label keeps its colour while pressed");
270        h.advance(Duration::from_millis(200));
271        assert_eq!(h.bg(6, 0), hover, "a clicked button settles back to its hover tone, calm under the pointer");
272    }
273
274    #[test]
275    fn choice_buttons_show_selection_and_do_not_flash() {
276        struct Modes(usize);
277        impl App for Modes {
278            type Msg = usize;
279            fn update(&mut self, mode: usize) -> Command<usize> {
280                self.0 = mode;
281                Command::none()
282            }
283            fn view(&self, ui: &mut View<'_, usize>) {
284                ui.row(|ui| {
285                    ui.add(Button::new("List").selected(self.0 == 0).on_press(0));
286                    ui.add(Button::new("Grid").selected(self.0 == 1).on_press(1));
287                });
288            }
289        }
290        let mut h = Harness::new(Modes(0), 20, 1);
291        assert!(h.screen().starts_with("▌ List"), "the chosen one carries a steady pillar: {}", h.screen());
292        h.click_text("Grid");
293        assert_eq!(h.app().0, 1);
294        let chosen = h.bg(12, 0);
295        h.advance(Duration::from_millis(200));
296        assert_eq!(h.bg(12, 0), chosen, "no flash: the selection itself is the feedback");
297    }
298
299    #[test]
300    fn presses_by_keyboard_and_click_and_flashes() {
301        let mut h = Harness::new(Demo::default(), 24, 1);
302        h.press("tab");
303        assert!(h.is_focused("save"));
304        let focused = h.bg(6, 0);
305        h.press("enter");
306        assert_eq!(h.app().presses, 1);
307        let flash = h.bg(6, 0);
308        assert!(brightness(flash) > brightness(focused), "a press flashes one tone brighter");
309        h.advance(Duration::from_millis(200));
310        assert_eq!(h.bg(6, 0), focused);
311        h.click_text("Save");
312        assert_eq!(h.app().presses, 2);
313    }
314
315    #[test]
316    fn hover_and_focus_raise_the_pillar_without_sliding() {
317        let mut h = Harness::new(Demo::default(), 24, 1);
318        assert_eq!(h.screen(), "  ⏎   Save     Wait\n");
319        h.hover(15, 0);
320        assert_eq!(h.screen(), "  ⏎   Save   ▌ Wait\n", "the pillar rises; the label stays where it is");
321        h.hover(23, 0);
322        h.press("tab");
323        assert_eq!(h.screen(), "▌ ⏎   Save     Wait\n", "the pillar comes before the shortcut segment");
324        let theme = h.env().theme();
325        assert_ne!(h.fg(0, 0), theme.color("ink"), "the pillar stays bright on the tint");
326        assert_eq!(h.bg(0, 0), h.bg(2, 0), "the pillar cell belongs to the shortcut segment");
327        h.set_reduced_motion(true);
328        assert_eq!(h.screen(), "▌ ⏎   Save     Wait\n");
329    }
330
331    /// Buttons with a shortcut, an icon or both, side by side.
332    struct Marked;
333
334    impl App for Marked {
335        type Msg = ();
336        fn update(&mut self, _: ()) -> Command<()> {
337            Command::none()
338        }
339        fn view(&self, ui: &mut View<'_, ()>) {
340            ui.column(|ui| {
341                ui.add(Button::new("Open palette").shortcut("ctrl p").on_press(()));
342                ui.add(Button::new("Deploy").icon("check").on_press(()));
343                ui.add(Button::new("Save").icon("check").shortcut("⏎").variant("primary").on_press(()));
344            });
345        }
346    }
347
348    #[test]
349    fn the_pillar_is_the_leftmost_cell_with_a_shortcut_an_icon_or_both() {
350        let mut h = Harness::new(Marked, 30, 3);
351        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
352        let rest = h.screen();
353        assert_eq!(rest, "  ctrl p   Open palette\n  ✓ Deploy\n  ⏎   ✓ Save\n");
354        for (row, hovered) in ["▌ ctrl p   Open palette", "▌ ✓ Deploy", "▌ ⏎   ✓ Save"].into_iter().enumerate()
355        {
356            let y = i32::try_from(row).unwrap_or(0);
357            h.hover(3, y);
358            let line = h.screen().lines().nth(row).unwrap_or_default().to_owned();
359            assert_eq!(line, hovered, "hover draws the pillar first");
360            for (x, (lit, calm)) in line.chars().zip(rest.lines().nth(row).unwrap_or_default().chars()).enumerate() {
361                assert!(x == 0 || lit == calm, "only the first cell changes: {line:?}");
362            }
363        }
364        h.hover(29, 2).press("tab");
365        assert!(h.screen().starts_with("▌ ctrl p   Open palette"), "keyboard focus too: {}", h.screen());
366    }
367
368    #[test]
369    fn a_theme_without_padding_draws_no_pillar_and_no_pillar_cell() {
370        let dir = std::env::temp_dir().join(format!("quvyta-button-flat-{}", std::process::id()));
371        std::fs::create_dir_all(&dir).expect("temp dir");
372        let theme = "[meta]\nname = \"Flat\"\nextends = \"monochrome\"\n[style.button]\npadding = [0, 0]\n";
373        std::fs::write(dir.join("flat.toml"), theme).expect("theme file");
374        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
375        let env = crate::env::Env::load(&dirs).expect("loads");
376        let mut h = Harness::with_env(Marked, env, 24, 3);
377        h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("flat");
378        h.hover(1, 0);
379        assert_eq!(h.screen(), " ctrl p Open palette\n✓ Deploy\n ⏎ ✓ Save\n");
380        std::fs::remove_dir_all(dir).ok();
381    }
382
383    /// A button with an icon that may be loading.
384    struct Job(bool);
385
386    impl App for Job {
387        type Msg = ();
388        fn update(&mut self, _: ()) -> Command<()> {
389            Command::none()
390        }
391        fn view(&self, ui: &mut View<'_, ()>) {
392            ui.add(Button::new("Count").icon("folder").loading(self.0).on_press(()));
393        }
394    }
395
396    #[test]
397    fn a_short_loading_state_keeps_the_raised_look() {
398        let mut idle = Harness::new(Job(false), 20, 1);
399        let mut busy = Harness::new(Job(true), 20, 1);
400        idle.hover(4, 0);
401        busy.hover(4, 0);
402        busy.set_glyph_mode(crate::icons::GlyphMode::Unicode);
403        assert!(busy.screen().contains("◜ Count"), "the default spinner turns in place of the icon: {}", busy.screen());
404        let label = |h: &Harness<Job>| h.find("Count");
405        assert_eq!(label(&idle), label(&busy), "the label does not jump while a quick job runs");
406        assert_eq!(idle.screen().starts_with('▌'), busy.screen().starts_with('▌'));
407        assert_eq!(idle.bg(10, 0), busy.bg(10, 0), "the surface keeps its hover tone");
408        busy.press("enter");
409    }
410
411    #[test]
412    fn held_enter_does_not_repeat() {
413        let mut h = Harness::new(Demo::default(), 24, 1);
414        h.press("tab").press("enter");
415        let event = crate::event::Event::Key(crate::event::KeyEvent::press("enter"));
416        let now = Duration::from_millis(330);
417        h.inject(event.clone(), now);
418        h.inject(event, now + Duration::from_millis(30));
419        assert_eq!(h.app().presses, 1);
420    }
421
422    #[test]
423    fn disabled_and_loading_ignore_presses() {
424        let mut h = Harness::new(Demo { disabled: true, loading: true, ..Demo::default() }, 24, 1);
425        h.press("tab").press("enter");
426        assert_eq!(h.app().presses, 0);
427        let theme = h.env().theme();
428        assert_eq!(h.fg(6, 0), theme.color("muted"));
429    }
430}