Skip to main content

qframe/runtime/
confirm.rs

1//! "Are you sure?" dialogs the runtime shows for [`Command::confirm`](super::Command::confirm).
2
3use 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
13/// Stands in for the word while the prompt is translated, so the prompt can be split around it
14/// and the word drawn in its own tone wherever the language puts it. A private-use character
15/// never appears in a translation.
16const WORD_MARK: &str = "\u{E000}";
17
18/// A question for [`Command::confirm`](super::Command::confirm): a title, an optional message,
19/// and the messages for the answers.
20///
21/// The dialog has two buttons, Cancel and the confirm button, labelled from
22/// `quvyta.confirm.cancel` and `quvyta.confirm.confirm` unless given. Cancel has focus. The
23/// question is dismissable by default: Esc and the close mark `×` at the top right cancel, always
24/// together; [`dismissable(false)`](Confirm::dismissable) turns both off so only the buttons
25/// answer. A click on the dimmed screen never answers, so a stray click is harmless.
26///
27/// [`alternative`](Confirm::alternative) adds a third way between the two, such as "Save ·
28/// Continue · Discard" for recovered work. The buttons then read Cancel, the alternative and the
29/// confirm button from left to right, Tab and Shift+Tab visit them in that order, and Cancel
30/// still has focus when the dialog opens; Esc and the close mark still cancel.
31///
32/// [`require_word`](Confirm::require_word) asks the user to type a word before the confirm button
33/// works, for actions that cannot be undone.
34pub 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    /// The word the user types to unlock the confirm button.
45    word: Option<String>,
46    /// Set by the dialog when the alternative is chosen. The runtime reports an answer as
47    /// confirmed or not; the alternative travels as a confirmation with this flag set, which
48    /// [`into_answer`](Self::into_answer) reads.
49    alternative_chosen: Arc<AtomicBool>,
50}
51
52impl<Msg> Confirm<Msg> {
53    /// Asks `title`; confirming sends `on_confirm`.
54    #[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    /// Explains the consequences below the title.
72    #[must_use]
73    pub fn message(mut self, message: impl Into<String>) -> Self {
74        self.message = Some(message.into());
75        self
76    }
77
78    /// Marks the question as destructive: a danger pillar down the dialog's left edge and a
79    /// danger confirm button.
80    #[must_use]
81    pub fn danger(mut self) -> Self {
82        self.danger = true;
83        self
84    }
85
86    /// Whether Esc and the close mark cancel the question; `true` by default. With `false`
87    /// neither works, the mark is not drawn and only the buttons answer.
88    #[must_use]
89    pub fn dismissable(mut self, dismissable: bool) -> Self {
90        self.dismissable = dismissable;
91        self
92    }
93
94    /// The confirm button's label, e.g. the action's own verb: "Remove".
95    #[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    /// The cancel button's label.
102    #[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    /// The message sent when the question is cancelled; without it cancelling only closes the
109    /// dialog.
110    #[must_use]
111    pub fn on_cancel(mut self, message: Msg) -> Self {
112        self.on_cancel = Some(message);
113        self
114    }
115
116    /// A third answer between Cancel and the confirm button: a button labelled `label` that sends
117    /// `message`, such as "Continue" beside "Discard" and "Save". Without it the dialog has its
118    /// two buttons exactly as before.
119    #[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    /// Asks the user to type `word` before confirming, for an action that cannot be undone, such
126    /// as emptying the trash for good: the dialog gains a line "Type `word` to confirm"
127    /// (`quvyta.confirm.type-word`, with the word in the `title` tone among `secondary` text)
128    /// and a text field below the message.
129    ///
130    /// The field has focus when the dialog opens and each question starts with it empty. The
131    /// confirm button is disabled, drawn in the disabled tone and passed over by Tab, until the
132    /// typed text matches the word; then it takes its danger or primary tone and Enter in the
133    /// field confirms too. Before that Enter does nothing and the dialog stays. Esc and the close
134    /// mark still cancel, and Tab and Shift+Tab visit the field, Cancel, the
135    /// [`alternative`](Self::alternative) and the confirm button in that order.
136    ///
137    /// Only the confirm button waits for the word: an alternative is a different, safe way out
138    /// and works at once.
139    ///
140    /// Matching ignores spaces around the text and case, the Turkish way included: `İ`, `I`, `ı`
141    /// and `i` are all the same letter, so "sil", "SİL", "SIL" and " Sil " all match "SİL", and
142    /// "iptal" matches "İPTAL". Typing the word is meant as a deliberate act, not a secret, so a
143    /// keyboard's idea of the dotted and dotless i never stands in the way. A blank word asks for
144    /// nothing and leaves the dialog as it is without this option.
145    ///
146    /// Keep it for what cannot be undone; a question that always asks for typing teaches people
147    /// to type without reading.
148    #[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    /// The same question answered with `map(message)`.
155    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    /// The message for an answer; a confirmation stands for the alternative when the dialog
172    /// recorded that choice.
173    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
182/// `text` for comparing typed words: without surrounding spaces, in lower case, with `İ`, `I`,
183/// `ı` and `i` all folded to `i`. A plain lower-casing would turn `İ` into `i` and a combining
184/// dot, and keep `ı` apart from `i`.
185fn 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/// The dialog's answers, the messages of its own buttons and of the word's field.
197#[derive(Debug, Clone, PartialEq, Eq)]
198enum Answer {
199    Cancel,
200    Alternative,
201    Confirm,
202    /// The field's text after an edit.
203    Typed(String),
204    /// Enter in the field.
205    Submit,
206}
207
208/// Which part took the pointer down, so the release answers only over that button and a drag
209/// that began in the field keeps selecting there.
210#[derive(Debug, Default)]
211struct ConfirmMemory {
212    pressed: Option<WidgetId>,
213}
214
215/// The text typed into the word's field. It lives in the layer's memory, which is keyed by the
216/// question's own id, so every question starts empty.
217#[derive(Debug, Default)]
218struct TypedWord(String);
219
220/// The dialog of the topmost pending [`Confirm`]. It is added after the application's view and
221/// draws a [`Modal`] of [`Answer`]s; the runtime turns the answer into the application's
222/// message, which never has to be cloned.
223pub(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    /// Whether the confirm button works: always without a word, and once `typed` matches it.
253    fn unlocked(&self, typed: &str) -> bool {
254        self.word.as_deref().is_none_or(|word| fold(word) == fold(typed))
255    }
256
257    /// The dialog, with ids assigned under this layer so focus and hits land on its buttons and
258    /// its field, which shows `typed`.
259    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        // Read left to right: the safe answer, the third way, the confirming one; focus visits
271        // them in the same order.
272        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            // The field comes first in the dialog, so it has focus when the question opens.
290            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    /// Hands `event` to `part` of the dialog painted at `rect`, as if it had received it itself:
305    /// it keeps its own memory, its requests (focus, captures, copies, the paste action) go out
306    /// as the layer's, and its answers come back.
307    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    /// Acts on the answers a part gave.
329    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                    // The disabled button never answers; this only guards the gate twice.
344                    if self.unlocked(&cx.memory::<TypedWord>().0) {
345                        cx.answer(true);
346                    }
347                }
348                Answer::Cancel => cx.answer(false),
349            }
350        }
351    }
352}
353
354/// "Type `word` to confirm", with the word in the `title` tone among `secondary` text.
355fn 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
367/// The word's field of a dialog built by [`ConfirmLayer::dialog`] for a question with a word:
368/// the last node of the body.
369fn field(dialog: &Modal<Answer>) -> Option<&Node<Answer>> {
370    dialog.children().first()?.widget.children().last()
371}
372
373/// The dialog's parts that take input: the word's field, when `word`, and the answer buttons.
374fn 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        // The field's edit menu is an overlay of its own. The runtime paints the overlays of the
397        // widgets in its tree, and this dialog is built by the layer, so the layer paints it.
398        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        // An open edit menu of the field takes the keys, Esc included, and the presses first, as
417        // it does anywhere else; a press outside it closes it and goes on.
418        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        // Esc and the close mark belong to the dialog surface: it reports them as its close
434        // message, which here is the cancel answer, and only while the question is dismissable.
435        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            // Everything else on the dimmed screen is swallowed.
477            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/// Where the word's field and the answer buttons were painted, for routing pointer events to
490/// them.
491#[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        /// A quit question with three long answers, in English or German.
523        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        // A danger pillar down the left edge and the close mark in the top right corner, on the
626        // row above the title.
627        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    /// The backgrounds of a resting danger button, disabled and enabled, drawn by themselves.
796    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            // Typed as one piece: the test keyboard has no capital İ.
850            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    /// The quit question at 40 columns, in English and German, with the labels of its three
984    /// answers in Tab order and the language.
985    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}