1use 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
12const DEFAULT_WIDTH: u16 = 56;
14
15const ACTION_GAP: u16 = 2;
17
18pub 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 parts: Vec<Node<Msg>>,
53}
54
55impl<Msg: Clone + 'static> Modal<Msg> {
56 #[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 #[must_use]
72 pub fn title(mut self, title: impl Into<String>) -> Self {
73 self.title = Some(title.into());
74 self
75 }
76
77 #[must_use]
79 pub fn variant(mut self, variant: impl Into<String>) -> Self {
80 self.variant = Some(variant.into());
81 self
82 }
83
84 #[must_use]
86 pub fn width(mut self, cells: u16) -> Self {
87 self.width = cells;
88 self
89 }
90
91 #[must_use]
94 pub fn on_close(mut self, message: Msg) -> Self {
95 self.on_close = Some(message);
96 self
97 }
98
99 #[must_use]
102 pub fn dismissable(mut self, dismissable: bool) -> Self {
103 self.dismissable = dismissable;
104 self
105 }
106
107 #[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 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
251struct ActionRow {
252 rows: u16,
254 rects: Vec<Rect>,
257 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 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 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}