1use std::marker::PhantomData;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use crate::event::{Event, MouseKind};
8use crate::geometry::{Rect, Size};
9use crate::i18n::Arg;
10use crate::widget::{EventCx, Length, MeasureCx, Node, PaintCx, Widget, WidgetId};
11use crate::widgets::{Button, Modal, Span, Text, TextInput};
12
13const WORD_MARK: &str = "\u{E000}";
17
18pub struct Confirm<Msg> {
35 title: String,
36 message: Option<String>,
37 confirm_label: Option<String>,
38 cancel_label: Option<String>,
39 danger: bool,
40 dismissable: bool,
41 on_confirm: Msg,
42 on_cancel: Option<Msg>,
43 alternative: Option<(String, Msg)>,
44 word: Option<String>,
46 alternative_chosen: Arc<AtomicBool>,
50}
51
52impl<Msg> Confirm<Msg> {
53 #[must_use]
55 pub fn new(title: impl Into<String>, on_confirm: Msg) -> Self {
56 Self {
57 title: title.into(),
58 message: None,
59 confirm_label: None,
60 cancel_label: None,
61 danger: false,
62 dismissable: true,
63 on_confirm,
64 on_cancel: None,
65 alternative: None,
66 word: None,
67 alternative_chosen: Arc::new(AtomicBool::new(false)),
68 }
69 }
70
71 #[must_use]
73 pub fn message(mut self, message: impl Into<String>) -> Self {
74 self.message = Some(message.into());
75 self
76 }
77
78 #[must_use]
81 pub fn danger(mut self) -> Self {
82 self.danger = true;
83 self
84 }
85
86 #[must_use]
89 pub fn dismissable(mut self, dismissable: bool) -> Self {
90 self.dismissable = dismissable;
91 self
92 }
93
94 #[must_use]
96 pub fn confirm_label(mut self, label: impl Into<String>) -> Self {
97 self.confirm_label = Some(label.into());
98 self
99 }
100
101 #[must_use]
103 pub fn cancel_label(mut self, label: impl Into<String>) -> Self {
104 self.cancel_label = Some(label.into());
105 self
106 }
107
108 #[must_use]
111 pub fn on_cancel(mut self, message: Msg) -> Self {
112 self.on_cancel = Some(message);
113 self
114 }
115
116 #[must_use]
120 pub fn alternative(mut self, label: impl Into<String>, message: Msg) -> Self {
121 self.alternative = Some((label.into(), message));
122 self
123 }
124
125 #[must_use]
149 pub fn require_word(mut self, word: impl Into<String>) -> Self {
150 self.word = Some(word.into()).filter(|word| !word.trim().is_empty());
151 self
152 }
153
154 pub(crate) fn map<B>(self, map: impl Fn(Msg) -> B) -> Confirm<B> {
156 Confirm {
157 title: self.title,
158 message: self.message,
159 confirm_label: self.confirm_label,
160 cancel_label: self.cancel_label,
161 danger: self.danger,
162 dismissable: self.dismissable,
163 on_confirm: map(self.on_confirm),
164 on_cancel: self.on_cancel.map(&map),
165 alternative: self.alternative.map(|(label, message)| (label, map(message))),
166 word: self.word,
167 alternative_chosen: self.alternative_chosen,
168 }
169 }
170
171 pub(crate) fn into_answer(self, confirmed: bool) -> Option<Msg> {
174 match self.alternative {
175 Some((_, message)) if confirmed && self.alternative_chosen.load(Ordering::Relaxed) => Some(message),
176 _ if confirmed => Some(self.on_confirm),
177 _ => self.on_cancel,
178 }
179 }
180}
181
182fn fold(text: &str) -> String {
186 let mut folded = String::with_capacity(text.len());
187 for c in text.trim().chars() {
188 match c {
189 'İ' | 'I' | 'ı' | 'i' => folded.push('i'),
190 _ => folded.extend(c.to_lowercase()),
191 }
192 }
193 folded
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198enum Answer {
199 Cancel,
200 Alternative,
201 Confirm,
202 Typed(String),
204 Submit,
206}
207
208#[derive(Debug, Default)]
211struct ConfirmMemory {
212 pressed: Option<WidgetId>,
213}
214
215#[derive(Debug, Default)]
218struct TypedWord(String);
219
220pub(crate) struct ConfirmLayer<Msg> {
224 title: String,
225 message: Option<String>,
226 confirm_label: Option<String>,
227 cancel_label: Option<String>,
228 danger: bool,
229 dismissable: bool,
230 alternative_label: Option<String>,
231 word: Option<String>,
232 alternative_chosen: Arc<AtomicBool>,
233 marker: PhantomData<fn() -> Msg>,
234}
235
236impl<Msg> ConfirmLayer<Msg> {
237 pub(crate) fn new(confirm: &Confirm<Msg>) -> Self {
238 Self {
239 title: confirm.title.clone(),
240 message: confirm.message.clone(),
241 confirm_label: confirm.confirm_label.clone(),
242 cancel_label: confirm.cancel_label.clone(),
243 danger: confirm.danger,
244 dismissable: confirm.dismissable,
245 alternative_label: confirm.alternative.as_ref().map(|(label, _)| label.clone()),
246 word: confirm.word.clone(),
247 alternative_chosen: Arc::clone(&confirm.alternative_chosen),
248 marker: PhantomData,
249 }
250 }
251
252 fn unlocked(&self, typed: &str) -> bool {
254 self.word.as_deref().is_none_or(|word| fold(word) == fold(typed))
255 }
256
257 fn dialog(&self, env: &crate::env::Env, id: WidgetId, typed: &str) -> Modal<Answer> {
260 let i18n = env.i18n();
261 let cancel = self.cancel_label.clone().unwrap_or_else(|| i18n.translate("quvyta.confirm.cancel", &[]));
262 let confirm = self.confirm_label.clone().unwrap_or_else(|| i18n.translate("quvyta.confirm.confirm", &[]));
263 let mut confirm_button = Button::new(confirm).on_press(Answer::Confirm).disabled(!self.unlocked(typed));
264 confirm_button = confirm_button.variant(if self.danger { "danger" } else { "primary" });
265 let mut dialog = Modal::new()
266 .title(self.title.clone())
267 .on_close(Answer::Cancel)
268 .dismissable(self.dismissable)
269 .action(Button::new(cancel).on_press(Answer::Cancel));
270 if let Some(label) = &self.alternative_label {
273 dialog = dialog.action(Button::new(label.clone()).on_press(Answer::Alternative));
274 }
275 dialog = dialog.action(confirm_button);
276 if self.danger {
277 dialog = dialog.variant("danger");
278 }
279 let mut body = Vec::new();
280 if let Some(message) = &self.message {
281 body.push(Node::new(Text::new(message.clone()), body.len()));
282 }
283 if let Some(word) = &self.word {
284 let mut prompt = Node::new(prompt(env, word), body.len());
285 if self.message.is_some() {
286 prompt.layout.padding.top = 1;
287 }
288 body.push(prompt);
289 let field = TextInput::new(typed).on_change(Answer::Typed).on_submit(|_| Answer::Submit);
291 let mut field = Node::new(field, body.len());
292 field.layout.width = Length::Fill(1);
293 body.push(field);
294 }
295 if !body.is_empty() {
296 crate::widget::Container::set_children(&mut dialog, body);
297 }
298 for child in dialog.children_mut() {
299 child.assign_ids(id);
300 }
301 dialog
302 }
303
304 fn forward(cx: &mut EventCx<'_, Msg>, part: &Node<Answer>, rect: Rect, event: &Event) -> (bool, Vec<Answer>) {
308 let mut answers = Vec::new();
309 let handled = {
310 let mut part_cx = EventCx {
311 id: part.id(),
312 rect,
313 focus_rect: cx.focus_rect,
314 env: cx.env,
315 memory: &mut *cx.memory,
316 interaction: cx.interaction,
317 messages: &mut answers,
318 effects: &mut *cx.effects,
319 now: cx.now,
320 persistent: cx.persistent,
321 preview: cx.preview,
322 holds_pointer: cx.holds_pointer,
323 };
324 part.widget.event(&mut part_cx, event)
325 };
326 (handled, answers)
327 }
328
329 fn settle(&self, cx: &mut EventCx<'_, Msg>, answers: Vec<Answer>) {
331 for answer in answers {
332 match answer {
333 Answer::Typed(text) => cx.memory::<TypedWord>().0 = text,
334 Answer::Submit => {
335 if self.unlocked(&cx.memory::<TypedWord>().0) {
336 cx.answer(true);
337 }
338 }
339 Answer::Alternative => {
340 self.alternative_chosen.store(true, Ordering::Relaxed);
341 cx.answer(true);
342 }
343 Answer::Confirm => {
344 if self.unlocked(&cx.memory::<TypedWord>().0) {
346 cx.answer(true);
347 }
348 }
349 Answer::Cancel => cx.answer(false),
350 }
351 }
352 }
353}
354
355fn prompt(env: &crate::env::Env, word: &str) -> Text {
357 let line = env.i18n().translate("quvyta.confirm.type-word", &[("word", Arg::from(WORD_MARK))]);
358 match line.split_once(WORD_MARK) {
359 Some((before, after)) => Text::rich([
360 Span::new(before).role("secondary"),
361 Span::new(word).role("title"),
362 Span::new(after).role("secondary"),
363 ]),
364 None => Text::new(line).role("secondary"),
365 }
366}
367
368fn field(dialog: &Modal<Answer>) -> Option<&Node<Answer>> {
371 dialog.children().first()?.widget.children().last()
372}
373
374fn parts(dialog: &Modal<Answer>, word: bool) -> Vec<&Node<Answer>> {
376 let field = if word { field(dialog) } else { None };
377 field.into_iter().chain(&dialog.children()[1..]).collect()
378}
379
380impl<Msg: 'static> Widget<Msg> for ConfirmLayer<Msg> {
381 fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
382 Size::default()
383 }
384
385 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
386 cx.request_overlay(area);
387 }
388
389 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
390 let typed = cx.memory::<TypedWord>().0.clone();
391 let dialog = self.dialog(cx.env(), cx.id(), &typed);
392 Widget::<Answer>::paint_overlay(&dialog, cx, anchor);
393 let parts = parts(&dialog, self.word.is_some());
394 let rects =
395 parts.iter().filter_map(|part| cx.frame.rects.get(&part.id()).map(|rect| (part.id(), *rect))).collect();
396 cx.memory::<PartRects>().0 = rects;
397 if self.word.is_some()
400 && let Some(field) = field(&dialog)
401 && let Some(rect) = cx.frame.rects.get(&field.id()).copied()
402 {
403 let saved = (cx.id, cx.layout);
404 (cx.id, cx.layout) = (field.id(), field.layout());
405 field.widget.paint_overlay(cx, rect);
406 (cx.id, cx.layout) = saved;
407 }
408 }
409
410 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
411 let typed = cx.memory::<TypedWord>().0.clone();
412 let dialog = self.dialog(cx.env, cx.id, &typed);
413 let field = if self.word.is_some() { field(&dialog) } else { None };
414 let rect_of = |cx: &mut EventCx<'_, Msg>, id: WidgetId| {
415 cx.memory::<PartRects>().0.iter().find(|(part, _)| *part == id).map(|(_, rect)| *rect).unwrap_or_default()
416 };
417 if let Some(field) = field
420 && cx.interaction.key_capture == Some(field.id())
421 && matches!(event, Event::Key(_) | Event::Mouse(_))
422 {
423 let rect = rect_of(cx, field.id());
424 let (used, answers) = Self::forward(cx, field, rect, event);
425 self.settle(cx, answers);
426 if used {
427 if let Event::Mouse(mouse) = event {
428 cx.memory::<ConfirmMemory>().pressed =
429 matches!(mouse.kind, MouseKind::Down(_)).then_some(field.id());
430 }
431 return true;
432 }
433 }
434 let mut closed = Vec::new();
437 let used = {
438 let mut dialog_cx = EventCx {
439 id: cx.id,
440 rect: cx.rect,
441 focus_rect: cx.focus_rect,
442 env: cx.env,
443 memory: &mut *cx.memory,
444 interaction: cx.interaction,
445 messages: &mut closed,
446 effects: &mut *cx.effects,
447 now: cx.now,
448 persistent: cx.persistent,
449 preview: cx.preview,
450 holds_pointer: cx.holds_pointer,
451 };
452 Widget::<Answer>::event(&dialog, &mut dialog_cx, event)
453 };
454 if closed.contains(&Answer::Cancel) {
455 cx.answer(false);
456 return true;
457 }
458 if used {
459 return true;
460 }
461 let parts = parts(&dialog, self.word.is_some());
462 let target = match event {
463 Event::Key(_) | Event::Paste(_) => parts.iter().find(|part| cx.interaction.focused == Some(part.id())),
464 Event::Mouse(mouse) => {
465 let rects = cx.memory::<PartRects>().0.clone();
466 let over = |part: &&&Node<Answer>| {
467 rects.iter().any(|(id, rect)| *id == part.id() && rect.contains(mouse.x, mouse.y))
468 };
469 let pressed = cx.memory::<ConfirmMemory>().pressed;
470 match mouse.kind {
471 MouseKind::Down(_) => parts.iter().find(over),
472 _ => parts.iter().find(|part| pressed == Some(part.id())),
473 }
474 }
475 Event::PointerOutside => None,
476 };
477 let Some(part) = target else {
478 return matches!(event, Event::Mouse(_));
480 };
481 if let Event::Mouse(mouse) = event {
482 cx.memory::<ConfirmMemory>().pressed = matches!(mouse.kind, MouseKind::Down(_)).then_some(part.id());
483 }
484 let rect = rect_of(cx, part.id());
485 let (handled, answers) = Self::forward(cx, part, rect, event);
486 self.settle(cx, answers);
487 handled || matches!(event, Event::Mouse(_))
488 }
489}
490
491#[derive(Debug, Default)]
494struct PartRects(Vec<(WidgetId, Rect)>);
495
496#[cfg(test)]
497mod tests {
498 use std::time::Duration;
499
500 use crate::event::{MouseButton, MouseKind};
501 use crate::runtime::{App, Command, Confirm, Harness};
502 use crate::widget::View;
503 use crate::widgets::{Button, Text};
504
505 #[derive(Default)]
506 struct Demo {
507 log: Vec<&'static str>,
508 }
509
510 #[derive(Clone)]
511 enum Msg {
512 Ask,
513 AskTwice,
514 Remove,
515 Kept,
516 Prune,
517 AskFirm,
518 AskRecover,
519 Saved,
520 Resumed,
521 Discarded,
522 AskTyped(&'static str),
523 AskTypedWithAlternative,
524 AskQuit(bool),
526 }
527
528 impl App for Demo {
529 type Msg = Msg;
530 fn update(&mut self, msg: Msg) -> Command<Msg> {
531 match msg {
532 Msg::Ask => {
533 return Command::confirm(
534 Confirm::new("Remove web?", Msg::Remove)
535 .message("Its volumes go too.")
536 .confirm_label("Remove")
537 .danger()
538 .on_cancel(Msg::Kept),
539 );
540 }
541 Msg::AskTwice => {
542 return Command::batch([
543 Command::confirm(Confirm::new("Prune images?", Msg::Prune)),
544 Command::confirm(Confirm::new("Remove web?", Msg::Remove)),
545 ]);
546 }
547 Msg::AskFirm => {
548 return Command::confirm(
549 Confirm::new("Rotate keys?", Msg::Remove).dismissable(false).on_cancel(Msg::Kept),
550 );
551 }
552 Msg::AskRecover => {
553 return Command::confirm(
554 Confirm::new("Recover the session?", Msg::Saved)
555 .message("47 minutes were counted.")
556 .confirm_label("Save")
557 .cancel_label("Discard")
558 .on_cancel(Msg::Discarded)
559 .alternative("Continue", Msg::Resumed),
560 );
561 }
562 Msg::AskTyped(word) => {
563 return Command::confirm(
564 Confirm::new("Empty the trash?", Msg::Remove)
565 .message("12 items go for good.")
566 .confirm_label("Empty")
567 .danger()
568 .on_cancel(Msg::Kept)
569 .require_word(word),
570 );
571 }
572 Msg::AskTypedWithAlternative => {
573 return Command::confirm(
574 Confirm::new("Empty the trash?", Msg::Remove)
575 .confirm_label("Empty")
576 .danger()
577 .on_cancel(Msg::Kept)
578 .alternative("Archive", Msg::Resumed)
579 .require_word("SİL"),
580 );
581 }
582 Msg::AskQuit(german) => {
583 let (title, finish, leave) = if german {
584 ("Beenden?", "Beenden und schließen", "Weiterlaufen lassen")
585 } else {
586 ("Quit?", "Finish and quit", "Leave running")
587 };
588 return Command::confirm(
589 Confirm::new(title, Msg::Saved)
590 .confirm_label(finish)
591 .on_cancel(Msg::Discarded)
592 .alternative(leave, Msg::Resumed),
593 );
594 }
595 Msg::Saved => self.log.push("saved"),
596 Msg::Resumed => self.log.push("resumed"),
597 Msg::Discarded => self.log.push("discarded"),
598 Msg::Remove => self.log.push("removed"),
599 Msg::Kept => self.log.push("kept"),
600 Msg::Prune => self.log.push("pruned"),
601 }
602 Command::none()
603 }
604 fn view(&self, ui: &mut View<'_, Msg>) {
605 ui.column(|ui| {
606 ui.add(Text::new("Containers"));
607 ui.add(Button::new("Remove web").on_press(Msg::Ask)).id("ask");
608 });
609 }
610 }
611
612 fn asked() -> Harness<Demo> {
613 let mut h = Harness::new(Demo::default(), 60, 14);
614 h.press("tab").press("enter").advance(Duration::from_millis(200));
615 h
616 }
617
618 fn cell(h: &Harness<Demo>, text: &str) -> (u16, u16) {
619 let (x, y) = h.find(text).unwrap_or_else(|| panic!("`{text}` on screen:\n{}", h.screen()));
620 (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0))
621 }
622
623 #[test]
624 fn shows_the_question_with_cancel_focused() {
625 let h = asked();
626 let screen = h.screen();
627 assert!(
630 screen.lines().nth(4).is_some_and(|l| l.contains("▌ Remove web?") && !l.contains('×'))
631 && screen.lines().nth(3).is_some_and(|l| l.ends_with('×')),
632 "{screen}"
633 );
634 for row in 3..=9 {
635 assert_eq!(h.fg(2, row), h.env().theme().color("danger"), "pillar on row {row}:\n{screen}");
636 }
637 assert!(screen.contains("Its volumes go too."));
638 let (x, y) = cell(&h, "Cancel");
639 assert_ne!(h.bg(x, y), h.env().theme().color("raised"), "Cancel has focus: {screen}");
640 let (x, y) = cell(&h, "Remove ");
641 assert_ne!(h.bg(x, y), h.env().theme().color("raised"), "Remove is a danger button");
642 }
643
644 #[test]
645 fn enter_on_the_safe_default_cancels_and_escape_cancels() {
646 let mut h = asked();
647 h.press("enter");
648 assert_eq!(h.app().log, ["kept"]);
649 assert!(!h.screen().contains("Remove web?"));
650 assert!(h.is_focused("ask"), "focus returns to the button that asked");
651 h.press("enter").advance(Duration::from_millis(200)).press("esc");
652 assert_eq!(h.app().log, ["kept", "kept"]);
653 }
654
655 #[test]
656 fn tab_then_enter_or_a_click_confirms() {
657 let mut h = asked();
658 h.press("tab").press("enter");
659 assert_eq!(h.app().log, ["removed"]);
660 h.press("enter").advance(Duration::from_millis(200));
661 h.click_text("Containers");
662 assert!(h.screen().contains("Remove web?"), "clicks on the dimmed screen do not answer");
663 let (x, y) = cell(&h, "Remove ");
664 h.click(i32::from(x), i32::from(y));
665 assert_eq!(h.app().log, ["removed", "removed"]);
666 }
667
668 #[test]
669 fn the_close_mark_cancels_and_lights_three_cells() {
670 let mut h = asked();
671 let (x, y) = cell(&h, "×");
672 assert_eq!(cell(&h, "Remove web?").1, y + 1, "the mark sits on the surface's first row");
673 let resting = h.bg(x, y);
674 h.hover(i32::from(x) + 1, i32::from(y));
675 let lit = h.bg(x, y);
676 assert_ne!(lit, resting);
677 assert_eq!((h.bg(x - 1, y), h.bg(x + 1, y)), (lit, lit));
678 h.click(i32::from(x), i32::from(y));
679 assert_eq!(h.app().log, ["kept"], "the mark answers like Esc: cancel");
680 assert!(!h.screen().contains("Remove web?"));
681 }
682
683 #[test]
684 fn a_question_that_is_not_dismissable_answers_only_with_its_buttons() {
685 let mut h = Harness::new(Demo::default(), 60, 14);
686 h.send(Msg::AskFirm).advance(Duration::from_millis(200));
687 let screen = h.screen();
688 assert!(screen.contains("Rotate keys?") && !screen.contains('×') && !screen.contains("esc"), "{screen}");
689 h.press("esc").click(57, 4).click(1, 12);
690 assert!(h.app().log.is_empty() && h.screen().contains("Rotate keys?"), "{}", h.screen());
691 h.press("enter");
692 assert_eq!(h.app().log, ["kept"]);
693 }
694
695 #[test]
696 fn questions_stack_and_the_newest_is_answered_first() {
697 let mut h = Harness::new(Demo::default(), 60, 14);
698 h.send(Msg::AskTwice).advance(Duration::from_millis(200));
699 assert!(h.screen().contains("Remove web?"));
700 h.press("tab").press("enter").advance(Duration::from_millis(200));
701 assert!(h.screen().contains("Prune images?"), "{}", h.screen());
702 h.press("tab").press("enter");
703 assert_eq!(h.app().log, ["removed", "pruned"]);
704 }
705
706 #[test]
707 fn a_two_way_question_draws_exactly_as_before() {
708 let h = asked();
709 let screen = h.screen();
710 let rows: Vec<&str> = screen.lines().collect();
711 assert_eq!(rows[3].trim_end().chars().last(), Some('×'), "{screen}");
712 assert_eq!(rows[4], " ▌ Remove web?", "{screen}");
713 assert_eq!(rows[6], " ▌ Its volumes go too.", "{screen}");
714 assert_eq!(rows[8], " ▌ esc close tab switch ▌ Cancel Remove", "{screen}");
715 assert_eq!(rows.iter().filter(|row| row.contains('▌')).count(), 7, "one surface, two buttons:\n{screen}");
716 }
717
718 fn recovering() -> Harness<Demo> {
719 let mut h = Harness::new(Demo::default(), 60, 14);
720 h.send(Msg::AskRecover).advance(Duration::from_millis(200));
721 h
722 }
723
724 #[test]
725 fn a_third_way_sits_between_cancel_and_confirm_with_cancel_focused() {
726 let mut h = recovering();
727 let screen = h.screen();
728 let row = screen.lines().find(|line| line.contains("Continue")).unwrap_or_else(|| panic!("{screen}"));
729 let at = |label: &str| row.find(label).unwrap_or_else(|| panic!("`{label}` in {row}"));
730 assert!(at("Discard") < at("Continue") && at("Continue") < at("Save"), "{row}");
731 assert!(!screen.contains(['[', ']', '|']), "{screen}");
732 let (dx, dy) = cell(&h, "Discard");
733 let (cx, cy) = cell(&h, "Continue");
734 let focused = h.bg(dx, dy);
735 assert_ne!(focused, h.bg(cx, cy), "Cancel has the focus, the third way rests: {screen}");
736 h.press("tab");
737 assert_eq!(h.bg(cx, cy), focused, "tab lifts the third way like the focused Cancel was");
738 }
739
740 #[test]
741 fn the_keyboard_reaches_each_way_in_reading_order() {
742 let mut h = recovering();
743 h.press("enter");
744 assert_eq!(h.app().log, ["discarded"], "enter on the focused Cancel");
745 let mut h = recovering();
746 h.press("tab").press("enter");
747 assert_eq!(h.app().log, ["resumed"], "tab reaches the third way first");
748 let mut h = recovering();
749 h.press("tab").press("tab").press("enter");
750 assert_eq!(h.app().log, ["saved"]);
751 let mut h = recovering();
752 h.press("shift+tab").press("enter");
753 assert_eq!(h.app().log, ["saved"], "shift tab goes round the other way");
754 let mut h = recovering();
755 h.press("esc");
756 assert_eq!(h.app().log, ["discarded"], "esc still cancels");
757 assert!(!h.screen().contains("Recover the session?"));
758 }
759
760 #[test]
761 fn the_mouse_reaches_each_way() {
762 for (label, answer) in [("Continue", "resumed"), ("Save", "saved"), ("Discard", "discarded")] {
763 let mut h = recovering();
764 let (x, y) = cell(&h, label);
765 h.click(i32::from(x), i32::from(y));
766 assert_eq!(h.app().log, [answer], "{label}");
767 assert!(!h.screen().contains("Recover the session?"));
768 }
769 let mut h = recovering();
770 let (x, y) = cell(&h, "Continue");
771 h.mouse(MouseKind::Down(MouseButton::Left), i32::from(x), i32::from(y));
772 let (x, y) = cell(&h, "Save");
773 h.mouse(MouseKind::Up(MouseButton::Left), i32::from(x), i32::from(y));
774 assert!(h.app().log.is_empty(), "a release over another button answers nothing");
775 }
776
777 #[test]
778 fn a_three_way_question_survives_tiny_terminals_and_ascii() {
779 let mut h = recovering();
780 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
781 assert!(!h.screen().contains(['[', ']', '|', '(', ')']), "{}", h.screen());
782 assert!(h.screen().contains("Continue"), "{}", h.screen());
783 for (width, height) in [(0, 0), (1, 1), (12, 4), (30, 8)] {
784 let mut h = Harness::new(Demo::default(), width, height);
785 h.send(Msg::AskRecover).advance(Duration::from_millis(200));
786 h.press("tab").press("tab").press("enter");
787 assert_eq!(h.app().log, ["saved"], "{width}×{height}");
788 }
789 }
790
791 fn typing(word: &'static str) -> Harness<Demo> {
792 let mut h = Harness::new(Demo::default(), 60, 16);
793 h.send(Msg::AskTyped(word)).advance(Duration::from_millis(200));
794 h
795 }
796
797 fn danger_tones() -> (Option<crate::color::Rgb>, Option<crate::color::Rgb>) {
799 struct Tones;
800 impl App for Tones {
801 type Msg = ();
802 fn update(&mut self, (): ()) -> Command<()> {
803 Command::none()
804 }
805 fn view(&self, ui: &mut View<'_, ()>) {
806 ui.column(|ui| {
807 ui.add(Button::new("Off").variant("danger").on_press(()).disabled(true));
808 ui.add(Button::new("On").variant("danger").on_press(()));
809 });
810 }
811 }
812 let h = Harness::new(Tones, 20, 2);
813 (h.bg(3, 0), h.bg(3, 1))
814 }
815
816 #[test]
817 fn a_word_to_type_shows_a_prompt_and_a_field_that_has_focus() {
818 let mut h = typing("SİL");
819 let screen = h.screen();
820 assert!(screen.contains("Type SİL to confirm"), "{screen}");
821 let (x, y) = cell(&h, "SİL to");
822 assert!(h.is_bold(x, y) && !h.is_bold(x - 2, y), "the word stands out by weight: {screen}");
823 assert_ne!(h.fg(x, y), h.fg(x - 2, y), "and by tone");
824 assert!(!screen.contains(['[', ']', '|', '(', ')', '"', '\'']), "{screen}");
825 h.type_text("si");
826 assert!(h.screen().contains("❯ si"), "typing goes straight into the field: {}", h.screen());
827 }
828
829 #[test]
830 fn enter_confirms_only_once_the_word_matches() {
831 let mut h = typing("SİL");
832 h.type_text("sal").press("enter");
833 assert!(h.app().log.is_empty() && h.screen().contains("Empty the trash?"), "{}", h.screen());
834 h.press("backspace").press("backspace").type_text("il").press("enter");
835 assert_eq!(h.app().log, ["removed"]);
836 assert!(!h.screen().contains("Empty the trash?"));
837 }
838
839 #[test]
840 fn matching_ignores_case_surrounding_spaces_and_the_turkish_i() {
841 for (word, typed) in [
842 ("SİL", "sil"),
843 ("SİL", "SİL"),
844 ("SİL", "SIL"),
845 ("SİL", " Sil "),
846 ("SİL", "sıl"),
847 ("İPTAL", "iptal"),
848 ("iptal", "IPTAL"),
849 ("web-1", "WEB-1"),
850 ] {
851 let mut h = typing(word);
853 h.paste(typed).press("enter");
854 assert_eq!(h.app().log, ["removed"], "`{typed}` for `{word}`");
855 }
856 for (word, typed) in [("SİL", "sl"), ("SİL", "si l"), ("İPTAL", "ptal")] {
857 let mut h = typing(word);
858 h.type_text(typed).press("enter");
859 assert!(h.app().log.is_empty(), "`{typed}` is not `{word}`");
860 }
861 assert_eq!(super::fold(" İIıi Ş "), "iiii ş");
862 }
863
864 #[test]
865 fn escape_cancels_with_a_word_half_typed() {
866 let mut h = typing("SİL");
867 h.type_text("si").press("esc");
868 assert_eq!(h.app().log, ["kept"]);
869 assert!(!h.screen().contains("Empty the trash?"));
870 }
871
872 #[test]
873 fn the_confirm_button_waits_in_the_disabled_tone_until_the_word_matches() {
874 let (disabled, enabled) = danger_tones();
875 assert_ne!(disabled, enabled);
876 let mut h = typing("SİL");
877 let (x, y) = cell(&h, "Empty ");
878 assert_eq!(h.bg(x, y), disabled, "{}", h.screen());
879 h.click(i32::from(x), i32::from(y));
880 assert!(h.app().log.is_empty() && h.screen().contains("Empty the trash?"), "a disabled button does nothing");
881 h.type_text("sil").hover(0, 0);
882 assert_eq!(h.bg(x, y), enabled, "{}", h.screen());
883 h.click(i32::from(x), i32::from(y));
884 assert_eq!(h.app().log, ["removed"]);
885 }
886
887 #[test]
888 fn tab_passes_over_the_locked_button_and_reaches_it_once_open() {
889 let mut h = typing("SİL");
890 h.press("tab").press("tab").type_text("sil");
891 assert!(h.screen().contains("❯ sil"), "tab went Cancel, then back to the field: {}", h.screen());
892 h.press("tab").press("tab").press("enter");
893 assert_eq!(h.app().log, ["removed"], "Cancel, then the confirm button");
894 let mut h = typing("SİL");
895 h.press("tab").press("enter");
896 assert_eq!(h.app().log, ["kept"], "the field, then Cancel");
897 }
898
899 #[test]
900 fn a_paste_fills_the_field() {
901 let mut h = typing("SİL");
902 h.paste("SİL").press("enter");
903 assert_eq!(h.app().log, ["removed"]);
904 }
905
906 #[test]
907 fn the_field_keeps_its_edit_menu() {
908 let mut h = typing("SİL");
909 h.set_system_clipboard(Some("SİL"));
910 let (x, y) = cell(&h, "❯");
911 h.mouse(MouseKind::Down(MouseButton::Right), i32::from(x) + 3, i32::from(y));
912 h.mouse(MouseKind::Up(MouseButton::Right), i32::from(x) + 3, i32::from(y));
913 h.advance(Duration::from_millis(200));
914 assert!(h.screen().contains("Select all"), "a right click opens the menu: {}", h.screen());
915 h.press("esc");
916 assert!(!h.screen().contains("Select all"), "esc closes the menu first: {}", h.screen());
917 assert!(h.app().log.is_empty() && h.screen().contains("Empty the trash?"), "and not the dialog");
918 h.mouse(MouseKind::Down(MouseButton::Right), i32::from(x) + 3, i32::from(y));
919 h.mouse(MouseKind::Up(MouseButton::Right), i32::from(x) + 3, i32::from(y));
920 h.advance(Duration::from_millis(200)).click_text("Paste");
921 assert!(h.screen().contains("❯ SİL"), "the menu pastes into the field: {}", h.screen());
922 h.press("enter");
923 assert_eq!(h.app().log, ["removed"]);
924 }
925
926 #[test]
927 fn every_question_starts_with_an_empty_field() {
928 let mut h = typing("SİL");
929 h.type_text("sil").press("enter");
930 h.send(Msg::AskTyped("SİL")).advance(Duration::from_millis(200));
931 assert!(!h.screen().contains("❯ sil"), "{}", h.screen());
932 h.press("enter");
933 assert_eq!(h.app().log, ["removed"], "an empty field does not confirm");
934 h.type_text("si").press("esc");
935 h.send(Msg::AskTyped("SİL")).advance(Duration::from_millis(200));
936 h.type_text("l").press("enter");
937 assert_eq!(h.app().log, ["removed", "kept"], "nothing is left over from the cancelled question");
938 }
939
940 #[test]
941 fn the_alternative_does_not_wait_for_the_word() {
942 let mut h = Harness::new(Demo::default(), 60, 16);
943 h.send(Msg::AskTypedWithAlternative).advance(Duration::from_millis(200));
944 let screen = h.screen();
945 let row = screen.lines().find(|line| line.contains("Archive")).unwrap_or_else(|| panic!("{screen}"));
946 assert!(row.find("Archive") < row.find("Empty"), "{row}");
947 h.press("tab").press("tab").press("enter");
948 assert_eq!(h.app().log, ["resumed"], "field, Cancel, then the alternative");
949 let mut h = Harness::new(Demo::default(), 60, 16);
950 h.send(Msg::AskTypedWithAlternative).advance(Duration::from_millis(200));
951 h.click_text("Archive");
952 assert_eq!(h.app().log, ["resumed"]);
953 let mut h = Harness::new(Demo::default(), 60, 16);
954 h.send(Msg::AskTypedWithAlternative).advance(Duration::from_millis(200));
955 h.type_text("sil").press("shift+tab").press("enter");
956 assert_eq!(h.app().log, ["removed"], "shift tab from the field reaches the open confirm button");
957 }
958
959 #[test]
960 fn a_word_to_type_survives_tiny_terminals_and_ascii() {
961 let mut h = typing("SİL");
962 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
963 let screen = h.screen();
964 assert!(!screen.contains(['[', ']', '|', '(', ')', '{', '}']), "{screen}");
965 assert!(screen.contains("Type SİL to confirm"), "{screen}");
966 for (width, height) in [(0, 0), (1, 1), (12, 4), (30, 8)] {
967 let mut h = Harness::new(Demo::default(), width, height);
968 h.send(Msg::AskTyped("SİL")).advance(Duration::from_millis(200));
969 h.type_text("sil").press("enter");
970 assert_eq!(h.app().log, ["removed"], "{width}×{height}");
971 let mut h = Harness::new(Demo::default(), width, height);
972 h.set_reduced_motion(true).send(Msg::AskTyped("SİL"));
973 h.set_glyph_mode(crate::icons::GlyphMode::Ascii).press("esc");
974 assert_eq!(h.app().log, ["kept"], "{width}×{height}");
975 }
976 }
977
978 #[test]
979 fn the_prompt_follows_the_language() {
980 let mut h = typing("SİL");
981 h.set_locale("tr");
982 assert!(h.screen().contains("Onaylamak için SİL yaz"), "{}", h.screen());
983 }
984
985 fn quitting() -> Vec<(Harness<Demo>, [&'static str; 3])> {
988 [
989 (false, "en", ["Cancel", "Leave running", "Finish and quit"]),
990 (true, "de", ["Abbrechen", "Weiterlaufen lassen", "Beenden und schließen"]),
991 ]
992 .into_iter()
993 .map(|(german, code, labels)| {
994 let mut h = Harness::new(Demo::default(), 40, 20);
995 h.set_locale(code).send(Msg::AskQuit(german)).advance(Duration::from_millis(200));
996 (h, labels)
997 })
998 .collect()
999 }
1000
1001 #[test]
1002 fn at_forty_columns_three_long_answers_stand_one_under_another_in_tab_order() {
1003 for (h, labels) in quitting() {
1004 let screen = h.screen();
1005 assert!(!screen.contains('…'), "{screen}");
1006 let rows: Vec<usize> = labels.iter().map(|label| usize::from(cell(&h, label).1)).collect();
1007 assert!(rows[0] < rows[1] && rows[1] < rows[2], "one per row, in Tab order: {screen}");
1008 let columns: Vec<u16> = labels.iter().map(|label| cell(&h, label).0).collect();
1009 assert!(columns.windows(2).all(|pair| pair[0] == pair[1]), "one column: {screen}");
1010 let lines: Vec<&str> = screen.lines().collect();
1011 assert!(
1012 lines[rows[0] + 1].trim_start_matches(' ').trim_start_matches('▌').trim().is_empty(),
1013 "a blank row between two buttons: {screen}"
1014 );
1015 assert!(!screen.contains(['[', ']', '|']), "{screen}");
1016 }
1017 }
1018
1019 #[test]
1020 fn at_forty_columns_the_keyboard_and_the_mouse_reach_every_stacked_answer() {
1021 for (tabs, answer) in [(0, "discarded"), (1, "resumed"), (2, "saved")] {
1022 for (mut h, _) in quitting() {
1023 for _ in 0..tabs {
1024 h.press("tab");
1025 }
1026 h.press("enter");
1027 assert_eq!(h.app().log, [answer], "{tabs} tabs");
1028 }
1029 }
1030 for (index, answer) in [(0, "discarded"), (1, "resumed"), (2, "saved")] {
1031 for (mut h, labels) in quitting() {
1032 let (x, y) = cell(&h, labels[index]);
1033 h.click(i32::from(x), i32::from(y));
1034 assert_eq!(h.app().log, [answer], "{}", labels[index]);
1035 }
1036 }
1037 }
1038}