Skip to main content

qframe/widgets/
hold_to_confirm.rs

1//! Actions confirmed by holding a key or a mouse button.
2
3use std::time::Duration;
4
5use crate::color::Rgb;
6use crate::event::{Event, KeyKind, MouseButton, MouseKind};
7use crate::geometry::{Rect, Size, clamp_u16};
8use crate::keymap::{Key, KeyChord};
9use crate::motion::Easing;
10use crate::text;
11use crate::theme::State;
12use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
13
14use super::cells;
15
16/// How long the key or button must be held when no duration is set: three bars of 400 ms.
17const DEFAULT_DURATION: Duration = Duration::from_millis(1200);
18
19/// Keyboards wait this long before repeating a held key. Without a repeat or a release in
20/// this time, the key counts as let go.
21const INITIAL_TIMEOUT: Duration = Duration::from_millis(650);
22
23/// Once a key repeats, a longer gap than this between repeats means it was let go.
24const REPEAT_TIMEOUT: Duration = Duration::from_millis(350);
25
26/// How often a held mouse button is looked at.
27const POINTER_REPEAT: Duration = Duration::from_millis(40);
28
29/// Time between frames while the bars fill.
30const FILL_FRAME: Duration = Duration::from_millis(16);
31
32/// Number of bars.
33const BARS: u16 = 3;
34
35/// Cells of one bar.
36const BAR: u16 = 3;
37
38/// Width of the bars with the cell between them.
39const BARS_WIDTH: u16 = BARS * BAR + BARS - 1;
40
41/// A control that sends its message only after a key or the mouse button is held on it.
42///
43/// While held, three bars fill one after another. Each bar is one block of colour: over its third
44/// of the duration the whole bar blends from the track colour to the theme's target colour
45/// (`to`, the warning tone in the built-in themes). When the third bar reaches the full colour the
46/// message is sent, once; nothing more happens until the key or button is let go and pressed
47/// again. Letting go empties the bars quickly, over `motion.enter`. With reduced motion each bar
48/// switches to the full colour at the end of its third and letting go empties them at once.
49///
50/// With no options it is a focusable chip: hold Enter or Space while it has focus, or press
51/// the mouse button on it. `key` makes a chord work from anywhere on the screen, e.g. holding
52/// `ctrl+q` to quit; `floating` draws nothing until the hold starts and then shows the label
53/// and bars on a small card in the top left corner of the screen.
54///
55/// Held keys arrive as repeats: with the kitty keyboard protocol as repeat and release events,
56/// elsewhere as the same key pressed again every few dozen milliseconds after the keyboard's
57/// repeat delay. The hold therefore counts as released on a release event, or when no repeat
58/// arrives within 650 ms of the press or 350 ms of the last repeat. A held mouse button is
59/// followed until it is released or leaves the control.
60///
61/// Hovered and focused controls show the pillar in their first cell, like buttons.
62///
63/// Where the label and the bars do not fit side by side, as on a narrow screen or in a longer
64/// language, the label wraps over the whole width and the bars go on a line of their own under
65/// it; the control reports the extra rows.
66///
67/// The bars fill towards the theme's `to` colour unless [`HoldToConfirm::color`] names another
68/// one, preferably a theme token such as `"$danger"` so the control follows the theme.
69///
70/// Style keys: `hold` (`bg`, `fg`, `bold`, `padding`, `track`, `to`, `pillar`) with states
71/// `hover`, `focus`, `active` (held), `disabled`; `hold-card` (`bg`, `padding`, `pillar`). Without
72/// a card `pillar`, the card's pillar blends from `muted` to `to` as the hold goes on.
73pub struct HoldToConfirm<Msg> {
74    label: String,
75    duration: Duration,
76    key: Option<KeyChord>,
77    floating: bool,
78    disabled: bool,
79    color: Option<String>,
80    on_confirm: Option<Msg>,
81}
82
83/// What is being held.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Source {
86    Key(Key),
87    Pointer,
88}
89
90#[derive(Debug, Default)]
91struct HoldMemory {
92    /// When the current hold started.
93    start: Option<Duration>,
94    /// The last sign that the hold goes on.
95    last: Duration,
96    repeated: bool,
97    source: Option<Source>,
98    /// Completed; waits for the key or button to be let go.
99    done: bool,
100    /// When the last hold was let go and how far it had come, so the bars can empty.
101    released: Option<(Duration, f32)>,
102}
103
104impl HoldMemory {
105    /// Lets the hold go at `now`; the bars empty from where they were.
106    fn release(&mut self, now: Duration, duration: Duration) {
107        let reached = self.progress(now, duration);
108        *self = Self::default();
109        if reached > 0.0 {
110            self.released = Some((now, reached));
111        }
112    }
113
114    fn timeout(&self) -> Option<Duration> {
115        match self.source {
116            Some(Source::Key(_)) => Some(self.last + if self.repeated { REPEAT_TIMEOUT } else { INITIAL_TIMEOUT }),
117            _ => None,
118        }
119    }
120
121    fn progress(&self, now: Duration, duration: Duration) -> f32 {
122        if self.done {
123            return 1.0;
124        }
125        let Some(start) = self.start else {
126            return 0.0;
127        };
128        if duration.is_zero() {
129            return 1.0;
130        }
131        (now.saturating_sub(start).as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0)
132    }
133}
134
135/// How filled bar `bar` is at `progress` of the whole hold: it blends during its own third. With
136/// reduced motion it jumps to full at the end of its third.
137fn bar_fill(progress: f32, bar: u16, reduced_motion: bool) -> f32 {
138    let within = (progress * f32::from(BARS) - f32::from(bar)).clamp(0.0, 1.0);
139    // The small nudge keeps a bar from missing its end by a rounding error in `progress`.
140    if reduced_motion { (within + 0.001).floor().min(1.0) } else { within }
141}
142
143impl<Msg: Clone + 'static> HoldToConfirm<Msg> {
144    /// A control labelled `label`, e.g. "Hold to delete".
145    #[must_use]
146    pub fn new(label: impl Into<String>) -> Self {
147        Self {
148            label: label.into(),
149            duration: DEFAULT_DURATION,
150            key: None,
151            floating: false,
152            disabled: false,
153            color: None,
154            on_confirm: None,
155        }
156    }
157
158    /// The message sent once the hold completes.
159    #[must_use]
160    pub fn on_confirm(mut self, message: Msg) -> Self {
161        self.on_confirm = Some(message);
162        self
163    }
164
165    /// How long to hold; 1.2 s by default. It does not shorten with reduced motion: the wait
166    /// protects the action, it is not decoration.
167    #[must_use]
168    pub fn duration(mut self, duration: Duration) -> Self {
169        self.duration = duration;
170        self
171    }
172
173    /// Holding `chord` anywhere on the screen confirms too, while the control is in the view.
174    ///
175    /// # Panics
176    ///
177    /// Panics when `chord` is not a valid chord such as `"ctrl+q"`; chords are fixed in code.
178    #[must_use]
179    pub fn key(mut self, chord: &str) -> Self {
180        self.key = Some(chord.parse().unwrap_or_else(|message| panic!("invalid chord `{chord}`: {message}")));
181        self
182    }
183
184    /// Takes no room and draws nothing until a hold starts, then shows a card in the top left
185    /// corner of the screen. Meant together with [`HoldToConfirm::key`].
186    #[must_use]
187    pub fn floating(mut self, floating: bool) -> Self {
188        self.floating = floating;
189        self
190    }
191
192    /// Greys the control out; holding does nothing.
193    #[must_use]
194    pub fn disabled(mut self, disabled: bool) -> Self {
195        self.disabled = disabled;
196        self
197    }
198
199    /// The colour the bars fill towards, written like a theme colour: a token such as
200    /// `"$danger"`, `"$success"` or `"$accent"`, a blend such as `"mix($accent, $danger, 50%)"`, or a
201    /// fixed `"#RRGGBB"`. Tokens are the intended use: they follow the theme, a fixed colour does
202    /// not. An expression that is not a single colour of the current theme (a typo, an unknown
203    /// token, `pulse()`) falls back to the theme's `to`, as if no colour were set; check one with
204    /// [`Theme::solid`](crate::theme::Theme::solid). Default: the theme's `to`, the warning tone.
205    #[must_use]
206    pub fn color(mut self, paint: impl Into<String>) -> Self {
207        self.color = Some(paint.into());
208        self
209    }
210
211    fn active(&self) -> bool {
212        !self.disabled && self.on_confirm.is_some()
213    }
214
215    /// The colour of a full bar: the chosen colour when it resolves, else the theme's `to`.
216    fn target(&self, cx: &PaintCx<'_>, hold: Option<Rgb>) -> Rgb {
217        self.color
218            .as_deref()
219            .and_then(|paint| cx.env().theme().solid(paint).ok())
220            .or(hold)
221            .unwrap_or_else(|| cx.color("warning"))
222    }
223
224    fn content_width(&self) -> u16 {
225        cells::sum([text::width(&self.label), 2, BARS_WIDTH])
226    }
227
228    /// The label's lines in content `width` cells wide: one line with the bars at its right
229    /// when both fit, else the label wrapped over the whole width with the bars on a line of
230    /// their own under it.
231    fn label_lines(&self, width: u16) -> Vec<String> {
232        if self.content_width() <= width { vec![self.label.clone()] } else { text::wrap(&self.label, width.max(1)) }
233    }
234
235    /// Rows the content takes in `width` cells: one, or the label's lines and the bars' line.
236    fn content_rows(&self, width: u16) -> u16 {
237        if self.content_width() <= width {
238            1
239        } else {
240            clamp_u16(i32::try_from(self.label_lines(width).len()).unwrap_or(i32::MAX)).saturating_add(1)
241        }
242    }
243
244    /// Paints the label and the bars from `(x, y)`.
245    fn paint_content(&self, cx: &mut PaintCx<'_>, x: i32, y: i32, width: u16, states: &[State], progress: f32) {
246        let style = cx.style("hold", None, states);
247        let mut label_style = style.text();
248        label_style.bg = None;
249        let track = style.color("track").unwrap_or_else(|| cx.color("active"));
250        let to = self.target(cx, style.color("to"));
251        let one_line = self.content_width() <= width;
252        let lines = self.label_lines(width);
253        let label_budget = if one_line { width.saturating_sub(BARS_WIDTH + 2) } else { width };
254        for (row, line) in (y..).zip(&lines) {
255            let label = text::truncate(line, label_budget).into_owned();
256            cx.text(x, row, &label, label_style, label_budget);
257        }
258        // Side by side the bars end at the right edge; under the label they start where it does.
259        let (bars_x, y) = if one_line {
260            (x + i32::from(width.saturating_sub(BARS_WIDTH)), y)
261        } else {
262            (x, y + i32::try_from(lines.len()).unwrap_or(0))
263        };
264        for bar in 0..BARS {
265            // Each bar owns one third of the hold and blends as a whole within it.
266            let fill = bar_fill(progress, bar, cx.reduced_motion());
267            let column = bars_x + i32::from(bar * (BAR + 1));
268            cx.clear(Rect::new(column, y, BAR, 1), track.mix(to, fill));
269        }
270    }
271
272    /// How far the bars are shown at `now`: the hold's progress while held, and afterwards the
273    /// progress at release emptying over `motion.enter`.
274    fn shown_progress(&self, cx: &mut PaintCx<'_>) -> f32 {
275        let now = cx.now();
276        let (progress, released) = {
277            let memory = cx.memory::<HoldMemory>();
278            (memory.progress(now, self.duration), memory.released)
279        };
280        match released {
281            Some((at, reached)) if progress == 0.0 => {
282                let emptied = cx.progress_since(at, cx.env().theme().motion().enter, Easing::Linear);
283                if emptied >= 1.0 {
284                    cx.memory::<HoldMemory>().released = None;
285                }
286                reached * (1.0 - emptied)
287            }
288            _ => progress,
289        }
290    }
291
292    fn start(&self, cx: &mut EventCx<'_, Msg>, source: Source) {
293        let now = cx.now();
294        let memory = cx.memory::<HoldMemory>();
295        *memory = HoldMemory { start: Some(now), last: now, source: Some(source), ..HoldMemory::default() };
296    }
297
298    fn release(&self, cx: &mut EventCx<'_, Msg>) {
299        let now = cx.now();
300        cx.memory::<HoldMemory>().release(now, self.duration);
301    }
302
303    /// Notes that the hold goes on and sends the message when it is complete.
304    fn keep(&self, cx: &mut EventCx<'_, Msg>) {
305        let now = cx.now();
306        let duration = self.duration;
307        let complete = {
308            let memory = cx.memory::<HoldMemory>();
309            memory.last = now;
310            let complete = !memory.done && memory.progress(now, duration) >= 1.0;
311            if complete {
312                memory.done = true;
313            }
314            complete
315        };
316        if complete && let Some(message) = &self.on_confirm {
317            cx.emit(message.clone());
318        }
319    }
320
321    fn is_trigger(&self, cx: &EventCx<'_, Msg>, chord: KeyChord, release: bool) -> bool {
322        let focused_key = cx.is_focused() && !self.floating && matches!(chord.key, Key::Enter | Key::Space);
323        let focused_key = focused_key && (release || chord.mods == crate::keymap::Modifiers::default());
324        let bound = self.key.is_some_and(|key| if release { key.key == chord.key } else { key == chord });
325        focused_key || bound
326    }
327}
328
329impl<Msg: Clone + 'static> Widget<Msg> for HoldToConfirm<Msg> {
330    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
331        if self.floating {
332            return Size::default();
333        }
334        let style = cx.env().theme().style("hold", None, &[]);
335        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 2));
336        let width = self.content_width().saturating_add(horizontal.saturating_mul(2)).min(available.width);
337        let rows = self.content_rows(width.saturating_sub(horizontal.saturating_mul(2)));
338        Size::new(width, vertical.saturating_mul(2).saturating_add(rows)).min(available)
339    }
340
341    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
342        let now = cx.now();
343        let (holding, progress) = {
344            let memory = cx.memory::<HoldMemory>();
345            if let Some(timeout) = memory.timeout().filter(|timeout| now > *timeout) {
346                // A silent key was let go when its repeats stopped, not when this frame noticed.
347                memory.release(timeout, self.duration);
348            }
349            (memory.start.is_some() || memory.done, memory.progress(now, self.duration))
350        };
351        if self.active() {
352            if let Some(key) = self.key {
353                cx.listen_key(key);
354            }
355            if holding || (cx.is_focused() && !self.floating) {
356                // Repeats of Enter and Space never reach focused widgets; listening hears them.
357                cx.listen_key(KeyChord::plain(Key::Enter));
358                cx.listen_key(KeyChord::plain(Key::Space));
359            }
360        }
361        if holding {
362            if progress < 1.0 {
363                cx.request_frame_in(FILL_FRAME);
364            }
365            if let Some(timeout) = cx.memory::<HoldMemory>().timeout() {
366                cx.request_frame_in(timeout.saturating_sub(now) + Duration::from_millis(1));
367            }
368        }
369        let shown = self.shown_progress(cx);
370        if self.floating {
371            if holding || shown > 0.0 {
372                cx.request_overlay(area);
373            }
374            return;
375        }
376        let mut states = if self.active() { cx.states() } else { Vec::new() };
377        if self.disabled {
378            states.push(State::Disabled);
379        }
380        if holding {
381            states.push(State::Active);
382        }
383        let style = cx.style("hold", None, &states);
384        let background = style.text().bg.unwrap_or_else(|| cx.color("raised"));
385        cx.clear(area, background);
386        if self.active() {
387            cx.register_hit(area);
388        }
389        let padding = style.padding();
390        let inner = area.inset(padding);
391        // Like a button, hover and focus raise the pillar in the first cell of the padding.
392        if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
393            cx.pillar(area.x, inner.y, color);
394        }
395        self.paint_content(cx, inner.x, inner.y, inner.width, &states, shown);
396    }
397
398    fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
399        let screen = cx.clip();
400        let progress = self.shown_progress(cx);
401        let card = cx.style("hold-card", None, &[]);
402        let background = card.text().bg.unwrap_or_else(|| cx.color("overlay"));
403        let padding = card.padding();
404        let width = self.content_width().saturating_add(padding.horizontal()).min(screen.width.saturating_sub(4));
405        let rows = self.content_rows(width.saturating_sub(padding.horizontal()));
406        let rect = Rect::new(screen.x + 2, screen.y + 1, width, padding.vertical().saturating_add(rows));
407        cx.floating(rect, |cx| {
408            cx.clear(rect, background);
409            let hold = cx.style("hold", None, &[State::Active]);
410            let to = self.target(cx, hold.color("to"));
411            let pillar: Rgb = card.color("pillar").unwrap_or_else(|| cx.color("muted").mix(to, progress));
412            for row in 0..rect.height {
413                cx.pillar(rect.x, rect.y + i32::from(row), pillar);
414            }
415            let inner = rect.inset(padding);
416            self.paint_content(cx, inner.x, inner.y, inner.width, &[State::Active], progress);
417        });
418    }
419
420    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
421        if !self.active() {
422            return false;
423        }
424        let now = cx.now();
425        match event {
426            Event::Key(key) => {
427                let release = key.kind == KeyKind::Release;
428                if !self.is_trigger(cx, key.chord, release) {
429                    return false;
430                }
431                let (source, timed_out) = {
432                    let memory = cx.memory::<HoldMemory>();
433                    (memory.source, memory.timeout().is_some_and(|timeout| now > timeout))
434                };
435                if release {
436                    if source == Some(Source::Key(key.chord.key)) {
437                        self.release(cx);
438                    }
439                    return true;
440                }
441                if timed_out || source != Some(Source::Key(key.chord.key)) {
442                    self.start(cx, Source::Key(key.chord.key));
443                    return true;
444                }
445                cx.memory::<HoldMemory>().repeated = true;
446                self.keep(cx);
447                true
448            }
449            Event::Mouse(mouse) if !self.floating => match mouse.kind {
450                MouseKind::Down(MouseButton::Left) => {
451                    cx.capture_pointer();
452                    cx.repeat_pointer(POINTER_REPEAT);
453                    self.start(cx, Source::Pointer);
454                    true
455                }
456                MouseKind::Drag(MouseButton::Left) if cx.memory::<HoldMemory>().source == Some(Source::Pointer) => {
457                    if cx.area().contains(mouse.x, mouse.y) {
458                        self.keep(cx);
459                    } else {
460                        self.release(cx);
461                    }
462                    true
463                }
464                MouseKind::Up(MouseButton::Left) => {
465                    self.release(cx);
466                    true
467                }
468                _ => false,
469            },
470            _ => false,
471        }
472    }
473
474    fn focusable(&self) -> bool {
475        self.active() && !self.floating
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::event::KeyEvent;
483    use crate::runtime::{App, Command, Harness};
484    use crate::widget::View;
485    use crate::widgets::TextInput;
486
487    #[derive(Default)]
488    struct Demo {
489        confirmed: u32,
490        floating: bool,
491        typed: String,
492        color: Option<&'static str>,
493    }
494
495    #[derive(Clone)]
496    enum Msg {
497        Confirm,
498        Typed(String),
499    }
500
501    impl App for Demo {
502        type Msg = Msg;
503        fn update(&mut self, msg: Msg) -> Command<Msg> {
504            match msg {
505                Msg::Confirm => self.confirmed += 1,
506                Msg::Typed(text) => self.typed = text,
507            }
508            Command::none()
509        }
510        fn view(&self, ui: &mut View<'_, Msg>) {
511            ui.column(|ui| {
512                let mut hold = HoldToConfirm::new("Hold to delete").key("ctrl+d").floating(self.floating);
513                if let Some(color) = self.color {
514                    hold = hold.color(color);
515                }
516                ui.add(hold.on_confirm(Msg::Confirm)).id("hold");
517                ui.add(TextInput::new(&self.typed).on_change(Msg::Typed)).id("field");
518            });
519        }
520    }
521
522    fn repeat(chord: &str) -> KeyEvent {
523        KeyEvent { kind: KeyKind::Repeat, ..KeyEvent::press(chord) }
524    }
525
526    fn release(chord: &str) -> KeyEvent {
527        KeyEvent { kind: KeyKind::Release, ..KeyEvent::press(chord) }
528    }
529
530    /// Holds `chord` for `total` with keep-alive presses every 30 ms, like a keyboard without
531    /// the kitty protocol.
532    fn hold_with_presses(h: &mut Harness<Demo>, chord: &str, total: Duration) {
533        h.key(KeyEvent::press(chord));
534        let mut held = Duration::ZERO;
535        while held < total {
536            h.advance(Duration::from_millis(30));
537            held += Duration::from_millis(30);
538            h.key(KeyEvent::press(chord));
539        }
540    }
541
542    #[test]
543    fn draws_label_and_empty_bars_without_brackets() {
544        let h = Harness::new(Demo::default(), 40, 2);
545        assert_eq!(h.screen(), "  Hold to delete\n  ❯\n");
546        let track = h.env().theme().color("active");
547        assert_eq!(h.bg(18, 0), track);
548        assert_eq!(h.bg(21, 0), h.env().theme().color("raised"), "one cell between bars");
549    }
550
551    /// The colour of every cell of the three bars, bar by bar, checking that a bar is one colour.
552    fn bars(h: &Harness<Demo>) -> [Option<Rgb>; 3] {
553        [18, 22, 26].map(|x| {
554            let cells = [h.bg(x, 0), h.bg(x + 1, 0), h.bg(x + 2, 0)];
555            assert!(cells.iter().all(|cell| *cell == cells[0]), "a bar blends as a whole: {cells:?}");
556            cells[0]
557        })
558    }
559
560    /// Whether two colours match within one step of rounding per channel.
561    fn near(a: Option<Rgb>, b: Rgb) -> bool {
562        a.is_some_and(|a| a.r.abs_diff(b.r) <= 1 && a.g.abs_diff(b.g) <= 1 && a.b.abs_diff(b.b) <= 1)
563    }
564
565    #[test]
566    fn bars_blend_whole_one_after_another_to_the_theme_colour() {
567        let mut h = Harness::new(Demo::default(), 40, 2);
568        let theme = h.env().theme().clone();
569        let track = theme.color("active").expect("track");
570        let to = theme.color("warning").expect("the target is the warning tone");
571        let half = track.mix(to, 0.5);
572        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
573        assert_eq!(bars(&h), [Some(track); 3]);
574        h.advance(Duration::from_millis(200));
575        let [first, second, third] = bars(&h);
576        assert!(near(first, half), "1/6: the first bar is halfway: {first:?}");
577        assert_eq!((second, third), (Some(track), Some(track)), "1/6: the others wait");
578        h.advance(Duration::from_millis(400));
579        let [first, second, third] = bars(&h);
580        assert_eq!(first, Some(to), "1/2: the first bar is full");
581        assert!(near(second, half), "1/2: the second bar is halfway: {second:?}");
582        assert_eq!(third, Some(track));
583        h.advance(Duration::from_millis(400));
584        let [first, second, third] = bars(&h);
585        assert_eq!((first, second), (Some(to), Some(to)), "5/6: two bars are full");
586        assert!(near(third, half), "5/6: the third bar is halfway: {third:?}");
587        assert_eq!(h.app().confirmed, 0, "nothing fires before the last bar is full");
588        h.advance(Duration::from_millis(200));
589        assert_eq!(bars(&h), [Some(to); 3]);
590        assert_eq!(h.app().confirmed, 1, "the full third bar fires");
591        h.advance(Duration::from_millis(400));
592        assert_eq!(h.app().confirmed, 1, "a completed hold sends once");
593    }
594
595    /// Holds the mouse button and checks the bars blend from the track to `to` at 1/6, 1/2 and
596    /// 5/6 of the hold and are all `to` at the end.
597    fn fills_towards(h: &mut Harness<Demo>, to: Rgb, label: &str) {
598        let track = h.env().theme().color("active").expect("track");
599        let half = track.mix(to, 0.5);
600        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
601        h.advance(Duration::from_millis(200));
602        let [first, second, third] = bars(h);
603        assert!(near(first, half) && second == Some(track) && third == Some(track), "{label} 1/6: {first:?}");
604        h.advance(Duration::from_millis(400));
605        let [first, second, third] = bars(h);
606        assert!(first == Some(to) && near(second, half) && third == Some(track), "{label} 1/2: {second:?}");
607        h.advance(Duration::from_millis(400));
608        let [first, second, third] = bars(h);
609        assert!(first == Some(to) && second == Some(to) && near(third, half), "{label} 5/6: {third:?}");
610        h.advance(Duration::from_millis(200));
611        assert_eq!(bars(h), [Some(to); 3], "{label}: full");
612        h.mouse(MouseKind::Up(MouseButton::Left), 4, 0);
613        h.advance(Duration::from_millis(500));
614    }
615
616    #[test]
617    fn a_theme_colour_can_be_chosen_in_every_theme() {
618        for (token, color) in
619            [("warning", "$warning"), ("danger", "$danger"), ("success", "$success"), ("accent", "$accent")]
620        {
621            let mut h = Harness::new(Demo { color: Some(color), ..Demo::default() }, 40, 2);
622            for id in ["monochrome", "iris", "nordic", "amber"] {
623                h.set_theme(id);
624                let to = h.env().theme().color(token).expect("token");
625                fills_towards(&mut h, to, &format!("{id} {color}"));
626            }
627            assert_eq!(h.app().confirmed, 4);
628        }
629    }
630
631    #[test]
632    fn a_blend_or_a_fixed_hex_colour_works_too() {
633        let mut h = Harness::new(Demo { color: Some("#38BDF8"), ..Demo::default() }, 40, 2);
634        fills_towards(&mut h, Rgb::new(0x38, 0xBD, 0xF8), "hex");
635        let mut h = Harness::new(Demo { color: Some("mix($accent, $danger, 50%)"), ..Demo::default() }, 40, 2);
636        let theme = h.env().theme().clone();
637        let blend = theme.color("danger").expect("danger").mix(theme.color("accent").expect("accent"), 0.5);
638        fills_towards(&mut h, blend, "mix");
639    }
640
641    #[test]
642    fn an_invalid_colour_falls_back_to_the_theme_target() {
643        for invalid in ["$dangr", "red", "#12", "pulse($accent, $danger)", ""] {
644            let mut h = Harness::new(Demo { color: Some(invalid), ..Demo::default() }, 40, 2);
645            let theme = h.env().theme().clone();
646            let error = theme.solid(invalid).expect_err("not a single colour");
647            assert!(!error.is_empty(), "{invalid}: the reason is reported");
648            fills_towards(&mut h, theme.color("warning").expect("warning"), invalid);
649        }
650    }
651
652    #[test]
653    fn the_target_colour_comes_from_the_theme() {
654        let mut h = Harness::new(Demo::default(), 40, 2);
655        for id in ["monochrome", "iris", "nordic", "amber"] {
656            h.set_theme(id);
657            let theme = h.env().theme().clone();
658            h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
659            h.advance(Duration::from_millis(1250));
660            assert_eq!(bars(&h), [theme.color("warning"); 3], "{id}");
661            h.mouse(MouseKind::Up(MouseButton::Left), 4, 0);
662            h.advance(Duration::from_millis(500));
663            assert_eq!(bars(&h), [theme.color("active"); 3], "{id}: empty again");
664        }
665    }
666
667    #[test]
668    fn the_key_holds_while_it_repeats_and_letting_go_empties_the_bars_quickly() {
669        let mut h = Harness::new(Demo::default(), 40, 2);
670        h.press("tab");
671        assert!(h.is_focused("hold"));
672        h.key(KeyEvent::press("enter"));
673        for _ in 0..25 {
674            h.advance(Duration::from_millis(30));
675            h.key(repeat("enter"));
676        }
677        let theme = h.env().theme().clone();
678        let (track, to) = (theme.color("active").expect("track"), theme.color("warning").expect("warning"));
679        assert_eq!(bars(&h)[0], Some(to), "750 ms fill the first bar");
680        h.key(release("enter"));
681        let enter = theme.motion().enter;
682        h.advance(enter / 2);
683        let [first, second, _] = bars(&h);
684        assert!(first != Some(track) || second != Some(track), "the bars empty over a moment, not at once");
685        h.advance(enter);
686        assert_eq!(bars(&h), [Some(track); 3], "empty after motion.enter");
687        assert_eq!(h.app().confirmed, 0);
688    }
689
690    #[test]
691    fn releasing_early_resets() {
692        let mut h = Harness::new(Demo::default(), 40, 2);
693        h.press("tab");
694        hold_with_presses(&mut h, "space", Duration::from_millis(600));
695        h.key(release("space"));
696        h.advance(h.env().theme().motion().enter);
697        assert_eq!(h.bg(18, 0), h.env().theme().color("active"));
698        hold_with_presses(&mut h, "space", Duration::from_millis(600));
699        assert_eq!(h.app().confirmed, 0, "the hold started over after the release");
700        hold_with_presses(&mut h, "space", Duration::from_millis(700));
701        assert_eq!(h.app().confirmed, 1);
702    }
703
704    #[test]
705    fn a_silent_key_counts_as_released() {
706        let mut h = Harness::new(Demo::default(), 40, 2);
707        h.press("tab").key(KeyEvent::press("enter"));
708        h.advance(Duration::from_millis(900));
709        assert_eq!(h.bg(18, 0), h.env().theme().color("active"), "no repeat within the delay: released");
710        h.key(KeyEvent::press("enter"));
711        h.advance(Duration::from_millis(1250)).key(KeyEvent::press("enter"));
712        assert_eq!(h.app().confirmed, 0, "a new press starts a new hold");
713    }
714
715    #[test]
716    fn a_chord_works_from_anywhere_and_floats_a_card() {
717        let mut h = Harness::new(Demo { floating: true, ..Demo::default() }, 40, 4);
718        h.click(3, 0).type_text("x");
719        assert_eq!(h.app().typed, "x");
720        assert!(!h.screen().contains("Hold to delete"));
721        hold_with_presses(&mut h, "ctrl+d", Duration::from_millis(300));
722        let screen = h.screen();
723        assert!(screen.contains("Hold to delete"), "{screen}");
724        assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("  ▌")), "the card has a pillar: {screen}");
725        hold_with_presses(&mut h, "ctrl+d", Duration::from_millis(1300));
726        assert_eq!(h.app().confirmed, 1);
727        h.key(release("d")).advance(Duration::from_millis(10));
728        assert!(h.screen().contains("Hold to delete"), "the card stays while its bars empty");
729        h.advance(h.env().theme().motion().enter);
730        assert!(!h.screen().contains("Hold to delete"));
731    }
732
733    #[test]
734    fn holding_the_mouse_button_confirms_and_leaving_cancels() {
735        let mut h = Harness::new(Demo::default(), 40, 2);
736        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
737        h.advance(Duration::from_millis(600));
738        assert_eq!(h.app().confirmed, 0);
739        h.mouse(MouseKind::Drag(MouseButton::Left), 4, 1);
740        h.advance(Duration::from_millis(900));
741        assert_eq!(h.app().confirmed, 0, "leaving the control cancels");
742        h.mouse(MouseKind::Up(MouseButton::Left), 4, 1);
743        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
744        for _ in 0..40 {
745            h.advance(Duration::from_millis(40));
746        }
747        assert_eq!(h.app().confirmed, 1, "the held button is followed without events");
748    }
749
750    #[test]
751    fn reduced_motion_switches_each_bar_at_the_end_of_its_third_and_empties_at_once() {
752        let mut h = Harness::new(Demo::default(), 40, 2);
753        h.set_reduced_motion(true);
754        let theme = h.env().theme().clone();
755        let (track, to) = (theme.color("active"), theme.color("warning"));
756        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
757        h.advance(Duration::from_millis(390));
758        assert_eq!(bars(&h), [track; 3], "just before a third nothing shows");
759        h.advance(Duration::from_millis(10));
760        assert_eq!(bars(&h), [to, track, track], "a third switches the first bar at once");
761        h.advance(Duration::from_millis(600));
762        assert_eq!(bars(&h), [to, to, track], "5/6: the third bar waits for its end");
763        h.mouse(MouseKind::Up(MouseButton::Left), 4, 0);
764        assert_eq!(bars(&h), [track; 3], "letting go empties at once");
765    }
766
767    #[test]
768    fn hover_and_focus_raise_the_pillar_in_the_first_cell() {
769        let mut h = Harness::new(Demo::default(), 40, 2);
770        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
771        assert!(h.screen().starts_with("  Hold to delete"));
772        h.hover(8, 0);
773        assert!(h.screen().starts_with("▌ Hold to delete"), "{}", h.screen());
774        h.hover(39, 1).press("tab");
775        assert!(h.screen().starts_with("▌ Hold to delete"), "{}", h.screen());
776    }
777
778    #[test]
779    fn a_narrow_control_keeps_the_whole_label_and_puts_the_bars_under_it() {
780        let h = Harness::new(Demo::default(), 20, 3);
781        let theme = h.env().theme();
782        assert!(h.screen().starts_with("  Hold to delete\n"), "{}", h.screen());
783        assert_eq!([h.bg(2, 1), h.bg(10, 1)], [theme.color("active"); 2], "the first and last bar: {}", h.screen());
784    }
785
786    /// A hold control with a long label, in English or German, in a column 40 cells wide.
787    struct Long {
788        german: bool,
789        floating: bool,
790    }
791
792    impl App for Long {
793        type Msg = ();
794        fn update(&mut self, (): ()) -> Command<()> {
795            Command::none()
796        }
797        fn view(&self, ui: &mut View<'_, ()>) {
798            let label = if self.german { "Alle Einträge endgültig löschen" } else { "Delete every record for good" };
799            ui.column(|ui| {
800                ui.add(HoldToConfirm::new(label).key("ctrl+d").floating(self.floating).on_confirm(())).id("hold");
801                ui.add(crate::widgets::Text::new("after"));
802            });
803        }
804    }
805
806    #[test]
807    fn at_forty_columns_a_long_label_puts_the_bars_under_it() {
808        for (german, code) in [(false, "en"), (true, "de")] {
809            let mut h = Harness::new(Long { german, floating: false }, 40, 6);
810            h.set_locale(code);
811            let screen = h.screen();
812            assert!(!screen.contains('…'), "{code}: {screen}");
813            let label = if german { "Alle Einträge endgültig löschen" } else { "Delete every record for good" };
814            assert!(screen.lines().next().is_some_and(|line| line.trim() == label), "{code}: {screen}");
815            let track = h.env().theme().color("active");
816            assert_eq!([h.bg(2, 1), h.bg(6, 1), h.bg(10, 1)], [track; 3], "the bars under the label: {code}: {screen}");
817            assert!(
818                screen.lines().nth(2).is_some_and(|line| line.starts_with("after")),
819                "the control reports two rows: {screen}"
820            );
821        }
822    }
823
824    #[test]
825    fn a_floating_card_grows_a_row_for_the_bars_under_a_long_label() {
826        let mut h = Harness::new(Long { german: true, floating: true }, 40, 8);
827        h.key(KeyEvent::press("ctrl+d")).advance(Duration::from_millis(30));
828        let screen = h.screen();
829        assert!(!screen.contains('…') && screen.contains("Alle Einträge") && screen.contains("löschen"), "{screen}");
830        let label = screen.lines().position(|line| line.contains("löschen")).unwrap_or_default();
831        let bars = u16::try_from(label + 1).unwrap_or_default();
832        let track = h.env().theme().color("active");
833        assert!((0..40).any(|x| h.bg(x, bars) == track), "the bars on the card's row under the label: {screen}");
834    }
835
836    /// A heading, a bar that fills the rest of the row and a hold control at its end, as a
837    /// progress pane lays them out.
838    struct Heading;
839
840    impl App for Heading {
841        type Msg = ();
842        fn update(&mut self, (): ()) -> Command<()> {
843            Command::none()
844        }
845        fn view(&self, ui: &mut View<'_, ()>) {
846            ui.column(|ui| {
847                ui.row(|ui| {
848                    ui.add(crate::widgets::Text::new("Installing paru").bold().no_wrap());
849                    ui.add(crate::widgets::ProgressBar::indeterminate()).fill_width();
850                    ui.add(HoldToConfirm::new("Hold to stop").on_confirm(()));
851                })
852                .gap(2)
853                .fill_width();
854                ui.add(crate::widgets::Text::new("after"));
855            });
856        }
857    }
858
859    #[test]
860    fn beside_a_bar_that_fills_the_row_the_control_keeps_one_line() {
861        let h = Harness::new(Heading, 80, 6);
862        let screen = h.screen();
863        assert!(screen.lines().next().is_some_and(|line| line.contains("Hold to stop")), "{screen}");
864        assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("after")), "the row is one line: {screen}");
865    }
866}