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.clear(rect, background);
380        let hold = cx.style("hold", None, &[State::Active]);
381        let to = self.target(cx, hold.color("to"));
382        let pillar: Rgb = card.color("pillar").unwrap_or_else(|| cx.color("muted").mix(to, progress));
383        for row in 0..rect.height {
384            cx.pillar(rect.x, rect.y + i32::from(row), pillar);
385        }
386        let inner = rect.inset(padding);
387        self.paint_content(cx, inner.x, inner.y, inner.width, &[State::Active], progress);
388    }
389
390    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
391        if !self.active() {
392            return false;
393        }
394        let now = cx.now();
395        match event {
396            Event::Key(key) => {
397                let release = key.kind == KeyKind::Release;
398                if !self.is_trigger(cx, key.chord, release) {
399                    return false;
400                }
401                let (source, timed_out) = {
402                    let memory = cx.memory::<HoldMemory>();
403                    (memory.source, memory.timeout().is_some_and(|timeout| now > timeout))
404                };
405                if release {
406                    if source == Some(Source::Key(key.chord.key)) {
407                        self.release(cx);
408                    }
409                    return true;
410                }
411                if timed_out || source != Some(Source::Key(key.chord.key)) {
412                    self.start(cx, Source::Key(key.chord.key));
413                    return true;
414                }
415                cx.memory::<HoldMemory>().repeated = true;
416                self.keep(cx);
417                true
418            }
419            Event::Mouse(mouse) if !self.floating => match mouse.kind {
420                MouseKind::Down(MouseButton::Left) => {
421                    cx.capture_pointer();
422                    cx.repeat_pointer(POINTER_REPEAT);
423                    self.start(cx, Source::Pointer);
424                    true
425                }
426                MouseKind::Drag(MouseButton::Left) if cx.memory::<HoldMemory>().source == Some(Source::Pointer) => {
427                    if cx.area().contains(mouse.x, mouse.y) {
428                        self.keep(cx);
429                    } else {
430                        self.release(cx);
431                    }
432                    true
433                }
434                MouseKind::Up(MouseButton::Left) => {
435                    self.release(cx);
436                    true
437                }
438                _ => false,
439            },
440            _ => false,
441        }
442    }
443
444    fn focusable(&self) -> bool {
445        self.active() && !self.floating
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::event::KeyEvent;
453    use crate::runtime::{App, Command, Harness};
454    use crate::widget::View;
455    use crate::widgets::TextInput;
456
457    #[derive(Default)]
458    struct Demo {
459        confirmed: u32,
460        floating: bool,
461        typed: String,
462        color: Option<&'static str>,
463    }
464
465    #[derive(Clone)]
466    enum Msg {
467        Confirm,
468        Typed(String),
469    }
470
471    impl App for Demo {
472        type Msg = Msg;
473        fn update(&mut self, msg: Msg) -> Command<Msg> {
474            match msg {
475                Msg::Confirm => self.confirmed += 1,
476                Msg::Typed(text) => self.typed = text,
477            }
478            Command::none()
479        }
480        fn view(&self, ui: &mut View<'_, Msg>) {
481            ui.column(|ui| {
482                let mut hold = HoldToConfirm::new("Hold to delete").key("ctrl+d").floating(self.floating);
483                if let Some(color) = self.color {
484                    hold = hold.color(color);
485                }
486                ui.add(hold.on_confirm(Msg::Confirm)).id("hold");
487                ui.add(TextInput::new(&self.typed).on_change(Msg::Typed)).id("field");
488            });
489        }
490    }
491
492    fn repeat(chord: &str) -> KeyEvent {
493        KeyEvent { kind: KeyKind::Repeat, ..KeyEvent::press(chord) }
494    }
495
496    fn release(chord: &str) -> KeyEvent {
497        KeyEvent { kind: KeyKind::Release, ..KeyEvent::press(chord) }
498    }
499
500    /// Holds `chord` for `total` with keep-alive presses every 30 ms, like a keyboard without
501    /// the kitty protocol.
502    fn hold_with_presses(h: &mut Harness<Demo>, chord: &str, total: Duration) {
503        h.key(KeyEvent::press(chord));
504        let mut held = Duration::ZERO;
505        while held < total {
506            h.advance(Duration::from_millis(30));
507            held += Duration::from_millis(30);
508            h.key(KeyEvent::press(chord));
509        }
510    }
511
512    #[test]
513    fn draws_label_and_empty_bars_without_brackets() {
514        let h = Harness::new(Demo::default(), 40, 2);
515        assert_eq!(h.screen(), "  Hold to delete\n  ❯\n");
516        let track = h.env().theme().color("active");
517        assert_eq!(h.bg(18, 0), track);
518        assert_eq!(h.bg(21, 0), h.env().theme().color("raised"), "one cell between bars");
519    }
520
521    /// The colour of every cell of the three bars, bar by bar, checking that a bar is one colour.
522    fn bars(h: &Harness<Demo>) -> [Option<Rgb>; 3] {
523        [18, 22, 26].map(|x| {
524            let cells = [h.bg(x, 0), h.bg(x + 1, 0), h.bg(x + 2, 0)];
525            assert!(cells.iter().all(|cell| *cell == cells[0]), "a bar blends as a whole: {cells:?}");
526            cells[0]
527        })
528    }
529
530    /// Whether two colours match within one step of rounding per channel.
531    fn near(a: Option<Rgb>, b: Rgb) -> bool {
532        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)
533    }
534
535    #[test]
536    fn bars_blend_whole_one_after_another_to_the_theme_colour() {
537        let mut h = Harness::new(Demo::default(), 40, 2);
538        let theme = h.env().theme().clone();
539        let track = theme.color("active").expect("track");
540        let to = theme.color("warning").expect("the target is the warning tone");
541        let half = track.mix(to, 0.5);
542        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
543        assert_eq!(bars(&h), [Some(track); 3]);
544        h.advance(Duration::from_millis(200));
545        let [first, second, third] = bars(&h);
546        assert!(near(first, half), "1/6: the first bar is halfway: {first:?}");
547        assert_eq!((second, third), (Some(track), Some(track)), "1/6: the others wait");
548        h.advance(Duration::from_millis(400));
549        let [first, second, third] = bars(&h);
550        assert_eq!(first, Some(to), "1/2: the first bar is full");
551        assert!(near(second, half), "1/2: the second bar is halfway: {second:?}");
552        assert_eq!(third, Some(track));
553        h.advance(Duration::from_millis(400));
554        let [first, second, third] = bars(&h);
555        assert_eq!((first, second), (Some(to), Some(to)), "5/6: two bars are full");
556        assert!(near(third, half), "5/6: the third bar is halfway: {third:?}");
557        assert_eq!(h.app().confirmed, 0, "nothing fires before the last bar is full");
558        h.advance(Duration::from_millis(200));
559        assert_eq!(bars(&h), [Some(to); 3]);
560        assert_eq!(h.app().confirmed, 1, "the full third bar fires");
561        h.advance(Duration::from_millis(400));
562        assert_eq!(h.app().confirmed, 1, "a completed hold sends once");
563    }
564
565    /// Holds the mouse button and checks the bars blend from the track to `to` at 1/6, 1/2 and
566    /// 5/6 of the hold and are all `to` at the end.
567    fn fills_towards(h: &mut Harness<Demo>, to: Rgb, label: &str) {
568        let track = h.env().theme().color("active").expect("track");
569        let half = track.mix(to, 0.5);
570        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
571        h.advance(Duration::from_millis(200));
572        let [first, second, third] = bars(h);
573        assert!(near(first, half) && second == Some(track) && third == Some(track), "{label} 1/6: {first:?}");
574        h.advance(Duration::from_millis(400));
575        let [first, second, third] = bars(h);
576        assert!(first == Some(to) && near(second, half) && third == Some(track), "{label} 1/2: {second:?}");
577        h.advance(Duration::from_millis(400));
578        let [first, second, third] = bars(h);
579        assert!(first == Some(to) && second == Some(to) && near(third, half), "{label} 5/6: {third:?}");
580        h.advance(Duration::from_millis(200));
581        assert_eq!(bars(h), [Some(to); 3], "{label}: full");
582        h.mouse(MouseKind::Up(MouseButton::Left), 4, 0);
583        h.advance(Duration::from_millis(500));
584    }
585
586    #[test]
587    fn a_theme_colour_can_be_chosen_in_every_theme() {
588        for (token, color) in
589            [("warning", "$warning"), ("danger", "$danger"), ("success", "$success"), ("accent", "$accent")]
590        {
591            let mut h = Harness::new(Demo { color: Some(color), ..Demo::default() }, 40, 2);
592            for id in ["monochrome", "iris", "nordic", "amber"] {
593                h.set_theme(id);
594                let to = h.env().theme().color(token).expect("token");
595                fills_towards(&mut h, to, &format!("{id} {color}"));
596            }
597            assert_eq!(h.app().confirmed, 4);
598        }
599    }
600
601    #[test]
602    fn a_blend_or_a_fixed_hex_colour_works_too() {
603        let mut h = Harness::new(Demo { color: Some("#38BDF8"), ..Demo::default() }, 40, 2);
604        fills_towards(&mut h, Rgb::new(0x38, 0xBD, 0xF8), "hex");
605        let mut h = Harness::new(Demo { color: Some("mix($accent, $danger, 50%)"), ..Demo::default() }, 40, 2);
606        let theme = h.env().theme().clone();
607        let blend = theme.color("danger").expect("danger").mix(theme.color("accent").expect("accent"), 0.5);
608        fills_towards(&mut h, blend, "mix");
609    }
610
611    #[test]
612    fn an_invalid_colour_falls_back_to_the_theme_target() {
613        for invalid in ["$dangr", "red", "#12", "pulse($accent, $danger)", ""] {
614            let mut h = Harness::new(Demo { color: Some(invalid), ..Demo::default() }, 40, 2);
615            let theme = h.env().theme().clone();
616            let error = theme.solid(invalid).expect_err("not a single colour");
617            assert!(!error.is_empty(), "{invalid}: the reason is reported");
618            fills_towards(&mut h, theme.color("warning").expect("warning"), invalid);
619        }
620    }
621
622    #[test]
623    fn the_target_colour_comes_from_the_theme() {
624        let mut h = Harness::new(Demo::default(), 40, 2);
625        for id in ["monochrome", "iris", "nordic", "amber"] {
626            h.set_theme(id);
627            let theme = h.env().theme().clone();
628            h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
629            h.advance(Duration::from_millis(1250));
630            assert_eq!(bars(&h), [theme.color("warning"); 3], "{id}");
631            h.mouse(MouseKind::Up(MouseButton::Left), 4, 0);
632            h.advance(Duration::from_millis(500));
633            assert_eq!(bars(&h), [theme.color("active"); 3], "{id}: empty again");
634        }
635    }
636
637    #[test]
638    fn the_key_holds_while_it_repeats_and_letting_go_empties_the_bars_quickly() {
639        let mut h = Harness::new(Demo::default(), 40, 2);
640        h.press("tab");
641        assert!(h.is_focused("hold"));
642        h.key(KeyEvent::press("enter"));
643        for _ in 0..25 {
644            h.advance(Duration::from_millis(30));
645            h.key(repeat("enter"));
646        }
647        let theme = h.env().theme().clone();
648        let (track, to) = (theme.color("active").expect("track"), theme.color("warning").expect("warning"));
649        assert_eq!(bars(&h)[0], Some(to), "750 ms fill the first bar");
650        h.key(release("enter"));
651        let enter = theme.motion().enter;
652        h.advance(enter / 2);
653        let [first, second, _] = bars(&h);
654        assert!(first != Some(track) || second != Some(track), "the bars empty over a moment, not at once");
655        h.advance(enter);
656        assert_eq!(bars(&h), [Some(track); 3], "empty after motion.enter");
657        assert_eq!(h.app().confirmed, 0);
658    }
659
660    #[test]
661    fn releasing_early_resets() {
662        let mut h = Harness::new(Demo::default(), 40, 2);
663        h.press("tab");
664        hold_with_presses(&mut h, "space", Duration::from_millis(600));
665        h.key(release("space"));
666        h.advance(h.env().theme().motion().enter);
667        assert_eq!(h.bg(18, 0), h.env().theme().color("active"));
668        hold_with_presses(&mut h, "space", Duration::from_millis(600));
669        assert_eq!(h.app().confirmed, 0, "the hold started over after the release");
670        hold_with_presses(&mut h, "space", Duration::from_millis(700));
671        assert_eq!(h.app().confirmed, 1);
672    }
673
674    #[test]
675    fn a_silent_key_counts_as_released() {
676        let mut h = Harness::new(Demo::default(), 40, 2);
677        h.press("tab").key(KeyEvent::press("enter"));
678        h.advance(Duration::from_millis(900));
679        assert_eq!(h.bg(18, 0), h.env().theme().color("active"), "no repeat within the delay: released");
680        h.key(KeyEvent::press("enter"));
681        h.advance(Duration::from_millis(1250)).key(KeyEvent::press("enter"));
682        assert_eq!(h.app().confirmed, 0, "a new press starts a new hold");
683    }
684
685    #[test]
686    fn a_chord_works_from_anywhere_and_floats_a_card() {
687        let mut h = Harness::new(Demo { floating: true, ..Demo::default() }, 40, 4);
688        h.click(3, 0).type_text("x");
689        assert_eq!(h.app().typed, "x");
690        assert!(!h.screen().contains("Hold to delete"));
691        hold_with_presses(&mut h, "ctrl+d", Duration::from_millis(300));
692        let screen = h.screen();
693        assert!(screen.contains("Hold to delete"), "{screen}");
694        assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("  ▌")), "the card has a pillar: {screen}");
695        hold_with_presses(&mut h, "ctrl+d", Duration::from_millis(1300));
696        assert_eq!(h.app().confirmed, 1);
697        h.key(release("d")).advance(Duration::from_millis(10));
698        assert!(h.screen().contains("Hold to delete"), "the card stays while its bars empty");
699        h.advance(h.env().theme().motion().enter);
700        assert!(!h.screen().contains("Hold to delete"));
701    }
702
703    #[test]
704    fn holding_the_mouse_button_confirms_and_leaving_cancels() {
705        let mut h = Harness::new(Demo::default(), 40, 2);
706        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
707        h.advance(Duration::from_millis(600));
708        assert_eq!(h.app().confirmed, 0);
709        h.mouse(MouseKind::Drag(MouseButton::Left), 4, 1);
710        h.advance(Duration::from_millis(900));
711        assert_eq!(h.app().confirmed, 0, "leaving the control cancels");
712        h.mouse(MouseKind::Up(MouseButton::Left), 4, 1);
713        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
714        for _ in 0..40 {
715            h.advance(Duration::from_millis(40));
716        }
717        assert_eq!(h.app().confirmed, 1, "the held button is followed without events");
718    }
719
720    #[test]
721    fn reduced_motion_switches_each_bar_at_the_end_of_its_third_and_empties_at_once() {
722        let mut h = Harness::new(Demo::default(), 40, 2);
723        h.set_reduced_motion(true);
724        let theme = h.env().theme().clone();
725        let (track, to) = (theme.color("active"), theme.color("warning"));
726        h.mouse(MouseKind::Down(MouseButton::Left), 4, 0);
727        h.advance(Duration::from_millis(390));
728        assert_eq!(bars(&h), [track; 3], "just before a third nothing shows");
729        h.advance(Duration::from_millis(10));
730        assert_eq!(bars(&h), [to, track, track], "a third switches the first bar at once");
731        h.advance(Duration::from_millis(600));
732        assert_eq!(bars(&h), [to, to, track], "5/6: the third bar waits for its end");
733        h.mouse(MouseKind::Up(MouseButton::Left), 4, 0);
734        assert_eq!(bars(&h), [track; 3], "letting go empties at once");
735    }
736
737    #[test]
738    fn hover_and_focus_raise_the_pillar_in_the_first_cell() {
739        let mut h = Harness::new(Demo::default(), 40, 2);
740        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
741        assert!(h.screen().starts_with("  Hold to delete"));
742        h.hover(8, 0);
743        assert!(h.screen().starts_with("▌ Hold to delete"), "{}", h.screen());
744        h.hover(39, 1).press("tab");
745        assert!(h.screen().starts_with("▌ Hold to delete"), "{}", h.screen());
746    }
747
748    #[test]
749    fn a_narrow_control_cuts_the_label_and_keeps_the_bars() {
750        let h = Harness::new(Demo::default(), 20, 2);
751        let theme = h.env().theme();
752        assert!(h.screen().starts_with("  Ho…"), "{}", h.screen());
753        assert_eq!(h.bg(17, 0), theme.color("active"), "the last bar is still drawn");
754    }
755}