Skip to main content

qframe/widgets/
modal.rs

1//! Modal dialogs.
2
3use crate::event::Event;
4use crate::geometry::{Rect, Size};
5use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget};
6
7use super::Button;
8use super::cells;
9use super::layer::{self, Backdrop, SurfacePosition};
10
11/// Width of a dialog when none is set, in cells.
12const DEFAULT_WIDTH: u16 = 56;
13
14/// Cells between two action buttons.
15const ACTION_GAP: u16 = 2;
16
17/// A dialog over a dimmed screen: a title, any content and action buttons.
18///
19/// Add it to the view while it should be shown, anywhere in the tree: it takes no room where it
20/// is added and is drawn over everything as a layer, centred on the screen. It enters with a
21/// short pop over the theme's `motion.enter` (at once with reduced motion). While it is open,
22/// focus stays inside (the first focusable widget is focused), Tab cycles through its widgets,
23/// nothing beneath reacts to keys or the pointer and application shortcuts are paused; when it
24/// is removed, focus returns to where it was. Dialogs stack: one added inside another, or later
25/// in the view, is on top.
26///
27/// A pillar runs down the whole left edge of the surface, accent-muted, in the danger colour for
28/// `variant("danger")`.
29///
30/// With no options it is a plain surface with its content that only the application closes.
31/// `on_close` makes it dismissable: Esc and the close mark `×` at the top right send the message,
32/// always together, and `close_on_click_outside` adds a click on the dimmed screen.
33/// `dismissable(false)` turns all of them off at once (and hides the mark) while keeping the
34/// message, e.g. while the dialog is busy. `title` and `variant("danger")` mark it, `action` adds
35/// buttons at the bottom right. A faint hint line at the bottom left names Esc and Tab when they
36/// do something.
37///
38/// Style keys: `modal` (`bg`, `padding`, `pillar`) with variants such as `modal.danger`,
39/// `modal-title` (`fg`, `bold`), `close-mark`, `layer-backdrop` (`scrim`, `strength` in percent),
40/// `layer-hint-key`, `layer-hint-label`. Hint labels: `quvyta.layer.close`,
41/// `quvyta.layer.switch`.
42pub struct Modal<Msg> {
43    title: Option<String>,
44    variant: Option<String>,
45    width: u16,
46    on_close: Option<Msg>,
47    dismissable: bool,
48    click_outside_closes: bool,
49    /// The body column first, then one node per action.
50    parts: Vec<Node<Msg>>,
51}
52
53impl<Msg: Clone + 'static> Modal<Msg> {
54    /// An empty dialog. Add its content with [`View::add_with`](crate::widget::View::add_with).
55    #[must_use]
56    pub fn new() -> Self {
57        Self {
58            title: None,
59            variant: None,
60            width: DEFAULT_WIDTH,
61            on_close: None,
62            dismissable: true,
63            click_outside_closes: false,
64            parts: vec![body(Vec::new())],
65        }
66    }
67
68    /// A bold heading in the first row.
69    #[must_use]
70    pub fn title(mut self, title: impl Into<String>) -> Self {
71        self.title = Some(title.into());
72        self
73    }
74
75    /// Theme variant; `"danger"` gives a destructive dialog a danger pillar.
76    #[must_use]
77    pub fn variant(mut self, variant: impl Into<String>) -> Self {
78        self.variant = Some(variant.into());
79        self
80    }
81
82    /// Width in cells, padding included; 56 by default. Narrow screens shrink it.
83    #[must_use]
84    pub fn width(mut self, cells: u16) -> Self {
85        self.width = cells;
86        self
87    }
88
89    /// The message sent when the dialog is dismissed: Esc inside it or a click on its close mark.
90    /// Without it neither exists and the dialog closes only through its own buttons.
91    #[must_use]
92    pub fn on_close(mut self, message: Msg) -> Self {
93        self.on_close = Some(message);
94        self
95    }
96
97    /// Whether the dialog can be dismissed; `true` by default. `false` turns off Esc, the close
98    /// mark and the click outside together, and hides the mark, without removing `on_close`.
99    #[must_use]
100    pub fn dismissable(mut self, dismissable: bool) -> Self {
101        self.dismissable = dismissable;
102        self
103    }
104
105    /// Also sends the close message when the dimmed screen around the dialog is clicked, while
106    /// the dialog is dismissable.
107    #[must_use]
108    pub fn close_on_click_outside(mut self, closes: bool) -> Self {
109        self.click_outside_closes = closes;
110        self
111    }
112
113    /// Adds a button to the action row at the bottom right, after the ones added before. Put
114    /// the safe action first: the first focusable widget has focus when the dialog opens.
115    #[must_use]
116    pub fn action(mut self, button: Button<Msg>) -> Self {
117        let index = self.parts.len();
118        self.parts.push(Node::new(button, index));
119        self
120    }
121}
122
123impl<Msg> Modal<Msg> {
124    fn is_dismissable(&self) -> bool {
125        self.dismissable && self.on_close.is_some()
126    }
127}
128
129fn body<Msg: 'static>(children: Vec<Node<Msg>>) -> Node<Msg> {
130    let mut column = Node::new(Flex::new(Axis::Column, children), 0);
131    column.layout.width = Length::Fill(1);
132    column
133}
134
135fn count_focusable<M: 'static>(node: &Node<M>) -> usize {
136    usize::from(node.widget.focusable()) + node.widget.children().iter().map(count_focusable).sum::<usize>()
137}
138
139impl<Msg: Clone + 'static> Default for Modal<Msg> {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl<Msg: Clone + 'static> Container<Msg> for Modal<Msg> {
146    fn set_children(&mut self, children: Vec<Node<Msg>>) {
147        self.parts[0] = body(children);
148    }
149}
150
151impl<Msg: Clone + 'static> Widget<Msg> for Modal<Msg> {
152    fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
153        Size::default()
154    }
155
156    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
157        cx.request_overlay(area);
158    }
159
160    fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
161        let screen = cx.clip();
162        let dismissable = self.is_dismissable();
163        let padding = layer::padding(cx, "modal", dismissable);
164        let width = self.width.min(screen.width.saturating_sub(2));
165        let inner_width = width.saturating_sub(padding.horizontal());
166        let title_rows: u16 = if self.title.is_some() { 2 } else { 0 };
167        let (body, actions) = self.parts.split_first().expect("a modal always has its body");
168        let action_sizes: Vec<Size> =
169            actions.iter().map(|action| cx.measure_child(action, Size::new(inner_width, 1))).collect();
170        let mut hints = Vec::new();
171        if dismissable {
172            hints.push(layer::hint(cx, "esc", "close"));
173        }
174        if count_focusable(body) + actions.len() > 1 {
175            hints.push(layer::hint(cx, "tab", "switch"));
176        }
177        let footer_rows: u16 = if actions.is_empty() && hints.is_empty() { 0 } else { 2 };
178        let chrome = cells::sum([padding.vertical(), title_rows, footer_rows]);
179        let available_body = screen.height.saturating_sub(chrome.saturating_add(2));
180        let body_height = cx.measure_child(body, Size::new(inner_width, available_body)).height;
181        let size = Size::new(width, chrome.saturating_add(body_height));
182
183        let look = layer::Look { style: "modal", variant: self.variant.as_deref(), dismissable };
184        let surface = layer::open(cx, size, SurfacePosition::Center, look);
185        let inner = surface.inner;
186        cx.with_clip(surface.shown, |cx| {
187            if let Some(title) = &self.title {
188                layer::title(cx, inner.x, inner.y, inner.width, title);
189            }
190            let body_rect = Rect::new(
191                inner.x,
192                inner.y + i32::from(title_rows),
193                inner.width,
194                inner.height.saturating_sub(title_rows + footer_rows),
195            );
196            cx.paint_child(body, body_rect);
197            if footer_rows == 0 {
198                return;
199            }
200            let row = inner.bottom() - 1;
201            let gaps = ACTION_GAP * u16::try_from(actions.len().saturating_sub(1)).unwrap_or(0);
202            let actions_width = action_sizes.iter().map(|size| size.width).sum::<u16>() + gaps;
203            let start = inner.right() - i32::from(actions_width);
204            // Painted left to right so Tab follows the order the user reads.
205            let mut x = start;
206            for (action, size) in actions.iter().zip(&action_sizes) {
207                cx.paint_child(action, Rect::new(x, row, size.width, 1));
208                x += i32::from(size.width + ACTION_GAP);
209            }
210            let hint_width = crate::geometry::clamp_u16(start - i32::from(ACTION_GAP) - inner.x);
211            layer::paint_hints(cx, inner.x, row, hint_width, &hints);
212        });
213        layer::finish(cx, &surface);
214    }
215
216    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
217        match layer::backdrop_event(cx, event, self.is_dismissable(), self.click_outside_closes) {
218            Backdrop::Close => {
219                if let Some(message) = &self.on_close {
220                    cx.emit(message.clone());
221                }
222                true
223            }
224            Backdrop::Swallowed => true,
225            Backdrop::Inside | Backdrop::Ignored => false,
226        }
227    }
228
229    fn children(&self) -> &[Node<Msg>] {
230        &self.parts
231    }
232
233    fn children_mut(&mut self) -> &mut [Node<Msg>] {
234        &mut self.parts
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use std::time::Duration;
241
242    use super::*;
243    use crate::runtime::{App, Command, Harness};
244    use crate::widget::View;
245    use crate::widgets::{Text, TextInput};
246
247    #[derive(Default)]
248    struct Demo {
249        open: bool,
250        nested: bool,
251        removed: u32,
252        name: String,
253        outside: bool,
254        busy: bool,
255        plain: bool,
256    }
257
258    #[derive(Clone)]
259    enum Msg {
260        Open,
261        Close,
262        Remove,
263        Nested(bool),
264        Name(String),
265    }
266
267    impl App for Demo {
268        type Msg = Msg;
269        fn update(&mut self, msg: Msg) -> Command<Msg> {
270            match msg {
271                Msg::Open => self.open = true,
272                Msg::Close => self.open = false,
273                Msg::Remove => {
274                    self.removed += 1;
275                    self.open = false;
276                }
277                Msg::Nested(on) => self.nested = on,
278                Msg::Name(name) => self.name = name,
279            }
280            Command::none()
281        }
282        fn view(&self, ui: &mut View<'_, Msg>) {
283            ui.column(|ui| {
284                ui.add(Text::new("Containers"));
285                ui.add(Button::new("Remove web").on_press(Msg::Open)).id("open");
286                ui.add(Button::new("Other").on_press(Msg::Nested(false))).id("other");
287                if self.open {
288                    let mut modal = Modal::new().width(40).on_close(Msg::Close).dismissable(!self.busy);
289                    if !self.plain {
290                        modal = modal.title("Remove web?").variant("danger");
291                    }
292                    let modal = modal
293                        .close_on_click_outside(self.outside)
294                        .action(Button::new("Cancel").on_press(Msg::Close))
295                        .action(Button::new("Remove").variant("danger").on_press(Msg::Remove));
296                    ui.add_with(modal, |ui| {
297                        ui.add(Text::new("Its volumes go too."));
298                        ui.add(TextInput::new(&self.name).on_change(Msg::Name)).id("name");
299                        if self.nested {
300                            ui.add_with(Modal::new().title("Sure?").on_close(Msg::Nested(false)).width(24), |ui| {
301                                ui.add(Text::new("Really."));
302                            });
303                        }
304                    });
305                }
306            });
307        }
308    }
309
310    fn opened(demo: Demo) -> Harness<Demo> {
311        let mut h = Harness::new(demo, 50, 16);
312        h.press("tab").press("enter").advance(Duration::from_millis(200));
313        h
314    }
315
316    #[test]
317    fn draws_a_dimmed_screen_a_pillar_down_the_edge_a_close_mark_and_right_aligned_actions() {
318        let h = opened(Demo::default());
319        let screen = h.screen();
320        // The danger pillar runs down every row of the surface, and the close mark takes the top
321        // right corner, on the top padding row above the title.
322        assert_eq!(
323            screen,
324            "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",
325            "{screen}"
326        );
327        let theme = h.env().theme();
328        assert_eq!(h.bg(20, 5), theme.color("overlay"));
329        for row in 4..=11 {
330            assert_eq!(h.fg(5, row), theme.color("danger"), "pillar on row {row}");
331        }
332        assert!(h.is_bold(8, 5));
333        let text = theme.color("text").expect("text colour");
334        let dimmed = h.fg(0, 0).expect("dimmed text");
335        assert_ne!(dimmed, text, "the screen behind is dimmed");
336    }
337
338    #[test]
339    fn a_plain_dialog_has_an_accent_muted_pillar_and_its_close_mark_on_the_first_row() {
340        let h = opened(Demo { plain: true, ..Demo::default() });
341        let screen = h.screen();
342        let lines: Vec<&str> = screen.lines().collect();
343        assert_eq!(lines[5], "     ▌                                     ×", "the top padding row: {screen}");
344        assert_eq!(lines[6], "     ▌  Its volumes go too.", "{screen}");
345        let theme = h.env().theme();
346        let muted =
347            theme.color("accent").zip(theme.color("overlay")).map(|(accent, overlay)| overlay.mix(accent, 0.45));
348        let pillar = h.fg(5, 6).expect("pillar colour");
349        let expected = muted.expect("theme colours");
350        let close = |a: u8, b: u8| a.abs_diff(b) <= 1;
351        assert!(close(pillar.r, expected.r) && close(pillar.g, expected.g), "{pillar:?} vs {expected:?}");
352        assert_ne!(Some(pillar), theme.color("accent"), "muted, not the full accent");
353    }
354
355    #[test]
356    fn the_close_mark_lights_three_cells_under_the_pointer_and_closes_on_a_click() {
357        let mut h = opened(Demo::default());
358        let (x, y) = h.find("×").expect("close mark");
359        let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
360        let resting = h.bg(column, row);
361        h.hover(x - 1, y);
362        let lit = h.bg(column, row);
363        assert_ne!(lit, resting, "the mark lights up");
364        assert_eq!((h.bg(column - 1, row), h.bg(column + 1, row)), (lit, lit), "all three cells light up");
365        assert_eq!(h.bg(column - 2, row), resting, "and no more");
366        h.click(x + 1, y);
367        assert!(!h.app().open, "a click on the mark closes");
368    }
369
370    #[test]
371    fn a_dialog_that_is_not_dismissable_has_no_close_mark_and_ignores_esc_and_outside_clicks() {
372        let mut h = opened(Demo { busy: true, outside: true, ..Demo::default() });
373        let screen = h.screen();
374        assert!(!screen.contains('×') && !screen.contains("esc close"), "{screen}");
375        h.press("esc").click(1, 14).click(44, 5);
376        assert!(h.app().open, "Esc, the corner and the dimmed screen do nothing");
377        h.click_text("Cancel");
378        assert!(!h.app().open, "its own buttons still close it");
379    }
380
381    #[test]
382    fn escape_and_the_close_mark_always_come_together() {
383        let mut h = opened(Demo::default());
384        h.press("esc");
385        assert!(!h.app().open, "Esc closes a dismissable dialog");
386        let mut h = opened(Demo::default());
387        h.click_text("×");
388        assert!(!h.app().open, "and so does its mark");
389    }
390
391    #[test]
392    fn the_pillar_and_the_close_mark_enter_with_the_surface_and_ascii_keeps_both() {
393        let mut h = Harness::new(Demo::default(), 50, 16);
394        h.press("tab").press("enter");
395        let danger = h.env().theme().color("danger");
396        let entering: Vec<_> = (4..12).filter_map(|row| h.fg(7, row)).collect();
397        assert!(h.screen().contains('▌'), "{}", h.screen());
398        assert!(entering.iter().all(|color| Some(*color) != danger), "the pillar fades in with the surface");
399        h.advance(Duration::from_millis(200));
400        assert_eq!(h.fg(5, 5), h.env().theme().color("danger"));
401        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
402        let screen = h.screen();
403        assert!(screen.lines().nth(4).is_some_and(|line| line.ends_with('x')), "{screen}");
404        assert_eq!(h.bg(5, 7), h.env().theme().color("danger"), "the ASCII pillar is a coloured cell");
405        let mut h = Harness::new(Demo::default(), 50, 16);
406        h.set_reduced_motion(true).press("tab").press("enter");
407        assert_eq!(h.fg(5, 9), h.env().theme().color("danger"), "at once with reduced motion");
408        assert!(h.screen().contains('×'));
409    }
410
411    #[test]
412    fn narrow_screens_keep_the_close_mark_inside_the_surface() {
413        let mut h = Harness::new(Demo::default(), 24, 16);
414        h.set_reduced_motion(true).press("tab").press("enter");
415        let screen = h.screen();
416        let lines: Vec<&str> = screen.lines().collect();
417        let mark = lines.iter().position(|line| line.contains('×')).unwrap_or_default();
418        assert!(lines[mark].ends_with('×') && lines[mark].chars().count() <= 23, "{screen}");
419        assert!(lines[mark + 1].contains("Remove web?"), "the mark sits on the row above the title: {screen}");
420    }
421
422    #[test]
423    fn the_close_mark_takes_the_top_right_cells_of_the_surface() {
424        let mut h = opened(Demo::default());
425        let overlay = h.env().theme().color("overlay");
426        let (x, y) = h.find("×").expect("close mark");
427        let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
428        let on_surface = |h: &Harness<Demo>, column: u16, row: u16| h.bg(column, row) == overlay;
429        assert!(on_surface(&h, column - 3, row), "the mark's row belongs to the surface");
430        assert!(!on_surface(&h, column, row - 1), "and it is the surface's first row");
431        assert!(on_surface(&h, column + 1, row + 1), "the mark's last cell is on the surface's last column");
432        assert!(!on_surface(&h, column + 2, row + 1), "and nothing of the surface lies beyond it");
433        h.hover(x, y);
434        let lit = h.bg(column, row);
435        assert_ne!(lit, overlay, "the mark lights up");
436        assert_eq!([h.bg(column - 1, row), h.bg(column + 1, row)], [lit, lit], "its three cells light together");
437        assert_eq!(h.bg(column - 2, row), overlay);
438    }
439
440    #[test]
441    fn focus_is_trapped_and_returns_on_close() {
442        let mut h = opened(Demo::default());
443        assert!(h.is_focused("name"), "the first focusable widget inside is focused");
444        h.press("tab").press("tab").press("tab");
445        assert!(h.is_focused("name"), "tab cycles inside the dialog");
446        h.press("shift+tab");
447        assert!(h.screen().contains("Remove"), "{}", h.screen());
448        h.press("enter");
449        assert_eq!(h.app().removed, 1, "{}", h.screen());
450        assert!(h.is_focused("open"), "focus returns to the button that opened the dialog");
451    }
452
453    #[test]
454    fn escape_closes_and_keys_never_reach_widgets_beneath() {
455        let mut h = opened(Demo::default());
456        h.type_text("db");
457        assert_eq!(h.app().name, "db");
458        h.press("esc");
459        assert!(!h.app().open);
460        assert!(!h.screen().contains("Remove web?"));
461    }
462
463    #[test]
464    fn clicks_outside_are_swallowed_or_close_when_asked() {
465        let mut h = opened(Demo::default());
466        h.click_text("Containers");
467        assert!(h.app().open, "clicks on the dimmed screen do nothing by default");
468        let mut h = opened(Demo { outside: true, ..Demo::default() });
469        h.click(1, 14);
470        assert!(!h.app().open);
471    }
472
473    #[test]
474    fn dialogs_stack_and_the_top_one_owns_the_keys() {
475        let mut h = opened(Demo { nested: true, ..Demo::default() });
476        let screen = h.screen();
477        assert!(screen.contains("Sure?") && screen.contains("Really."), "{screen}");
478        h.press("esc");
479        assert!(!h.app().nested);
480        assert!(h.app().open, "only the top dialog closed");
481    }
482
483    #[test]
484    fn pops_in_and_appears_at_once_with_reduced_motion() {
485        let mut h = Harness::new(Demo::default(), 50, 16);
486        h.press("tab").press("enter");
487        let entering = h.bg(7, 5);
488        h.advance(Duration::from_millis(200));
489        assert_ne!(entering, h.bg(7, 5), "the surface grows in over motion.enter");
490        let mut h = Harness::new(Demo::default(), 50, 16);
491        h.set_reduced_motion(true).press("tab").press("enter");
492        assert_eq!(h.bg(7, 5), h.env().theme().color("overlay"));
493    }
494}