Skip to main content

frus_widgets/
dialog.rs

1//! [`Dialog`] and [`AlertDialog`]: the **modal** the framework did not have.
2//!
3//! A drawer, a bottom sheet and a menu were all here; the one that asks a question and
4//! waits for the answer was not — and the name `AlertDialog` was taken by a widget that is
5//! not a dialog at all. That one is [`Alert`](crate::Alert) now, which is what it is.
6//!
7//! Both are **controlled**, like every other overlay here: the application holds the open
8//! flag and says what closes it. There is no `showDialog`, because there is no route stack
9//! the framework owns to push onto — `open` is a field of the application's state, and
10//! that is the whole difference.
11//!
12//! ```ignore
13//! AlertDialog::new(app.confirm_open)
14//!     .title("Delete this task?")
15//!     .content("This cannot be undone.")
16//!     .action(button("Cancel", Msg::CloseConfirm))
17//!     .action(button("Delete", Msg::Delete))
18//!     .on_dismiss(Msg::CloseConfirm)
19//!     .body(screen)
20//! ```
21//!
22//! [`Dialog`] is the surface on its own — rounded, elevated, held off the window's edges,
23//! at least 280 wide — for content that is not a title-and-actions box.
24
25use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder, TextAlign, TextStyle};
26use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
27
28use crate::interaction::Status;
29use crate::portal::Placement;
30use crate::rowcolumn::VerticalDirection;
31use crate::theme::Theme;
32use crate::widget::Widget;
33
34/// How far the dialog is held off the window's edges — the reference's
35/// `_defaultInsetPadding` (`dialog.dart:32`).
36pub const DIALOG_INSET_PADDING: Insets = Insets {
37    top: 24.0,
38    right: 40.0,
39    bottom: 24.0,
40    left: 40.0,
41};
42/// The narrowest a dialog gets, whatever its content (`dialog.dart:275`).
43pub const DIALOG_MIN_WIDTH: f32 = 280.0;
44/// The Material 3 corner (`dialog.dart:1967`).
45pub const DIALOG_RADIUS: f32 = 28.0;
46/// The Material 3 elevation (`dialog.dart:1966`).
47pub const DIALOG_ELEVATION: f32 = 6.0;
48/// The margin around the title / content / actions column.
49const EDGE: f32 = 24.0;
50/// The gap above content that follows a title or an icon (`dialog.dart:859`).
51const CONTENT_GAP: f32 = 16.0;
52/// The gap under a title with nothing below it (`dialog.dart:829`).
53const TITLE_ONLY_GAP: f32 = 20.0;
54/// The space between two action buttons — half the reference's `buttonPadding.horizontal`,
55/// which is 16 (`dialog.dart:882`).
56const ACTION_GAP: f32 = 8.0;
57
58/// The title's type: what the caller said, else the theme, else the reference's
59/// `headlineSmall` (`dialog.dart:1988`).
60fn title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
61    over.or(theme.and_then(|t| t.widgets.dialog.title_text_style))
62        .unwrap_or_else(|| crate::theme::type_scale(theme).headline_small)
63}
64
65/// The content's type: `bodyMedium` (`dialog.dart:1991`).
66fn content_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
67    over.or(theme.and_then(|t| t.widgets.dialog.content_text_style))
68        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
69}
70
71/// Hands a subtree a default text style, which is how the reference styles a dialog's
72/// title and content — a `DefaultTextStyle` around the widget the caller passed, never a
73/// style forced onto it (`dialog.dart:842`).
74fn texted<Msg: Clone + 'static>(
75    style: TextStyle,
76    align: Option<TextAlign>,
77    child: Box<dyn Widget<Msg>>,
78) -> crate::Themed<Msg> {
79    crate::Themed::tweak(
80        move |t: &mut Theme| {
81            t.widgets.text.style = style;
82            if let Some(align) = align {
83                t.widgets.text.align = Some(align);
84            }
85        },
86        child,
87    )
88}
89
90/// The panel itself: a rounded, elevated surface, at least [`DIALOG_MIN_WIDTH`] wide and
91/// held off the window's edges by its margin.
92struct DialogSurface<Msg> {
93    children: Vec<Box<dyn Widget<Msg>>>,
94    background: Option<Color>,
95    elevation: Option<f32>,
96    shadow_color: Option<Color>,
97    surface_tint: Option<Color>,
98    shape: Option<ShapeBorder>,
99    inset_padding: Option<Insets>,
100    min_width: Option<f32>,
101    max_width: Option<f32>,
102}
103
104impl<Msg> DialogSurface<Msg> {
105    fn elevation(&self, theme: Option<&Theme>) -> f32 {
106        self.elevation
107            .or(theme.and_then(|t| t.widgets.dialog.elevation))
108            .unwrap_or(DIALOG_ELEVATION)
109    }
110
111    /// **What shape this dialog is** (`dialog.dart:1967`): its own word, then the
112    /// theme's, then a 28-radius rounded rectangle.
113    fn shape(&self, theme: Option<&Theme>) -> ShapeBorder {
114        crate::resolve_shape(
115            self.shape,
116            theme.and_then(|t| t.widgets.dialog.shape),
117            None,
118            ShapeBorder::rounded(DIALOG_RADIUS),
119        )
120    }
121
122    /// The surface's colour, tinted for its elevation **only where a tint is named**.
123    ///
124    /// The reference's Material 3 dialog makes both the shadow and the tint transparent
125    /// (`dialog.dart:1982`): the container tone already carries the height. A caller or a
126    /// theme that names a tint is asking for the older look, and gets it.
127    fn background(&self, theme: &Theme) -> Color {
128        let base = self
129            .background
130            .or(theme.widgets.dialog.color)
131            .unwrap_or(theme.scheme.surface_container_high);
132        match self.surface_tint.or(theme.widgets.dialog.surface_tint) {
133            Some(tint) => base.surface_tint(tint, self.elevation(Some(theme))),
134            None => base,
135        }
136    }
137
138    fn sizing(&self, theme: Option<&Theme>) -> Style {
139        let margin = self
140            .inset_padding
141            .or(theme.and_then(|t| t.widgets.dialog.inset_padding))
142            .unwrap_or(DIALOG_INSET_PADDING);
143        Style {
144            flex_direction: FlexDirection::Column,
145            height: Dimension::Auto,
146            min_width: Dimension::Length(self.min_width.unwrap_or(DIALOG_MIN_WIDTH)),
147            max_width: self.max_width.map_or(Dimension::Auto, Dimension::Length),
148            margin,
149            ..Default::default()
150        }
151    }
152}
153
154impl<Msg: Clone> Widget<Msg> for DialogSurface<Msg> {
155    fn style(&self) -> Style {
156        self.sizing(None)
157    }
158
159    fn style_themed(&self, theme: &Theme) -> Style {
160        self.sizing(Some(theme))
161    }
162
163    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
164        &self.children
165    }
166
167    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
168        let o = status.opacity;
169        let shape = self.shape(Some(theme));
170        // The shadow is a rectangle whatever the shape is, so it takes the corners the
171        // shape resolves to and none at all from one that has no rounded form.
172        let radius = shape
173            .as_rounded(bounds)
174            .map(|(_, radius)| radius)
175            .unwrap_or(BorderRadius::ZERO);
176        let depth = self.elevation(Some(theme));
177        // The shadow, drawn the way `Card` draws one: the blur grows with the depth and
178        // the drop is half of it. The reference's Material 3 shadow colour is transparent
179        // — the tone carries the height — so a shadow appears only where one is named.
180        let shadow = self
181            .shadow_color
182            .or(theme.widgets.dialog.shadow_color)
183            .unwrap_or(Color::TRANSPARENT);
184        if depth > 0.0 && shadow.a > 0.0 {
185            // The reference's shadows for this height (milestone 606).
186            frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
187        }
188        scene.draw_shape(bounds, shape, self.background(theme).fade(o));
189    }
190
191    fn on_click(&self) -> Option<Msg> {
192        None
193    }
194}
195
196/// A **modal surface** over the screen: rounded, elevated, held off the window's edges.
197///
198/// Controlled, like every overlay here: `open` says whether it is showing, and
199/// [`Dialog::on_dismiss`] is what a click on the scrim sends. The screen behind stays
200/// built and visible.
201pub struct Dialog<Msg = crate::callback::Callback> {
202    open: bool,
203    on_dismiss: Option<Msg>,
204    content: Option<Box<dyn Widget<Msg>>>,
205    panel: Option<Box<dyn Widget<Msg>>>,
206    children: Vec<Box<dyn Widget<Msg>>>,
207    background: Option<Color>,
208    elevation: Option<f32>,
209    shadow_color: Option<Color>,
210    surface_tint: Option<Color>,
211    shape: Option<ShapeBorder>,
212    inset_padding: Option<Insets>,
213    min_width: Option<f32>,
214    max_width: Option<f32>,
215}
216
217impl<Msg: Clone + 'static> Dialog<Msg> {
218    /// Creates a dialog; `open` says whether it is showing.
219    pub fn new(open: bool) -> Self {
220        Self {
221            open,
222            on_dismiss: None,
223            content: None,
224            panel: None,
225            children: Vec::new(),
226            background: None,
227            elevation: None,
228            shadow_color: None,
229            surface_tint: None,
230            shape: None,
231            inset_padding: None,
232            min_width: None,
233            max_width: None,
234        }
235    }
236
237    /// What a click on the scrim sends. Without it the scrim is inert, which is the
238    /// reference's `barrierDismissible: false` — for a dialog that must be answered.
239    #[must_use]
240    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
241        self.on_dismiss = Some(message.into());
242        self
243    }
244
245    /// What the dialog holds.
246    #[must_use]
247    pub fn child(mut self, content: impl Widget<Msg> + 'static) -> Self {
248        self.content = Some(Box::new(content));
249        self
250    }
251
252    /// The surface's colour. The scheme's `surfaceContainerHigh` by default, as in the
253    /// reference (`dialog.dart:1979`).
254    #[must_use]
255    pub fn background(mut self, color: Color) -> Self {
256        self.background = Some(color);
257        self
258    }
259
260    /// How far off the page it sits. `6` by default (`dialog.dart:1966`).
261    #[must_use]
262    pub fn elevation(mut self, elevation: f32) -> Self {
263        self.elevation = Some(elevation);
264        self
265    }
266
267    /// The shadow's colour. **Transparent by default**, as the reference's Material 3
268    /// dialog is: the container tone carries the height instead of a drop shadow.
269    #[must_use]
270    pub fn shadow_color(mut self, color: Color) -> Self {
271        self.shadow_color = Some(color);
272        self
273    }
274
275    /// The colour the surface is tinted towards for its elevation. Unset — the default —
276    /// nothing is tinted, which is the reference's Material 3 answer.
277    #[must_use]
278    pub fn surface_tint(mut self, color: Color) -> Self {
279        self.surface_tint = Some(color);
280        self
281    }
282
283    /// **What shape it is** (`dialog.dart:1967`). A 28-radius rounded rectangle by
284    /// default.
285    ///
286    /// It took a `BorderRadius` under this name until milestone 451, which is the
287    /// clearest case of the deviation that milestone closed: `shape` in the reference is
288    /// a `ShapeBorder`, and a corner radius is one of the things one can be. See
289    /// [`Dialog::radius`] for the shorthand.
290    #[must_use]
291    pub fn shape(mut self, shape: ShapeBorder) -> Self {
292        self.shape = Some(shape);
293        self
294    }
295
296    /// **Its corners**, as a shorthand for a rounded rectangle of that radius. The last
297    /// of this and [`shape`](Self::shape) to be called is the one that counts.
298    #[must_use]
299    pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
300        self.shape(ShapeBorder::rounded(radius))
301    }
302
303    /// How far the dialog is held off the window's edges. 40 across and 24 down by
304    /// default (`dialog.dart:32`).
305    #[must_use]
306    pub fn inset_padding(mut self, padding: Insets) -> Self {
307        self.inset_padding = Some(padding);
308        self
309    }
310
311    /// The narrowest it gets, whatever its content. `280` by default
312    /// (`dialog.dart:275`).
313    #[must_use]
314    pub fn min_width(mut self, width: f32) -> Self {
315        self.min_width = Some(width);
316        self
317    }
318
319    /// The widest it gets. Unbounded by default, as the reference's `constraints` is.
320    #[must_use]
321    pub fn max_width(mut self, width: f32) -> Self {
322        self.max_width = Some(width);
323        self
324    }
325
326    /// Sets the screen behind it and finalises the dialog.
327    #[must_use]
328    pub fn body(mut self, body: impl Widget<Msg> + 'static) -> Self {
329        self.panel = self.content.take().map(|content| {
330            Box::new(DialogSurface {
331                children: vec![content],
332                background: self.background,
333                elevation: self.elevation,
334                shadow_color: self.shadow_color,
335                surface_tint: self.surface_tint,
336                shape: self.shape,
337                inset_padding: self.inset_padding,
338                min_width: self.min_width,
339                max_width: self.max_width,
340            }) as Box<dyn Widget<Msg>>
341        });
342        self.children = vec![Box::new(body)];
343        self
344    }
345}
346
347impl<Msg: Clone> Widget<Msg> for Dialog<Msg> {
348    fn style(&self) -> Style {
349        Style {
350            height: Dimension::Percent(1.0),
351            flex_direction: FlexDirection::Column,
352            ..Default::default()
353        }
354    }
355
356    /// The width it was **offered**, not the width its parent came out at.
357    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
358        crate::widget::FillAxes::WIDTH
359    }
360
361    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
362        &self.children
363    }
364
365    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
366
367    fn on_click(&self) -> Option<Msg> {
368        None
369    }
370
371    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
372        self.panel.as_ref().map(|p| (p.as_ref(), Placement::Center))
373    }
374
375    fn overlay_dismiss(&self) -> Option<Msg> {
376        self.on_dismiss.clone()
377    }
378
379    fn anim_target(&self) -> Option<f32> {
380        Some(if self.open { 1.0 } else { 0.0 })
381    }
382}
383
384/// Where the action buttons sit along the bottom row.
385pub type ActionsAlignment = Justify;
386
387/// A dialog that **asks something**: an optional icon, an optional title, some content,
388/// and the buttons that answer it.
389///
390/// The paddings are the reference's, and conditional the way the reference's are: a title
391/// under an icon loses its top padding, a title with content below it loses its bottom
392/// padding, and a title with nothing below it keeps twenty of it (`dialog.dart:824`).
393/// An icon also **centres the title**, which is the one place the presence of one slot
394/// changes how another is aligned (`dialog.dart:844`).
395pub struct AlertDialog<Msg = crate::callback::Callback> {
396    dialog: Dialog<Msg>,
397    icon: Option<Box<dyn Widget<Msg>>>,
398    icon_color: Option<Color>,
399    title: Option<Box<dyn Widget<Msg>>>,
400    title_text_style: Option<TextStyle>,
401    content: Option<Box<dyn Widget<Msg>>>,
402    content_text_style: Option<TextStyle>,
403    actions: Vec<Box<dyn Widget<Msg>>>,
404    actions_alignment: Option<ActionsAlignment>,
405    actions_overflow_alignment: Option<Align>,
406    actions_overflow_direction: Option<VerticalDirection>,
407    actions_overflow_button_spacing: Option<f32>,
408}
409
410impl<Msg: Clone + 'static> AlertDialog<Msg> {
411    /// Creates one; `open` says whether it is showing.
412    pub fn new(open: bool) -> Self {
413        Self {
414            dialog: Dialog::new(open),
415            icon: None,
416            icon_color: None,
417            title: None,
418            title_text_style: None,
419            content: None,
420            content_text_style: None,
421            actions: Vec::new(),
422            actions_alignment: None,
423            actions_overflow_alignment: None,
424            actions_overflow_direction: None,
425            actions_overflow_button_spacing: None,
426        }
427    }
428
429    /// What a click on the scrim sends. Without it the scrim is inert — the reference's
430    /// `barrierDismissible: false`, for a question a button has to answer.
431    #[must_use]
432    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
433        self.dialog = self.dialog.on_dismiss(message);
434        self
435    }
436
437    /// A glyph above the title, centred. Having one **centres the title too**.
438    #[must_use]
439    pub fn icon(mut self, icon: impl Widget<Msg> + 'static) -> Self {
440        self.icon = Some(Box::new(icon));
441        self
442    }
443
444    /// The icon's colour. The scheme's `secondary` by default (`dialog.dart:1976`).
445    #[must_use]
446    pub fn icon_color(mut self, color: Color) -> Self {
447        self.icon_color = Some(color);
448        self
449    }
450
451    /// The heading, as text.
452    #[must_use]
453    pub fn title(self, title: impl Into<String>) -> Self {
454        let text = crate::Text::new(title);
455        self.title_widget(text)
456    }
457
458    /// The heading, as a widget.
459    #[must_use]
460    pub fn title_widget(mut self, title: impl Widget<Msg> + 'static) -> Self {
461        self.title = Some(Box::new(title));
462        self
463    }
464
465    /// The heading's type. `headlineSmall` by default.
466    #[must_use]
467    pub fn title_text_style(mut self, style: TextStyle) -> Self {
468        self.title_text_style = Some(style);
469        self
470    }
471
472    /// What the dialog says, as text.
473    #[must_use]
474    pub fn content(self, content: impl Into<String>) -> Self {
475        let text = crate::Text::new(content);
476        self.content_widget(text)
477    }
478
479    /// What the dialog says, as a widget.
480    #[must_use]
481    pub fn content_widget(mut self, content: impl Widget<Msg> + 'static) -> Self {
482        self.content = Some(Box::new(content));
483        self
484    }
485
486    /// The content's type. `bodyMedium` by default.
487    #[must_use]
488    pub fn content_text_style(mut self, style: TextStyle) -> Self {
489        self.content_text_style = Some(style);
490        self
491    }
492
493    /// Adds a button to the row along the bottom.
494    #[must_use]
495    pub fn action(mut self, action: impl Widget<Msg> + 'static) -> Self {
496        self.actions.push(Box::new(action));
497        self
498    }
499
500    /// Where the buttons sit along that row. The trailing end by default, as in the
501    /// reference (`MainAxisAlignment.end`).
502    #[must_use]
503    pub fn actions_alignment(mut self, alignment: ActionsAlignment) -> Self {
504        self.actions_alignment = Some(alignment);
505        self
506    }
507
508    /// Which edge the buttons line up on **once they have stacked** — see
509    /// [`OverflowBar::overflow_alignment`](crate::OverflowBar::overflow_alignment). The
510    /// trailing end by default, so a column of answers sits where the row of them was
511    /// (`dialog.dart:610`).
512    #[must_use]
513    pub fn actions_overflow_alignment(mut self, alignment: Align) -> Self {
514        self.actions_overflow_alignment = Some(alignment);
515        self
516    }
517
518    /// Which way that column runs — see
519    /// [`OverflowBar::overflow_direction`](crate::OverflowBar::overflow_direction).
520    /// Downwards by default, so the buttons stack in the order they were given.
521    ///
522    /// Worth saying for a destructive answer: last on a line is *first* in a column, so a
523    /// dialog that folds moves "Delete" to where the thumb was reaching for "Cancel".
524    /// [`VerticalDirection::Up`] is how an application says to keep it last.
525    #[must_use]
526    pub fn actions_overflow_direction(mut self, direction: VerticalDirection) -> Self {
527        self.actions_overflow_direction = Some(direction);
528        self
529    }
530
531    /// The space between the buttons **once they have stacked**, which is not the space
532    /// between them on one line (`dialog.dart:613`). Nothing by default, as in the
533    /// reference: buttons that already pad themselves need no more.
534    #[must_use]
535    pub fn actions_overflow_button_spacing(mut self, spacing: f32) -> Self {
536        self.actions_overflow_button_spacing = Some(spacing);
537        self
538    }
539
540    /// The surface's colour — see [`Dialog::background`].
541    #[must_use]
542    pub fn background(mut self, color: Color) -> Self {
543        self.dialog = self.dialog.background(color);
544        self
545    }
546
547    /// How far off the page it sits — see [`Dialog::elevation`].
548    #[must_use]
549    pub fn elevation(mut self, elevation: f32) -> Self {
550        self.dialog = self.dialog.elevation(elevation);
551        self
552    }
553
554    /// The shadow's colour — see [`Dialog::shadow_color`].
555    #[must_use]
556    pub fn shadow_color(mut self, color: Color) -> Self {
557        self.dialog = self.dialog.shadow_color(color);
558        self
559    }
560
561    /// The tint for its elevation — see [`Dialog::surface_tint`].
562    #[must_use]
563    pub fn surface_tint(mut self, color: Color) -> Self {
564        self.dialog = self.dialog.surface_tint(color);
565        self
566    }
567
568    /// **What shape it is** — see [`Dialog::shape`].
569    #[must_use]
570    pub fn shape(mut self, shape: ShapeBorder) -> Self {
571        self.dialog = self.dialog.shape(shape);
572        self
573    }
574
575    /// How far off the window's edges — see [`Dialog::inset_padding`].
576    #[must_use]
577    pub fn inset_padding(mut self, padding: Insets) -> Self {
578        self.dialog = self.dialog.inset_padding(padding);
579        self
580    }
581
582    /// The narrowest it gets — see [`Dialog::min_width`].
583    #[must_use]
584    pub fn min_width(mut self, width: f32) -> Self {
585        self.dialog = self.dialog.min_width(width);
586        self
587    }
588
589    /// The widest it gets — see [`Dialog::max_width`].
590    #[must_use]
591    pub fn max_width(mut self, width: f32) -> Self {
592        self.dialog = self.dialog.max_width(width);
593        self
594    }
595
596    /// Sets the screen behind it and finalises the dialog.
597    ///
598    /// The column is composed by a [`ThemeBuilder`](crate::ThemeBuilder): the title's type
599    /// comes from the theme and its alignment from whether there is an icon, so what the
600    /// column *contains* cannot be decided before the theme is known.
601    #[must_use]
602    pub fn body(self, body: impl Widget<Msg> + 'static) -> Dialog<Msg> {
603        let AlertDialog {
604            dialog,
605            icon,
606            icon_color,
607            title,
608            title_text_style,
609            content,
610            content_text_style,
611            actions,
612            actions_alignment,
613            actions_overflow_alignment,
614            actions_overflow_direction,
615            actions_overflow_button_spacing,
616        } = self;
617        let has_icon = icon.is_some();
618        let has_title = title.is_some();
619        let has_content = content.is_some();
620        let column = crate::ThemeBuilder::boxed(move |theme| {
621            // **The column stretches** (`dialog.dart:928`) and the centring is the
622            // *text's* own: a slot laid out at its natural width has nothing for a text
623            // alignment to move it within, so an icon would centre nothing.
624            let mut column = crate::Flex::column().align(Align::Stretch);
625            if let Some(icon) = icon {
626                // 24 all round, less at the bottom when something follows
627                // (`dialog.dart:795`).
628                let below = if has_title {
629                    CONTENT_GAP
630                } else if has_content {
631                    0.0
632                } else {
633                    EDGE
634                };
635                let color = icon_color
636                    .or(theme.widgets.dialog.icon_color)
637                    .unwrap_or(theme.scheme.secondary);
638                column = column.child(
639                    crate::Flex::column()
640                        .align(Align::Center)
641                        .padding_each(EDGE, EDGE, below, EDGE)
642                        .child(crate::Themed::tweak(
643                            move |t: &mut Theme| t.widgets.icon.color = Some(color),
644                            icon,
645                        )),
646                );
647            }
648            if let Some(title) = title {
649                let above = if has_icon { 0.0 } else { EDGE };
650                let below = if has_content { 0.0 } else { TITLE_ONLY_GAP };
651                let align = if has_icon {
652                    TextAlign::Center
653                } else {
654                    TextAlign::Start
655                };
656                // The row is what the alignment moves the title within. A text declares
657                // the width of its own words, so a text alignment alone has nothing to
658                // move it inside — that is the difference between this and the
659                // reference's `textAlign`, which works because its column stretches the
660                // text to the box. Both are set: the row places a single line, the
661                // alignment places the lines of a title that wrapped.
662                column = column.child(
663                    crate::Flex::column()
664                        .padding_each(above, EDGE, below, EDGE)
665                        .child(
666                            crate::Flex::row()
667                                .justify(if has_icon {
668                                    Justify::Center
669                                } else {
670                                    Justify::Start
671                                })
672                                .child(texted(
673                                    title_style(title_text_style, Some(theme)),
674                                    Some(align),
675                                    title,
676                                )),
677                        ),
678                );
679            }
680            if let Some(content) = content {
681                let above = if has_icon || has_title {
682                    CONTENT_GAP
683                } else {
684                    EDGE
685                };
686                column = column.child(
687                    crate::Flex::column()
688                        .padding_each(above, EDGE, EDGE, EDGE)
689                        .child(texted(
690                            content_style(content_text_style, Some(theme)),
691                            None,
692                            content,
693                        )),
694                );
695            }
696            if !actions.is_empty() {
697                // 24 at the sides and the bottom, nothing on top: whatever is above has
698                // already spaced itself (`dialog.dart:1994`).
699                // **A bar, not a row** (`dialog.dart:606`): a row neither wraps nor
700                // shrinks, so two buttons that stopped fitting — which is what a reader
701                // who has turned their font size up has — were drawn straight past the
702                // surface holding them.
703                let mut row = crate::OverflowBar::new()
704                    .alignment(actions_alignment.unwrap_or(Justify::End))
705                    .spacing(ACTION_GAP)
706                    .overflow_alignment(actions_overflow_alignment.unwrap_or(Align::End))
707                    .overflow_direction(actions_overflow_direction.unwrap_or_default())
708                    .overflow_spacing(actions_overflow_button_spacing.unwrap_or(0.0));
709                for action in actions {
710                    row = row.child_boxed(action);
711                }
712                column = column.child(
713                    crate::Flex::column()
714                        .padding_each(0.0, EDGE, EDGE, EDGE)
715                        .child(row),
716                );
717            }
718            Box::new(column) as Box<dyn Widget<Msg>>
719        });
720        dialog.child(column).body(body)
721    }
722}
723
724/// The padding around a [`SimpleDialog`]'s title (`dialog.dart:1168`).
725const SIMPLE_TITLE_PADDING: Insets = Insets {
726    top: 24.0,
727    right: 24.0,
728    bottom: 0.0,
729    left: 24.0,
730};
731/// The padding around its list of options (`dialog.dart:1171`) — **no side padding**,
732/// because each option is a full-width row that pads itself and lights up across the
733/// whole dialog when it is pressed.
734const SIMPLE_CONTENT_PADDING: Insets = Insets {
735    top: 12.0,
736    right: 0.0,
737    bottom: 16.0,
738    left: 0.0,
739};
740/// A [`SimpleDialogOption`]'s own padding (`dialog.dart:1082`).
741const OPTION_PADDING: Insets = Insets {
742    top: 8.0,
743    right: 24.0,
744    bottom: 8.0,
745    left: 24.0,
746};
747
748/// One row of a [`SimpleDialog`]: a full-width option that answers the question by being
749/// chosen.
750///
751/// It is a widget of its own rather than a closure argument for the reason the reference
752/// makes it one — the row is the tappable thing, and its ink has to run the full width of
753/// the dialog, which only a widget that *is* the row can do.
754pub struct SimpleDialogOption<Msg = crate::callback::Callback> {
755    child: Option<Box<dyn Widget<Msg>>>,
756    on_press: Option<Msg>,
757    padding: Option<Insets>,
758}
759
760impl<Msg: Clone + 'static> SimpleDialogOption<Msg> {
761    /// An option showing `label`, sending `message` when it is chosen.
762    pub fn new(label: impl Into<String>, message: Msg) -> Self {
763        Self {
764            child: Some(Box::new(crate::Text::new(label))),
765            on_press: Some(message),
766            padding: None,
767        }
768    }
769
770    /// The same, with a widget in place of the text.
771    pub fn with_child(child: impl Widget<Msg> + 'static) -> Self {
772        Self {
773            child: Some(Box::new(child)),
774            on_press: None,
775            padding: None,
776        }
777    }
778
779    /// What choosing it sends. Without one it cannot be chosen, which is the reference's
780    /// null `onPressed`.
781    #[must_use]
782    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
783        self.on_press = Some(message.into());
784        self
785    }
786
787    /// The space around the row's content. 8 down and 24 across by default.
788    #[must_use]
789    pub fn padding(mut self, padding: Insets) -> Self {
790        self.padding = Some(padding);
791        self
792    }
793
794    /// Assembles the option.
795    pub fn build(self) -> Box<dyn Widget<Msg>> {
796        let padding = self.padding.unwrap_or(OPTION_PADDING);
797        let mut row = crate::Flex::row().align(Align::Center).padding_each(
798            padding.top,
799            padding.right,
800            padding.bottom,
801            padding.left,
802        );
803        if let Some(child) = self.child {
804            row = row.child_boxed(child);
805        }
806        let mut ink = crate::InkWell::new();
807        if let Some(message) = self.on_press {
808            ink = ink.on_click(message);
809        }
810        Box::new(ink.child(row))
811    }
812}
813
814/// A dialog that **offers a choice**: a title, and a list of options to pick from.
815///
816/// The one the reference calls simple, and the difference from [`AlertDialog`] is what it
817/// is for: an alert dialog asks a question and puts the answers in a row of buttons at the
818/// bottom, this one lists them and each row *is* an answer.
819pub struct SimpleDialog<Msg = crate::callback::Callback> {
820    dialog: Dialog<Msg>,
821    title: Option<Box<dyn Widget<Msg>>>,
822    title_text_style: Option<TextStyle>,
823    title_padding: Option<Insets>,
824    content_padding: Option<Insets>,
825    content_text_style: Option<TextStyle>,
826    options: Vec<Box<dyn Widget<Msg>>>,
827}
828
829impl<Msg: Clone + 'static> SimpleDialog<Msg> {
830    /// Creates one; `open` says whether it is showing.
831    pub fn new(open: bool) -> Self {
832        Self {
833            dialog: Dialog::new(open),
834            title: None,
835            title_text_style: None,
836            title_padding: None,
837            content_padding: None,
838            content_text_style: None,
839            options: Vec::new(),
840        }
841    }
842
843    /// What a click on the scrim sends — see [`Dialog::on_dismiss`].
844    #[must_use]
845    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
846        self.dialog = self.dialog.on_dismiss(message);
847        self
848    }
849
850    /// The heading, as text.
851    #[must_use]
852    pub fn title(self, title: impl Into<String>) -> Self {
853        let text = crate::Text::new(title);
854        self.title_widget(text)
855    }
856
857    /// The heading, as a widget.
858    #[must_use]
859    pub fn title_widget(mut self, title: impl Widget<Msg> + 'static) -> Self {
860        self.title = Some(Box::new(title));
861        self
862    }
863
864    /// The heading's type. `headlineSmall` by default, as [`AlertDialog`]'s is.
865    #[must_use]
866    pub fn title_text_style(mut self, style: TextStyle) -> Self {
867        self.title_text_style = Some(style);
868        self
869    }
870
871    /// The space around the heading.
872    #[must_use]
873    pub fn title_padding(mut self, padding: Insets) -> Self {
874        self.title_padding = Some(padding);
875        self
876    }
877
878    /// The space around the list of options. **No side padding** by default: an option is
879    /// a full-width row that pads itself.
880    #[must_use]
881    pub fn content_padding(mut self, padding: Insets) -> Self {
882        self.content_padding = Some(padding);
883        self
884    }
885
886    /// What the options are set in. `bodyMedium` by default.
887    #[must_use]
888    pub fn content_text_style(mut self, style: TextStyle) -> Self {
889        self.content_text_style = Some(style);
890        self
891    }
892
893    /// Adds an option — usually a [`SimpleDialogOption`].
894    #[must_use]
895    pub fn option(mut self, option: impl Widget<Msg> + 'static) -> Self {
896        self.options.push(Box::new(option));
897        self
898    }
899
900    /// The surface's colour — see [`Dialog::background`].
901    #[must_use]
902    pub fn background(mut self, color: Color) -> Self {
903        self.dialog = self.dialog.background(color);
904        self
905    }
906
907    /// How far off the page it sits — see [`Dialog::elevation`].
908    #[must_use]
909    pub fn elevation(mut self, elevation: f32) -> Self {
910        self.dialog = self.dialog.elevation(elevation);
911        self
912    }
913
914    /// **What shape it is** — see [`Dialog::shape`].
915    #[must_use]
916    pub fn shape(mut self, shape: ShapeBorder) -> Self {
917        self.dialog = self.dialog.shape(shape);
918        self
919    }
920
921    /// How far off the window's edges — see [`Dialog::inset_padding`].
922    #[must_use]
923    pub fn inset_padding(mut self, padding: Insets) -> Self {
924        self.dialog = self.dialog.inset_padding(padding);
925        self
926    }
927
928    /// Sets the screen behind it and finalises the dialog.
929    #[must_use]
930    pub fn body(self, body: impl Widget<Msg> + 'static) -> Dialog<Msg> {
931        let SimpleDialog {
932            dialog,
933            title,
934            title_text_style,
935            title_padding,
936            content_padding,
937            content_text_style,
938            options,
939        } = self;
940        let has_options = !options.is_empty();
941        let column = crate::ThemeBuilder::boxed(move |theme| {
942            let mut column = crate::Flex::column().align(Align::Stretch);
943            if let Some(title) = title {
944                let pad = title_padding.unwrap_or(SIMPLE_TITLE_PADDING);
945                // The reference drops the title's **bottom** padding when options follow:
946                // the list has a top padding of its own, and two of them stacked would be
947                // a gap nobody asked for (`dialog.dart:1316`).
948                let bottom = if has_options { 0.0 } else { pad.bottom };
949                column = column.child(
950                    crate::Flex::column()
951                        .padding_each(pad.top, pad.right, bottom, pad.left)
952                        .child(texted(
953                            title_style(title_text_style, Some(theme)),
954                            None,
955                            title,
956                        )),
957                );
958            }
959            if has_options {
960                let pad = content_padding.unwrap_or(SIMPLE_CONTENT_PADDING);
961                let mut list = crate::Flex::column().align(Align::Stretch);
962                for option in options {
963                    list = list.child_boxed(option);
964                }
965                column = column.child(
966                    crate::Flex::column()
967                        .padding_each(pad.top, pad.right, pad.bottom, pad.left)
968                        .child(texted(
969                            content_style(content_text_style, Some(theme)),
970                            None,
971                            Box::new(list),
972                        )),
973                );
974            }
975            Box::new(column) as Box<dyn Widget<Msg>>
976        });
977        dialog.child(column).body(body)
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use crate::{build_ui, Container, Runtime, Size, Text};
985
986    #[derive(Clone, Debug, PartialEq)]
987    enum Msg {
988        Close,
989        Delete,
990    }
991
992    fn texts(dialog: &Dialog<Msg>) -> Vec<String> {
993        build_ui(
994            dialog,
995            Size::new(400.0, 800.0),
996            &Runtime::default(),
997            &Theme::default(),
998        )
999        .scene()
1000        .primitives()
1001        .iter()
1002        .filter_map(|p| match p {
1003            frus_core::Primitive::Text { text, .. } => Some(text.clone()),
1004            _ => None,
1005        })
1006        .collect()
1007    }
1008
1009    /// Closed, it is an overlay with nothing showing: the animation target is what the
1010    /// runtime reads, and the screen behind is untouched either way.
1011    #[test]
1012    fn a_closed_dialog_is_an_overlay_at_rest() {
1013        let dialog = Dialog::<Msg>::new(false)
1014            .child(Text::new("Question"))
1015            .body(Container::new());
1016        assert_eq!(Widget::<Msg>::anim_target(&dialog), Some(0.0));
1017    }
1018
1019    #[test]
1020    fn an_open_dialog_is_a_centred_overlay_that_the_scrim_closes() {
1021        let dialog = Dialog::<Msg>::new(true)
1022            .on_dismiss(Msg::Close)
1023            .child(Text::new("Question"))
1024            .body(Container::new());
1025        let (_, placement) = Widget::<Msg>::overlay(&dialog).expect("an open dialog is shown");
1026        assert_eq!(placement, Placement::Center);
1027        assert_eq!(Widget::<Msg>::overlay_dismiss(&dialog), Some(Msg::Close));
1028        assert_eq!(Widget::<Msg>::anim_target(&dialog), Some(1.0));
1029    }
1030
1031    /// **A dialog that must be answered has an inert scrim.** Saying nothing about
1032    /// dismissal is the reference's `barrierDismissible: false`, and it has to be the
1033    /// answer rather than a default, because a dialog that closes itself on a stray click
1034    /// has not been answered.
1035    #[test]
1036    fn a_dialog_that_must_be_answered_has_an_inert_scrim() {
1037        let dialog = Dialog::<Msg>::new(true)
1038            .child(Text::new("Question"))
1039            .body(Container::new());
1040        assert_eq!(Widget::<Msg>::overlay_dismiss(&dialog), None);
1041    }
1042
1043    /// The surface's own geometry is the reference's: never narrower than 280, and held
1044    /// off the window's edges by 40 across and 24 down.
1045    #[test]
1046    fn the_surface_is_the_reference_s_box() {
1047        let dialog = Dialog::<Msg>::new(true)
1048            .child(Text::new("."))
1049            .body(Container::new());
1050        let (panel, _) = Widget::<Msg>::overlay(&dialog).expect("shown");
1051        assert_eq!(panel.style().min_width, Dimension::Length(DIALOG_MIN_WIDTH));
1052        assert_eq!(panel.style().margin, DIALOG_INSET_PADDING);
1053    }
1054
1055    /// **Two long answers at a reader's font size stay inside the surface.**
1056    ///
1057    /// The everyday way to see the bug this closes is not a contrived label: it is a
1058    /// system font size turned up. The buttons grow, the dialog does not grow with them,
1059    /// and a plain row neither wraps nor shrinks — so "Delete permanently" was drawn at a
1060    /// negative x, off the surface and off the screen, where nobody can press it.
1061    #[test]
1062    fn long_actions_at_a_large_text_scale_stack_inside_the_dialog() {
1063        let window = Size::new(400.0, 800.0);
1064        let _surface = crate::MediaQuery::new(window)
1065            .with_text_scaler(2.0)
1066            .install();
1067        let dialog = AlertDialog::<Msg>::new(true)
1068            .title("Delete this task?")
1069            .content("This cannot be undone.")
1070            .action(crate::dsl::button("Cancel", Msg::Close))
1071            .action(crate::dsl::button("Delete permanently", Msg::Delete))
1072            .body(Container::new());
1073        let ui = build_ui(&dialog, window, &Runtime::default(), &Theme::default());
1074        let theme = Theme::default();
1075        let mut surface = None;
1076        let mut buttons = Vec::new();
1077        for p in ui.scene().primitives() {
1078            if let frus_core::Primitive::Rect { rect, color, .. } = p {
1079                if color.r == theme.scheme.primary.r && color.g == theme.scheme.primary.g {
1080                    buttons.push(*rect);
1081                } else if rect.width > 200.0 && rect.height > 100.0 && color.a == 1.0 {
1082                    surface.get_or_insert(*rect);
1083                }
1084            }
1085        }
1086        let surface = surface.expect("the dialog's surface");
1087        assert!(
1088            surface.x >= 0.0 && surface.x + surface.width <= window.width,
1089            "the surface itself is on the screen: {surface:?}"
1090        );
1091        assert_eq!(buttons.len(), 2, "both buttons: {buttons:?}");
1092        for button in &buttons {
1093            assert!(
1094                button.x >= surface.x && button.x + button.width <= surface.x + surface.width,
1095                "{button:?} is inside {surface:?}"
1096            );
1097        }
1098        assert_ne!(
1099            buttons[0].y, buttons[1].y,
1100            "and they stacked rather than squashed: {buttons:?}"
1101        );
1102    }
1103
1104    /// Everything the alert dialog was given is drawn.
1105    #[test]
1106    fn an_alert_dialog_shows_its_title_its_content_and_its_actions() {
1107        let dialog = AlertDialog::<Msg>::new(true)
1108            .title("Delete this task?")
1109            .content("This cannot be undone.")
1110            .action(crate::dsl::button("Cancel", Msg::Close))
1111            .action(crate::dsl::button("Delete", Msg::Delete))
1112            .body(Container::new());
1113        let found = texts(&dialog);
1114        for wanted in [
1115            "Delete this task?",
1116            "This cannot be undone.",
1117            "Cancel",
1118            "Delete",
1119        ] {
1120            assert!(
1121                found.iter().any(|t| t == wanted),
1122                "{wanted:?} is missing from {found:?}"
1123            );
1124        }
1125    }
1126
1127    /// **The title is a heading, and the content is not.** The reference gives them
1128    /// `headlineSmall` and `bodyMedium`, handed down as a default text style rather than
1129    /// forced onto the widget the caller passed — so a caller's own `Text::size` still
1130    /// wins, and a plain string gets the type the specification asks for.
1131    #[test]
1132    fn the_title_and_the_content_take_the_reference_s_two_roles() {
1133        let dialog = AlertDialog::<Msg>::new(true)
1134            .title("Heading")
1135            .content("Body")
1136            .max_width(340.0)
1137            .body(Container::new());
1138        let ui = build_ui(
1139            &dialog,
1140            Size::new(400.0, 800.0),
1141            &Runtime::default(),
1142            &Theme::default(),
1143        );
1144        let size_of = |wanted: &str| {
1145            ui.scene()
1146                .primitives()
1147                .iter()
1148                .find_map(|p| match p {
1149                    frus_core::Primitive::Text { text, size, .. } if text == wanted => Some(*size),
1150                    _ => None,
1151                })
1152                .expect("drawn")
1153        };
1154        let theme = Theme::default();
1155        assert_eq!(
1156            size_of("Heading"),
1157            theme.text.headline_small.resolved().size
1158        );
1159        assert_eq!(size_of("Body"), theme.text.body_medium.resolved().size);
1160        assert!(size_of("Heading") > size_of("Body"));
1161    }
1162
1163    /// A simple dialog lists its options, and each row **is** an answer: choosing one
1164    /// sends its message, which is the difference from an alert dialog's row of buttons.
1165    #[test]
1166    fn a_simple_dialog_lists_options_that_answer_it() {
1167        let dialog = SimpleDialog::<Msg>::new(true)
1168            .title("Set backup account")
1169            .option(SimpleDialogOption::new("someone@example.com", Msg::Close).build())
1170            .option(SimpleDialogOption::new("Add account", Msg::Delete).build())
1171            .body(Container::new());
1172        let found = texts(&dialog);
1173        for wanted in ["Set backup account", "someone@example.com", "Add account"] {
1174            assert!(
1175                found.iter().any(|t| t == wanted),
1176                "{wanted:?} is missing from {found:?}"
1177            );
1178        }
1179        // And the rows are clickable. The panel is the dialog's **overlay**, not one of
1180        // its children — a modal is drawn over the screen, not in it — so the walk has to
1181        // start there. `texts` above has already built it.
1182        fn walk(widget: &dyn Widget<Msg>, out: &mut Vec<Msg>) {
1183            if let Some(message) = widget.on_click() {
1184                out.push(message);
1185            }
1186            for child in widget.children() {
1187                walk(child.as_ref(), out);
1188            }
1189        }
1190        let (panel, _) = Widget::<Msg>::overlay(&dialog).expect("an open dialog is shown");
1191        let mut clicks = Vec::new();
1192        walk(panel, &mut clicks);
1193        assert!(
1194            clicks.contains(&Msg::Close) && clicks.contains(&Msg::Delete),
1195            "an option that cannot be chosen is not an option: {clicks:?}"
1196        );
1197    }
1198
1199    /// **An option's row runs the full width of the dialog**, which is what the reference's
1200    /// zero side padding on the list is for: the row is the tappable thing, and its ink has
1201    /// to reach both edges. Padding the *list* instead would inset the ripple.
1202    #[test]
1203    fn an_option_s_row_reaches_both_edges_of_the_dialog() {
1204        let dialog = SimpleDialog::<Msg>::new(true)
1205            .title("Pick one")
1206            .option(SimpleDialogOption::new("An option", Msg::Close).build())
1207            .body(Container::new());
1208        let ui = build_ui(
1209            &dialog,
1210            Size::new(400.0, 800.0),
1211            &Runtime::default(),
1212            &Theme::default(),
1213        );
1214        // The dialog's own surface, and the option's text: the text starts one option
1215        // padding in from the surface's edge, not one option padding **plus** a list one.
1216        let surface = ui
1217            .scene()
1218            .primitives()
1219            .iter()
1220            .find_map(|p| match p {
1221                frus_core::Primitive::Rect { rect, radius, .. } if radius.top_left > 0.0 => {
1222                    Some(*rect)
1223                }
1224                _ => None,
1225            })
1226            .expect("the dialog's surface is drawn");
1227        let option = ui
1228            .scene()
1229            .primitives()
1230            .iter()
1231            .find_map(|p| match p {
1232                frus_core::Primitive::Text { text, bounds, .. } if text == "An option" => {
1233                    Some(*bounds)
1234                }
1235                _ => None,
1236            })
1237            .expect("the option is drawn");
1238        assert!(
1239            (option.x - (surface.x + OPTION_PADDING.left)).abs() < 0.5,
1240            "the option is inset twice: surface {surface:?}, option {option:?}"
1241        );
1242    }
1243
1244    /// **An icon centres the title.** It is the one place where the presence of one slot
1245    /// changes how another is aligned, and the reference does it in as many words
1246    /// (`dialog.dart:844`).
1247    #[test]
1248    fn an_icon_centres_the_title_under_it() {
1249        let title_of = |icon: bool| {
1250            let mut dialog = AlertDialog::<Msg>::new(true).title("Heading");
1251            if icon {
1252                dialog = dialog.icon(Text::new("!"));
1253            }
1254            let dialog = dialog.body(Container::new());
1255            build_ui(
1256                &dialog,
1257                Size::new(400.0, 800.0),
1258                &Runtime::default(),
1259                &Theme::default(),
1260            )
1261            .scene()
1262            .primitives()
1263            .iter()
1264            .find_map(|p| match p {
1265                frus_core::Primitive::Text {
1266                    text,
1267                    align,
1268                    bounds,
1269                    ..
1270                } if text == "Heading" => Some((*align, *bounds)),
1271                _ => None,
1272            })
1273            .expect("the title is drawn")
1274        };
1275        let (with, with_box) = title_of(true);
1276        let (without, without_box) = title_of(false);
1277        assert_eq!(with, TextAlign::Center, "an icon must centre the title");
1278        assert_eq!(
1279            without,
1280            TextAlign::Start,
1281            "without one it starts at the leading edge"
1282        );
1283        // And it is where it says it is. The dialog is centred in a 400-wide window, so
1284        // its own centre is 200; a centred title's box is centred on that, and one that
1285        // starts at the leading edge sits to the left of it.
1286        let centre = |b: frus_core::Rect| b.x + b.width / 2.0;
1287        assert!(
1288            (centre(with_box) - 200.0).abs() < 1.0,
1289            "the title is not on the dialog's centre line: {with_box:?}"
1290        );
1291        assert!(
1292            centre(without_box) < centre(with_box) - 10.0,
1293            "the title did not move when an icon was put above it: {without_box:?}"
1294        );
1295    }
1296}