Skip to main content

qframe/widgets/
button.rs

1//! Buttons.
2
3use super::cells;
4use super::press::{self, Press};
5use crate::event::{Event, MouseButton, MouseKind};
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 middle click sends a second message when the button has one
18/// ([`on_middle_press`](Self::on_middle_press)). A theme padding of zero columns leaves no room for
19/// the pillar and draws none. Style keys (`pillar` sets the pillar colour): `button`,
20/// `button.<variant>`, states `hover`, `focus`, `pressed`, `disabled`; the shortcut segment uses
21/// `button-key` and `button-key.<variant>`.
22pub struct Button<Msg> {
23    label: String,
24    icon: Option<String>,
25    shortcut: Option<String>,
26    variant: Option<String>,
27    disabled: bool,
28    loading: bool,
29    selected: Option<bool>,
30    faint: bool,
31    on_press: Option<Msg>,
32    on_middle_press: Option<Msg>,
33}
34
35impl<Msg> Button<Msg> {
36    /// A button labelled `label`.
37    #[must_use]
38    pub fn new(label: impl Into<String>) -> Self {
39        Self {
40            label: label.into(),
41            icon: None,
42            shortcut: None,
43            variant: None,
44            disabled: false,
45            loading: false,
46            selected: None,
47            faint: false,
48            on_press: None,
49            on_middle_press: None,
50        }
51    }
52
53    /// The message sent when the button is pressed.
54    #[must_use]
55    pub fn on_press(mut self, message: Msg) -> Self {
56        self.on_press = Some(message);
57        self
58    }
59
60    /// The message sent when the middle mouse button is pressed and released over the button,
61    /// such as closing a tab or opening a link in a new one. The middle press flashes the button
62    /// like any press. It takes effect on a button that can be pressed at all, so give it
63    /// [`on_press`](Self::on_press) too; without this option the middle button passes the button
64    /// by, as the right button does.
65    #[must_use]
66    pub fn on_middle_press(mut self, message: Msg) -> Self {
67        self.on_middle_press = Some(message);
68        self
69    }
70
71    /// Theme variant, e.g. `"primary"` or `"danger"`.
72    #[must_use]
73    pub fn variant(mut self, variant: impl Into<String>) -> Self {
74        self.variant = Some(variant.into());
75        self
76    }
77
78    /// Icon key drawn before the label.
79    #[must_use]
80    pub fn icon(mut self, key: impl Into<String>) -> Self {
81        self.icon = Some(key.into());
82        self
83    }
84
85    /// Key label drawn in a darker segment on the left, e.g. `"⏎"` or `"ctrl s"`. The segment
86    /// starts with the button's pillar cell, so the pillar stays the leftmost mark.
87    #[must_use]
88    pub fn shortcut(mut self, label: impl Into<String>) -> Self {
89        self.shortcut = Some(label.into());
90        self
91    }
92
93    /// Greys the button out; it cannot be focused or pressed.
94    #[must_use]
95    pub fn disabled(mut self, disabled: bool) -> Self {
96        self.disabled = disabled;
97        self
98    }
99
100    /// Shows a spinner in the default spinner style instead of the icon and ignores presses.
101    #[must_use]
102    pub fn loading(mut self, loading: bool) -> Self {
103        self.loading = loading;
104        self
105    }
106
107    /// Makes this a choice button, such as one of a few view modes: `selected` shows it chosen,
108    /// raised with a steady pillar. Choosing is its own feedback, so a choice button does not
109    /// flash when pressed.
110    #[must_use]
111    pub fn selected(mut self, selected: bool) -> Self {
112        self.selected = Some(selected);
113        self
114    }
115
116    /// Draws the button a step quieter, for a screen that has gone still, such as a running timer
117    /// nobody has touched for a while. Only the tone changes: the button still presses, and the
118    /// pointer over it or the keyboard on it bring its full tone back, so it never hides the
119    /// fact that it can be used.
120    #[must_use]
121    pub fn faint(mut self, faint: bool) -> Self {
122        self.faint = faint;
123        self
124    }
125
126    fn active(&self) -> bool {
127        !self.disabled && !self.loading && self.on_press.is_some()
128    }
129}
130
131/// How far a faint button's ground moves towards the surface around it.
132const FAINT_GROUND: f32 = 0.6;
133
134/// How far a faint button's text moves towards its ground: far enough to step back, not so far
135/// that it stops being read.
136const FAINT_TEXT: f32 = 0.35;
137
138/// Width of the shortcut segment for `key`: the pillar cell when the padding leaves room for a
139/// pillar, then the key with a space on each side.
140fn key_width(key: &str, horizontal_padding: u16) -> u16 {
141    cells::sum([u16::from(horizontal_padding >= 1), text::width(key), 2])
142}
143
144impl<Msg: Clone + 'static> Button<Msg> {
145    fn press(&self, cx: &mut EventCx<'_, Msg>) {
146        self.send(cx, self.on_press.as_ref());
147    }
148
149    fn send(&self, cx: &mut EventCx<'_, Msg>, message: Option<&Msg>) {
150        if let Some(message) = message {
151            if self.selected.is_none() {
152                cx.flash();
153            }
154            cx.emit(message.clone());
155        }
156    }
157
158    /// Reads `event` as a middle click when the button has a message for one. The press captures
159    /// the pointer like a left press, so releasing elsewhere cancels.
160    fn middle_press(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> Option<bool> {
161        let message = self.on_middle_press.as_ref()?;
162        let Event::Mouse(mouse) = event else {
163            return None;
164        };
165        match mouse.kind {
166            MouseKind::Down(MouseButton::Middle) => {
167                cx.capture_pointer();
168                Some(true)
169            }
170            MouseKind::Up(MouseButton::Middle) => {
171                if cx.holds_pointer() && cx.area().contains(mouse.x, mouse.y) {
172                    self.send(cx, Some(message));
173                }
174                Some(true)
175            }
176            _ => None,
177        }
178    }
179}
180
181impl<Msg: Clone + 'static> Widget<Msg> for Button<Msg> {
182    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
183        let theme = cx.env().theme();
184        let style = theme.style("button", self.variant.as_deref(), &[]);
185        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 2));
186        let icon_width = self
187            .icon
188            .as_deref()
189            .map_or(0, |key| text::width(&cx.env().icons().glyph(key)) + 1)
190            .max(if self.loading { 2 } else { 0 });
191        let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, horizontal));
192        let width = cells::sum([shortcut_width, horizontal.saturating_mul(2), icon_width, text::width(&self.label)]);
193        Size::new(width, vertical.saturating_mul(2).saturating_add(1)).min(available)
194    }
195
196    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
197        // A loading button ignores presses but keeps its hover and focus look, so a short job does
198        // not make it blink.
199        let interactive = !self.disabled && self.on_press.is_some();
200        // A clicked button stays calm under the pointer; its focus breathes only when it was
201        // reached with the keyboard.
202        let mut states = if interactive { cx.pressable_states() } else { Vec::new() };
203        if self.selected == Some(true) {
204            states.push(State::Selected);
205        }
206        if self.disabled {
207            states.push(State::Disabled);
208        }
209        let variant = self.variant.as_deref();
210        let style = cx.style("button", variant, &states);
211        let mut surface = style.text();
212        let mut background = surface.bg.unwrap_or_else(|| cx.color("raised"));
213        // Faint only while nothing is on it: hover, focus and a press speak at full tone.
214        let quiet =
215            self.faint && !states.iter().any(|state| matches!(state, State::Hover | State::Focus | State::Pressed));
216        if quiet {
217            background = background.mix(cx.color("surface"), FAINT_GROUND);
218            surface.bg = Some(background);
219            surface.fg = surface.fg.map(|fg| fg.mix(background, FAINT_TEXT));
220        }
221        cx.clear(area, background);
222        if interactive {
223            cx.register_hit(area);
224        }
225
226        let padding = style.padding();
227        let y = area.y + i32::from(padding.top);
228        let mut x = area.x;
229        let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, padding.left));
230        if let Some(shortcut) = &self.shortcut {
231            let mut key_style = cx.style("button-key", variant, &states).text();
232            if quiet {
233                let ground = key_style.bg.unwrap_or(background).mix(cx.color("surface"), FAINT_GROUND);
234                key_style.bg = Some(ground);
235                key_style.fg = key_style.fg.map(|fg| fg.mix(ground, FAINT_TEXT));
236            }
237            cx.clear(Rect::new(x, area.y, shortcut_width, area.height), key_style.bg.unwrap_or(background));
238            let label = format!(" {shortcut} ");
239            let label_width = text::width(&label);
240            cx.text(x + i32::from(shortcut_width - label_width), y, &label, key_style, label_width);
241        }
242        // Hover and focus raise the pillar in the button's first cell: the leftmost mark of the
243        // raised thing, before the shortcut segment. Buttons never slide: only list structures
244        // do, so every button behaves the same whatever its width.
245        let pillar = style.color("pillar").filter(|_| padding.left >= 1);
246        if let Some(color) = pillar {
247            cx.pillar(x, y, color);
248        }
249        x += i32::from(shortcut_width);
250        x += i32::from(padding.left);
251        let right = area.right() - i32::from(padding.right);
252        let budget = |x: i32| crate::geometry::clamp_u16(right - x);
253
254        if self.loading {
255            // The same turning arc as a plain `Spinner`, so busy states look alike everywhere.
256            let spinner = super::SpinnerStyle::default().animation();
257            let cell = cx.animation(spinner, surface, Some(std::time::Duration::ZERO));
258            x += i32::from(cx.text(x, y, &cell.glyph, cell.style, budget(x).min(1)));
259            x += 1;
260        } else if let Some(icon) = &self.icon {
261            let glyph = cx.env().icons().glyph(icon).into_owned();
262            x += i32::from(cx.text(x, y, &glyph, surface, budget(x)));
263            x += 1;
264        }
265        let label = text::truncate(&self.label, budget(x)).into_owned();
266        cx.text(x, y, &label, surface, budget(x));
267    }
268
269    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
270        if !self.active() {
271            return false;
272        }
273        if let Some(used) = self.middle_press(cx, event) {
274            return used;
275        }
276        match press::read(cx, event) {
277            Press::Ignored => false,
278            Press::Used => true,
279            Press::Key | Press::Click(..) => {
280                self.press(cx);
281                true
282            }
283        }
284    }
285
286    fn focusable(&self) -> bool {
287        self.active()
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::event::{MouseButton, MouseKind};
295    use crate::runtime::{App, Command, Harness};
296    use crate::widget::View;
297    use std::time::Duration;
298
299    #[derive(Default)]
300    struct Demo {
301        presses: u32,
302        disabled: bool,
303        loading: bool,
304    }
305
306    impl App for Demo {
307        type Msg = ();
308        fn update(&mut self, _: ()) -> Command<()> {
309            self.presses += 1;
310            Command::none()
311        }
312        fn view(&self, ui: &mut View<'_, ()>) {
313            ui.row(|ui| {
314                ui.add(Button::new("Save").variant("primary").shortcut("⏎").on_press(()).disabled(self.disabled))
315                    .id("save");
316                ui.add(Button::new("Wait").loading(self.loading).on_press(()));
317            })
318            .gap(1);
319        }
320    }
321
322    /// A faint button beside a normal one, to compare their tones.
323    #[derive(Default)]
324    struct Quiet {
325        presses: u32,
326    }
327
328    impl App for Quiet {
329        type Msg = ();
330        fn update(&mut self, (): ()) -> Command<()> {
331            self.presses += 1;
332            Command::none()
333        }
334        fn view(&self, ui: &mut View<'_, ()>) {
335            ui.row(|ui| {
336                ui.add(Button::new("Stop").faint(true).on_press(())).id("stop");
337                ui.add(Button::new("Pause").on_press(()));
338            })
339            .gap(1);
340        }
341    }
342
343    #[test]
344    fn a_faint_button_is_quieter_still_presses_and_speaks_up_under_the_pointer() {
345        let mut h = Harness::new(Quiet::default(), 30, 1);
346        let (stop, pause) = (h.find("Stop").expect("stop"), h.find("Pause").expect("pause"));
347        let tone = |h: &Harness<Quiet>, (x, y): (i32, i32)| {
348            let (x, y) = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
349            (h.fg(x, y), h.bg(x, y))
350        };
351        assert_ne!(tone(&h, stop), tone(&h, pause), "the faint button steps back");
352        h.click(stop.0, stop.1);
353        assert_eq!(h.app().presses, 1, "and still presses");
354        let settle = Duration::from_millis(600);
355        h.hover(stop.0, stop.1).advance(settle);
356        let hovered = tone(&h, stop);
357        h.hover(pause.0, pause.1).advance(settle);
358        let pause_hovered = tone(&h, pause);
359        assert_eq!(hovered, pause_hovered, "under the pointer it has its full tone");
360    }
361
362    #[test]
363    fn a_faint_button_is_still_read_in_sixteen_colours() {
364        let mut h = Harness::new(Quiet::default(), 30, 1);
365        h.set_depth(crate::color::ColorDepth::Ansi16);
366        let (x, y) = h.find("Stop").expect("stop");
367        let cell = &h.buffer()[(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0))];
368        let entry = |color| match color {
369            ratatui_core::style::Color::Indexed(index) => crate::color::Rgb::from_ansi256(index),
370            other => panic!("a palette frame sent {other:?}"),
371        };
372        let ratio = entry(cell.fg).contrast_ratio(entry(cell.bg));
373        assert!(ratio >= 1.6, "the label does not melt into its ground: {ratio:.2}");
374    }
375
376    #[test]
377    fn draws_without_brackets() {
378        let h = Harness::new(Demo::default(), 24, 1);
379        assert_eq!(h.screen(), "  ⏎   Save     Wait\n");
380        let theme = h.env().theme();
381        assert_ne!(h.bg(6, 0), theme.color("accent"), "a resting primary button is a tint, not the full fill");
382        assert_eq!(h.fg(6, 0), theme.color("accent"));
383    }
384
385    /// The sum of a colour's channels, to compare how bright two tones are.
386    fn brightness(color: Option<crate::color::Rgb>) -> u32 {
387        color.map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b))
388    }
389
390    #[test]
391    fn tones_climb_from_rest_to_hover_to_press_and_never_invert() {
392        let mut h = Harness::new(Demo::default(), 24, 1);
393        let rest = h.bg(6, 0);
394        h.hover(6, 0);
395        let hover = h.bg(6, 0);
396        h.mouse(MouseKind::Down(MouseButton::Left), 6, 0).mouse(MouseKind::Up(MouseButton::Left), 6, 0);
397        let pressed = h.bg(6, 0);
398        assert!(brightness(rest) < brightness(hover) && brightness(hover) < brightness(pressed));
399        assert_eq!(h.fg(6, 0), h.env().theme().color("accent"), "the label keeps its colour while pressed");
400        h.advance(Duration::from_millis(200));
401        assert_eq!(h.bg(6, 0), hover, "a clicked button settles back to its hover tone, calm under the pointer");
402    }
403
404    #[test]
405    fn choice_buttons_show_selection_and_do_not_flash() {
406        struct Modes(usize);
407        impl App for Modes {
408            type Msg = usize;
409            fn update(&mut self, mode: usize) -> Command<usize> {
410                self.0 = mode;
411                Command::none()
412            }
413            fn view(&self, ui: &mut View<'_, usize>) {
414                ui.row(|ui| {
415                    ui.add(Button::new("List").selected(self.0 == 0).on_press(0));
416                    ui.add(Button::new("Grid").selected(self.0 == 1).on_press(1));
417                });
418            }
419        }
420        let mut h = Harness::new(Modes(0), 20, 1);
421        assert!(h.screen().starts_with("▌ List"), "the chosen one carries a steady pillar: {}", h.screen());
422        h.click_text("Grid");
423        assert_eq!(h.app().0, 1);
424        let chosen = h.bg(12, 0);
425        h.advance(Duration::from_millis(200));
426        assert_eq!(h.bg(12, 0), chosen, "no flash: the selection itself is the feedback");
427    }
428
429    #[test]
430    fn presses_by_keyboard_and_click_and_flashes() {
431        let mut h = Harness::new(Demo::default(), 24, 1);
432        h.press("tab");
433        assert!(h.is_focused("save"));
434        let focused = h.bg(6, 0);
435        h.press("enter");
436        assert_eq!(h.app().presses, 1);
437        let flash = h.bg(6, 0);
438        assert!(brightness(flash) > brightness(focused), "a press flashes one tone brighter");
439        h.advance(Duration::from_millis(200));
440        assert_eq!(h.bg(6, 0), focused);
441        h.click_text("Save");
442        assert_eq!(h.app().presses, 2);
443    }
444
445    #[test]
446    fn hover_and_focus_raise_the_pillar_without_sliding() {
447        let mut h = Harness::new(Demo::default(), 24, 1);
448        assert_eq!(h.screen(), "  ⏎   Save     Wait\n");
449        h.hover(15, 0);
450        assert_eq!(h.screen(), "  ⏎   Save   ▌ Wait\n", "the pillar rises; the label stays where it is");
451        h.hover(23, 0);
452        h.press("tab");
453        assert_eq!(h.screen(), "▌ ⏎   Save     Wait\n", "the pillar comes before the shortcut segment");
454        let theme = h.env().theme();
455        assert_ne!(h.fg(0, 0), theme.color("ink"), "the pillar stays bright on the tint");
456        assert_eq!(h.bg(0, 0), h.bg(2, 0), "the pillar cell belongs to the shortcut segment");
457        h.set_reduced_motion(true);
458        assert_eq!(h.screen(), "▌ ⏎   Save     Wait\n");
459    }
460
461    /// Buttons with a shortcut, an icon or both, side by side.
462    struct Marked;
463
464    impl App for Marked {
465        type Msg = ();
466        fn update(&mut self, _: ()) -> Command<()> {
467            Command::none()
468        }
469        fn view(&self, ui: &mut View<'_, ()>) {
470            ui.column(|ui| {
471                ui.add(Button::new("Open palette").shortcut("ctrl p").on_press(()));
472                ui.add(Button::new("Deploy").icon("check").on_press(()));
473                ui.add(Button::new("Save").icon("check").shortcut("⏎").variant("primary").on_press(()));
474            });
475        }
476    }
477
478    #[test]
479    fn the_pillar_is_the_leftmost_cell_with_a_shortcut_an_icon_or_both() {
480        let mut h = Harness::new(Marked, 30, 3);
481        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
482        let rest = h.screen();
483        assert_eq!(rest, "  ctrl p   Open palette\n  ✓ Deploy\n  ⏎   ✓ Save\n");
484        for (row, hovered) in ["▌ ctrl p   Open palette", "▌ ✓ Deploy", "▌ ⏎   ✓ Save"].into_iter().enumerate()
485        {
486            let y = i32::try_from(row).unwrap_or(0);
487            h.hover(3, y);
488            let line = h.screen().lines().nth(row).unwrap_or_default().to_owned();
489            assert_eq!(line, hovered, "hover draws the pillar first");
490            for (x, (lit, calm)) in line.chars().zip(rest.lines().nth(row).unwrap_or_default().chars()).enumerate() {
491                assert!(x == 0 || lit == calm, "only the first cell changes: {line:?}");
492            }
493        }
494        h.hover(29, 2).press("tab");
495        assert!(h.screen().starts_with("▌ ctrl p   Open palette"), "keyboard focus too: {}", h.screen());
496    }
497
498    #[test]
499    fn a_theme_without_padding_draws_no_pillar_and_no_pillar_cell() {
500        let dir = std::env::temp_dir().join(format!("quvyta-button-flat-{}", std::process::id()));
501        std::fs::create_dir_all(&dir).expect("temp dir");
502        let theme = "[meta]\nname = \"Flat\"\nextends = \"monochrome\"\n[style.button]\npadding = [0, 0]\n";
503        std::fs::write(dir.join("flat.toml"), theme).expect("theme file");
504        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
505        let env = crate::env::Env::load(&dirs).expect("loads");
506        let mut h = Harness::with_env(Marked, env, 24, 3);
507        h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("flat");
508        h.hover(1, 0);
509        assert_eq!(h.screen(), " ctrl p Open palette\n✓ Deploy\n ⏎ ✓ Save\n");
510        std::fs::remove_dir_all(dir).ok();
511    }
512
513    /// A button with an icon that may be loading.
514    struct Job(bool);
515
516    impl App for Job {
517        type Msg = ();
518        fn update(&mut self, _: ()) -> Command<()> {
519            Command::none()
520        }
521        fn view(&self, ui: &mut View<'_, ()>) {
522            ui.add(Button::new("Count").icon("folder").loading(self.0).on_press(()));
523        }
524    }
525
526    #[test]
527    fn a_short_loading_state_keeps_the_raised_look() {
528        let mut idle = Harness::new(Job(false), 20, 1);
529        let mut busy = Harness::new(Job(true), 20, 1);
530        idle.hover(4, 0);
531        busy.hover(4, 0);
532        busy.set_glyph_mode(crate::icons::GlyphMode::Unicode);
533        assert!(busy.screen().contains("◜ Count"), "the default spinner turns in place of the icon: {}", busy.screen());
534        let label = |h: &Harness<Job>| h.find("Count");
535        assert_eq!(label(&idle), label(&busy), "the label does not jump while a quick job runs");
536        assert_eq!(idle.screen().starts_with('▌'), busy.screen().starts_with('▌'));
537        assert_eq!(idle.bg(10, 0), busy.bg(10, 0), "the surface keeps its hover tone");
538        busy.press("enter");
539    }
540
541    #[test]
542    fn held_enter_does_not_repeat() {
543        let mut h = Harness::new(Demo::default(), 24, 1);
544        h.press("tab").press("enter");
545        let event = crate::event::Event::Key(crate::event::KeyEvent::press("enter"));
546        let now = Duration::from_millis(330);
547        h.inject(event.clone(), now);
548        h.inject(event, now + Duration::from_millis(30));
549        assert_eq!(h.app().presses, 1);
550    }
551
552    #[test]
553    fn disabled_and_loading_ignore_presses() {
554        let mut h = Harness::new(Demo { disabled: true, loading: true, ..Demo::default() }, 24, 1);
555        h.press("tab").press("enter");
556        assert_eq!(h.app().presses, 0);
557        let theme = h.env().theme();
558        assert_eq!(h.fg(6, 0), theme.color("muted"));
559    }
560
561    /// A tab that a middle click closes, next to a plain button.
562    #[derive(Default)]
563    struct TabStrip {
564        heard: Vec<&'static str>,
565    }
566
567    impl App for TabStrip {
568        type Msg = &'static str;
569        fn update(&mut self, heard: &'static str) -> Command<&'static str> {
570            self.heard.push(heard);
571            Command::none()
572        }
573        fn view(&self, ui: &mut View<'_, &'static str>) {
574            ui.row(|ui| {
575                ui.add(Button::new("docs.rs").on_press("open docs").on_middle_press("close docs"));
576                ui.add(Button::new("crates.io").on_press("open crates"));
577            })
578            .gap(1);
579        }
580    }
581
582    #[test]
583    fn a_middle_click_sends_the_middle_message_and_a_left_click_the_usual_one() {
584        let mut h = Harness::new(TabStrip::default(), 30, 1);
585        let (x, y) = h.find("docs.rs").expect("the tab");
586        let cell = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
587        let hovered = h.hover(x, y).bg(cell.0, cell.1);
588        h.mouse(MouseKind::Down(MouseButton::Middle), x, y);
589        assert!(h.app().heard.is_empty(), "nothing before the release");
590        h.mouse(MouseKind::Up(MouseButton::Middle), x, y);
591        assert_eq!(h.app().heard, ["close docs"]);
592        assert!(brightness(h.bg(cell.0, cell.1)) > brightness(hovered), "a middle press flashes like any press");
593        h.advance(Duration::from_millis(300)).click(x, y);
594        assert_eq!(h.app().heard, ["close docs", "open docs"], "the left button still opens");
595        h.mouse(MouseKind::Down(MouseButton::Middle), x, y).mouse(MouseKind::Up(MouseButton::Middle), 29, 0);
596        assert_eq!(h.app().heard.len(), 2, "releasing elsewhere cancels");
597    }
598
599    #[test]
600    fn a_button_without_a_middle_message_ignores_the_middle_button() {
601        let mut h = Harness::new(TabStrip::default(), 30, 1);
602        let (x, y) = h.find("crates.io").expect("the button");
603        let cell = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
604        let hovered = h.hover(x, y).bg(cell.0, cell.1);
605        h.mouse(MouseKind::Down(MouseButton::Middle), x, y).mouse(MouseKind::Up(MouseButton::Middle), x, y);
606        assert!(h.app().heard.is_empty(), "{:?}", h.app().heard);
607        assert_eq!(h.bg(cell.0, cell.1), hovered, "and does not flash");
608        h.click(x, y);
609        assert_eq!(h.app().heard, ["open crates"]);
610    }
611}