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