1use 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
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> {
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Source {
86 Key(Key),
87 Pointer,
88}
89
90#[derive(Debug, Default)]
91struct HoldMemory {
92 start: Option<Duration>,
94 last: Duration,
96 repeated: bool,
97 source: Option<Source>,
98 done: bool,
100 released: Option<(Duration, f32)>,
102}
103
104impl HoldMemory {
105 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
135fn 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 if reduced_motion { (within + 0.001).floor().min(1.0) } else { within }
141}
142
143impl<Msg: Clone + 'static> HoldToConfirm<Msg> {
144 #[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 #[must_use]
160 pub fn on_confirm(mut self, message: Msg) -> Self {
161 self.on_confirm = Some(message);
162 self
163 }
164
165 #[must_use]
168 pub fn duration(mut self, duration: Duration) -> Self {
169 self.duration = duration;
170 self
171 }
172
173 #[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 #[must_use]
187 pub fn floating(mut self, floating: bool) -> Self {
188 self.floating = floating;
189 self
190 }
191
192 #[must_use]
194 pub fn disabled(mut self, disabled: bool) -> Self {
195 self.disabled = disabled;
196 self
197 }
198
199 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}