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                holds_pointer: cx.holds_pointer,
323            };
324            part.widget.event(&mut part_cx, event)
325        };
326        (handled, answers)
327    }
328
329    /// Acts on the answers a part gave.
330    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                    // The disabled button never answers; this only guards the gate twice.
345                    if self.unlocked(&cx.memory::<TypedWord>().0) {
346                        cx.answer(true);
347                    }
348                }
349                Answer::Cancel => cx.answer(false),
350            }
351        }
352    }
353}
354
355/// "Type `word` to confirm", with the word in the `title` tone among `secondary` text.
356fn 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
368/// The word's field of a dialog built by [`ConfirmLayer::dialog`] for a question with a word:
369/// the last node of the body.
370fn field(dialog: &Modal<Answer>) -> Option<&Node<Answer>> {
371    dialog.children().first()?.widget.children().last()
372}
373
374/// The dialog's parts that take input: the word's field, when `word`, and the answer buttons.
375fn 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        // The field's edit menu is an overlay of its own. The runtime paints the overlays of the
398        // widgets in its tree, and this dialog is built by the layer, so the layer paints it.
399        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        // An open edit menu of the field takes the keys, Esc included, and the presses first, as
418        // it does anywhere else; a press outside it closes it and goes on.
419        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        // Esc and the close mark belong to the dialog surface: it reports them as its close
435        // message, which here is the cancel answer, and only while the question is dismissable.
436        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            // Everything else on the dimmed screen is swallowed.
479            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/// Where the word's field and the answer buttons were painted, for routing pointer events to
492/// them.
493#[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        /// A quit question with three long answers, in English or German.
525        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        // A danger pillar down the left edge and the close mark in the top right corner, on the
628        // row above the title.
629        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    /// The backgrounds of a resting danger button, disabled and enabled, drawn by themselves.
798    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            // Typed as one piece: the test keyboard has no capital İ.
852            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    /// The quit question at 40 columns, in English and German, with the labels of its three
986    /// answers in Tab order and the language.
987    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}