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