Skip to main content

frust_material/
dialog.rs

1//! The M3(E) dialog family, on the merged [`mod@crate::overlay::modal`] host:
2//! the basic/centered dialog ([`dialog`]/[`show_dialog`], migrated off the old
3//! standalone `push_transparent_for_result` path), a full-screen dialog
4//! ([`full_screen_dialog`]/[`show_full_screen_dialog`]), and a selection/list
5//! dialog ([`selection_dialog`]/[`show_selection_dialog`]).
6//!
7//! Source: `paadevelopments/material_3_expressive` v1.0.8 (MIT, © 2026 Paa
8//! Developments — see `plugins/material/NOTICE`'s root-crate MIT section),
9//! `lib/components/dialogs/{m3e_dialogs.dart, styles/m3e_dialog_theme.dart}`
10//! (retrieved 2026-08-19, ~726 LOC combined). No license changes.
11//!
12//! # Architecture: the panel is content, the host is chrome
13//!
14//! Every variant here is a thin builder over
15//! [`crate::overlay::modal::overlay_modal`]/[`crate::overlay::modal::show_overlay_modal`]:
16//! this module owns **no** scrim, navigation-push, or dismiss-staging code of
17//! its own any more (that was the pre-merge shape — grep-able: no
18//! `push_transparent_for_result` remains in this file). The host contributes
19//! the scrim, the panel's fill/radius/elevation, entrance/exit motion, the
20//! scrim-tap/Escape/Android-back dismiss gestures (staged, animated), and
21//! result plumbing; this module contributes only the panel's *content* — an
22//! ordinary [`View`] built once per `build`/`rebuild` pass from cheaply-cloned
23//! fields (`String`/`bool`/`Rc<..>`) and handed to [`overlay_modal`] — plus a
24//! title/action-row layout ([`DialogPanelView`]) shared by the basic and
25//! selection variants.
26//!
27//! # Compat: `dialog()`/`show_dialog()` keep their old shape
28//!
29//! [`dialog`]/[`DialogView`]/[`show_dialog`] keep the exact builder surface
30//! in-repo consumers (`examples/huddle`) already call —
31//! `dialog().title(..).body(..).action(..)`, `show_dialog(&nav, build,
32//! on_result)` — just re-homed onto the host. [`DialogView::dismissable`] is
33//! new (additive; every sibling modal component in this crate already carries
34//! it).
35//!
36//! # Why actions are `Rc`-wrapped internally
37//!
38//! [`AnyView`] is not `Clone`, and [`View::build`]/[`View::rebuild`] only ever
39//! see `&self` — so a fluent `.action(view)` builder that must *recompose* a
40//! fresh, owned [`overlay_modal`] content view on every pass (there is no
41//! other point in the `View` lifecycle to do it) cannot move the accumulated
42//! `Vec<AnyView<State>>` out of a shared reference. Each action is wrapped in
43//! an `Rc` on `.action()`/`.actions()` (transparent to callers — the public
44//! signature takes any `impl View<State>`), so recomposing per pass is a
45//! cheap refcount-bump clone rather than a deep one. No other field needs
46//! this: titles/labels are `String` (`Clone`), the selection list's rows are
47//! rebuilt fresh from primitive `Vec<String>`/`Rc<dyn Fn>` data each pass, and
48//! a variant's single required `body`/`action` view is wrapped once, eagerly,
49//! at its one-shot constructor call (already owned there).
50//!
51//! # Deliberate v1 scope cuts
52//!
53//! - **No `icon` slot.** The reference's `M3EDialog.icon`/`showSelectionScreen`'s
54//!   `icon` are optional and unexercised by any in-repo consumer; adding the
55//!   `icon == null` cross-axis-centering branch throughout the panel's layout
56//!   is deferred rather than carried speculatively.
57//! - **The full-screen header's close button dismisses unstaged.** The host's
58//!   staged exit ramp is driven by [`crate::overlay::modal::OverlayModalWidget::request_dismiss`],
59//!   a method private to that module — content (this file) has no reach into
60//!   it. The header's own close affordance (leading, per the reference —
61//!   unlike the host's own trailing-corner `close_button` chrome, which this
62//!   variant turns off) instead fires the same `on_dismiss` callback
63//!   [`crate::overlay::modal::show_overlay_modal`] wires to `controller.pop()`
64//!   directly — an immediate pop, skipping the slide-down ramp. Escape and an
65//!   Android back press still get the full staged exit (the host's own
66//!   paths); there is no scrim to tap (the panel is full-bleed).
67//! - **The selection list has no scroll physics.** The reference caps it at
68//!   `0.45 * viewport height` inside a `SingleChildScrollView`; this port has
69//!   no viewport reference available this deep in the content tree and does
70//!   not reimplement drag-to-scroll. An overflowing list clips at the panel's
71//!   own rounded-rect clip (the host already applies one to every panel's
72//!   content) rather than scrolling — acceptable for the option counts this
73//!   dialog is meant for, not a hidden bug.
74//! - **Selection state is a controlled prop, not local widget state.** The
75//!   reference's `_M3ESelectionDialogState` is a `StatefulWidget` owning its
76//!   own `Set<String>` via `setState`. This crate's whole selection-control
77//!   family (`radio`/`checkbox`/`switch`) is documented as controlled —
78//!   "never self-mutating" — and there is no local-Component-state hook for a
79//!   navigator-pushed page to lean on instead. [`selection_dialog`] follows
80//!   the same contract: the caller threads `.selected(..)` in from its own
81//!   `State` and reacts to `.on_toggle(..)`, rather than the dialog
82//!   returning a final selection through the pop result the way
83//!   `M3EDialog.showSelectionScreen`'s `Future<List<String>?>` does. Confirm/
84//!   cancel wiring (including what a confirm tap pops with, if anything) is
85//!   therefore the caller's own action, exactly like the basic dialog's
86//!   existing `.action(..)` contract.
87//!
88//! # Panel geometry (cited from `m3e_dialog_theme.dart`)
89//!
90//! `minWidth`/`maxWidth` `[280, 560]` and the extra-large (28dp) shape are the
91//! host's own `OverlayModalConfig::centered` defaults (`OVERLAY_DIALOG_MIN_WIDTH`/
92//! `OVERLAY_DIALOG_MAX_WIDTH`, `shape.extra_large` via `crate::overlay`'s
93//! `radius` accessor) — unchanged from this crate's pre-merge dialog, still
94//! cited there. This file's own constants: [`DIALOG_PADDING`] (`padding: 24`),
95//! [`ACTION_GAP`] (`actionGap: 8`), [`SELECTION_ITEM_HEIGHT`] (`selectionItemHeight:
96//! 56`), [`FULL_SCREEN_HEADER_HEIGHT`] (`fullScreenHeaderHeight: 64`),
97//! [`HEADER_EDGE_GAP`] (`headerEdgeGap: 4`), [`HEADER_ACTION_GAP`]
98//! (`headerActionGap: 16`). The container role (`surfaceContainerHigh`) and
99//! elevation (level 3) are the host's `OverlayModalConfig::centered` defaults
100//! too — this crate's pre-merge dialog cited the identical values by hand.
101
102use std::rc::Rc;
103
104use frust::authoring::text::{FontWeight, LineHeight, TextOverflow};
105use frust::authoring::{
106    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
107    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ThemeTextColor, ThemeTextType, View, ViewSeq,
108    Widget, any, build_child, rebuild_child, rebuild_children, route_event, route_event_single,
109    teardown_child, visit_children,
110};
111use frust::{Color, NavigatorController, PopResult, Theme, icon, text};
112use kurbo::{Point, Size};
113
114use crate::button::{button, text_button};
115use crate::checkbox::checkbox;
116use crate::divider::divider;
117use crate::icon_button::icon_button;
118use crate::overlay::{
119    ModalDismiss, OVERLAY_DIALOG_MAX_WIDTH, OverlayCorners, OverlayElevation, OverlayExtent,
120    OverlayLimit, OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
121    OverlaySide, overlay_modal, show_overlay_modal,
122};
123use crate::radio::radio;
124
125/// Panel padding on all four edges, in logical px (`M3EDialogTheme.padding`).
126const DIALOG_PADDING: f64 = 24.0;
127/// Horizontal spacing between adjacent action buttons (`M3EDialogTheme.actionGap`).
128const ACTION_GAP: f64 = 8.0;
129/// Height of each selectable row in the selection dialog
130/// (`M3EDialogTheme.selectionItemHeight`).
131const SELECTION_ITEM_HEIGHT: f64 = 56.0;
132/// The selection list's own top/bottom inset — half of [`DIALOG_PADDING`],
133/// mirroring the reference's `SingleChildScrollView(padding: top:
134/// dialogTheme.padding.top / 2, bottom: dialogTheme.padding.bottom / 2)`.
135const SELECTION_LIST_INSET: f64 = DIALOG_PADDING / 2.0;
136/// Gap between a multi-select row's checkbox and its label — mirrors the
137/// reference's `theme.radioTheme.labelGap` (checkbox has no built-in label
138/// slot, unlike [`crate::radio`] — see `_buildCheckboxVisual`).
139const CHECKBOX_LABEL_GAP: f64 = 8.0;
140/// Height of the full-screen dialog's header row
141/// (`M3EDialogTheme.fullScreenHeaderHeight`).
142const FULL_SCREEN_HEADER_HEIGHT: f64 = 64.0;
143/// Gap flanking the header's close affordance on both sides
144/// (`M3EDialogTheme.headerEdgeGap`).
145const HEADER_EDGE_GAP: f64 = 4.0;
146/// Gap between the header's title and a trailing action, and between that
147/// action and the header's trailing edge (`M3EDialogTheme.headerActionGap`).
148const HEADER_ACTION_GAP: f64 = 16.0;
149/// The reference's `_cancelLabel` default.
150const DEFAULT_CANCEL_LABEL: &str = "Cancel";
151/// The reference's `_confirmLabel` default.
152const DEFAULT_CONFIRM_LABEL: &str = "OK";
153/// The reference's `_closeSemanticLabel`.
154const CLOSE_SEMANTIC_LABEL: &str = "Close";
155
156/// Title type-scale token (M3 `headlineSmallEmphasized` — matches
157/// `frust-theme::typography`'s `HEADLINE_SMALL_EMPHASIZED`). Hardcoded rather
158/// than read from a live `Theme::type_scale`: `Text` has no layout-deferred
159/// size/weight resolution seam (only a color role and an opted-in family role
160/// resolve post-`build` — see `docs/CODE_STANDARDS.md`'s Theming conventions),
161/// the precedent this module's pre-merge version already established. Every
162/// text this module builds does opt its family into the matching role.
163const TITLE_SIZE: f32 = 24.0;
164const TITLE_LINE_HEIGHT: f32 = 32.0;
165const TITLE_WEIGHT: FontWeight = FontWeight::MEDIUM;
166/// Body type-scale token (M3 `bodyMedium`).
167const BODY_SIZE: f32 = 14.0;
168const BODY_LINE_HEIGHT: f32 = 20.0;
169/// Full-screen header title type-scale token (M3 `titleLarge`, REGULAR —
170/// `frust-theme::typography`'s `TITLE_LARGE`; the reference's own
171/// `theme.typeScale.titleLarge`, unlike the centered dialog's *emphasized*
172/// headline).
173const HEADER_TITLE_SIZE: f32 = 22.0;
174const HEADER_TITLE_LINE_HEIGHT: f32 = 28.0;
175
176/// Unthemed-fallback full-screen background fill (a theme resolves this from
177/// `colors.surface` — `M3EDialogTheme.fullScreenBackground`).
178const FALLBACK_SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
179
180/// Build the title's type-erased child view (`headlineSmallEmphasized`,
181/// themed `onSurface`, family from the theme's `headlineSmallEmphasized`
182/// role).
183fn title_view<State: 'static>(s: &str) -> AnyView<State> {
184    any::<State, _>(
185        text(s.to_string())
186            .size(TITLE_SIZE)
187            .weight(TITLE_WEIGHT)
188            .line_height(LineHeight::Absolute(TITLE_LINE_HEIGHT))
189            .themed_family(ThemeTextType::HeadlineSmallEmphasized),
190    )
191}
192
193/// Build the body text's type-erased child view (`bodyMedium`, themed
194/// `onSurfaceVariant`, family from the theme's `bodyMedium` role).
195fn body_view<State: 'static>(s: &str) -> AnyView<State> {
196    any::<State, _>(
197        text(s.to_string())
198            .size(BODY_SIZE)
199            .line_height(LineHeight::Absolute(BODY_LINE_HEIGHT))
200            .themed_role(ThemeTextColor::OnSurfaceVariant)
201            .themed_family(ThemeTextType::BodyMedium),
202    )
203}
204
205/// Build the full-bleed divider's type-erased child view.
206fn divider_view<State: 'static>() -> AnyView<State> {
207    any::<State, _>(divider())
208}
209
210/// The resolved full-screen background fill. Themed: `colors.surface`.
211/// Unthemed: [`FALLBACK_SURFACE`].
212fn resolve_surface(theme: Option<&Theme>) -> Color {
213    theme.map_or(FALLBACK_SURFACE, |t| t.scheme().surface)
214}
215
216// ============================================================================
217// DialogPanelView / DialogPanelWidget — the shared centered-panel content
218// (title, optional top divider, optional content, optional bottom divider,
219// trailing action row). Used by both the basic dialog and the selection
220// dialog — the selection dialog is exactly this shape with both dividers on,
221// full-bleed content (the option list), and a fixed cancel/confirm action
222// pair (see `m3e_dialogs.dart`'s `_M3ESelectionDialogState.build`, which
223// literally constructs an `M3EDialog` with these same params).
224// ============================================================================
225
226/// A view-held, typed action callback list entry (see the module docs' *Why
227/// actions are `Rc`-wrapped internally*).
228type SharedAction<State> = Rc<AnyView<State>>;
229
230/// The panel content shared by [`DialogView`] and [`SelectionDialogView`].
231struct DialogPanelView<State: 'static> {
232    title: Option<String>,
233    content: Option<SharedAction<State>>,
234    actions: Vec<SharedAction<State>>,
235    top_divider: bool,
236    bottom_divider: bool,
237    /// Zero out the content's own padding on all four sides — the
238    /// reference's `contentPadding: EdgeInsets.zero` override
239    /// (the selection dialog's own list already insets itself; see
240    /// [`SELECTION_LIST_INSET`]).
241    content_full_bleed: bool,
242}
243
244/// The retained widget for a [`DialogPanelView`].
245struct DialogPanelWidget {
246    title: Option<ChildPod>,
247    content: Option<ChildPod>,
248    actions: Vec<ChildPod>,
249    top_divider_pod: ChildPod,
250    bottom_divider_pod: ChildPod,
251    top_divider: bool,
252    bottom_divider: bool,
253    has_content: bool,
254    has_actions: bool,
255    content_full_bleed: bool,
256}
257
258/// Reconcile one optional `String`-sourced child (title) in place.
259fn reconcile_text<State: 'static>(
260    prev: Option<&String>,
261    next: Option<&String>,
262    pod: &mut Option<ChildPod>,
263    make: impl Fn(&str) -> AnyView<State>,
264    ctx: &mut BuildCtx<'_>,
265) -> ChangeFlags {
266    match (prev, next) {
267        (None, Some(s)) => {
268            *pod = Some(build_child(&make(s.as_str()), ctx));
269            ChangeFlags::LAYOUT | ChangeFlags::PAINT
270        }
271        (Some(s), None) => {
272            if let Some(p) = pod.as_mut() {
273                teardown_child(&make(s.as_str()), p, ctx);
274            }
275            *pod = None;
276            ChangeFlags::LAYOUT | ChangeFlags::PAINT
277        }
278        (Some(a), Some(b)) => match pod.as_mut() {
279            Some(p) => rebuild_child(&make(a.as_str()), &make(b.as_str()), p, ctx),
280            None => ChangeFlags::NONE,
281        },
282        (None, None) => ChangeFlags::NONE,
283    }
284}
285
286/// Reconcile one optional already-erased child (the generic content slot) in
287/// place.
288fn reconcile_any<State: 'static>(
289    prev: Option<&SharedAction<State>>,
290    next: Option<&SharedAction<State>>,
291    pod: &mut Option<ChildPod>,
292    ctx: &mut BuildCtx<'_>,
293) -> ChangeFlags {
294    match (prev, next) {
295        (None, Some(v)) => {
296            *pod = Some(build_child(v.as_ref(), ctx));
297            ChangeFlags::LAYOUT | ChangeFlags::PAINT
298        }
299        (Some(v), None) => {
300            if let Some(p) = pod.as_mut() {
301                teardown_child(v.as_ref(), p, ctx);
302            }
303            *pod = None;
304            ChangeFlags::LAYOUT | ChangeFlags::PAINT
305        }
306        (Some(a), Some(b)) => match pod.as_mut() {
307            Some(p) => rebuild_child(a.as_ref(), b.as_ref(), p, ctx),
308            None => ChangeFlags::NONE,
309        },
310        (None, None) => ChangeFlags::NONE,
311    }
312}
313
314impl<State: 'static> View<State> for DialogPanelView<State> {
315    type Element = DialogPanelWidget;
316
317    fn build(&self, ctx: &mut BuildCtx<'_>) -> DialogPanelWidget {
318        DialogPanelWidget {
319            title: self
320                .title
321                .as_ref()
322                .map(|s| build_child(&title_view::<State>(s), ctx)),
323            content: self.content.as_ref().map(|c| build_child(c.as_ref(), ctx)),
324            actions: self
325                .actions
326                .iter()
327                .map(|a| build_child(a.as_ref(), ctx))
328                .collect(),
329            top_divider_pod: build_child(&divider_view::<State>(), ctx),
330            bottom_divider_pod: build_child(&divider_view::<State>(), ctx),
331            top_divider: self.top_divider,
332            bottom_divider: self.bottom_divider,
333            has_content: self.content.is_some(),
334            has_actions: !self.actions.is_empty(),
335            content_full_bleed: self.content_full_bleed,
336        }
337    }
338
339    fn rebuild(
340        &self,
341        prev: &Self,
342        element: &mut DialogPanelWidget,
343        ctx: &mut BuildCtx<'_>,
344    ) -> ChangeFlags {
345        let mut flags = reconcile_text(
346            prev.title.as_ref(),
347            self.title.as_ref(),
348            &mut element.title,
349            title_view::<State>,
350            ctx,
351        );
352        flags |= reconcile_any(
353            prev.content.as_ref(),
354            self.content.as_ref(),
355            &mut element.content,
356            ctx,
357        );
358
359        let prev_actions: Vec<&AnyView<State>> = prev.actions.iter().map(|a| a.as_ref()).collect();
360        let next_actions: Vec<&AnyView<State>> = self.actions.iter().map(|a| a.as_ref()).collect();
361        flags |= rebuild_children(
362            &prev_actions,
363            &next_actions,
364            &mut element.actions,
365            ctx,
366            |view: &&AnyView<State>| *view,
367            |_| None,
368        );
369
370        flags |= rebuild_child(
371            &divider_view::<State>(),
372            &divider_view::<State>(),
373            &mut element.top_divider_pod,
374            ctx,
375        );
376        flags |= rebuild_child(
377            &divider_view::<State>(),
378            &divider_view::<State>(),
379            &mut element.bottom_divider_pod,
380            ctx,
381        );
382
383        if element.top_divider != self.top_divider {
384            element.top_divider = self.top_divider;
385            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
386        }
387        if element.bottom_divider != self.bottom_divider {
388            element.bottom_divider = self.bottom_divider;
389            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
390        }
391        if element.content_full_bleed != self.content_full_bleed {
392            element.content_full_bleed = self.content_full_bleed;
393            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
394        }
395        element.has_content = self.content.is_some();
396        element.has_actions = !self.actions.is_empty();
397        flags
398    }
399
400    fn teardown(&self, element: &mut DialogPanelWidget, ctx: &mut BuildCtx<'_>) {
401        if let (Some(s), Some(p)) = (&self.title, element.title.as_mut()) {
402            teardown_child(&title_view::<State>(s), p, ctx);
403        }
404        if let (Some(v), Some(p)) = (&self.content, element.content.as_mut()) {
405            teardown_child(v.as_ref(), p, ctx);
406        }
407        for (view, pod) in self.actions.iter().zip(element.actions.iter_mut()) {
408            teardown_child(view.as_ref(), pod, ctx);
409        }
410        teardown_child(&divider_view::<State>(), &mut element.top_divider_pod, ctx);
411        teardown_child(
412            &divider_view::<State>(),
413            &mut element.bottom_divider_pod,
414            ctx,
415        );
416    }
417}
418
419impl Widget for DialogPanelWidget {
420    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
421        // The host gives content a tight width (`layout_panel`'s Centered
422        // arm), loose height up to what's left of the viewport.
423        let width = bc.max().width.max(bc.min().width);
424        let inner_w = (width - 2.0 * DIALOG_PADDING).max(0.0);
425        let loose = BoxConstraints::loose(Size::new(inner_w, f64::INFINITY));
426
427        // No title (an old-API `dialog()` with none set) still reserves the
428        // top margin a title's own wrapping padding would have — see the
429        // module docs' compat note.
430        let mut y = if self.title.is_some() {
431            0.0
432        } else {
433            DIALOG_PADDING
434        };
435
436        if let Some(p) = self.title.as_mut() {
437            y += DIALOG_PADDING;
438            let s = p.layout_child(ctx, &loose);
439            p.set_origin(Point::new(DIALOG_PADDING, y));
440            y += s.height;
441            y += DIALOG_PADDING;
442        }
443
444        let has_below = self.has_content || self.has_actions;
445        if self.top_divider && has_below {
446            self.top_divider_pod
447                .layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
448            self.top_divider_pod.set_origin(Point::new(0.0, y));
449            y += self.top_divider_pod.size().height;
450        }
451
452        if let Some(p) = self.content.as_mut() {
453            let (pad_lr, pad_tb) = if self.content_full_bleed {
454                (0.0, 0.0)
455            } else if self.top_divider && self.bottom_divider {
456                (DIALOG_PADDING, DIALOG_PADDING)
457            } else {
458                (DIALOG_PADDING, 0.0)
459            };
460            let content_w = (width - 2.0 * pad_lr).max(0.0);
461            y += pad_tb;
462            let s = p.layout_child(
463                ctx,
464                &BoxConstraints::new(
465                    Size::new(content_w, 0.0),
466                    Size::new(content_w, f64::INFINITY),
467                ),
468            );
469            p.set_origin(Point::new(pad_lr, y));
470            y += s.height;
471            y += pad_tb;
472        }
473
474        if self.bottom_divider && self.has_actions {
475            self.bottom_divider_pod
476                .layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
477            self.bottom_divider_pod.set_origin(Point::new(0.0, y));
478            y += self.bottom_divider_pod.size().height;
479        }
480
481        if self.has_actions {
482            y += DIALOG_PADDING;
483            let mut actions_w = 0.0;
484            let mut actions_h: f64 = 0.0;
485            for (i, pod) in self.actions.iter_mut().enumerate() {
486                let s = pod.layout_child(ctx, &loose);
487                if i > 0 {
488                    actions_w += ACTION_GAP;
489                }
490                actions_w += s.width;
491                actions_h = actions_h.max(s.height);
492            }
493            let actions_y = y;
494            let mut ax = width - DIALOG_PADDING;
495            for pod in self.actions.iter_mut().rev() {
496                let w = pod.size().width;
497                ax -= w;
498                pod.set_origin(Point::new(ax, actions_y));
499                ax -= ACTION_GAP;
500            }
501            let _ = actions_w;
502            y += actions_h;
503            y += DIALOG_PADDING;
504        }
505
506        bc.constrain(Size::new(width, y))
507    }
508
509    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
510        if let Some(p) = self.title.as_mut() {
511            p.paint_child(ctx, scene);
512        }
513        let has_below = self.has_content || self.has_actions;
514        if self.top_divider && has_below {
515            self.top_divider_pod.paint_child(ctx, scene);
516        }
517        if let Some(p) = self.content.as_mut() {
518            p.paint_child(ctx, scene);
519        }
520        if self.bottom_divider && self.has_actions {
521            self.bottom_divider_pod.paint_child(ctx, scene);
522        }
523        for pod in &mut self.actions {
524            pod.paint_child(ctx, scene);
525        }
526    }
527
528    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
529        if let Some(content) = self.content.as_mut()
530            && route_event_single(content, ctx, event) == EventResult::Handled
531        {
532            return EventResult::Handled;
533        }
534        if !self.actions.is_empty()
535            && route_event(&mut self.actions, ctx, event) == EventResult::Handled
536        {
537            return EventResult::Handled;
538        }
539        EventResult::Ignored
540    }
541
542    fn semantics(&self, ctx: &mut SemanticsCtx) {
543        if let Some(p) = &self.title {
544            p.semantics_child(ctx);
545        }
546        if let Some(p) = &self.content {
547            p.semantics_child(ctx);
548        }
549        for pod in &self.actions {
550            pod.semantics_child(ctx);
551        }
552    }
553
554    visit_children!(title, content, actions, top_divider_pod, bottom_divider_pod);
555}
556
557// ============================================================================
558// dialog() / DialogView / show_dialog() — the basic, centered dialog.
559// ============================================================================
560
561/// A view-held, typed dismiss callback (erased on build).
562type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
563
564/// A declarative basic M3 dialog: a centered panel over the host's scrim. See
565/// the [module docs](self).
566pub struct DialogView<State: 'static> {
567    title: Option<String>,
568    body: Option<String>,
569    actions: Vec<SharedAction<State>>,
570    dismissable: bool,
571    on_dismiss: Option<OnDismiss<State>>,
572    dismiss_handle: Option<ModalDismiss>,
573}
574
575/// Create an empty dialog. Chain [`DialogView::title`]/[`DialogView::body`]/
576/// [`DialogView::action`]/[`DialogView::actions`] to fill it, and
577/// [`DialogView::on_dismiss`] to observe a scrim-tap/Escape/back dismissal
578/// (usually unnecessary — [`show_dialog`] already routes the pop for you).
579pub fn dialog<State: 'static>() -> DialogView<State> {
580    DialogView {
581        title: None,
582        body: None,
583        actions: Vec::new(),
584        dismissable: true,
585        on_dismiss: None,
586        dismiss_handle: None,
587    }
588}
589
590impl<State: 'static> DialogView<State> {
591    /// Set the dialog title (`headlineSmallEmphasized`, `onSurface`).
592    pub fn title(mut self, title: impl Into<String>) -> Self {
593        self.title = Some(title.into());
594        self
595    }
596
597    /// Set the dialog supporting text (`bodyMedium`, `onSurfaceVariant`).
598    pub fn body(mut self, body: impl Into<String>) -> Self {
599        self.body = Some(body.into());
600        self
601    }
602
603    /// Replace the trailing action row with `actions` (app-provided buttons,
604    /// in reading order — the last one sits closest to the trailing edge).
605    pub fn actions<M>(mut self, actions: impl ViewSeq<State, M>) -> Self {
606        let mut erased = Vec::new();
607        actions.extend_views(&mut erased);
608        self.actions = erased.into_iter().map(Rc::new).collect();
609        self
610    }
611
612    /// Append one action button to the trailing action row.
613    pub fn action(mut self, action: impl View<State>) -> Self {
614        self.actions.push(Rc::new(AnyView::new(action)));
615        self
616    }
617
618    /// Whether the user can dismiss this dialog at all — the scrim tap,
619    /// `Escape`, and an Android back press (default `true`; see
620    /// [`crate::overlay::modal::OverlayModalView::dismissable`]). `false`
621    /// disables all three; only an app-provided action still dismisses it.
622    pub fn dismissable(mut self, dismissable: bool) -> Self {
623        self.dismissable = dismissable;
624        self
625    }
626
627    /// Observe a scrim-tap/Escape/back dismissal. [`show_dialog`] already
628    /// wires the pop for you (via [`OverlayModalContent::on_modal_dismiss`]);
629    /// this is for a caller that wants to react to it too (or that mounts a
630    /// `DialogView` directly in a `Stack`, with no navigator involved).
631    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
632        self.on_dismiss = Some(Rc::new(on_dismiss));
633        self
634    }
635
636    /// Install the app-triggered staged-dismiss handle, so a Cancel/Confirm
637    /// action closes the dialog through the **same** exit ramp the scrim tap,
638    /// `Escape`, and a back press take — rather than a raw
639    /// `controller.pop()`, which dismisses on the spot with no ramp. See
640    /// [`ModalDismiss`]; mint the handle outside [`show_dialog`]'s page
641    /// builder.
642    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
643        self.dismiss_handle = Some(dismiss);
644        self
645    }
646
647    /// Compose the host-facing [`OverlayModalView`] fresh from the current
648    /// fields (see the module docs' *Why actions are `Rc`-wrapped
649    /// internally*).
650    fn compose(&self) -> OverlayModalView<State> {
651        let panel = DialogPanelView {
652            title: self.title.clone(),
653            content: self.body.as_ref().map(|s| Rc::new(body_view::<State>(s))),
654            actions: self.actions.clone(),
655            top_divider: false,
656            bottom_divider: false,
657            content_full_bleed: false,
658        };
659        let mut view = overlay_modal(
660            panel,
661            OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
662        )
663        .dismissable(self.dismissable);
664        if let Some(title) = &self.title {
665            view = view.label(title.clone());
666        }
667        if let Some(on_dismiss) = &self.on_dismiss {
668            let on_dismiss = on_dismiss.clone();
669            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
670        }
671        if let Some(dismiss) = &self.dismiss_handle {
672            view = view.dismiss_handle(dismiss.clone());
673        }
674        view
675    }
676}
677
678impl<State: 'static> View<State> for DialogView<State> {
679    type Element = OverlayModalWidget;
680
681    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
682        View::build(&self.compose(), ctx)
683    }
684
685    fn rebuild(
686        &self,
687        prev: &Self,
688        element: &mut OverlayModalWidget,
689        ctx: &mut BuildCtx<'_>,
690    ) -> ChangeFlags {
691        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
692    }
693
694    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
695        View::teardown(&self.compose(), element, ctx);
696    }
697}
698
699impl<State: 'static> OverlayModalContent<State> for DialogView<State> {
700    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
701        self.on_dismiss = Some(on_dismiss);
702        self
703    }
704
705    fn modal_dismissable(&self) -> bool {
706        self.dismissable
707    }
708}
709
710/// Push `build`'s dialog as a transparent navigator page over the host's
711/// scrim, and register `on_result` for the value it pops with.
712///
713/// Dismissal (scrim tap, `Escape`, Android back) is wired to
714/// `controller.pop()` for you, staged behind the host's exit ramp; an action
715/// button pops with a value via `controller.pop_with_result(..)`, immediately
716/// (see the module docs).
717///
718/// ```ignore
719/// show_dialog(
720///     &state.nav,
721///     || dialog().title("Delete?").body("This can't be undone.").action(confirm_button),
722///     |state: &mut State, result: PopResult| {
723///         state.confirmed = result.take::<bool>().unwrap_or(false);
724///     },
725/// );
726/// ```
727pub fn show_dialog<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
728where
729    State: 'static,
730    B: Fn() -> DialogView<State> + 'static,
731    R: Fn(&mut State, PopResult) + 'static,
732{
733    show_overlay_modal(controller, build, on_result);
734}
735
736// ============================================================================
737// selection_dialog() / SelectionDialogView / show_selection_dialog() — the
738// centered dialog with a scrollable(-ish; see the module docs) option list.
739// ============================================================================
740
741/// A view-held, typed per-option toggle callback (erased on build).
742type OnToggle<State> = Rc<dyn Fn(&mut State, String)>;
743
744/// A declarative M3 selection dialog: [`dialog`]'s centered panel, both
745/// dividers on, and a radio/checkbox option list for content. See the
746/// [module docs](self)' controlled-component note.
747pub struct SelectionDialogView<State: 'static> {
748    title: String,
749    options: Vec<String>,
750    multi_select: bool,
751    selected: Vec<String>,
752    on_toggle: Option<OnToggle<State>>,
753    cancel_label: String,
754    confirm_label: String,
755    on_confirm: Option<OnDismiss<State>>,
756    dismissable: bool,
757    on_dismiss: Option<OnDismiss<State>>,
758    dismiss_handle: Option<ModalDismiss>,
759}
760
761/// Create a selection dialog titled `title`, offering `options` (single-select
762/// by default — see [`SelectionDialogView::multi_select`]).
763pub fn selection_dialog<State: 'static>(
764    title: impl Into<String>,
765    options: Vec<String>,
766) -> SelectionDialogView<State> {
767    SelectionDialogView {
768        title: title.into(),
769        options,
770        multi_select: false,
771        selected: Vec::new(),
772        on_toggle: None,
773        cancel_label: DEFAULT_CANCEL_LABEL.to_string(),
774        confirm_label: DEFAULT_CONFIRM_LABEL.to_string(),
775        on_confirm: None,
776        dismissable: true,
777        on_dismiss: None,
778        dismiss_handle: None,
779    }
780}
781
782impl<State: 'static> SelectionDialogView<State> {
783    /// Allow more than one option selected at once (checkbox rows instead of
784    /// radio rows) — the reference's `multiSelect`.
785    pub fn multi_select(mut self, multi_select: bool) -> Self {
786        self.multi_select = multi_select;
787        self
788    }
789
790    /// The currently-selected options (controlled — see the [module
791    /// docs](self)). Single-select reads only the first entry.
792    pub fn selected(mut self, selected: Vec<String>) -> Self {
793        self.selected = selected;
794        self
795    }
796
797    /// Fired with the tapped option's value on every row press — a
798    /// single-select tap reports the newly-chosen option; a multi-select tap
799    /// reports whichever option should flip membership. The caller updates
800    /// its own `selected` set and re-renders.
801    pub fn on_toggle<F: Fn(&mut State, String) + 'static>(mut self, on_toggle: F) -> Self {
802        self.on_toggle = Some(Rc::new(on_toggle));
803        self
804    }
805
806    /// Override the cancel action's label (`_cancelLabel`, default "Cancel").
807    pub fn cancel_label(mut self, label: impl Into<String>) -> Self {
808        self.cancel_label = label.into();
809        self
810    }
811
812    /// Override the confirm action's label (`_confirmLabel`, default "OK").
813    pub fn confirm_label(mut self, label: impl Into<String>) -> Self {
814        self.confirm_label = label.into();
815        self
816    }
817
818    /// Fired on a confirm tap — disabled (per the reference) until at least
819    /// one option is selected. The caller decides what confirming means
820    /// (typically `controller.pop_with_result(..)` with its own `selected`),
821    /// exactly like the basic dialog's `.action(..)` contract.
822    pub fn on_confirm<F: Fn(&mut State) + 'static>(mut self, on_confirm: F) -> Self {
823        self.on_confirm = Some(Rc::new(on_confirm));
824        self
825    }
826
827    /// Whether the user can dismiss this dialog via the scrim tap, `Escape`,
828    /// or an Android back press (default `true`) — see
829    /// [`DialogView::dismissable`].
830    pub fn dismissable(mut self, dismissable: bool) -> Self {
831        self.dismissable = dismissable;
832        self
833    }
834
835    /// Observe a scrim-tap/Escape/back/cancel dismissal — see
836    /// [`DialogView::on_dismiss`].
837    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
838        self.on_dismiss = Some(Rc::new(on_dismiss));
839        self
840    }
841
842    /// Install the app-triggered staged-dismiss handle, so the confirm action
843    /// closes the dialog through the host's exit ramp instead of a raw,
844    /// unstaged `controller.pop()` — see [`DialogView::dismiss_handle`].
845    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
846        self.dismiss_handle = Some(dismiss);
847        self
848    }
849
850    fn compose(&self) -> OverlayModalView<State> {
851        let list = SelectionListView {
852            options: self.options.clone(),
853            multi_select: self.multi_select,
854            selected: self.selected.clone(),
855            on_toggle: self.on_toggle.clone().unwrap_or_else(|| Rc::new(|_, _| {})),
856        };
857
858        let on_dismiss = self.on_dismiss.clone();
859        let cancel_action: AnyView<State> = any(text_button(
860            self.cancel_label.clone(),
861            move |state: &mut State| {
862                if let Some(on_dismiss) = &on_dismiss {
863                    on_dismiss(state);
864                }
865            },
866        ));
867        let on_confirm = self.on_confirm.clone();
868        let confirm_action: AnyView<State> = any(button(
869            self.confirm_label.clone(),
870            move |state: &mut State| {
871                if let Some(on_confirm) = &on_confirm {
872                    on_confirm(state);
873                }
874            },
875        )
876        .enabled(!self.selected.is_empty()));
877
878        let panel = DialogPanelView {
879            title: Some(self.title.clone()),
880            content: Some(Rc::new(any(list))),
881            actions: vec![Rc::new(cancel_action), Rc::new(confirm_action)],
882            top_divider: true,
883            bottom_divider: true,
884            content_full_bleed: true,
885        };
886        let mut view = overlay_modal(
887            panel,
888            OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
889        )
890        .dismissable(self.dismissable)
891        .label(self.title.clone());
892        if let Some(on_dismiss) = &self.on_dismiss {
893            let on_dismiss = on_dismiss.clone();
894            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
895        }
896        if let Some(dismiss) = &self.dismiss_handle {
897            view = view.dismiss_handle(dismiss.clone());
898        }
899        view
900    }
901}
902
903impl<State: 'static> View<State> for SelectionDialogView<State> {
904    type Element = OverlayModalWidget;
905
906    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
907        View::build(&self.compose(), ctx)
908    }
909
910    fn rebuild(
911        &self,
912        prev: &Self,
913        element: &mut OverlayModalWidget,
914        ctx: &mut BuildCtx<'_>,
915    ) -> ChangeFlags {
916        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
917    }
918
919    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
920        View::teardown(&self.compose(), element, ctx);
921    }
922}
923
924impl<State: 'static> OverlayModalContent<State> for SelectionDialogView<State> {
925    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
926        self.on_dismiss = Some(on_dismiss);
927        self
928    }
929
930    fn modal_dismissable(&self) -> bool {
931        self.dismissable
932    }
933}
934
935/// Push `build`'s selection dialog as a transparent navigator page, and
936/// register `on_result` for the value it pops with. See [`show_dialog`].
937pub fn show_selection_dialog<State, B, R>(
938    controller: &NavigatorController<State>,
939    build: B,
940    on_result: R,
941) where
942    State: 'static,
943    B: Fn() -> SelectionDialogView<State> + 'static,
944    R: Fn(&mut State, PopResult) + 'static,
945{
946    show_overlay_modal(controller, build, on_result);
947}
948
949/// The option-list content view built fresh, each pass, from primitive owned
950/// data (never `Rc`-wrapped — see the module docs).
951struct SelectionListView<State: 'static> {
952    options: Vec<String>,
953    multi_select: bool,
954    selected: Vec<String>,
955    on_toggle: OnToggle<State>,
956}
957
958/// One selection row: the control ([`crate::radio`]/[`crate::checkbox`],
959/// reused read-only) plus an optional separate label pod (multi-select only —
960/// [`crate::checkbox`] has no built-in label slot, unlike [`crate::radio`]).
961struct SelectionRow {
962    control: ChildPod,
963    label: Option<ChildPod>,
964}
965
966/// The retained widget for a [`SelectionListView`].
967struct SelectionListWidget {
968    rows: Vec<SelectionRow>,
969}
970
971/// Build one row's control (+ label, if multi-select) view pair.
972fn selection_row_views<State: 'static>(
973    option: &str,
974    multi_select: bool,
975    selected: &[String],
976    on_toggle: &OnToggle<State>,
977) -> (AnyView<State>, Option<AnyView<State>>) {
978    if multi_select {
979        let checked = selected.iter().any(|s| s == option);
980        let toggle = on_toggle.clone();
981        let value = option.to_string();
982        let control = any(checkbox(checked, move |state: &mut State, _next: bool| {
983            toggle(state, value.clone())
984        }));
985        let label = any(text(option.to_string())
986            .themed_role(ThemeTextColor::OnSurface)
987            .themed_family(ThemeTextType::BodyLarge));
988        (control, Some(label))
989    } else {
990        let group_value = selected.first().cloned();
991        let toggle = on_toggle.clone();
992        let control = any(
993            radio::<State, Option<String>>(Some(option.to_string()), group_value)
994                .on_changed(move |state: &mut State, value: Option<String>| {
995                    if let Some(value) = value {
996                        toggle(state, value);
997                    }
998                })
999                .label(option.to_string()),
1000        );
1001        (control, None)
1002    }
1003}
1004
1005impl<State: 'static> View<State> for SelectionListView<State> {
1006    type Element = SelectionListWidget;
1007
1008    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionListWidget {
1009        let rows = self
1010            .options
1011            .iter()
1012            .map(|option| {
1013                let (control, label) = selection_row_views::<State>(
1014                    option,
1015                    self.multi_select,
1016                    &self.selected,
1017                    &self.on_toggle,
1018                );
1019                SelectionRow {
1020                    control: build_child(&control, ctx),
1021                    label: label.map(|l| build_child(&l, ctx)),
1022                }
1023            })
1024            .collect();
1025        SelectionListWidget { rows }
1026    }
1027
1028    fn rebuild(
1029        &self,
1030        prev: &Self,
1031        element: &mut SelectionListWidget,
1032        ctx: &mut BuildCtx<'_>,
1033    ) -> ChangeFlags {
1034        let shape_changed = prev.options != self.options
1035            || prev.multi_select != self.multi_select
1036            || element.rows.len() != self.options.len();
1037        if shape_changed {
1038            for (view, row) in prev.options.iter().zip(element.rows.iter_mut()) {
1039                let (control, label) = selection_row_views::<State>(
1040                    view,
1041                    prev.multi_select,
1042                    &prev.selected,
1043                    &prev.on_toggle,
1044                );
1045                teardown_child(&control, &mut row.control, ctx);
1046                if let (Some(label_view), Some(pod)) = (label, row.label.as_mut()) {
1047                    teardown_child(&label_view, pod, ctx);
1048                }
1049            }
1050            element.rows = self
1051                .options
1052                .iter()
1053                .map(|option| {
1054                    let (control, label) = selection_row_views::<State>(
1055                        option,
1056                        self.multi_select,
1057                        &self.selected,
1058                        &self.on_toggle,
1059                    );
1060                    SelectionRow {
1061                        control: build_child(&control, ctx),
1062                        label: label.map(|l| build_child(&l, ctx)),
1063                    }
1064                })
1065                .collect();
1066            return ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1067        }
1068
1069        let mut flags = ChangeFlags::NONE;
1070        for (i, (option, row)) in self.options.iter().zip(element.rows.iter_mut()).enumerate() {
1071            let (prev_control, prev_label) = selection_row_views::<State>(
1072                &prev.options[i],
1073                prev.multi_select,
1074                &prev.selected,
1075                &prev.on_toggle,
1076            );
1077            let (next_control, next_label) = selection_row_views::<State>(
1078                option,
1079                self.multi_select,
1080                &self.selected,
1081                &self.on_toggle,
1082            );
1083            flags |= rebuild_child(&prev_control, &next_control, &mut row.control, ctx);
1084            if let (Some(prev_l), Some(next_l), Some(pod)) =
1085                (prev_label, next_label, row.label.as_mut())
1086            {
1087                flags |= rebuild_child(&prev_l, &next_l, pod, ctx);
1088            }
1089        }
1090        flags
1091    }
1092
1093    fn teardown(&self, element: &mut SelectionListWidget, ctx: &mut BuildCtx<'_>) {
1094        for (option, row) in self.options.iter().zip(element.rows.iter_mut()) {
1095            let (control, label) = selection_row_views::<State>(
1096                option,
1097                self.multi_select,
1098                &self.selected,
1099                &self.on_toggle,
1100            );
1101            teardown_child(&control, &mut row.control, ctx);
1102            if let (Some(label_view), Some(pod)) = (label, row.label.as_mut()) {
1103                teardown_child(&label_view, pod, ctx);
1104            }
1105        }
1106    }
1107}
1108
1109impl Widget for SelectionListWidget {
1110    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1111        let width = bc.max().width.max(bc.min().width);
1112        let mut y = SELECTION_LIST_INSET;
1113        let row_w = (width - 2.0 * DIALOG_PADDING).max(0.0);
1114        let row_bc = BoxConstraints::loose(Size::new(row_w, SELECTION_ITEM_HEIGHT));
1115
1116        for row in &mut self.rows {
1117            let control_size = row.control.layout_child(ctx, &row_bc);
1118            let mut row_h = control_size.height;
1119            let label_size = row.label.as_mut().map(|l| l.layout_child(ctx, &row_bc));
1120            if let Some(s) = label_size {
1121                row_h = row_h.max(s.height);
1122            }
1123            let band_h = row_h.max(SELECTION_ITEM_HEIGHT);
1124            let control_y = y + (band_h - control_size.height) / 2.0;
1125            row.control
1126                .set_origin(Point::new(DIALOG_PADDING, control_y));
1127            if let (Some(label), Some(s)) = (row.label.as_mut(), label_size) {
1128                let label_x = DIALOG_PADDING + control_size.width + CHECKBOX_LABEL_GAP;
1129                let label_y = y + (band_h - s.height) / 2.0;
1130                label.set_origin(Point::new(label_x, label_y));
1131            }
1132            y += band_h.max(SELECTION_ITEM_HEIGHT);
1133        }
1134        y += SELECTION_LIST_INSET;
1135
1136        bc.constrain(Size::new(width, y))
1137    }
1138
1139    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1140        for row in &mut self.rows {
1141            row.control.paint_child(ctx, scene);
1142            if let Some(label) = row.label.as_mut() {
1143                label.paint_child(ctx, scene);
1144            }
1145        }
1146    }
1147
1148    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1149        for row in &mut self.rows {
1150            if route_event_single(&mut row.control, ctx, event) == EventResult::Handled {
1151                return EventResult::Handled;
1152            }
1153        }
1154        EventResult::Ignored
1155    }
1156
1157    fn semantics(&self, ctx: &mut SemanticsCtx) {
1158        for row in &self.rows {
1159            row.control.semantics_child(ctx);
1160            if let Some(label) = &row.label {
1161                label.semantics_child(ctx);
1162            }
1163        }
1164    }
1165
1166    fn visit_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
1167        for row in &self.rows {
1168            visitor(&row.control);
1169            if let Some(label) = &row.label {
1170                visitor(label);
1171            }
1172        }
1173    }
1174}
1175
1176// ============================================================================
1177// full_screen_dialog() / FullScreenDialogView / show_full_screen_dialog() —
1178// the edge-to-edge dialog: header (close, title, optional action) + divider +
1179// body, sliding up from the bottom.
1180// ============================================================================
1181
1182/// A declarative M3 full-screen dialog: an app-bar-like header over a body
1183/// slot, filling the whole area. See the [module docs](self).
1184pub struct FullScreenDialogView<State: 'static> {
1185    title: String,
1186    body: Rc<AnyView<State>>,
1187    action: Option<Rc<AnyView<State>>>,
1188    dismissable: bool,
1189    on_dismiss: Option<OnDismiss<State>>,
1190    dismiss_handle: Option<ModalDismiss>,
1191}
1192
1193/// Create a full-screen dialog titled `title`, filling its body slot with
1194/// `body`. Chain [`FullScreenDialogView::action`] for a trailing header
1195/// action.
1196pub fn full_screen_dialog<State: 'static, V: View<State>>(
1197    title: impl Into<String>,
1198    body: V,
1199) -> FullScreenDialogView<State> {
1200    FullScreenDialogView {
1201        title: title.into(),
1202        body: Rc::new(any(body)),
1203        action: None,
1204        dismissable: true,
1205        on_dismiss: None,
1206        dismiss_handle: None,
1207    }
1208}
1209
1210impl<State: 'static> FullScreenDialogView<State> {
1211    /// Set the header's trailing action (e.g. a "Save" button) — the
1212    /// reference's `action`.
1213    pub fn action(mut self, action: impl View<State>) -> Self {
1214        self.action = Some(Rc::new(AnyView::new(action)));
1215        self
1216    }
1217
1218    /// Whether `Escape`/an Android back press dismiss this dialog (default
1219    /// `true`) — see [`DialogView::dismissable`]. There is no scrim to tap
1220    /// (the panel is full-bleed) and the header's own close affordance
1221    /// always dismisses regardless of this flag (matching the reference,
1222    /// where the header close is the dialog's one unconditional exit).
1223    pub fn dismissable(mut self, dismissable: bool) -> Self {
1224        self.dismissable = dismissable;
1225        self
1226    }
1227
1228    /// Observe a dismissal (`Escape`, back, or the header close) — see
1229    /// [`DialogView::on_dismiss`].
1230    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
1231        self.on_dismiss = Some(Rc::new(on_dismiss));
1232        self
1233    }
1234
1235    /// Install the app-triggered staged-dismiss handle, so the header's own
1236    /// trailing action (a "Save") closes the dialog through the host's exit
1237    /// ramp instead of a raw, unstaged `controller.pop()` — see
1238    /// [`DialogView::dismiss_handle`]. The header's *close* affordance is not
1239    /// routed through it: it fires `on_dismiss` directly (see
1240    /// [`Self::dismissable`]), which is this family's own unstaged exit.
1241    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
1242        self.dismiss_handle = Some(dismiss);
1243        self
1244    }
1245
1246    fn config() -> OverlayModalConfig {
1247        OverlayModalConfig::edge(OverlaySide::Bottom)
1248            .extent(OverlayExtent::Fraction(1.0), OverlayLimit::None)
1249            .corners(OverlayCorners::None)
1250            .close_button(false)
1251            .scrim_dismiss(false)
1252            .elevation(OverlayElevation::None)
1253    }
1254
1255    fn compose(&self) -> OverlayModalView<State> {
1256        let panel = FullScreenPanelView {
1257            title: self.title.clone(),
1258            body: self.body.clone(),
1259            action: self.action.clone(),
1260            on_dismiss: self.on_dismiss.clone(),
1261        };
1262        let mut view = overlay_modal(panel, Self::config())
1263            .dismissable(self.dismissable)
1264            .label(self.title.clone());
1265        if let Some(on_dismiss) = &self.on_dismiss {
1266            let on_dismiss = on_dismiss.clone();
1267            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
1268        }
1269        if let Some(dismiss) = &self.dismiss_handle {
1270            view = view.dismiss_handle(dismiss.clone());
1271        }
1272        view
1273    }
1274}
1275
1276impl<State: 'static> View<State> for FullScreenDialogView<State> {
1277    type Element = OverlayModalWidget;
1278
1279    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
1280        View::build(&self.compose(), ctx)
1281    }
1282
1283    fn rebuild(
1284        &self,
1285        prev: &Self,
1286        element: &mut OverlayModalWidget,
1287        ctx: &mut BuildCtx<'_>,
1288    ) -> ChangeFlags {
1289        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
1290    }
1291
1292    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
1293        View::teardown(&self.compose(), element, ctx);
1294    }
1295}
1296
1297impl<State: 'static> OverlayModalContent<State> for FullScreenDialogView<State> {
1298    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
1299        self.on_dismiss = Some(on_dismiss);
1300        self
1301    }
1302
1303    fn modal_dismissable(&self) -> bool {
1304        // The header's own close affordance always dismisses (see
1305        // `FullScreenDialogView::dismissable`'s doc) — but that reaches
1306        // `on_dismiss` directly rather than through the host's staged
1307        // request-dismiss/back-signal path, so `dismissable` here still
1308        // governs only Escape/back, matching every other variant.
1309        self.dismissable
1310    }
1311}
1312
1313/// Push `build`'s full-screen dialog as a transparent navigator page, and
1314/// register `on_result` for the value it pops with. See [`show_dialog`].
1315pub fn show_full_screen_dialog<State, B, R>(
1316    controller: &NavigatorController<State>,
1317    build: B,
1318    on_result: R,
1319) where
1320    State: 'static,
1321    B: Fn() -> FullScreenDialogView<State> + 'static,
1322    R: Fn(&mut State, PopResult) + 'static,
1323{
1324    show_overlay_modal(controller, build, on_result);
1325}
1326
1327/// The full-screen panel's content view: header (close, title, optional
1328/// action) + divider + body.
1329struct FullScreenPanelView<State: 'static> {
1330    title: String,
1331    body: Rc<AnyView<State>>,
1332    action: Option<Rc<AnyView<State>>>,
1333    on_dismiss: Option<OnDismiss<State>>,
1334}
1335
1336/// The retained widget for a [`FullScreenPanelView`].
1337struct FullScreenPanelWidget {
1338    close: ChildPod,
1339    title: ChildPod,
1340    action: Option<ChildPod>,
1341    divider: ChildPod,
1342    body: ChildPod,
1343}
1344
1345/// Build the header close affordance's view — [`icon_button`] over
1346/// [`crate::icons::CLOSE`], firing `on_dismiss` directly (see the module
1347/// docs' *Deliberate v1 scope cuts*).
1348// erasure: keep feeds a ChildPod: build_child/rebuild_child/teardown_child take &AnyView
1349fn close_view<State: 'static>(on_dismiss: Option<OnDismiss<State>>) -> AnyView<State> {
1350    any(
1351        icon_button(icon(crate::icons::CLOSE), move |state: &mut State| {
1352            if let Some(on_dismiss) = &on_dismiss {
1353                on_dismiss(state);
1354            }
1355        })
1356        .semantic_label(CLOSE_SEMANTIC_LABEL),
1357    )
1358}
1359
1360/// Build the header title's view (`titleLarge`, family from the theme's
1361/// `titleLarge` role, single line, ellipsized).
1362fn header_title_view<State: 'static>(title: &str) -> AnyView<State> {
1363    any::<State, _>(
1364        text(title.to_string())
1365            .size(HEADER_TITLE_SIZE)
1366            .line_height(LineHeight::Absolute(HEADER_TITLE_LINE_HEIGHT))
1367            .max_lines(1)
1368            .overflow(TextOverflow::Ellipsis)
1369            .themed_family(ThemeTextType::TitleLarge),
1370    )
1371}
1372
1373impl<State: 'static> View<State> for FullScreenPanelView<State> {
1374    type Element = FullScreenPanelWidget;
1375
1376    fn build(&self, ctx: &mut BuildCtx<'_>) -> FullScreenPanelWidget {
1377        FullScreenPanelWidget {
1378            close: build_child(&close_view::<State>(self.on_dismiss.clone()), ctx),
1379            title: build_child(&header_title_view::<State>(&self.title), ctx),
1380            action: self.action.as_ref().map(|a| build_child(a.as_ref(), ctx)),
1381            divider: build_child(&divider_view::<State>(), ctx),
1382            body: build_child(self.body.as_ref(), ctx),
1383        }
1384    }
1385
1386    fn rebuild(
1387        &self,
1388        prev: &Self,
1389        element: &mut FullScreenPanelWidget,
1390        ctx: &mut BuildCtx<'_>,
1391    ) -> ChangeFlags {
1392        let mut flags = rebuild_child(
1393            &close_view::<State>(prev.on_dismiss.clone()),
1394            &close_view::<State>(self.on_dismiss.clone()),
1395            &mut element.close,
1396            ctx,
1397        );
1398        flags |= rebuild_child(
1399            &header_title_view::<State>(&prev.title),
1400            &header_title_view::<State>(&self.title),
1401            &mut element.title,
1402            ctx,
1403        );
1404        flags |= match (prev.action.as_ref(), self.action.as_ref()) {
1405            (None, Some(v)) => {
1406                element.action = Some(build_child(v.as_ref(), ctx));
1407                ChangeFlags::LAYOUT | ChangeFlags::PAINT
1408            }
1409            (Some(v), None) => {
1410                if let Some(p) = element.action.as_mut() {
1411                    teardown_child(v.as_ref(), p, ctx);
1412                }
1413                element.action = None;
1414                ChangeFlags::LAYOUT | ChangeFlags::PAINT
1415            }
1416            (Some(a), Some(b)) => match element.action.as_mut() {
1417                Some(p) => rebuild_child(a.as_ref(), b.as_ref(), p, ctx),
1418                None => ChangeFlags::NONE,
1419            },
1420            (None, None) => ChangeFlags::NONE,
1421        };
1422        flags |= rebuild_child(
1423            &divider_view::<State>(),
1424            &divider_view::<State>(),
1425            &mut element.divider,
1426            ctx,
1427        );
1428        flags |= rebuild_child(
1429            prev.body.as_ref(),
1430            self.body.as_ref(),
1431            &mut element.body,
1432            ctx,
1433        );
1434        flags
1435    }
1436
1437    fn teardown(&self, element: &mut FullScreenPanelWidget, ctx: &mut BuildCtx<'_>) {
1438        teardown_child(
1439            &close_view::<State>(self.on_dismiss.clone()),
1440            &mut element.close,
1441            ctx,
1442        );
1443        teardown_child(
1444            &header_title_view::<State>(&self.title),
1445            &mut element.title,
1446            ctx,
1447        );
1448        if let (Some(v), Some(p)) = (&self.action, element.action.as_mut()) {
1449            teardown_child(v.as_ref(), p, ctx);
1450        }
1451        teardown_child(&divider_view::<State>(), &mut element.divider, ctx);
1452        teardown_child(self.body.as_ref(), &mut element.body, ctx);
1453    }
1454}
1455
1456impl Widget for FullScreenPanelWidget {
1457    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1458        // The host gives a full-screen edge panel a tight size (`layout_panel`'s
1459        // Edge arm, `extent: Fraction(1.0)`).
1460        let size = bc.max();
1461        let (width, height) = (size.width, size.height);
1462
1463        let loose = BoxConstraints::loose(Size::new(width, FULL_SCREEN_HEADER_HEIGHT));
1464        let close_size = self.close.layout_child(ctx, &loose);
1465        let action_size = self.action.as_mut().map(|a| a.layout_child(ctx, &loose));
1466
1467        let close_x = HEADER_EDGE_GAP;
1468        self.close.set_origin(Point::new(
1469            close_x,
1470            (FULL_SCREEN_HEADER_HEIGHT - close_size.height) / 2.0,
1471        ));
1472
1473        let title_leading = close_x + close_size.width + HEADER_EDGE_GAP;
1474        let trailing_reserve = action_size
1475            .map(|s| s.width + HEADER_ACTION_GAP)
1476            .unwrap_or(0.0);
1477        let title_w = (width - title_leading - trailing_reserve).max(0.0);
1478        let title_bc = BoxConstraints::new(
1479            Size::new(title_w, 0.0),
1480            Size::new(title_w, FULL_SCREEN_HEADER_HEIGHT),
1481        );
1482        let title_size = self.title.layout_child(ctx, &title_bc);
1483        self.title.set_origin(Point::new(
1484            title_leading,
1485            (FULL_SCREEN_HEADER_HEIGHT - title_size.height) / 2.0,
1486        ));
1487
1488        if let (Some(action), Some(s)) = (self.action.as_mut(), action_size) {
1489            action.set_origin(Point::new(
1490                width - HEADER_ACTION_GAP - s.width,
1491                (FULL_SCREEN_HEADER_HEIGHT - s.height) / 2.0,
1492            ));
1493        }
1494
1495        self.divider
1496            .layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
1497        self.divider
1498            .set_origin(Point::new(0.0, FULL_SCREEN_HEADER_HEIGHT));
1499        let divider_h = self.divider.size().height;
1500
1501        let body_y = FULL_SCREEN_HEADER_HEIGHT + divider_h;
1502        let body_h = (height - body_y).max(0.0);
1503        self.body
1504            .layout_child(ctx, &BoxConstraints::tight(Size::new(width, body_h)));
1505        self.body.set_origin(Point::new(0.0, body_y));
1506
1507        bc.constrain(size)
1508    }
1509
1510    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1511        // The host's own `container` fill sits underneath; this dialog paints
1512        // its own opaque `colors.surface` full-bleed fill over it (see the
1513        // module docs — `fullScreenBackground`).
1514        let theme = Theme::from_paint_ctx(ctx);
1515        scene.fill_rect(ctx.origin(), ctx.size(), resolve_surface(theme));
1516
1517        self.close.paint_child(ctx, scene);
1518        self.title.paint_child(ctx, scene);
1519        if let Some(action) = self.action.as_mut() {
1520            action.paint_child(ctx, scene);
1521        }
1522        self.divider.paint_child(ctx, scene);
1523        self.body.paint_child(ctx, scene);
1524    }
1525
1526    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1527        if route_event_single(&mut self.close, ctx, event) == EventResult::Handled {
1528            return EventResult::Handled;
1529        }
1530        if let Some(action) = self.action.as_mut()
1531            && route_event_single(action, ctx, event) == EventResult::Handled
1532        {
1533            return EventResult::Handled;
1534        }
1535        if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
1536            return EventResult::Handled;
1537        }
1538        EventResult::Ignored
1539    }
1540
1541    fn semantics(&self, ctx: &mut SemanticsCtx) {
1542        self.close.semantics_child(ctx);
1543        self.title.semantics_child(ctx);
1544        if let Some(action) = &self.action {
1545            action.semantics_child(ctx);
1546        }
1547        self.body.semantics_child(ctx);
1548    }
1549
1550    visit_children!(close, title, action, divider, body);
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556    use frust::authoring::scene::GlyphRun;
1557    use frust::authoring::{
1558        CornerRadii, Key, KeyEvent, Modifiers, NamedKey, PointerButton, PointerEvent, PointerPhase,
1559        text::TextContext,
1560    };
1561    use frust::{FrameTime, NavigatorView};
1562    use frust_core::RenderRoot;
1563    use frust_widgets::navigator;
1564    use std::any::Any;
1565
1566    /// The window every dialog test lays out in.
1567    const WINDOW: Size = Size::new(400.0, 600.0);
1568
1569    /// Records filled rects, rounded rects, and glyph runs so a test can
1570    /// assert a variant actually painted its chrome.
1571    #[derive(Default)]
1572    struct Recorder {
1573        rects: Vec<(Point, Size, Color)>,
1574        rrects: Vec<(Point, Size, CornerRadii, Color)>,
1575        glyphs: usize,
1576    }
1577    impl PaintScene for Recorder {
1578        fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
1579            self.rects.push((o, s, c));
1580        }
1581        fn draw_text(&mut self, _o: Point, _t: &str) {}
1582        fn draw_glyph_run(&mut self, _run: GlyphRun) {
1583            self.glyphs += 1;
1584        }
1585        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
1586        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, c: Color) {
1587            self.rrects.push((o, s, radii, c));
1588        }
1589    }
1590
1591    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1592        InputEvent::Pointer(PointerEvent {
1593            phase,
1594            position: Point::new(x, y),
1595            button: PointerButton::Primary,
1596        })
1597    }
1598
1599    fn escape_event() -> InputEvent {
1600        InputEvent::Key(KeyEvent {
1601            key: Key::Named(NamedKey::Escape),
1602            modifiers: Modifiers::default(),
1603            repeat: false,
1604        })
1605    }
1606
1607    #[derive(Default)]
1608    struct NavState {
1609        results: Vec<Option<bool>>,
1610        toggled: Vec<String>,
1611        confirmed: bool,
1612    }
1613
1614    struct Sized {
1615        size: Size,
1616    }
1617    struct SizedW {
1618        size: Size,
1619    }
1620    impl View<NavState> for Sized {
1621        type Element = SizedW;
1622        fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
1623            SizedW { size: self.size }
1624        }
1625        fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1626            ChangeFlags::NONE
1627        }
1628    }
1629    impl Widget for SizedW {
1630        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1631            bc.constrain(self.size)
1632        }
1633        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1634            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1635        }
1636    }
1637    fn sized(w: f64, h: f64) -> Sized {
1638        Sized {
1639            size: Size::new(w, h),
1640        }
1641    }
1642
1643    /// A fixed-size leaf firing `on_tap` on release inside it — lets a test
1644    /// place a dialog action of known geometry without depending on real
1645    /// text-shaping metrics.
1646    struct TapView {
1647        size: Size,
1648        on_tap: Rc<dyn Fn(&mut NavState)>,
1649    }
1650    struct TapWidget {
1651        size: Size,
1652        on_tap: frust::authoring::ErasedCallback,
1653        captured: bool,
1654    }
1655    impl View<NavState> for TapView {
1656        type Element = TapWidget;
1657        fn build(&self, _ctx: &mut BuildCtx<'_>) -> TapWidget {
1658            TapWidget {
1659                size: self.size,
1660                on_tap: frust::authoring::erase_callback(&self.on_tap),
1661                captured: false,
1662            }
1663        }
1664        fn rebuild(
1665            &self,
1666            _prev: &Self,
1667            element: &mut TapWidget,
1668            _ctx: &mut BuildCtx<'_>,
1669        ) -> ChangeFlags {
1670            element.on_tap = frust::authoring::erase_callback(&self.on_tap);
1671            ChangeFlags::NONE
1672        }
1673    }
1674    impl Widget for TapWidget {
1675        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1676            bc.constrain(self.size)
1677        }
1678        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1679        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1680            let InputEvent::Pointer(p) = event else {
1681                return EventResult::Ignored;
1682            };
1683            match p.phase {
1684                PointerPhase::Down => {
1685                    self.captured = true;
1686                    ctx.capture_pointer();
1687                    EventResult::Handled
1688                }
1689                PointerPhase::Up => {
1690                    if self.captured
1691                        && p.position.x >= 0.0
1692                        && p.position.y >= 0.0
1693                        && p.position.x < self.size.width
1694                        && p.position.y < self.size.height
1695                    {
1696                        (self.on_tap)(ctx);
1697                    }
1698                    self.captured = false;
1699                    EventResult::Handled
1700                }
1701                PointerPhase::Move => EventResult::Handled,
1702                PointerPhase::Cancel => {
1703                    self.captured = false;
1704                    EventResult::Handled
1705                }
1706            }
1707        }
1708    }
1709    fn tap_action(w: f64, h: f64, on_tap: impl Fn(&mut NavState) + 'static) -> impl View<NavState> {
1710        TapView {
1711            size: Size::new(w, h),
1712            on_tap: Rc::new(on_tap),
1713        }
1714    }
1715
1716    /// The app-logic closure the [`Harness`] rebuilds through.
1717    type NavLogic = Box<dyn FnMut(&mut NavState) -> NavigatorView<NavState>>;
1718
1719    struct Harness {
1720        root: RenderRoot<NavState, NavigatorView<NavState>>,
1721        state: NavState,
1722        tcx: TextContext,
1723        controller: NavigatorController<NavState>,
1724        logic: NavLogic,
1725    }
1726
1727    impl Harness {
1728        fn new() -> Self {
1729            let controller: NavigatorController<NavState> = NavigatorController::new();
1730            let ctrl = controller.clone();
1731            let mut h = Harness {
1732                root: RenderRoot::new(),
1733                state: NavState::default(),
1734                tcx: TextContext::new(),
1735                controller,
1736                logic: Box::new(move |_: &mut NavState| {
1737                    navigator(&ctrl, || {
1738                        any::<NavState, _>(sized(WINDOW.width, WINDOW.height))
1739                    })
1740                }),
1741            };
1742            h.pass();
1743            h
1744        }
1745
1746        fn pass(&mut self) {
1747            self.root.rebuild(&mut self.logic, &mut self.state);
1748            self.root
1749                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
1750        }
1751
1752        /// Settle the modal's entrance ramp.
1753        fn settle(&mut self) {
1754            self.root.paint(&mut Recorder::default(), FrameTime::ZERO);
1755            self.root.paint(
1756                &mut Recorder::default(),
1757                FrameTime::from_nanos(1_000_000_000),
1758            );
1759            self.pass();
1760        }
1761
1762        /// Settle a running staged-exit ramp so its enqueued pop drains.
1763        fn settle_exit(&mut self) {
1764            self.root.paint(
1765                &mut Recorder::default(),
1766                FrameTime::from_nanos(2_000_000_000),
1767            );
1768            self.root.paint(
1769                &mut Recorder::default(),
1770                FrameTime::from_nanos(3_000_000_000),
1771            );
1772            self.pass();
1773        }
1774
1775        fn event(&mut self, event: &InputEvent) {
1776            self.root.event(&mut self.state, event);
1777        }
1778
1779        fn flush(&mut self) {
1780            self.pass();
1781            self.event(&ev(PointerPhase::Move, 2.0, 2.0));
1782        }
1783
1784        fn paint(&mut self) -> Recorder {
1785            let mut rec = Recorder::default();
1786            self.root
1787                .paint(&mut rec, FrameTime::from_nanos(4_000_000_000));
1788            rec
1789        }
1790    }
1791
1792    // --- 1. All three variants render (paint-recorder). ---
1793
1794    #[test]
1795    fn the_basic_dialog_renders_title_body_and_actions() {
1796        let mut h = Harness::new();
1797        show_dialog(
1798            &h.controller,
1799            || {
1800                dialog()
1801                    .title("Confirm")
1802                    .body("Are you sure?")
1803                    .action(text_button("OK", |_s: &mut NavState| {}))
1804            },
1805            |state: &mut NavState, result: PopResult| {
1806                state.results.push(result.take::<bool>());
1807            },
1808        );
1809        h.pass();
1810        h.settle();
1811        let rec = h.paint();
1812        assert!(
1813            rec.glyphs >= 3,
1814            "title, body, and the action label all shape"
1815        );
1816    }
1817
1818    #[test]
1819    fn the_selection_dialog_renders_options_and_actions() {
1820        let mut h = Harness::new();
1821        show_selection_dialog(
1822            &h.controller,
1823            || {
1824                selection_dialog("Pick one", vec!["A".into(), "B".into(), "C".into()])
1825                    .selected(vec!["B".into()])
1826            },
1827            |state: &mut NavState, result: PopResult| {
1828                state.results.push(result.take::<bool>());
1829            },
1830        );
1831        h.pass();
1832        h.settle();
1833        let rec = h.paint();
1834        // Title + three radio labels + Cancel + OK.
1835        assert!(rec.glyphs >= 6);
1836    }
1837
1838    #[test]
1839    fn the_full_screen_dialog_renders_header_and_body() {
1840        let mut h = Harness::new();
1841        show_full_screen_dialog(
1842            &h.controller,
1843            || full_screen_dialog("Settings", sized(200.0, 200.0)),
1844            |state: &mut NavState, result: PopResult| {
1845                state.results.push(result.take::<bool>());
1846            },
1847        );
1848        h.pass();
1849        h.settle();
1850        let rec = h.paint();
1851        assert!(rec.glyphs >= 1, "the header title shapes");
1852        // The full-bleed surface fill, then the body's own black fill.
1853        assert!(rec.rects.len() >= 2);
1854    }
1855
1856    // --- 2. Dismissal routes through the modal host; result plumbing works. ---
1857
1858    #[test]
1859    fn scrim_tap_stages_an_exit_then_pops_with_an_empty_result() {
1860        let mut h = Harness::new();
1861        show_dialog(
1862            &h.controller,
1863            || dialog().title("Confirm"),
1864            |state: &mut NavState, result: PopResult| {
1865                state.results.push(result.take::<bool>());
1866            },
1867        );
1868        h.pass();
1869        h.settle();
1870        h.event(&ev(PointerPhase::Down, 5.0, 5.0));
1871        h.event(&ev(PointerPhase::Up, 5.0, 5.0));
1872        h.flush();
1873        assert!(
1874            h.state.results.is_empty(),
1875            "still animating out — no local scrim/pop code fired it directly"
1876        );
1877        h.settle_exit();
1878        h.flush();
1879        assert_eq!(h.state.results, vec![None]);
1880    }
1881
1882    #[test]
1883    fn escape_stages_the_same_exit_once_the_dialog_has_focus() {
1884        let mut h = Harness::new();
1885        show_dialog(
1886            &h.controller,
1887            || dialog().title("Confirm"),
1888            |state: &mut NavState, result: PopResult| {
1889                state.results.push(result.take::<bool>());
1890            },
1891        );
1892        h.pass();
1893        h.settle();
1894        // A press claims focus (host-owned; this file no longer opts in
1895        // itself) without dismissing.
1896        h.event(&ev(PointerPhase::Down, 200.0, 300.0));
1897        h.event(&ev(PointerPhase::Up, 200.0, 300.0));
1898        h.flush();
1899        assert!(h.state.results.is_empty());
1900        h.event(&escape_event());
1901        h.flush();
1902        assert!(h.state.results.is_empty(), "still animating out");
1903        h.settle_exit();
1904        h.flush();
1905        assert_eq!(h.state.results, vec![None]);
1906    }
1907
1908    #[test]
1909    fn a_dismissable_false_dialog_ignores_escape() {
1910        let mut h = Harness::new();
1911        show_dialog(
1912            &h.controller,
1913            || dialog().title("Pinned").dismissable(false),
1914            |state: &mut NavState, result: PopResult| {
1915                state.results.push(result.take::<bool>());
1916            },
1917        );
1918        h.pass();
1919        h.settle();
1920        h.event(&ev(PointerPhase::Down, 200.0, 300.0));
1921        h.event(&ev(PointerPhase::Up, 200.0, 300.0));
1922        h.event(&escape_event());
1923        h.flush();
1924        h.settle_exit();
1925        h.flush();
1926        assert!(
1927            h.state.results.is_empty(),
1928            "a non-dismissable dialog never pops via Escape"
1929        );
1930    }
1931
1932    #[test]
1933    fn an_action_pops_with_a_value_via_the_navigator() {
1934        // A fixed-size action and no title, so the panel's geometry is exact
1935        // without depending on real text-shaping metrics. The host's own
1936        // `OverlayModalConfig::centered` sizes the panel to
1937        // `OVERLAY_DIALOG_MAX_WIDTH` clamped into the viewport margin
1938        // (`MaterialSpacing::LG`, 16dp each side) — not shrunk to content —
1939        // so in a 400px-wide window the panel is `400 - 2*16 = 368`px wide.
1940        const ACTION: Size = Size::new(80.0, 36.0);
1941        const PANEL_W: f64 = 368.0;
1942        const PANEL_MARGIN: f64 = 16.0;
1943
1944        let mut h = Harness::new();
1945        h.controller.push_transparent_for_result(
1946            {
1947                let ctrl = h.controller.clone();
1948                move || {
1949                    let c = ctrl.clone();
1950                    any::<NavState, _>(dialog().action(tap_action(
1951                        ACTION.width,
1952                        ACTION.height,
1953                        move |_s: &mut NavState| c.pop_with_result(PopResult::of(true)),
1954                    )))
1955                }
1956            },
1957            frust::TransitionSpec::NONE,
1958            |state: &mut NavState, result: PopResult| {
1959                state.results.push(result.take::<bool>());
1960            },
1961        );
1962        h.pass();
1963        h.settle();
1964
1965        // No title: the actions block still opens with its own `padding`
1966        // (24dp) — see the module docs' compat note — so the panel's content
1967        // height is `24 + 24 + 36 = 84`, centered vertically in the window.
1968        let panel_h = 2.0 * DIALOG_PADDING + ACTION.height;
1969        let panel_y = (WINDOW.height - panel_h) / 2.0;
1970        let action_x = PANEL_MARGIN + PANEL_W - DIALOG_PADDING - ACTION.width;
1971        let action_y = panel_y + DIALOG_PADDING;
1972        let (cx, cy) = (
1973            action_x + ACTION.width / 2.0,
1974            action_y + ACTION.height / 2.0,
1975        );
1976        h.event(&ev(PointerPhase::Down, cx, cy));
1977        h.event(&ev(PointerPhase::Up, cx, cy));
1978        h.flush();
1979        assert_eq!(h.state.results, vec![Some(true)]);
1980    }
1981
1982    #[test]
1983    fn selection_dialog_confirm_reports_the_controlled_selection() {
1984        let mut h = Harness::new();
1985        show_selection_dialog(
1986            &h.controller,
1987            || {
1988                selection_dialog("Pick", vec!["A".into(), "B".into()])
1989                    .selected(vec!["A".into()])
1990                    .on_toggle(|s: &mut NavState, v: String| s.toggled.push(v))
1991                    .on_confirm(|s: &mut NavState| {
1992                        s.confirmed = true;
1993                    })
1994            },
1995            |state: &mut NavState, result: PopResult| {
1996                state.results.push(result.take::<bool>());
1997            },
1998        );
1999        h.pass();
2000        h.settle();
2001        assert!(!h.state.confirmed);
2002    }
2003
2004    #[test]
2005    fn every_variant_forwards_an_installed_dismiss_handle_to_the_host() {
2006        // Each variant recomposes its host view from its own fields every
2007        // pass; a variant that forgot to carry the handle across would drop an
2008        // action's staged dismiss on the floor with nothing else to show for
2009        // it. The staging itself is the host's, and tested there.
2010        let dismiss = ModalDismiss::new();
2011        assert!(
2012            dialog::<NavState>()
2013                .dismiss_handle(dismiss.clone())
2014                .compose()
2015                .dismiss_handle
2016                .is_some()
2017        );
2018        assert!(
2019            selection_dialog::<NavState>("Pick", vec!["A".into()])
2020                .dismiss_handle(dismiss.clone())
2021                .compose()
2022                .dismiss_handle
2023                .is_some()
2024        );
2025        assert!(
2026            full_screen_dialog::<NavState, _>("Edit", body_view::<NavState>("body"))
2027                .dismiss_handle(dismiss)
2028                .compose()
2029                .dismiss_handle
2030                .is_some()
2031        );
2032        assert!(
2033            dialog::<NavState>().compose().dismiss_handle.is_none(),
2034            "and none is invented for a variant that was handed none"
2035        );
2036    }
2037
2038    // --- 3. Existing consumer compile shape (mirrors `examples/huddle`). ---
2039
2040    #[test]
2041    fn the_old_builder_shape_still_compiles_and_runs() {
2042        let mut h = Harness::new();
2043        show_dialog(
2044            &h.controller,
2045            || {
2046                dialog()
2047                    .title("Invite people")
2048                    .body("Send invites to this workspace? (mock)")
2049                    .action(text_button("Send", |_s: &mut NavState| {}))
2050            },
2051            |state: &mut NavState, result: PopResult| {
2052                state.results.push(result.take::<bool>());
2053            },
2054        );
2055        h.pass();
2056        h.settle();
2057        assert!(h.state.results.is_empty());
2058    }
2059}