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 };
323 part.widget.event(&mut part_cx, event)
324 };
325 (handled, answers)
326 }
327
328 fn settle(&self, cx: &mut EventCx<'_, Msg>, answers: Vec<Answer>) {
330 for answer in answers {
331 match answer {
332 Answer::Typed(text) => cx.memory::<TypedWord>().0 = text,
333 Answer::Submit => {
334 if self.unlocked(&cx.memory::<TypedWord>().0) {
335 cx.answer(true);
336 }
337 }
338 Answer::Alternative => {
339 self.alternative_chosen.store(true, Ordering::Relaxed);
340 cx.answer(true);
341 }
342 Answer::Confirm => {
343 if self.unlocked(&cx.memory::<TypedWord>().0) {
345 cx.answer(true);
346 }
347 }
348 Answer::Cancel => cx.answer(false),
349 }
350 }
351 }
352}
353
354fn prompt(env: &crate::env::Env, word: &str) -> Text {
356 let line = env.i18n().translate("quvyta.confirm.type-word", &[("word", Arg::from(WORD_MARK))]);
357 match line.split_once(WORD_MARK) {
358 Some((before, after)) => Text::rich([
359 Span::new(before).role("secondary"),
360 Span::new(word).role("title"),
361 Span::new(after).role("secondary"),
362 ]),
363 None => Text::new(line).role("secondary"),
364 }
365}
366
367fn field(dialog: &Modal<Answer>) -> Option<&Node<Answer>> {
370 dialog.children().first()?.widget.children().last()
371}
372
373fn parts(dialog: &Modal<Answer>, word: bool) -> Vec<&Node<Answer>> {
375 let field = if word { field(dialog) } else { None };
376 field.into_iter().chain(&dialog.children()[1..]).collect()
377}
378
379impl<Msg: 'static> Widget<Msg> for ConfirmLayer<Msg> {
380 fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
381 Size::default()
382 }
383
384 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
385 cx.request_overlay(area);
386 }
387
388 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
389 let typed = cx.memory::<TypedWord>().0.clone();
390 let dialog = self.dialog(cx.env(), cx.id(), &typed);
391 Widget::<Answer>::paint_overlay(&dialog, cx, anchor);
392 let parts = parts(&dialog, self.word.is_some());
393 let rects =
394 parts.iter().filter_map(|part| cx.frame.rects.get(&part.id()).map(|rect| (part.id(), *rect))).collect();
395 cx.memory::<PartRects>().0 = rects;
396 if self.word.is_some()
399 && let Some(field) = field(&dialog)
400 && let Some(rect) = cx.frame.rects.get(&field.id()).copied()
401 {
402 let saved = (cx.id, cx.layout);
403 (cx.id, cx.layout) = (field.id(), field.layout());
404 field.widget.paint_overlay(cx, rect);
405 (cx.id, cx.layout) = saved;
406 }
407 }
408
409 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
410 let typed = cx.memory::<TypedWord>().0.clone();
411 let dialog = self.dialog(cx.env, cx.id, &typed);
412 let field = if self.word.is_some() { field(&dialog) } else { None };
413 let rect_of = |cx: &mut EventCx<'_, Msg>, id: WidgetId| {
414 cx.memory::<PartRects>().0.iter().find(|(part, _)| *part == id).map(|(_, rect)| *rect).unwrap_or_default()
415 };
416 if let Some(field) = field
419 && cx.interaction.key_capture == Some(field.id())
420 && matches!(event, Event::Key(_) | Event::Mouse(_))
421 {
422 let rect = rect_of(cx, field.id());
423 let (used, answers) = Self::forward(cx, field, rect, event);
424 self.settle(cx, answers);
425 if used {
426 if let Event::Mouse(mouse) = event {
427 cx.memory::<ConfirmMemory>().pressed =
428 matches!(mouse.kind, MouseKind::Down(_)).then_some(field.id());
429 }
430 return true;
431 }
432 }
433 let mut closed = Vec::new();
436 let used = {
437 let mut dialog_cx = EventCx {
438 id: cx.id,
439 rect: cx.rect,
440 focus_rect: cx.focus_rect,
441 env: cx.env,
442 memory: &mut *cx.memory,
443 interaction: cx.interaction,
444 messages: &mut closed,
445 effects: &mut *cx.effects,
446 now: cx.now,
447 persistent: cx.persistent,
448 preview: cx.preview,
449 };
450 Widget::<Answer>::event(&dialog, &mut dialog_cx, event)
451 };
452 if closed.contains(&Answer::Cancel) {
453 cx.answer(false);
454 return true;
455 }
456 if used {
457 return true;
458 }
459 let parts = parts(&dialog, self.word.is_some());
460 let target = match event {
461 Event::Key(_) | Event::Paste(_) => parts.iter().find(|part| cx.interaction.focused == Some(part.id())),
462 Event::Mouse(mouse) => {
463 let rects = cx.memory::<PartRects>().0.clone();
464 let over = |part: &&&Node<Answer>| {
465 rects.iter().any(|(id, rect)| *id == part.id() && rect.contains(mouse.x, mouse.y))
466 };
467 let pressed = cx.memory::<ConfirmMemory>().pressed;
468 match mouse.kind {
469 MouseKind::Down(_) => parts.iter().find(over),
470 _ => parts.iter().find(|part| pressed == Some(part.id())),
471 }
472 }
473 Event::PointerOutside => None,
474 };
475 let Some(part) = target else {
476 return matches!(event, Event::Mouse(_));
478 };
479 if let Event::Mouse(mouse) = event {
480 cx.memory::<ConfirmMemory>().pressed = matches!(mouse.kind, MouseKind::Down(_)).then_some(part.id());
481 }
482 let rect = rect_of(cx, part.id());
483 let (handled, answers) = Self::forward(cx, part, rect, event);
484 self.settle(cx, answers);
485 handled || matches!(event, Event::Mouse(_))
486 }
487}
488
489#[derive(Debug, Default)]
492struct PartRects(Vec<(WidgetId, Rect)>);
493
494#[cfg(test)]
495mod tests {
496 use std::time::Duration;
497
498 use crate::event::{MouseButton, MouseKind};
499 use crate::runtime::{App, Command, Confirm, Harness};
500 use crate::widget::View;
501 use crate::widgets::{Button, Text};
502
503 #[derive(Default)]
504 struct Demo {
505 log: Vec<&'static str>,
506 }
507
508 #[derive(Clone)]
509 enum Msg {
510 Ask,
511 AskTwice,
512 Remove,
513 Kept,
514 Prune,
515 AskFirm,
516 AskRecover,
517 Saved,
518 Resumed,
519 Discarded,
520 AskTyped(&'static str),
521 AskTypedWithAlternative,
522 AskQuit(bool),
524 }
525
526 impl App for Demo {
527 type Msg = Msg;
528 fn update(&mut self, msg: Msg) -> Command<Msg> {
529 match msg {
530 Msg::Ask => {
531 return Command::confirm(
532 Confirm::new("Remove web?", Msg::Remove)
533 .message("Its volumes go too.")
534 .confirm_label("Remove")
535 .danger()
536 .on_cancel(Msg::Kept),
537 );
538 }
539 Msg::AskTwice => {
540 return Command::batch([
541 Command::confirm(Confirm::new("Prune images?", Msg::Prune)),
542 Command::confirm(Confirm::new("Remove web?", Msg::Remove)),
543 ]);
544 }
545 Msg::AskFirm => {
546 return Command::confirm(
547 Confirm::new("Rotate keys?", Msg::Remove).dismissable(false).on_cancel(Msg::Kept),
548 );
549 }
550 Msg::AskRecover => {
551 return Command::confirm(
552 Confirm::new("Recover the session?", Msg::Saved)
553 .message("47 minutes were counted.")
554 .confirm_label("Save")
555 .cancel_label("Discard")
556 .on_cancel(Msg::Discarded)
557 .alternative("Continue", Msg::Resumed),
558 );
559 }
560 Msg::AskTyped(word) => {
561 return Command::confirm(
562 Confirm::new("Empty the trash?", Msg::Remove)
563 .message("12 items go for good.")
564 .confirm_label("Empty")
565 .danger()
566 .on_cancel(Msg::Kept)
567 .require_word(word),
568 );
569 }
570 Msg::AskTypedWithAlternative => {
571 return Command::confirm(
572 Confirm::new("Empty the trash?", Msg::Remove)
573 .confirm_label("Empty")
574 .danger()
575 .on_cancel(Msg::Kept)
576 .alternative("Archive", Msg::Resumed)
577 .require_word("SİL"),
578 );
579 }
580 Msg::AskQuit(german) => {
581 let (title, finish, leave) = if german {
582 ("Beenden?", "Beenden und schließen", "Weiterlaufen lassen")
583 } else {
584 ("Quit?", "Finish and quit", "Leave running")
585 };
586 return Command::confirm(
587 Confirm::new(title, Msg::Saved)
588 .confirm_label(finish)
589 .on_cancel(Msg::Discarded)
590 .alternative(leave, Msg::Resumed),
591 );
592 }
593 Msg::Saved => self.log.push("saved"),
594 Msg::Resumed => self.log.push("resumed"),
595 Msg::Discarded => self.log.push("discarded"),
596 Msg::Remove => self.log.push("removed"),
597 Msg::Kept => self.log.push("kept"),
598 Msg::Prune => self.log.push("pruned"),
599 }
600 Command::none()
601 }
602 fn view(&self, ui: &mut View<'_, Msg>) {
603 ui.column(|ui| {
604 ui.add(Text::new("Containers"));
605 ui.add(Button::new("Remove web").on_press(Msg::Ask)).id("ask");
606 });
607 }
608 }
609
610 fn asked() -> Harness<Demo> {
611 let mut h = Harness::new(Demo::default(), 60, 14);
612 h.press("tab").press("enter").advance(Duration::from_millis(200));
613 h
614 }
615
616 fn cell(h: &Harness<Demo>, text: &str) -> (u16, u16) {
617 let (x, y) = h.find(text).unwrap_or_else(|| panic!("`{text}` on screen:\n{}", h.screen()));
618 (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0))
619 }
620
621 #[test]
622 fn shows_the_question_with_cancel_focused() {
623 let h = asked();
624 let screen = h.screen();
625 assert!(
628 screen.lines().nth(4).is_some_and(|l| l.contains("▌ Remove web?") && !l.contains('×'))
629 && screen.lines().nth(3).is_some_and(|l| l.ends_with('×')),
630 "{screen}"
631 );
632 for row in 3..=9 {
633 assert_eq!(h.fg(2, row), h.env().theme().color("danger"), "pillar on row {row}:\n{screen}");
634 }
635 assert!(screen.contains("Its volumes go too."));
636 let (x, y) = cell(&h, "Cancel");
637 assert_ne!(h.bg(x, y), h.env().theme().color("raised"), "Cancel has focus: {screen}");
638 let (x, y) = cell(&h, "Remove ");
639 assert_ne!(h.bg(x, y), h.env().theme().color("raised"), "Remove is a danger button");
640 }
641
642 #[test]
643 fn enter_on_the_safe_default_cancels_and_escape_cancels() {
644 let mut h = asked();
645 h.press("enter");
646 assert_eq!(h.app().log, ["kept"]);
647 assert!(!h.screen().contains("Remove web?"));
648 assert!(h.is_focused("ask"), "focus returns to the button that asked");
649 h.press("enter").advance(Duration::from_millis(200)).press("esc");
650 assert_eq!(h.app().log, ["kept", "kept"]);
651 }
652
653 #[test]
654 fn tab_then_enter_or_a_click_confirms() {
655 let mut h = asked();
656 h.press("tab").press("enter");
657 assert_eq!(h.app().log, ["removed"]);
658 h.press("enter").advance(Duration::from_millis(200));
659 h.click_text("Containers");
660 assert!(h.screen().contains("Remove web?"), "clicks on the dimmed screen do not answer");
661 let (x, y) = cell(&h, "Remove ");
662 h.click(i32::from(x), i32::from(y));
663 assert_eq!(h.app().log, ["removed", "removed"]);
664 }
665
666 #[test]
667 fn the_close_mark_cancels_and_lights_three_cells() {
668 let mut h = asked();
669 let (x, y) = cell(&h, "×");
670 assert_eq!(cell(&h, "Remove web?").1, y + 1, "the mark sits on the surface's first row");
671 let resting = h.bg(x, y);
672 h.hover(i32::from(x) + 1, i32::from(y));
673 let lit = h.bg(x, y);
674 assert_ne!(lit, resting);
675 assert_eq!((h.bg(x - 1, y), h.bg(x + 1, y)), (lit, lit));
676 h.click(i32::from(x), i32::from(y));
677 assert_eq!(h.app().log, ["kept"], "the mark answers like Esc: cancel");
678 assert!(!h.screen().contains("Remove web?"));
679 }
680
681 #[test]
682 fn a_question_that_is_not_dismissable_answers_only_with_its_buttons() {
683 let mut h = Harness::new(Demo::default(), 60, 14);
684 h.send(Msg::AskFirm).advance(Duration::from_millis(200));
685 let screen = h.screen();
686 assert!(screen.contains("Rotate keys?") && !screen.contains('×') && !screen.contains("esc"), "{screen}");
687 h.press("esc").click(57, 4).click(1, 12);
688 assert!(h.app().log.is_empty() && h.screen().contains("Rotate keys?"), "{}", h.screen());
689 h.press("enter");
690 assert_eq!(h.app().log, ["kept"]);
691 }
692
693 #[test]
694 fn questions_stack_and_the_newest_is_answered_first() {
695 let mut h = Harness::new(Demo::default(), 60, 14);
696 h.send(Msg::AskTwice).advance(Duration::from_millis(200));
697 assert!(h.screen().contains("Remove web?"));
698 h.press("tab").press("enter").advance(Duration::from_millis(200));
699 assert!(h.screen().contains("Prune images?"), "{}", h.screen());
700 h.press("tab").press("enter");
701 assert_eq!(h.app().log, ["removed", "pruned"]);
702 }
703
704 #[test]
705 fn a_two_way_question_draws_exactly_as_before() {
706 let h = asked();
707 let screen = h.screen();
708 let rows: Vec<&str> = screen.lines().collect();
709 assert_eq!(rows[3].trim_end().chars().last(), Some('×'), "{screen}");
710 assert_eq!(rows[4], " ▌ Remove web?", "{screen}");
711 assert_eq!(rows[6], " ▌ Its volumes go too.", "{screen}");
712 assert_eq!(rows[8], " ▌ esc close tab switch ▌ Cancel Remove", "{screen}");
713 assert_eq!(rows.iter().filter(|row| row.contains('▌')).count(), 7, "one surface, two buttons:\n{screen}");
714 }
715
716 fn recovering() -> Harness<Demo> {
717 let mut h = Harness::new(Demo::default(), 60, 14);
718 h.send(Msg::AskRecover).advance(Duration::from_millis(200));
719 h
720 }
721
722 #[test]
723 fn a_third_way_sits_between_cancel_and_confirm_with_cancel_focused() {
724 let mut h = recovering();
725 let screen = h.screen();
726 let row = screen.lines().find(|line| line.contains("Continue")).unwrap_or_else(|| panic!("{screen}"));
727 let at = |label: &str| row.find(label).unwrap_or_else(|| panic!("`{label}` in {row}"));
728 assert!(at("Discard") < at("Continue") && at("Continue") < at("Save"), "{row}");
729 assert!(!screen.contains(['[', ']', '|']), "{screen}");
730 let (dx, dy) = cell(&h, "Discard");
731 let (cx, cy) = cell(&h, "Continue");
732 let focused = h.bg(dx, dy);
733 assert_ne!(focused, h.bg(cx, cy), "Cancel has the focus, the third way rests: {screen}");
734 h.press("tab");
735 assert_eq!(h.bg(cx, cy), focused, "tab lifts the third way like the focused Cancel was");
736 }
737
738 #[test]
739 fn the_keyboard_reaches_each_way_in_reading_order() {
740 let mut h = recovering();
741 h.press("enter");
742 assert_eq!(h.app().log, ["discarded"], "enter on the focused Cancel");
743 let mut h = recovering();
744 h.press("tab").press("enter");
745 assert_eq!(h.app().log, ["resumed"], "tab reaches the third way first");
746 let mut h = recovering();
747 h.press("tab").press("tab").press("enter");
748 assert_eq!(h.app().log, ["saved"]);
749 let mut h = recovering();
750 h.press("shift+tab").press("enter");
751 assert_eq!(h.app().log, ["saved"], "shift tab goes round the other way");
752 let mut h = recovering();
753 h.press("esc");
754 assert_eq!(h.app().log, ["discarded"], "esc still cancels");
755 assert!(!h.screen().contains("Recover the session?"));
756 }
757
758 #[test]
759 fn the_mouse_reaches_each_way() {
760 for (label, answer) in [("Continue", "resumed"), ("Save", "saved"), ("Discard", "discarded")] {
761 let mut h = recovering();
762 let (x, y) = cell(&h, label);
763 h.click(i32::from(x), i32::from(y));
764 assert_eq!(h.app().log, [answer], "{label}");
765 assert!(!h.screen().contains("Recover the session?"));
766 }
767 let mut h = recovering();
768 let (x, y) = cell(&h, "Continue");
769 h.mouse(MouseKind::Down(MouseButton::Left), i32::from(x), i32::from(y));
770 let (x, y) = cell(&h, "Save");
771 h.mouse(MouseKind::Up(MouseButton::Left), i32::from(x), i32::from(y));
772 assert!(h.app().log.is_empty(), "a release over another button answers nothing");
773 }
774
775 #[test]
776 fn a_three_way_question_survives_tiny_terminals_and_ascii() {
777 let mut h = recovering();
778 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
779 assert!(!h.screen().contains(['[', ']', '|', '(', ')']), "{}", h.screen());
780 assert!(h.screen().contains("Continue"), "{}", h.screen());
781 for (width, height) in [(0, 0), (1, 1), (12, 4), (30, 8)] {
782 let mut h = Harness::new(Demo::default(), width, height);
783 h.send(Msg::AskRecover).advance(Duration::from_millis(200));
784 h.press("tab").press("tab").press("enter");
785 assert_eq!(h.app().log, ["saved"], "{width}×{height}");
786 }
787 }
788
789 fn typing(word: &'static str) -> Harness<Demo> {
790 let mut h = Harness::new(Demo::default(), 60, 16);
791 h.send(Msg::AskTyped(word)).advance(Duration::from_millis(200));
792 h
793 }
794
795 fn danger_tones() -> (Option<crate::color::Rgb>, Option<crate::color::Rgb>) {
797 struct Tones;
798 impl App for Tones {
799 type Msg = ();
800 fn update(&mut self, (): ()) -> Command<()> {
801 Command::none()
802 }
803 fn view(&self, ui: &mut View<'_, ()>) {
804 ui.column(|ui| {
805 ui.add(Button::new("Off").variant("danger").on_press(()).disabled(true));
806 ui.add(Button::new("On").variant("danger").on_press(()));
807 });
808 }
809 }
810 let h = Harness::new(Tones, 20, 2);
811 (h.bg(3, 0), h.bg(3, 1))
812 }
813
814 #[test]
815 fn a_word_to_type_shows_a_prompt_and_a_field_that_has_focus() {
816 let mut h = typing("SİL");
817 let screen = h.screen();
818 assert!(screen.contains("Type SİL to confirm"), "{screen}");
819 let (x, y) = cell(&h, "SİL to");
820 assert!(h.is_bold(x, y) && !h.is_bold(x - 2, y), "the word stands out by weight: {screen}");
821 assert_ne!(h.fg(x, y), h.fg(x - 2, y), "and by tone");
822 assert!(!screen.contains(['[', ']', '|', '(', ')', '"', '\'']), "{screen}");
823 h.type_text("si");
824 assert!(h.screen().contains("❯ si"), "typing goes straight into the field: {}", h.screen());
825 }
826
827 #[test]
828 fn enter_confirms_only_once_the_word_matches() {
829 let mut h = typing("SİL");
830 h.type_text("sal").press("enter");
831 assert!(h.app().log.is_empty() && h.screen().contains("Empty the trash?"), "{}", h.screen());
832 h.press("backspace").press("backspace").type_text("il").press("enter");
833 assert_eq!(h.app().log, ["removed"]);
834 assert!(!h.screen().contains("Empty the trash?"));
835 }
836
837 #[test]
838 fn matching_ignores_case_surrounding_spaces_and_the_turkish_i() {
839 for (word, typed) in [
840 ("SİL", "sil"),
841 ("SİL", "SİL"),
842 ("SİL", "SIL"),
843 ("SİL", " Sil "),
844 ("SİL", "sıl"),
845 ("İPTAL", "iptal"),
846 ("iptal", "IPTAL"),
847 ("web-1", "WEB-1"),
848 ] {
849 let mut h = typing(word);
851 h.paste(typed).press("enter");
852 assert_eq!(h.app().log, ["removed"], "`{typed}` for `{word}`");
853 }
854 for (word, typed) in [("SİL", "sl"), ("SİL", "si l"), ("İPTAL", "ptal")] {
855 let mut h = typing(word);
856 h.type_text(typed).press("enter");
857 assert!(h.app().log.is_empty(), "`{typed}` is not `{word}`");
858 }
859 assert_eq!(super::fold(" İIıi Ş "), "iiii ş");
860 }
861
862 #[test]
863 fn escape_cancels_with_a_word_half_typed() {
864 let mut h = typing("SİL");
865 h.type_text("si").press("esc");
866 assert_eq!(h.app().log, ["kept"]);
867 assert!(!h.screen().contains("Empty the trash?"));
868 }
869
870 #[test]
871 fn the_confirm_button_waits_in_the_disabled_tone_until_the_word_matches() {
872 let (disabled, enabled) = danger_tones();
873 assert_ne!(disabled, enabled);
874 let mut h = typing("SİL");
875 let (x, y) = cell(&h, "Empty ");
876 assert_eq!(h.bg(x, y), disabled, "{}", h.screen());
877 h.click(i32::from(x), i32::from(y));
878 assert!(h.app().log.is_empty() && h.screen().contains("Empty the trash?"), "a disabled button does nothing");
879 h.type_text("sil").hover(0, 0);
880 assert_eq!(h.bg(x, y), enabled, "{}", h.screen());
881 h.click(i32::from(x), i32::from(y));
882 assert_eq!(h.app().log, ["removed"]);
883 }
884
885 #[test]
886 fn tab_passes_over_the_locked_button_and_reaches_it_once_open() {
887 let mut h = typing("SİL");
888 h.press("tab").press("tab").type_text("sil");
889 assert!(h.screen().contains("❯ sil"), "tab went Cancel, then back to the field: {}", h.screen());
890 h.press("tab").press("tab").press("enter");
891 assert_eq!(h.app().log, ["removed"], "Cancel, then the confirm button");
892 let mut h = typing("SİL");
893 h.press("tab").press("enter");
894 assert_eq!(h.app().log, ["kept"], "the field, then Cancel");
895 }
896
897 #[test]
898 fn a_paste_fills_the_field() {
899 let mut h = typing("SİL");
900 h.paste("SİL").press("enter");
901 assert_eq!(h.app().log, ["removed"]);
902 }
903
904 #[test]
905 fn the_field_keeps_its_edit_menu() {
906 let mut h = typing("SİL");
907 h.set_system_clipboard(Some("SİL"));
908 let (x, y) = cell(&h, "❯");
909 h.mouse(MouseKind::Down(MouseButton::Right), i32::from(x) + 3, i32::from(y));
910 h.mouse(MouseKind::Up(MouseButton::Right), i32::from(x) + 3, i32::from(y));
911 h.advance(Duration::from_millis(200));
912 assert!(h.screen().contains("Select all"), "a right click opens the menu: {}", h.screen());
913 h.press("esc");
914 assert!(!h.screen().contains("Select all"), "esc closes the menu first: {}", h.screen());
915 assert!(h.app().log.is_empty() && h.screen().contains("Empty the trash?"), "and not the dialog");
916 h.mouse(MouseKind::Down(MouseButton::Right), i32::from(x) + 3, i32::from(y));
917 h.mouse(MouseKind::Up(MouseButton::Right), i32::from(x) + 3, i32::from(y));
918 h.advance(Duration::from_millis(200)).click_text("Paste");
919 assert!(h.screen().contains("❯ SİL"), "the menu pastes into the field: {}", h.screen());
920 h.press("enter");
921 assert_eq!(h.app().log, ["removed"]);
922 }
923
924 #[test]
925 fn every_question_starts_with_an_empty_field() {
926 let mut h = typing("SİL");
927 h.type_text("sil").press("enter");
928 h.send(Msg::AskTyped("SİL")).advance(Duration::from_millis(200));
929 assert!(!h.screen().contains("❯ sil"), "{}", h.screen());
930 h.press("enter");
931 assert_eq!(h.app().log, ["removed"], "an empty field does not confirm");
932 h.type_text("si").press("esc");
933 h.send(Msg::AskTyped("SİL")).advance(Duration::from_millis(200));
934 h.type_text("l").press("enter");
935 assert_eq!(h.app().log, ["removed", "kept"], "nothing is left over from the cancelled question");
936 }
937
938 #[test]
939 fn the_alternative_does_not_wait_for_the_word() {
940 let mut h = Harness::new(Demo::default(), 60, 16);
941 h.send(Msg::AskTypedWithAlternative).advance(Duration::from_millis(200));
942 let screen = h.screen();
943 let row = screen.lines().find(|line| line.contains("Archive")).unwrap_or_else(|| panic!("{screen}"));
944 assert!(row.find("Archive") < row.find("Empty"), "{row}");
945 h.press("tab").press("tab").press("enter");
946 assert_eq!(h.app().log, ["resumed"], "field, Cancel, then the alternative");
947 let mut h = Harness::new(Demo::default(), 60, 16);
948 h.send(Msg::AskTypedWithAlternative).advance(Duration::from_millis(200));
949 h.click_text("Archive");
950 assert_eq!(h.app().log, ["resumed"]);
951 let mut h = Harness::new(Demo::default(), 60, 16);
952 h.send(Msg::AskTypedWithAlternative).advance(Duration::from_millis(200));
953 h.type_text("sil").press("shift+tab").press("enter");
954 assert_eq!(h.app().log, ["removed"], "shift tab from the field reaches the open confirm button");
955 }
956
957 #[test]
958 fn a_word_to_type_survives_tiny_terminals_and_ascii() {
959 let mut h = typing("SİL");
960 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
961 let screen = h.screen();
962 assert!(!screen.contains(['[', ']', '|', '(', ')', '{', '}']), "{screen}");
963 assert!(screen.contains("Type SİL to confirm"), "{screen}");
964 for (width, height) in [(0, 0), (1, 1), (12, 4), (30, 8)] {
965 let mut h = Harness::new(Demo::default(), width, height);
966 h.send(Msg::AskTyped("SİL")).advance(Duration::from_millis(200));
967 h.type_text("sil").press("enter");
968 assert_eq!(h.app().log, ["removed"], "{width}×{height}");
969 let mut h = Harness::new(Demo::default(), width, height);
970 h.set_reduced_motion(true).send(Msg::AskTyped("SİL"));
971 h.set_glyph_mode(crate::icons::GlyphMode::Ascii).press("esc");
972 assert_eq!(h.app().log, ["kept"], "{width}×{height}");
973 }
974 }
975
976 #[test]
977 fn the_prompt_follows_the_language() {
978 let mut h = typing("SİL");
979 h.set_locale("tr");
980 assert!(h.screen().contains("Onaylamak için SİL yaz"), "{}", h.screen());
981 }
982
983 fn quitting() -> Vec<(Harness<Demo>, [&'static str; 3])> {
986 [
987 (false, "en", ["Cancel", "Leave running", "Finish and quit"]),
988 (true, "de", ["Abbrechen", "Weiterlaufen lassen", "Beenden und schließen"]),
989 ]
990 .into_iter()
991 .map(|(german, code, labels)| {
992 let mut h = Harness::new(Demo::default(), 40, 20);
993 h.set_locale(code).send(Msg::AskQuit(german)).advance(Duration::from_millis(200));
994 (h, labels)
995 })
996 .collect()
997 }
998
999 #[test]
1000 fn at_forty_columns_three_long_answers_stand_one_under_another_in_tab_order() {
1001 for (h, labels) in quitting() {
1002 let screen = h.screen();
1003 assert!(!screen.contains('…'), "{screen}");
1004 let rows: Vec<usize> = labels.iter().map(|label| usize::from(cell(&h, label).1)).collect();
1005 assert!(rows[0] < rows[1] && rows[1] < rows[2], "one per row, in Tab order: {screen}");
1006 let columns: Vec<u16> = labels.iter().map(|label| cell(&h, label).0).collect();
1007 assert!(columns.windows(2).all(|pair| pair[0] == pair[1]), "one column: {screen}");
1008 let lines: Vec<&str> = screen.lines().collect();
1009 assert!(
1010 lines[rows[0] + 1].trim_start_matches(' ').trim_start_matches('▌').trim().is_empty(),
1011 "a blank row between two buttons: {screen}"
1012 );
1013 assert!(!screen.contains(['[', ']', '|']), "{screen}");
1014 }
1015 }
1016
1017 #[test]
1018 fn at_forty_columns_the_keyboard_and_the_mouse_reach_every_stacked_answer() {
1019 for (tabs, answer) in [(0, "discarded"), (1, "resumed"), (2, "saved")] {
1020 for (mut h, _) in quitting() {
1021 for _ in 0..tabs {
1022 h.press("tab");
1023 }
1024 h.press("enter");
1025 assert_eq!(h.app().log, [answer], "{tabs} tabs");
1026 }
1027 }
1028 for (index, answer) in [(0, "discarded"), (1, "resumed"), (2, "saved")] {
1029 for (mut h, labels) in quitting() {
1030 let (x, y) = cell(&h, labels[index]);
1031 h.click(i32::from(x), i32::from(y));
1032 assert_eq!(h.app().log, [answer], "{}", labels[index]);
1033 }
1034 }
1035 }
1036}