Skip to main content

qframe/widgets/
modal.rs

1//! Modal dialogs.
2
3use crate::event::Event;
4use crate::geometry::{Rect, Size};
5use crate::text;
6use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget};
7
8use super::Button;
9use super::cells;
10use super::layer::{self, Backdrop, SurfacePosition};
11
12/// Width of a dialog when none is set, in cells.
13const DEFAULT_WIDTH: u16 = 56;
14
15/// Cells between two action buttons.
16const ACTION_GAP: u16 = 2;
17
18/// A dialog over a dimmed screen: a title, any content and action buttons.
19///
20/// Add it to the view while it should be shown, anywhere in the tree: it takes no room where it
21/// is added and is drawn over everything as a layer, centred on the screen. It enters with a
22/// short pop over the theme's `motion.enter` (at once with reduced motion). While it is open,
23/// focus stays inside (the first focusable widget is focused), Tab cycles through its widgets,
24/// nothing beneath reacts to keys or the pointer and application shortcuts are paused; when it
25/// is removed, focus returns to where it was. Dialogs stack: one added inside another, or later
26/// in the view, is on top.
27///
28/// A pillar runs down the whole left edge of the surface, accent-muted, in the danger colour for
29/// `variant("danger")`.
30///
31/// With no options it is a plain surface with its content that only the application closes.
32/// `on_close` makes it dismissable: Esc and the close mark `×` at the top right send the message,
33/// always together, and `close_on_click_outside` adds a click on the dimmed screen.
34/// `dismissable(false)` turns all of them off at once (and hides the mark) while keeping the
35/// message, e.g. while the dialog is busy. `title` and `variant("danger")` mark it, `action` adds
36/// buttons at the bottom right; on a screen too narrow for them side by side they stand one under
37/// another, in Tab order, and never get cut. A faint hint line at the bottom left names Esc and
38/// Tab when they do something.
39///
40/// Style keys: `modal` (`bg`, `padding`, `pillar`) with variants such as `modal.danger`,
41/// `modal-title` (`fg`, `bold`), `close-mark`, `layer-backdrop` (`scrim`, `strength` in percent),
42/// `layer-hint-key`, `layer-hint-label`. Hint labels: `quvyta.layer.close`,
43/// `quvyta.layer.switch`.
44pub struct Modal<Msg> {
45    title: Option<String>,
46    variant: Option<String>,
47    width: u16,
48    on_close: Option<Msg>,
49    dismissable: bool,
50    click_outside_closes: bool,
51    /// The body column first, then one node per action.
52    parts: Vec<Node<Msg>>,
53}
54
55impl<Msg: Clone + 'static> Modal<Msg> {
56    /// An empty dialog. Add its content with [`View::add_with`](crate::widget::View::add_with).
57    #[must_use]
58    pub fn new() -> Self {
59        Self {
60            title: None,
61            variant: None,
62            width: DEFAULT_WIDTH,
63            on_close: None,
64            dismissable: true,
65            click_outside_closes: false,
66            parts: vec![body(Vec::new())],
67        }
68    }
69
70    /// A bold heading in the first row; a title wider than the dialog wraps onto more rows.
71    #[must_use]
72    pub fn title(mut self, title: impl Into<String>) -> Self {
73        self.title = Some(title.into());
74        self
75    }
76
77    /// Theme variant; `"danger"` gives a destructive dialog a danger pillar.
78    #[must_use]
79    pub fn variant(mut self, variant: impl Into<String>) -> Self {
80        self.variant = Some(variant.into());
81        self
82    }
83
84    /// Width in cells, padding included; 56 by default. Narrow screens shrink it.
85    #[must_use]
86    pub fn width(mut self, cells: u16) -> Self {
87        self.width = cells;
88        self
89    }
90
91    /// The message sent when the dialog is dismissed: Esc inside it or a click on its close mark.
92    /// Without it neither exists and the dialog closes only through its own buttons.
93    #[must_use]
94    pub fn on_close(mut self, message: Msg) -> Self {
95        self.on_close = Some(message);
96        self
97    }
98
99    /// Whether the dialog can be dismissed; `true` by default. `false` turns off Esc, the close
100    /// mark and the click outside together, and hides the mark, without removing `on_close`.
101    #[must_use]
102    pub fn dismissable(mut self, dismissable: bool) -> Self {
103        self.dismissable = dismissable;
104        self
105    }
106
107    /// Also sends the close message when the dimmed screen around the dialog is clicked, while
108    /// the dialog is dismissable.
109    #[must_use]
110    pub fn close_on_click_outside(mut self, closes: bool) -> Self {
111        self.click_outside_closes = closes;
112        self
113    }
114
115    /// Adds a button to the action row at the bottom right, after the ones added before. Put
116    /// the safe action first: the first focusable widget has focus when the dialog opens. When
117    /// the buttons do not fit on one row they stand one under another, first added on top, so
118    /// the last one, usually the confirming button, stays last: at the right end of the row, at
119    /// the bottom of the column.
120    #[must_use]
121    pub fn action(mut self, button: Button<Msg>) -> Self {
122        let index = self.parts.len();
123        self.parts.push(Node::new(button, index));
124        self
125    }
126}
127
128impl<Msg> Modal<Msg> {
129    fn is_dismissable(&self) -> bool {
130        self.dismissable && self.on_close.is_some()
131    }
132}
133
134fn body<Msg: 'static>(children: Vec<Node<Msg>>) -> Node<Msg> {
135    let mut column = Node::new(Flex::new(Axis::Column, children), 0);
136    column.layout.width = Length::Fill(1);
137    column
138}
139
140impl<Msg: Clone + 'static> Default for Modal<Msg> {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl<Msg: Clone + 'static> Container<Msg> for Modal<Msg> {
147    fn set_children(&mut self, children: Vec<Node<Msg>>) {
148        self.parts[0] = body(children);
149    }
150}
151
152impl<Msg: Clone + 'static> Widget<Msg> for Modal<Msg> {
153    fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
154        Size::default()
155    }
156
157    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
158        cx.request_overlay(area);
159    }
160
161    fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
162        let screen = cx.clip();
163        let dismissable = self.is_dismissable();
164        let padding = layer::padding(cx, "modal", dismissable);
165        let width = self.width.min(screen.width.saturating_sub(2));
166        let inner_width = width.saturating_sub(padding.horizontal());
167        // A long title wraps rather than being cut; a blank row always follows it.
168        let title_lines = self.title.as_deref().map_or_else(Vec::new, |title| text::wrap(title, inner_width.max(1)));
169        let title_rows = match title_lines.len() {
170            0 => 0,
171            lines => u16::try_from(lines).unwrap_or(u16::MAX).saturating_add(1),
172        };
173        let (body, actions) = self.parts.split_first().expect("a modal always has its body");
174        let action_sizes: Vec<Size> =
175            actions.iter().map(|action| cx.measure_child(action, Size::new(inner_width, 1))).collect();
176        let placed = ActionRow::place(&action_sizes, inner_width);
177        let mut hints = Vec::new();
178        if dismissable {
179            hints.push(layer::hint(cx, "esc", "close"));
180        }
181        if body.count_focusable() + actions.len() > 1 {
182            hints.push(layer::hint(cx, "tab", "switch"));
183        }
184        let footer_rows: u16 = if actions.is_empty() && hints.is_empty() { 0 } else { placed.rows.max(1) + 1 };
185        let chrome = cells::sum([padding.vertical(), title_rows, footer_rows]);
186        let available_body = screen.height.saturating_sub(chrome.saturating_add(2));
187        let body_height = cx.measure_child(body, Size::new(inner_width, available_body)).height;
188        let size = Size::new(width, chrome.saturating_add(body_height));
189
190        let look = layer::Look { style: "modal", variant: self.variant.as_deref(), dismissable };
191        let surface = layer::open(cx, size, SurfacePosition::Center, look);
192        let inner = surface.inner;
193        cx.with_clip(surface.shown, |cx| {
194            for (y, line) in (inner.y..).zip(&title_lines) {
195                layer::title(cx, inner.x, y, inner.width, line);
196            }
197            let body_rect = Rect::new(
198                inner.x,
199                inner.y + i32::from(title_rows),
200                inner.width,
201                inner.height.saturating_sub(title_rows + footer_rows),
202            );
203            cx.paint_child(body, body_rect);
204            if footer_rows == 0 {
205                return;
206            }
207            let first_row = inner.bottom() - i32::from(placed.rows.max(1));
208            // Painted in order so Tab follows the order the user reads, whether the buttons
209            // share a row or stand one under another.
210            for (action, rect) in actions.iter().zip(&placed.rects) {
211                let rect = Rect::new(inner.right() - rect.x, first_row + rect.y, rect.width, rect.height);
212                cx.paint_child(action, rect);
213            }
214            let hint_width = crate::geometry::clamp_u16(i32::from(inner.width) - i32::from(placed.last_row_width));
215            layer::paint_hints(cx, inner.x, inner.bottom() - 1, hint_width, &hints);
216        });
217        layer::finish(cx, &surface);
218    }
219
220    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
221        match layer::backdrop_event(cx, event, self.is_dismissable(), self.click_outside_closes) {
222            Backdrop::Close => {
223                if let Some(message) = &self.on_close {
224                    cx.emit(message.clone());
225                }
226                true
227            }
228            Backdrop::Swallowed => true,
229            Backdrop::Inside | Backdrop::Ignored => false,
230        }
231    }
232
233    fn children(&self) -> &[Node<Msg>] {
234        &self.parts
235    }
236
237    fn children_mut(&mut self) -> &mut [Node<Msg>] {
238        &mut self.parts
239    }
240}
241
242/// Where the action buttons go at the bottom of a dialog.
243///
244/// They share one row at the bottom right while they fit. When they do not, they stand one under
245/// another at the bottom right, in the order they were added, which is also the Tab order: the
246/// first added on top and the last added, the confirming button by convention, at the bottom,
247/// the same place it takes at the right end of the single row. Stacked buttons all take the
248/// width of the widest, so they read as one column, with a blank row between two of them so
249/// their tones never merge.
250#[derive(Debug, Clone, PartialEq, Eq)]
251struct ActionRow {
252    /// Rows the buttons take, gaps included.
253    rows: u16,
254    /// One rect per button: `x` counts cells back from the right edge of the content, `y` rows
255    /// down from the first action row.
256    rects: Vec<Rect>,
257    /// Cells the buttons take on the last row, with the gap before them, which the hint line
258    /// keeps clear of.
259    last_row_width: u16,
260}
261
262impl ActionRow {
263    fn place(sizes: &[Size], width: u16) -> Self {
264        if sizes.is_empty() {
265            return Self { rows: 0, rects: Vec::new(), last_row_width: ACTION_GAP };
266        }
267        let count = u16::try_from(sizes.len()).unwrap_or(u16::MAX);
268        let gaps = ACTION_GAP.saturating_mul(count - 1);
269        let one_row = cells::sum(sizes.iter().map(|size| size.width)).saturating_add(gaps);
270        if one_row <= width || sizes.len() == 1 {
271            let mut from_right = i32::from(one_row);
272            let rects = sizes
273                .iter()
274                .map(|size| {
275                    let rect = Rect::new(from_right, 0, size.width, 1);
276                    from_right -= i32::from(size.width.saturating_add(ACTION_GAP));
277                    rect
278                })
279                .collect();
280            return Self { rows: 1, rects, last_row_width: one_row.saturating_add(ACTION_GAP) };
281        }
282        let column = sizes.iter().map(|size| size.width).max().unwrap_or(0).min(width);
283        let rects = (0..count).map(|index| Rect::new(i32::from(column), i32::from(index * 2), column, 1)).collect();
284        Self { rows: count * 2 - 1, rects, last_row_width: column.saturating_add(ACTION_GAP) }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use std::time::Duration;
291
292    use super::*;
293    use crate::runtime::{App, Command, Harness};
294    use crate::widget::View;
295    use crate::widgets::{Text, TextInput};
296
297    #[derive(Default)]
298    struct Demo {
299        open: bool,
300        nested: bool,
301        removed: u32,
302        name: String,
303        outside: bool,
304        busy: bool,
305        plain: bool,
306    }
307
308    #[derive(Clone)]
309    enum Msg {
310        Open,
311        Close,
312        Remove,
313        Nested(bool),
314        Name(String),
315    }
316
317    impl App for Demo {
318        type Msg = Msg;
319        fn update(&mut self, msg: Msg) -> Command<Msg> {
320            match msg {
321                Msg::Open => self.open = true,
322                Msg::Close => self.open = false,
323                Msg::Remove => {
324                    self.removed += 1;
325                    self.open = false;
326                }
327                Msg::Nested(on) => self.nested = on,
328                Msg::Name(name) => self.name = name,
329            }
330            Command::none()
331        }
332        fn view(&self, ui: &mut View<'_, Msg>) {
333            ui.column(|ui| {
334                ui.add(Text::new("Containers"));
335                ui.add(Button::new("Remove web").on_press(Msg::Open)).id("open");
336                ui.add(Button::new("Other").on_press(Msg::Nested(false))).id("other");
337                if self.open {
338                    let mut modal = Modal::new().width(40).on_close(Msg::Close).dismissable(!self.busy);
339                    if !self.plain {
340                        modal = modal.title("Remove web?").variant("danger");
341                    }
342                    let modal = modal
343                        .close_on_click_outside(self.outside)
344                        .action(Button::new("Cancel").on_press(Msg::Close))
345                        .action(Button::new("Remove").variant("danger").on_press(Msg::Remove));
346                    ui.add_with(modal, |ui| {
347                        ui.add(Text::new("Its volumes go too."));
348                        ui.add(TextInput::new(&self.name).on_change(Msg::Name)).id("name");
349                        if self.nested {
350                            ui.add_with(Modal::new().title("Sure?").on_close(Msg::Nested(false)).width(24), |ui| {
351                                ui.add(Text::new("Really."));
352                            });
353                        }
354                    });
355                }
356            });
357        }
358    }
359
360    fn opened(demo: Demo) -> Harness<Demo> {
361        let mut h = Harness::new(demo, 50, 16);
362        h.press("tab").press("enter").advance(Duration::from_millis(200));
363        h
364    }
365
366    #[test]
367    fn draws_a_dimmed_screen_a_pillar_down_the_edge_a_close_mark_and_right_aligned_actions() {
368        let h = opened(Demo::default());
369        let screen = h.screen();
370        // The danger pillar runs down every row of the surface, and the close mark takes the top
371        // right corner, on the top padding row above the title.
372        assert_eq!(
373            screen,
374            "Containers\n  Remove web\n  Other\n\n     ▌                                     ×\n     ▌  Remove web?\n     ▌\n     ▌  Its volumes go too.\n     ▌  ▌ ❯\n     ▌\n     ▌  esc close     Cancel      Remove\n     ▌\n\n\n\n\n",
375            "{screen}"
376        );
377        let theme = h.env().theme();
378        assert_eq!(h.bg(20, 5), theme.color("overlay"));
379        for row in 4..=11 {
380            assert_eq!(h.fg(5, row), theme.color("danger"), "pillar on row {row}");
381        }
382        assert!(h.is_bold(8, 5));
383        let text = theme.color("text").expect("text colour");
384        let dimmed = h.fg(0, 0).expect("dimmed text");
385        assert_ne!(dimmed, text, "the screen behind is dimmed");
386    }
387
388    #[test]
389    fn a_plain_dialog_has_an_accent_muted_pillar_and_its_close_mark_on_the_first_row() {
390        let h = opened(Demo { plain: true, ..Demo::default() });
391        let screen = h.screen();
392        let lines: Vec<&str> = screen.lines().collect();
393        assert_eq!(lines[5], "     ▌                                     ×", "the top padding row: {screen}");
394        assert_eq!(lines[6], "     ▌  Its volumes go too.", "{screen}");
395        let theme = h.env().theme();
396        let muted =
397            theme.color("accent").zip(theme.color("overlay")).map(|(accent, overlay)| overlay.mix(accent, 0.45));
398        let pillar = h.fg(5, 6).expect("pillar colour");
399        let expected = muted.expect("theme colours");
400        let close = |a: u8, b: u8| a.abs_diff(b) <= 1;
401        assert!(close(pillar.r, expected.r) && close(pillar.g, expected.g), "{pillar:?} vs {expected:?}");
402        assert_ne!(Some(pillar), theme.color("accent"), "muted, not the full accent");
403    }
404
405    #[test]
406    fn the_close_mark_lights_three_cells_under_the_pointer_and_closes_on_a_click() {
407        let mut h = opened(Demo::default());
408        let (x, y) = h.find("×").expect("close mark");
409        let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
410        let resting = h.bg(column, row);
411        h.hover(x - 1, y);
412        let lit = h.bg(column, row);
413        assert_ne!(lit, resting, "the mark lights up");
414        assert_eq!((h.bg(column - 1, row), h.bg(column + 1, row)), (lit, lit), "all three cells light up");
415        assert_eq!(h.bg(column - 2, row), resting, "and no more");
416        h.click(x + 1, y);
417        assert!(!h.app().open, "a click on the mark closes");
418    }
419
420    #[test]
421    fn a_dialog_that_is_not_dismissable_has_no_close_mark_and_ignores_esc_and_outside_clicks() {
422        let mut h = opened(Demo { busy: true, outside: true, ..Demo::default() });
423        let screen = h.screen();
424        assert!(!screen.contains('×') && !screen.contains("esc close"), "{screen}");
425        h.press("esc").click(1, 14).click(44, 5);
426        assert!(h.app().open, "Esc, the corner and the dimmed screen do nothing");
427        h.click_text("Cancel");
428        assert!(!h.app().open, "its own buttons still close it");
429    }
430
431    #[test]
432    fn escape_and_the_close_mark_always_come_together() {
433        let mut h = opened(Demo::default());
434        h.press("esc");
435        assert!(!h.app().open, "Esc closes a dismissable dialog");
436        let mut h = opened(Demo::default());
437        h.click_text("×");
438        assert!(!h.app().open, "and so does its mark");
439    }
440
441    #[test]
442    fn the_pillar_and_the_close_mark_enter_with_the_surface_and_ascii_keeps_both() {
443        let mut h = Harness::new(Demo::default(), 50, 16);
444        h.press("tab").press("enter");
445        let danger = h.env().theme().color("danger");
446        let entering: Vec<_> = (4..12).filter_map(|row| h.fg(7, row)).collect();
447        assert!(h.screen().contains('▌'), "{}", h.screen());
448        assert!(entering.iter().all(|color| Some(*color) != danger), "the pillar fades in with the surface");
449        h.advance(Duration::from_millis(200));
450        assert_eq!(h.fg(5, 5), h.env().theme().color("danger"));
451        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
452        let screen = h.screen();
453        assert!(screen.lines().nth(4).is_some_and(|line| line.ends_with('x')), "{screen}");
454        assert_eq!(h.bg(5, 7), h.env().theme().color("danger"), "the ASCII pillar is a coloured cell");
455        let mut h = Harness::new(Demo::default(), 50, 16);
456        h.set_reduced_motion(true).press("tab").press("enter");
457        assert_eq!(h.fg(5, 9), h.env().theme().color("danger"), "at once with reduced motion");
458        assert!(h.screen().contains('×'));
459    }
460
461    #[test]
462    fn narrow_screens_keep_the_close_mark_inside_the_surface() {
463        let mut h = Harness::new(Demo::default(), 24, 16);
464        h.set_reduced_motion(true).press("tab").press("enter");
465        let screen = h.screen();
466        let lines: Vec<&str> = screen.lines().collect();
467        let mark = lines.iter().position(|line| line.contains('×')).unwrap_or_default();
468        assert!(lines[mark].ends_with('×') && lines[mark].chars().count() <= 23, "{screen}");
469        assert!(lines[mark + 1].contains("Remove web?"), "the mark sits on the row above the title: {screen}");
470    }
471
472    #[test]
473    fn the_close_mark_takes_the_top_right_cells_of_the_surface() {
474        let mut h = opened(Demo::default());
475        let overlay = h.env().theme().color("overlay");
476        let (x, y) = h.find("×").expect("close mark");
477        let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
478        let on_surface = |h: &Harness<Demo>, column: u16, row: u16| h.bg(column, row) == overlay;
479        assert!(on_surface(&h, column - 3, row), "the mark's row belongs to the surface");
480        assert!(!on_surface(&h, column, row - 1), "and it is the surface's first row");
481        assert!(on_surface(&h, column + 1, row + 1), "the mark's last cell is on the surface's last column");
482        assert!(!on_surface(&h, column + 2, row + 1), "and nothing of the surface lies beyond it");
483        h.hover(x, y);
484        let lit = h.bg(column, row);
485        assert_ne!(lit, overlay, "the mark lights up");
486        assert_eq!([h.bg(column - 1, row), h.bg(column + 1, row)], [lit, lit], "its three cells light together");
487        assert_eq!(h.bg(column - 2, row), overlay);
488    }
489
490    #[test]
491    fn focus_is_trapped_and_returns_on_close() {
492        let mut h = opened(Demo::default());
493        assert!(h.is_focused("name"), "the first focusable widget inside is focused");
494        h.press("tab").press("tab").press("tab");
495        assert!(h.is_focused("name"), "tab cycles inside the dialog");
496        h.press("shift+tab");
497        assert!(h.screen().contains("Remove"), "{}", h.screen());
498        h.press("enter");
499        assert_eq!(h.app().removed, 1, "{}", h.screen());
500        assert!(h.is_focused("open"), "focus returns to the button that opened the dialog");
501    }
502
503    #[test]
504    fn escape_closes_and_keys_never_reach_widgets_beneath() {
505        let mut h = opened(Demo::default());
506        h.type_text("db");
507        assert_eq!(h.app().name, "db");
508        h.press("esc");
509        assert!(!h.app().open);
510        assert!(!h.screen().contains("Remove web?"));
511    }
512
513    #[test]
514    fn clicks_outside_are_swallowed_or_close_when_asked() {
515        let mut h = opened(Demo::default());
516        h.click_text("Containers");
517        assert!(h.app().open, "clicks on the dimmed screen do nothing by default");
518        let mut h = opened(Demo { outside: true, ..Demo::default() });
519        h.click(1, 14);
520        assert!(!h.app().open);
521    }
522
523    #[test]
524    fn dialogs_stack_and_the_top_one_owns_the_keys() {
525        let mut h = opened(Demo { nested: true, ..Demo::default() });
526        let screen = h.screen();
527        assert!(screen.contains("Sure?") && screen.contains("Really."), "{screen}");
528        h.press("esc");
529        assert!(!h.app().nested);
530        assert!(h.app().open, "only the top dialog closed");
531    }
532
533    #[test]
534    fn pops_in_and_appears_at_once_with_reduced_motion() {
535        let mut h = Harness::new(Demo::default(), 50, 16);
536        h.press("tab").press("enter");
537        let entering = h.bg(7, 5);
538        h.advance(Duration::from_millis(200));
539        assert_ne!(entering, h.bg(7, 5), "the surface grows in over motion.enter");
540        let mut h = Harness::new(Demo::default(), 50, 16);
541        h.set_reduced_motion(true).press("tab").press("enter");
542        assert_eq!(h.bg(7, 5), h.env().theme().color("overlay"));
543    }
544
545    /// A dialog with three long actions, which cannot share a row at 40 columns.
546    struct ThreeWays {
547        chosen: Option<&'static str>,
548    }
549
550    impl App for ThreeWays {
551        type Msg = &'static str;
552        fn update(&mut self, msg: &'static str) -> Command<&'static str> {
553            self.chosen = Some(msg);
554            Command::none()
555        }
556        fn view(&self, ui: &mut View<'_, &'static str>) {
557            let modal = Modal::new()
558                .title("Quit?")
559                .on_close("closed")
560                .action(Button::new("Cancel").on_press("cancel"))
561                .action(Button::new("Leave running").on_press("leave"))
562                .action(Button::new("Finish and quit").variant("primary").on_press("finish"));
563            ui.add_with(modal, |ui| {
564                ui.add(Text::new("A session is running."));
565            });
566        }
567    }
568
569    #[test]
570    fn actions_that_do_not_fit_stand_one_under_another_with_the_last_at_the_bottom() {
571        let mut h = Harness::new(ThreeWays { chosen: None }, 40, 20);
572        h.set_reduced_motion(true).advance(Duration::from_millis(1));
573        let screen = h.screen();
574        assert!(!screen.contains('…'), "{screen}");
575        let place = |h: &Harness<ThreeWays>, label: &str| h.find(label).unwrap_or_else(|| panic!("{label}: {screen}"));
576        let (cancel, leave, finish) = (place(&h, "Cancel"), place(&h, "Leave running"), place(&h, "Finish and quit"));
577        assert_eq!((leave.1 - cancel.1, finish.1 - leave.1), (2, 2), "{screen}");
578        assert_eq!((cancel.0, leave.0), (finish.0, finish.0), "one column: {screen}");
579        let lines: Vec<&str> = screen.lines().collect();
580        let last = usize::try_from(finish.1).unwrap_or_default();
581        assert!(lines[last].contains("esc close"), "the hints share the last row: {screen}");
582        h.press("tab").press("tab").press("enter");
583        assert_eq!(h.app().chosen, Some("finish"), "Tab goes down the column");
584        let mut h = Harness::new(ThreeWays { chosen: None }, 40, 20);
585        h.set_reduced_motion(true).advance(Duration::from_millis(1));
586        h.click(leave.0 + 1, leave.1);
587        assert_eq!(h.app().chosen, Some("leave"));
588    }
589
590    #[test]
591    fn stacked_actions_take_the_width_of_the_widest() {
592        let wide = ActionRow::place(&[Size::new(10, 1), Size::new(17, 1)], 40);
593        assert_eq!((wide.rows, wide.rects[0].x, wide.rects[1].x), (1, 29, 17));
594        let narrow = ActionRow::place(&[Size::new(10, 1), Size::new(17, 1), Size::new(19, 1)], 32);
595        assert_eq!(narrow.rows, 5);
596        assert!(narrow.rects.iter().all(|rect| rect.width == 19 && rect.x == 19), "{narrow:?}");
597        assert_eq!(narrow.rects.iter().map(|rect| rect.y).collect::<Vec<_>>(), [0, 2, 4]);
598    }
599
600    #[test]
601    fn a_long_title_wraps_instead_of_being_cut() {
602        struct Long(&'static str);
603        impl App for Long {
604            type Msg = ();
605            fn update(&mut self, (): ()) -> Command<()> {
606                Command::none()
607            }
608            fn view(&self, ui: &mut View<'_, ()>) {
609                let modal = Modal::new().title(self.0).on_close(()).action(Button::new("OK").on_press(()));
610                ui.add_with(modal, |ui| {
611                    ui.add(Text::new("Body"));
612                });
613            }
614        }
615        for (code, title) in [
616            ("en", "Empty the trash for good, with every record in it?"),
617            ("de", "Den Papierkorb endgültig leeren, mit allen Einträgen darin?"),
618        ] {
619            let mut h = Harness::new(Long(title), 40, 16);
620            h.set_locale(code).set_reduced_motion(true).advance(Duration::from_millis(1));
621            let screen = h.screen();
622            assert!(!screen.contains('…'), "{code}: {screen}");
623            let shown: Vec<&str> = screen
624                .lines()
625                .map(|line| line.trim_start_matches(' ').trim_start_matches('▌').trim())
626                .filter(|line| !line.is_empty())
627                .collect();
628            assert!(shown.join(" ").contains(title), "every word of the title, in order: {code}: {screen}");
629            let body = shown.iter().position(|line| *line == "Body").unwrap_or_default();
630            let lines: Vec<&str> = screen.lines().collect();
631            let body_row = lines.iter().position(|line| line.contains("Body")).unwrap_or_default();
632            assert!(body >= 2, "{code}: {screen}");
633            assert!(
634                lines[body_row - 1].trim_start_matches(' ').trim_start_matches('▌').trim().is_empty(),
635                "a blank row after the title: {screen}"
636            );
637        }
638    }
639}