1use 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
16const DEFAULT_DURATION: Duration = Duration::from_millis(1200);
18
19const INITIAL_TIMEOUT: Duration = Duration::from_millis(650);
22
23const REPEAT_TIMEOUT: Duration = Duration::from_millis(350);
25
26const POINTER_REPEAT: Duration = Duration::from_millis(40);
28
29const FILL_FRAME: Duration = Duration::from_millis(16);
31
32const BARS: u16 = 3;
34
35const BAR: u16 = 3;
37
38const BARS_WIDTH: u16 = BARS * BAR + BARS - 1;
40
41pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Source {
82 Key(Key),
83 Pointer,
84}
85
86#[derive(Debug, Default)]
87struct HoldMemory {
88 start: Option<Duration>,
90 last: Duration,
92 repeated: bool,
93 source: Option<Source>,
94 done: bool,
96 released: Option<(Duration, f32)>,
98}
99
100impl HoldMemory {
101 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
131fn 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 if reduced_motion { (within + 0.001).floor().min(1.0) } else { within }
137}
138
139impl<Msg: Clone + 'static> HoldToConfirm<Msg> {
140 #[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 #[must_use]
156 pub fn on_confirm(mut self, message: Msg) -> Self {
157 self.on_confirm = Some(message);
158 self
159 }
160
161 #[must_use]
164 pub fn duration(mut self, duration: Duration) -> Self {
165 self.duration = duration;
166 self
167 }
168
169 #[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 #[must_use]
183 pub fn floating(mut self, floating: bool) -> Self {
184 self.floating = floating;
185 self
186 }
187
188 #[must_use]
190 pub fn disabled(mut self, disabled: bool) -> Self {
191 self.disabled = disabled;
192 self
193 }
194
195 #[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 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 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 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 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 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 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 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 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 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 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 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 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}