1use 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
11const DEFAULT_WIDTH: u16 = 56;
13
14const ACTION_GAP: u16 = 2;
16
17pub 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 parts: Vec<Node<Msg>>,
51}
52
53impl<Msg: Clone + 'static> Modal<Msg> {
54 #[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 #[must_use]
70 pub fn title(mut self, title: impl Into<String>) -> Self {
71 self.title = Some(title.into());
72 self
73 }
74
75 #[must_use]
77 pub fn variant(mut self, variant: impl Into<String>) -> Self {
78 self.variant = Some(variant.into());
79 self
80 }
81
82 #[must_use]
84 pub fn width(mut self, cells: u16) -> Self {
85 self.width = cells;
86 self
87 }
88
89 #[must_use]
92 pub fn on_close(mut self, message: Msg) -> Self {
93 self.on_close = Some(message);
94 self
95 }
96
97 #[must_use]
100 pub fn dismissable(mut self, dismissable: bool) -> Self {
101 self.dismissable = dismissable;
102 self
103 }
104
105 #[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 #[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
135impl<Msg: Clone + 'static> Default for Modal<Msg> {
136 fn default() -> Self {
137 Self::new()
138 }
139}
140
141impl<Msg: Clone + 'static> Container<Msg> for Modal<Msg> {
142 fn set_children(&mut self, children: Vec<Node<Msg>>) {
143 self.parts[0] = body(children);
144 }
145}
146
147impl<Msg: Clone + 'static> Widget<Msg> for Modal<Msg> {
148 fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
149 Size::default()
150 }
151
152 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
153 cx.request_overlay(area);
154 }
155
156 fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
157 let screen = cx.clip();
158 let dismissable = self.is_dismissable();
159 let padding = layer::padding(cx, "modal", dismissable);
160 let width = self.width.min(screen.width.saturating_sub(2));
161 let inner_width = width.saturating_sub(padding.horizontal());
162 let title_rows: u16 = if self.title.is_some() { 2 } else { 0 };
163 let (body, actions) = self.parts.split_first().expect("a modal always has its body");
164 let action_sizes: Vec<Size> =
165 actions.iter().map(|action| cx.measure_child(action, Size::new(inner_width, 1))).collect();
166 let mut hints = Vec::new();
167 if dismissable {
168 hints.push(layer::hint(cx, "esc", "close"));
169 }
170 if body.count_focusable() + actions.len() > 1 {
171 hints.push(layer::hint(cx, "tab", "switch"));
172 }
173 let footer_rows: u16 = if actions.is_empty() && hints.is_empty() { 0 } else { 2 };
174 let chrome = cells::sum([padding.vertical(), title_rows, footer_rows]);
175 let available_body = screen.height.saturating_sub(chrome.saturating_add(2));
176 let body_height = cx.measure_child(body, Size::new(inner_width, available_body)).height;
177 let size = Size::new(width, chrome.saturating_add(body_height));
178
179 let look = layer::Look { style: "modal", variant: self.variant.as_deref(), dismissable };
180 let surface = layer::open(cx, size, SurfacePosition::Center, look);
181 let inner = surface.inner;
182 cx.with_clip(surface.shown, |cx| {
183 if let Some(title) = &self.title {
184 layer::title(cx, inner.x, inner.y, inner.width, title);
185 }
186 let body_rect = Rect::new(
187 inner.x,
188 inner.y + i32::from(title_rows),
189 inner.width,
190 inner.height.saturating_sub(title_rows + footer_rows),
191 );
192 cx.paint_child(body, body_rect);
193 if footer_rows == 0 {
194 return;
195 }
196 let row = inner.bottom() - 1;
197 let gaps = ACTION_GAP * u16::try_from(actions.len().saturating_sub(1)).unwrap_or(0);
198 let actions_width = action_sizes.iter().map(|size| size.width).sum::<u16>() + gaps;
199 let start = inner.right() - i32::from(actions_width);
200 let mut x = start;
202 for (action, size) in actions.iter().zip(&action_sizes) {
203 cx.paint_child(action, Rect::new(x, row, size.width, 1));
204 x += i32::from(size.width + ACTION_GAP);
205 }
206 let hint_width = crate::geometry::clamp_u16(start - i32::from(ACTION_GAP) - inner.x);
207 layer::paint_hints(cx, inner.x, row, hint_width, &hints);
208 });
209 layer::finish(cx, &surface);
210 }
211
212 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
213 match layer::backdrop_event(cx, event, self.is_dismissable(), self.click_outside_closes) {
214 Backdrop::Close => {
215 if let Some(message) = &self.on_close {
216 cx.emit(message.clone());
217 }
218 true
219 }
220 Backdrop::Swallowed => true,
221 Backdrop::Inside | Backdrop::Ignored => false,
222 }
223 }
224
225 fn children(&self) -> &[Node<Msg>] {
226 &self.parts
227 }
228
229 fn children_mut(&mut self) -> &mut [Node<Msg>] {
230 &mut self.parts
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use std::time::Duration;
237
238 use super::*;
239 use crate::runtime::{App, Command, Harness};
240 use crate::widget::View;
241 use crate::widgets::{Text, TextInput};
242
243 #[derive(Default)]
244 struct Demo {
245 open: bool,
246 nested: bool,
247 removed: u32,
248 name: String,
249 outside: bool,
250 busy: bool,
251 plain: bool,
252 }
253
254 #[derive(Clone)]
255 enum Msg {
256 Open,
257 Close,
258 Remove,
259 Nested(bool),
260 Name(String),
261 }
262
263 impl App for Demo {
264 type Msg = Msg;
265 fn update(&mut self, msg: Msg) -> Command<Msg> {
266 match msg {
267 Msg::Open => self.open = true,
268 Msg::Close => self.open = false,
269 Msg::Remove => {
270 self.removed += 1;
271 self.open = false;
272 }
273 Msg::Nested(on) => self.nested = on,
274 Msg::Name(name) => self.name = name,
275 }
276 Command::none()
277 }
278 fn view(&self, ui: &mut View<'_, Msg>) {
279 ui.column(|ui| {
280 ui.add(Text::new("Containers"));
281 ui.add(Button::new("Remove web").on_press(Msg::Open)).id("open");
282 ui.add(Button::new("Other").on_press(Msg::Nested(false))).id("other");
283 if self.open {
284 let mut modal = Modal::new().width(40).on_close(Msg::Close).dismissable(!self.busy);
285 if !self.plain {
286 modal = modal.title("Remove web?").variant("danger");
287 }
288 let modal = modal
289 .close_on_click_outside(self.outside)
290 .action(Button::new("Cancel").on_press(Msg::Close))
291 .action(Button::new("Remove").variant("danger").on_press(Msg::Remove));
292 ui.add_with(modal, |ui| {
293 ui.add(Text::new("Its volumes go too."));
294 ui.add(TextInput::new(&self.name).on_change(Msg::Name)).id("name");
295 if self.nested {
296 ui.add_with(Modal::new().title("Sure?").on_close(Msg::Nested(false)).width(24), |ui| {
297 ui.add(Text::new("Really."));
298 });
299 }
300 });
301 }
302 });
303 }
304 }
305
306 fn opened(demo: Demo) -> Harness<Demo> {
307 let mut h = Harness::new(demo, 50, 16);
308 h.press("tab").press("enter").advance(Duration::from_millis(200));
309 h
310 }
311
312 #[test]
313 fn draws_a_dimmed_screen_a_pillar_down_the_edge_a_close_mark_and_right_aligned_actions() {
314 let h = opened(Demo::default());
315 let screen = h.screen();
316 assert_eq!(
319 screen,
320 "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",
321 "{screen}"
322 );
323 let theme = h.env().theme();
324 assert_eq!(h.bg(20, 5), theme.color("overlay"));
325 for row in 4..=11 {
326 assert_eq!(h.fg(5, row), theme.color("danger"), "pillar on row {row}");
327 }
328 assert!(h.is_bold(8, 5));
329 let text = theme.color("text").expect("text colour");
330 let dimmed = h.fg(0, 0).expect("dimmed text");
331 assert_ne!(dimmed, text, "the screen behind is dimmed");
332 }
333
334 #[test]
335 fn a_plain_dialog_has_an_accent_muted_pillar_and_its_close_mark_on_the_first_row() {
336 let h = opened(Demo { plain: true, ..Demo::default() });
337 let screen = h.screen();
338 let lines: Vec<&str> = screen.lines().collect();
339 assert_eq!(lines[5], " ▌ ×", "the top padding row: {screen}");
340 assert_eq!(lines[6], " ▌ Its volumes go too.", "{screen}");
341 let theme = h.env().theme();
342 let muted =
343 theme.color("accent").zip(theme.color("overlay")).map(|(accent, overlay)| overlay.mix(accent, 0.45));
344 let pillar = h.fg(5, 6).expect("pillar colour");
345 let expected = muted.expect("theme colours");
346 let close = |a: u8, b: u8| a.abs_diff(b) <= 1;
347 assert!(close(pillar.r, expected.r) && close(pillar.g, expected.g), "{pillar:?} vs {expected:?}");
348 assert_ne!(Some(pillar), theme.color("accent"), "muted, not the full accent");
349 }
350
351 #[test]
352 fn the_close_mark_lights_three_cells_under_the_pointer_and_closes_on_a_click() {
353 let mut h = opened(Demo::default());
354 let (x, y) = h.find("×").expect("close mark");
355 let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
356 let resting = h.bg(column, row);
357 h.hover(x - 1, y);
358 let lit = h.bg(column, row);
359 assert_ne!(lit, resting, "the mark lights up");
360 assert_eq!((h.bg(column - 1, row), h.bg(column + 1, row)), (lit, lit), "all three cells light up");
361 assert_eq!(h.bg(column - 2, row), resting, "and no more");
362 h.click(x + 1, y);
363 assert!(!h.app().open, "a click on the mark closes");
364 }
365
366 #[test]
367 fn a_dialog_that_is_not_dismissable_has_no_close_mark_and_ignores_esc_and_outside_clicks() {
368 let mut h = opened(Demo { busy: true, outside: true, ..Demo::default() });
369 let screen = h.screen();
370 assert!(!screen.contains('×') && !screen.contains("esc close"), "{screen}");
371 h.press("esc").click(1, 14).click(44, 5);
372 assert!(h.app().open, "Esc, the corner and the dimmed screen do nothing");
373 h.click_text("Cancel");
374 assert!(!h.app().open, "its own buttons still close it");
375 }
376
377 #[test]
378 fn escape_and_the_close_mark_always_come_together() {
379 let mut h = opened(Demo::default());
380 h.press("esc");
381 assert!(!h.app().open, "Esc closes a dismissable dialog");
382 let mut h = opened(Demo::default());
383 h.click_text("×");
384 assert!(!h.app().open, "and so does its mark");
385 }
386
387 #[test]
388 fn the_pillar_and_the_close_mark_enter_with_the_surface_and_ascii_keeps_both() {
389 let mut h = Harness::new(Demo::default(), 50, 16);
390 h.press("tab").press("enter");
391 let danger = h.env().theme().color("danger");
392 let entering: Vec<_> = (4..12).filter_map(|row| h.fg(7, row)).collect();
393 assert!(h.screen().contains('▌'), "{}", h.screen());
394 assert!(entering.iter().all(|color| Some(*color) != danger), "the pillar fades in with the surface");
395 h.advance(Duration::from_millis(200));
396 assert_eq!(h.fg(5, 5), h.env().theme().color("danger"));
397 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
398 let screen = h.screen();
399 assert!(screen.lines().nth(4).is_some_and(|line| line.ends_with('x')), "{screen}");
400 assert_eq!(h.bg(5, 7), h.env().theme().color("danger"), "the ASCII pillar is a coloured cell");
401 let mut h = Harness::new(Demo::default(), 50, 16);
402 h.set_reduced_motion(true).press("tab").press("enter");
403 assert_eq!(h.fg(5, 9), h.env().theme().color("danger"), "at once with reduced motion");
404 assert!(h.screen().contains('×'));
405 }
406
407 #[test]
408 fn narrow_screens_keep_the_close_mark_inside_the_surface() {
409 let mut h = Harness::new(Demo::default(), 24, 16);
410 h.set_reduced_motion(true).press("tab").press("enter");
411 let screen = h.screen();
412 let lines: Vec<&str> = screen.lines().collect();
413 let mark = lines.iter().position(|line| line.contains('×')).unwrap_or_default();
414 assert!(lines[mark].ends_with('×') && lines[mark].chars().count() <= 23, "{screen}");
415 assert!(lines[mark + 1].contains("Remove web?"), "the mark sits on the row above the title: {screen}");
416 }
417
418 #[test]
419 fn the_close_mark_takes_the_top_right_cells_of_the_surface() {
420 let mut h = opened(Demo::default());
421 let overlay = h.env().theme().color("overlay");
422 let (x, y) = h.find("×").expect("close mark");
423 let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
424 let on_surface = |h: &Harness<Demo>, column: u16, row: u16| h.bg(column, row) == overlay;
425 assert!(on_surface(&h, column - 3, row), "the mark's row belongs to the surface");
426 assert!(!on_surface(&h, column, row - 1), "and it is the surface's first row");
427 assert!(on_surface(&h, column + 1, row + 1), "the mark's last cell is on the surface's last column");
428 assert!(!on_surface(&h, column + 2, row + 1), "and nothing of the surface lies beyond it");
429 h.hover(x, y);
430 let lit = h.bg(column, row);
431 assert_ne!(lit, overlay, "the mark lights up");
432 assert_eq!([h.bg(column - 1, row), h.bg(column + 1, row)], [lit, lit], "its three cells light together");
433 assert_eq!(h.bg(column - 2, row), overlay);
434 }
435
436 #[test]
437 fn focus_is_trapped_and_returns_on_close() {
438 let mut h = opened(Demo::default());
439 assert!(h.is_focused("name"), "the first focusable widget inside is focused");
440 h.press("tab").press("tab").press("tab");
441 assert!(h.is_focused("name"), "tab cycles inside the dialog");
442 h.press("shift+tab");
443 assert!(h.screen().contains("Remove"), "{}", h.screen());
444 h.press("enter");
445 assert_eq!(h.app().removed, 1, "{}", h.screen());
446 assert!(h.is_focused("open"), "focus returns to the button that opened the dialog");
447 }
448
449 #[test]
450 fn escape_closes_and_keys_never_reach_widgets_beneath() {
451 let mut h = opened(Demo::default());
452 h.type_text("db");
453 assert_eq!(h.app().name, "db");
454 h.press("esc");
455 assert!(!h.app().open);
456 assert!(!h.screen().contains("Remove web?"));
457 }
458
459 #[test]
460 fn clicks_outside_are_swallowed_or_close_when_asked() {
461 let mut h = opened(Demo::default());
462 h.click_text("Containers");
463 assert!(h.app().open, "clicks on the dimmed screen do nothing by default");
464 let mut h = opened(Demo { outside: true, ..Demo::default() });
465 h.click(1, 14);
466 assert!(!h.app().open);
467 }
468
469 #[test]
470 fn dialogs_stack_and_the_top_one_owns_the_keys() {
471 let mut h = opened(Demo { nested: true, ..Demo::default() });
472 let screen = h.screen();
473 assert!(screen.contains("Sure?") && screen.contains("Really."), "{screen}");
474 h.press("esc");
475 assert!(!h.app().nested);
476 assert!(h.app().open, "only the top dialog closed");
477 }
478
479 #[test]
480 fn pops_in_and_appears_at_once_with_reduced_motion() {
481 let mut h = Harness::new(Demo::default(), 50, 16);
482 h.press("tab").press("enter");
483 let entering = h.bg(7, 5);
484 h.advance(Duration::from_millis(200));
485 assert_ne!(entering, h.bg(7, 5), "the surface grows in over motion.enter");
486 let mut h = Harness::new(Demo::default(), 50, 16);
487 h.set_reduced_motion(true).press("tab").press("enter");
488 assert_eq!(h.bg(7, 5), h.env().theme().color("overlay"));
489 }
490}