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*).
1348fn close_view<State: 'static>(on_dismiss: Option<OnDismiss<State>>) -> impl View<State> {
1349    icon_button(icon(crate::icons::CLOSE), move |state: &mut State| {
1350        if let Some(on_dismiss) = &on_dismiss {
1351            on_dismiss(state);
1352        }
1353    })
1354    .semantic_label(CLOSE_SEMANTIC_LABEL)
1355}
1356
1357/// Build the header title's view (`titleLarge`, family from the theme's
1358/// `titleLarge` role, single line, ellipsized).
1359fn header_title_view<State: 'static>(title: &str) -> AnyView<State> {
1360    any::<State, _>(
1361        text(title.to_string())
1362            .size(HEADER_TITLE_SIZE)
1363            .line_height(LineHeight::Absolute(HEADER_TITLE_LINE_HEIGHT))
1364            .max_lines(1)
1365            .overflow(TextOverflow::Ellipsis)
1366            .themed_family(ThemeTextType::TitleLarge),
1367    )
1368}
1369
1370impl<State: 'static> View<State> for FullScreenPanelView<State> {
1371    type Element = FullScreenPanelWidget;
1372
1373    fn build(&self, ctx: &mut BuildCtx<'_>) -> FullScreenPanelWidget {
1374        FullScreenPanelWidget {
1375            close: build_child(&close_view::<State>(self.on_dismiss.clone()), ctx),
1376            title: build_child(&header_title_view::<State>(&self.title), ctx),
1377            action: self.action.as_ref().map(|a| build_child(a.as_ref(), ctx)),
1378            divider: build_child(&divider_view::<State>(), ctx),
1379            body: build_child(self.body.as_ref(), ctx),
1380        }
1381    }
1382
1383    fn rebuild(
1384        &self,
1385        prev: &Self,
1386        element: &mut FullScreenPanelWidget,
1387        ctx: &mut BuildCtx<'_>,
1388    ) -> ChangeFlags {
1389        let mut flags = rebuild_child(
1390            &close_view::<State>(prev.on_dismiss.clone()),
1391            &close_view::<State>(self.on_dismiss.clone()),
1392            &mut element.close,
1393            ctx,
1394        );
1395        flags |= rebuild_child(
1396            &header_title_view::<State>(&prev.title),
1397            &header_title_view::<State>(&self.title),
1398            &mut element.title,
1399            ctx,
1400        );
1401        flags |= match (prev.action.as_ref(), self.action.as_ref()) {
1402            (None, Some(v)) => {
1403                element.action = Some(build_child(v.as_ref(), ctx));
1404                ChangeFlags::LAYOUT | ChangeFlags::PAINT
1405            }
1406            (Some(v), None) => {
1407                if let Some(p) = element.action.as_mut() {
1408                    teardown_child(v.as_ref(), p, ctx);
1409                }
1410                element.action = None;
1411                ChangeFlags::LAYOUT | ChangeFlags::PAINT
1412            }
1413            (Some(a), Some(b)) => match element.action.as_mut() {
1414                Some(p) => rebuild_child(a.as_ref(), b.as_ref(), p, ctx),
1415                None => ChangeFlags::NONE,
1416            },
1417            (None, None) => ChangeFlags::NONE,
1418        };
1419        flags |= rebuild_child(
1420            &divider_view::<State>(),
1421            &divider_view::<State>(),
1422            &mut element.divider,
1423            ctx,
1424        );
1425        flags |= rebuild_child(
1426            prev.body.as_ref(),
1427            self.body.as_ref(),
1428            &mut element.body,
1429            ctx,
1430        );
1431        flags
1432    }
1433
1434    fn teardown(&self, element: &mut FullScreenPanelWidget, ctx: &mut BuildCtx<'_>) {
1435        teardown_child(
1436            &close_view::<State>(self.on_dismiss.clone()),
1437            &mut element.close,
1438            ctx,
1439        );
1440        teardown_child(
1441            &header_title_view::<State>(&self.title),
1442            &mut element.title,
1443            ctx,
1444        );
1445        if let (Some(v), Some(p)) = (&self.action, element.action.as_mut()) {
1446            teardown_child(v.as_ref(), p, ctx);
1447        }
1448        teardown_child(&divider_view::<State>(), &mut element.divider, ctx);
1449        teardown_child(self.body.as_ref(), &mut element.body, ctx);
1450    }
1451}
1452
1453impl Widget for FullScreenPanelWidget {
1454    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1455        // The host gives a full-screen edge panel a tight size (`layout_panel`'s
1456        // Edge arm, `extent: Fraction(1.0)`).
1457        let size = bc.max();
1458        let (width, height) = (size.width, size.height);
1459
1460        let loose = BoxConstraints::loose(Size::new(width, FULL_SCREEN_HEADER_HEIGHT));
1461        let close_size = self.close.layout_child(ctx, &loose);
1462        let action_size = self.action.as_mut().map(|a| a.layout_child(ctx, &loose));
1463
1464        let close_x = HEADER_EDGE_GAP;
1465        self.close.set_origin(Point::new(
1466            close_x,
1467            (FULL_SCREEN_HEADER_HEIGHT - close_size.height) / 2.0,
1468        ));
1469
1470        let title_leading = close_x + close_size.width + HEADER_EDGE_GAP;
1471        let trailing_reserve = action_size
1472            .map(|s| s.width + HEADER_ACTION_GAP)
1473            .unwrap_or(0.0);
1474        let title_w = (width - title_leading - trailing_reserve).max(0.0);
1475        let title_bc = BoxConstraints::new(
1476            Size::new(title_w, 0.0),
1477            Size::new(title_w, FULL_SCREEN_HEADER_HEIGHT),
1478        );
1479        let title_size = self.title.layout_child(ctx, &title_bc);
1480        self.title.set_origin(Point::new(
1481            title_leading,
1482            (FULL_SCREEN_HEADER_HEIGHT - title_size.height) / 2.0,
1483        ));
1484
1485        if let (Some(action), Some(s)) = (self.action.as_mut(), action_size) {
1486            action.set_origin(Point::new(
1487                width - HEADER_ACTION_GAP - s.width,
1488                (FULL_SCREEN_HEADER_HEIGHT - s.height) / 2.0,
1489            ));
1490        }
1491
1492        self.divider
1493            .layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
1494        self.divider
1495            .set_origin(Point::new(0.0, FULL_SCREEN_HEADER_HEIGHT));
1496        let divider_h = self.divider.size().height;
1497
1498        let body_y = FULL_SCREEN_HEADER_HEIGHT + divider_h;
1499        let body_h = (height - body_y).max(0.0);
1500        self.body
1501            .layout_child(ctx, &BoxConstraints::tight(Size::new(width, body_h)));
1502        self.body.set_origin(Point::new(0.0, body_y));
1503
1504        bc.constrain(size)
1505    }
1506
1507    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1508        // The host's own `container` fill sits underneath; this dialog paints
1509        // its own opaque `colors.surface` full-bleed fill over it (see the
1510        // module docs — `fullScreenBackground`).
1511        let theme = Theme::from_paint_ctx(ctx);
1512        scene.fill_rect(ctx.origin(), ctx.size(), resolve_surface(theme));
1513
1514        self.close.paint_child(ctx, scene);
1515        self.title.paint_child(ctx, scene);
1516        if let Some(action) = self.action.as_mut() {
1517            action.paint_child(ctx, scene);
1518        }
1519        self.divider.paint_child(ctx, scene);
1520        self.body.paint_child(ctx, scene);
1521    }
1522
1523    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1524        if route_event_single(&mut self.close, ctx, event) == EventResult::Handled {
1525            return EventResult::Handled;
1526        }
1527        if let Some(action) = self.action.as_mut()
1528            && route_event_single(action, ctx, event) == EventResult::Handled
1529        {
1530            return EventResult::Handled;
1531        }
1532        if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
1533            return EventResult::Handled;
1534        }
1535        EventResult::Ignored
1536    }
1537
1538    fn semantics(&self, ctx: &mut SemanticsCtx) {
1539        self.close.semantics_child(ctx);
1540        self.title.semantics_child(ctx);
1541        if let Some(action) = &self.action {
1542            action.semantics_child(ctx);
1543        }
1544        self.body.semantics_child(ctx);
1545    }
1546
1547    visit_children!(close, title, action, divider, body);
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553    use frust::authoring::scene::GlyphRun;
1554    use frust::authoring::{
1555        CornerRadii, Key, KeyEvent, Modifiers, NamedKey, PointerButton, PointerEvent, PointerPhase,
1556        text::TextContext,
1557    };
1558    use frust::{FrameTime, NavigatorView};
1559    use frust_core::RenderRoot;
1560    use frust_widgets::navigator;
1561    use std::any::Any;
1562
1563    /// The window every dialog test lays out in.
1564    const WINDOW: Size = Size::new(400.0, 600.0);
1565
1566    /// Records filled rects, rounded rects, and glyph runs so a test can
1567    /// assert a variant actually painted its chrome.
1568    #[derive(Default)]
1569    struct Recorder {
1570        rects: Vec<(Point, Size, Color)>,
1571        rrects: Vec<(Point, Size, CornerRadii, Color)>,
1572        glyphs: usize,
1573    }
1574    impl PaintScene for Recorder {
1575        fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
1576            self.rects.push((o, s, c));
1577        }
1578        fn draw_text(&mut self, _o: Point, _t: &str) {}
1579        fn draw_glyph_run(&mut self, _run: GlyphRun) {
1580            self.glyphs += 1;
1581        }
1582        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
1583        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, c: Color) {
1584            self.rrects.push((o, s, radii, c));
1585        }
1586    }
1587
1588    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1589        InputEvent::Pointer(PointerEvent {
1590            phase,
1591            position: Point::new(x, y),
1592            button: PointerButton::Primary,
1593        })
1594    }
1595
1596    fn escape_event() -> InputEvent {
1597        InputEvent::Key(KeyEvent {
1598            key: Key::Named(NamedKey::Escape),
1599            modifiers: Modifiers::default(),
1600            repeat: false,
1601        })
1602    }
1603
1604    #[derive(Default)]
1605    struct NavState {
1606        results: Vec<Option<bool>>,
1607        toggled: Vec<String>,
1608        confirmed: bool,
1609    }
1610
1611    struct Sized {
1612        size: Size,
1613    }
1614    struct SizedW {
1615        size: Size,
1616    }
1617    impl View<NavState> for Sized {
1618        type Element = SizedW;
1619        fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
1620            SizedW { size: self.size }
1621        }
1622        fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1623            ChangeFlags::NONE
1624        }
1625    }
1626    impl Widget for SizedW {
1627        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1628            bc.constrain(self.size)
1629        }
1630        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1631            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1632        }
1633    }
1634    fn sized(w: f64, h: f64) -> Sized {
1635        Sized {
1636            size: Size::new(w, h),
1637        }
1638    }
1639
1640    /// A fixed-size leaf firing `on_tap` on release inside it — lets a test
1641    /// place a dialog action of known geometry without depending on real
1642    /// text-shaping metrics.
1643    struct TapView {
1644        size: Size,
1645        on_tap: Rc<dyn Fn(&mut NavState)>,
1646    }
1647    struct TapWidget {
1648        size: Size,
1649        on_tap: frust::authoring::ErasedCallback,
1650        captured: bool,
1651    }
1652    impl View<NavState> for TapView {
1653        type Element = TapWidget;
1654        fn build(&self, _ctx: &mut BuildCtx<'_>) -> TapWidget {
1655            TapWidget {
1656                size: self.size,
1657                on_tap: frust::authoring::erase_callback(&self.on_tap),
1658                captured: false,
1659            }
1660        }
1661        fn rebuild(
1662            &self,
1663            _prev: &Self,
1664            element: &mut TapWidget,
1665            _ctx: &mut BuildCtx<'_>,
1666        ) -> ChangeFlags {
1667            element.on_tap = frust::authoring::erase_callback(&self.on_tap);
1668            ChangeFlags::NONE
1669        }
1670    }
1671    impl Widget for TapWidget {
1672        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1673            bc.constrain(self.size)
1674        }
1675        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1676        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1677            let InputEvent::Pointer(p) = event else {
1678                return EventResult::Ignored;
1679            };
1680            match p.phase {
1681                PointerPhase::Down => {
1682                    self.captured = true;
1683                    ctx.capture_pointer();
1684                    EventResult::Handled
1685                }
1686                PointerPhase::Up => {
1687                    if self.captured
1688                        && p.position.x >= 0.0
1689                        && p.position.y >= 0.0
1690                        && p.position.x < self.size.width
1691                        && p.position.y < self.size.height
1692                    {
1693                        (self.on_tap)(ctx);
1694                    }
1695                    self.captured = false;
1696                    EventResult::Handled
1697                }
1698                PointerPhase::Move => EventResult::Handled,
1699                PointerPhase::Cancel => {
1700                    self.captured = false;
1701                    EventResult::Handled
1702                }
1703            }
1704        }
1705    }
1706    fn tap_action(w: f64, h: f64, on_tap: impl Fn(&mut NavState) + 'static) -> impl View<NavState> {
1707        TapView {
1708            size: Size::new(w, h),
1709            on_tap: Rc::new(on_tap),
1710        }
1711    }
1712
1713    /// The app-logic closure the [`Harness`] rebuilds through.
1714    type NavLogic = Box<dyn FnMut(&mut NavState) -> NavigatorView<NavState>>;
1715
1716    struct Harness {
1717        root: RenderRoot<NavState, NavigatorView<NavState>>,
1718        state: NavState,
1719        tcx: TextContext,
1720        controller: NavigatorController<NavState>,
1721        logic: NavLogic,
1722    }
1723
1724    impl Harness {
1725        fn new() -> Self {
1726            let controller: NavigatorController<NavState> = NavigatorController::new();
1727            let ctrl = controller.clone();
1728            let mut h = Harness {
1729                root: RenderRoot::new(),
1730                state: NavState::default(),
1731                tcx: TextContext::new(),
1732                controller,
1733                logic: Box::new(move |_: &mut NavState| {
1734                    navigator(&ctrl, || {
1735                        any::<NavState, _>(sized(WINDOW.width, WINDOW.height))
1736                    })
1737                }),
1738            };
1739            h.pass();
1740            h
1741        }
1742
1743        fn pass(&mut self) {
1744            self.root.rebuild(&mut self.logic, &mut self.state);
1745            self.root
1746                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
1747        }
1748
1749        /// Settle the modal's entrance ramp.
1750        fn settle(&mut self) {
1751            self.root.paint(&mut Recorder::default(), FrameTime::ZERO);
1752            self.root.paint(
1753                &mut Recorder::default(),
1754                FrameTime::from_nanos(1_000_000_000),
1755            );
1756            self.pass();
1757        }
1758
1759        /// Settle a running staged-exit ramp so its enqueued pop drains.
1760        fn settle_exit(&mut self) {
1761            self.root.paint(
1762                &mut Recorder::default(),
1763                FrameTime::from_nanos(2_000_000_000),
1764            );
1765            self.root.paint(
1766                &mut Recorder::default(),
1767                FrameTime::from_nanos(3_000_000_000),
1768            );
1769            self.pass();
1770        }
1771
1772        fn event(&mut self, event: &InputEvent) {
1773            self.root.event(&mut self.state, event);
1774        }
1775
1776        fn flush(&mut self) {
1777            self.pass();
1778            self.event(&ev(PointerPhase::Move, 2.0, 2.0));
1779        }
1780
1781        fn paint(&mut self) -> Recorder {
1782            let mut rec = Recorder::default();
1783            self.root
1784                .paint(&mut rec, FrameTime::from_nanos(4_000_000_000));
1785            rec
1786        }
1787    }
1788
1789    // --- 1. All three variants render (paint-recorder). ---
1790
1791    #[test]
1792    fn the_basic_dialog_renders_title_body_and_actions() {
1793        let mut h = Harness::new();
1794        show_dialog(
1795            &h.controller,
1796            || {
1797                dialog()
1798                    .title("Confirm")
1799                    .body("Are you sure?")
1800                    .action(text_button("OK", |_s: &mut NavState| {}))
1801            },
1802            |state: &mut NavState, result: PopResult| {
1803                state.results.push(result.take::<bool>());
1804            },
1805        );
1806        h.pass();
1807        h.settle();
1808        let rec = h.paint();
1809        assert!(
1810            rec.glyphs >= 3,
1811            "title, body, and the action label all shape"
1812        );
1813    }
1814
1815    #[test]
1816    fn the_selection_dialog_renders_options_and_actions() {
1817        let mut h = Harness::new();
1818        show_selection_dialog(
1819            &h.controller,
1820            || {
1821                selection_dialog("Pick one", vec!["A".into(), "B".into(), "C".into()])
1822                    .selected(vec!["B".into()])
1823            },
1824            |state: &mut NavState, result: PopResult| {
1825                state.results.push(result.take::<bool>());
1826            },
1827        );
1828        h.pass();
1829        h.settle();
1830        let rec = h.paint();
1831        // Title + three radio labels + Cancel + OK.
1832        assert!(rec.glyphs >= 6);
1833    }
1834
1835    #[test]
1836    fn the_full_screen_dialog_renders_header_and_body() {
1837        let mut h = Harness::new();
1838        show_full_screen_dialog(
1839            &h.controller,
1840            || full_screen_dialog("Settings", sized(200.0, 200.0)),
1841            |state: &mut NavState, result: PopResult| {
1842                state.results.push(result.take::<bool>());
1843            },
1844        );
1845        h.pass();
1846        h.settle();
1847        let rec = h.paint();
1848        assert!(rec.glyphs >= 1, "the header title shapes");
1849        // The full-bleed surface fill, then the body's own black fill.
1850        assert!(rec.rects.len() >= 2);
1851    }
1852
1853    // --- 2. Dismissal routes through the modal host; result plumbing works. ---
1854
1855    #[test]
1856    fn scrim_tap_stages_an_exit_then_pops_with_an_empty_result() {
1857        let mut h = Harness::new();
1858        show_dialog(
1859            &h.controller,
1860            || dialog().title("Confirm"),
1861            |state: &mut NavState, result: PopResult| {
1862                state.results.push(result.take::<bool>());
1863            },
1864        );
1865        h.pass();
1866        h.settle();
1867        h.event(&ev(PointerPhase::Down, 5.0, 5.0));
1868        h.event(&ev(PointerPhase::Up, 5.0, 5.0));
1869        h.flush();
1870        assert!(
1871            h.state.results.is_empty(),
1872            "still animating out — no local scrim/pop code fired it directly"
1873        );
1874        h.settle_exit();
1875        h.flush();
1876        assert_eq!(h.state.results, vec![None]);
1877    }
1878
1879    #[test]
1880    fn escape_stages_the_same_exit_once_the_dialog_has_focus() {
1881        let mut h = Harness::new();
1882        show_dialog(
1883            &h.controller,
1884            || dialog().title("Confirm"),
1885            |state: &mut NavState, result: PopResult| {
1886                state.results.push(result.take::<bool>());
1887            },
1888        );
1889        h.pass();
1890        h.settle();
1891        // A press claims focus (host-owned; this file no longer opts in
1892        // itself) without dismissing.
1893        h.event(&ev(PointerPhase::Down, 200.0, 300.0));
1894        h.event(&ev(PointerPhase::Up, 200.0, 300.0));
1895        h.flush();
1896        assert!(h.state.results.is_empty());
1897        h.event(&escape_event());
1898        h.flush();
1899        assert!(h.state.results.is_empty(), "still animating out");
1900        h.settle_exit();
1901        h.flush();
1902        assert_eq!(h.state.results, vec![None]);
1903    }
1904
1905    #[test]
1906    fn a_dismissable_false_dialog_ignores_escape() {
1907        let mut h = Harness::new();
1908        show_dialog(
1909            &h.controller,
1910            || dialog().title("Pinned").dismissable(false),
1911            |state: &mut NavState, result: PopResult| {
1912                state.results.push(result.take::<bool>());
1913            },
1914        );
1915        h.pass();
1916        h.settle();
1917        h.event(&ev(PointerPhase::Down, 200.0, 300.0));
1918        h.event(&ev(PointerPhase::Up, 200.0, 300.0));
1919        h.event(&escape_event());
1920        h.flush();
1921        h.settle_exit();
1922        h.flush();
1923        assert!(
1924            h.state.results.is_empty(),
1925            "a non-dismissable dialog never pops via Escape"
1926        );
1927    }
1928
1929    #[test]
1930    fn an_action_pops_with_a_value_via_the_navigator() {
1931        // A fixed-size action and no title, so the panel's geometry is exact
1932        // without depending on real text-shaping metrics. The host's own
1933        // `OverlayModalConfig::centered` sizes the panel to
1934        // `OVERLAY_DIALOG_MAX_WIDTH` clamped into the viewport margin
1935        // (`MaterialSpacing::LG`, 16dp each side) — not shrunk to content —
1936        // so in a 400px-wide window the panel is `400 - 2*16 = 368`px wide.
1937        const ACTION: Size = Size::new(80.0, 36.0);
1938        const PANEL_W: f64 = 368.0;
1939        const PANEL_MARGIN: f64 = 16.0;
1940
1941        let mut h = Harness::new();
1942        h.controller.push_transparent_for_result(
1943            {
1944                let ctrl = h.controller.clone();
1945                move || {
1946                    let c = ctrl.clone();
1947                    any::<NavState, _>(dialog().action(tap_action(
1948                        ACTION.width,
1949                        ACTION.height,
1950                        move |_s: &mut NavState| c.pop_with_result(PopResult::of(true)),
1951                    )))
1952                }
1953            },
1954            frust::TransitionSpec::NONE,
1955            |state: &mut NavState, result: PopResult| {
1956                state.results.push(result.take::<bool>());
1957            },
1958        );
1959        h.pass();
1960        h.settle();
1961
1962        // No title: the actions block still opens with its own `padding`
1963        // (24dp) — see the module docs' compat note — so the panel's content
1964        // height is `24 + 24 + 36 = 84`, centered vertically in the window.
1965        let panel_h = 2.0 * DIALOG_PADDING + ACTION.height;
1966        let panel_y = (WINDOW.height - panel_h) / 2.0;
1967        let action_x = PANEL_MARGIN + PANEL_W - DIALOG_PADDING - ACTION.width;
1968        let action_y = panel_y + DIALOG_PADDING;
1969        let (cx, cy) = (
1970            action_x + ACTION.width / 2.0,
1971            action_y + ACTION.height / 2.0,
1972        );
1973        h.event(&ev(PointerPhase::Down, cx, cy));
1974        h.event(&ev(PointerPhase::Up, cx, cy));
1975        h.flush();
1976        assert_eq!(h.state.results, vec![Some(true)]);
1977    }
1978
1979    #[test]
1980    fn selection_dialog_confirm_reports_the_controlled_selection() {
1981        let mut h = Harness::new();
1982        show_selection_dialog(
1983            &h.controller,
1984            || {
1985                selection_dialog("Pick", vec!["A".into(), "B".into()])
1986                    .selected(vec!["A".into()])
1987                    .on_toggle(|s: &mut NavState, v: String| s.toggled.push(v))
1988                    .on_confirm(|s: &mut NavState| {
1989                        s.confirmed = true;
1990                    })
1991            },
1992            |state: &mut NavState, result: PopResult| {
1993                state.results.push(result.take::<bool>());
1994            },
1995        );
1996        h.pass();
1997        h.settle();
1998        assert!(!h.state.confirmed);
1999    }
2000
2001    #[test]
2002    fn every_variant_forwards_an_installed_dismiss_handle_to_the_host() {
2003        // Each variant recomposes its host view from its own fields every
2004        // pass; a variant that forgot to carry the handle across would drop an
2005        // action's staged dismiss on the floor with nothing else to show for
2006        // it. The staging itself is the host's, and tested there.
2007        let dismiss = ModalDismiss::new();
2008        assert!(
2009            dialog::<NavState>()
2010                .dismiss_handle(dismiss.clone())
2011                .compose()
2012                .dismiss_handle
2013                .is_some()
2014        );
2015        assert!(
2016            selection_dialog::<NavState>("Pick", vec!["A".into()])
2017                .dismiss_handle(dismiss.clone())
2018                .compose()
2019                .dismiss_handle
2020                .is_some()
2021        );
2022        assert!(
2023            full_screen_dialog::<NavState, _>("Edit", body_view::<NavState>("body"))
2024                .dismiss_handle(dismiss)
2025                .compose()
2026                .dismiss_handle
2027                .is_some()
2028        );
2029        assert!(
2030            dialog::<NavState>().compose().dismiss_handle.is_none(),
2031            "and none is invented for a variant that was handed none"
2032        );
2033    }
2034
2035    // --- 3. Existing consumer compile shape (mirrors `examples/huddle`). ---
2036
2037    #[test]
2038    fn the_old_builder_shape_still_compiles_and_runs() {
2039        let mut h = Harness::new();
2040        show_dialog(
2041            &h.controller,
2042            || {
2043                dialog()
2044                    .title("Invite people")
2045                    .body("Send invites to this workspace? (mock)")
2046                    .action(text_button("Send", |_s: &mut NavState| {}))
2047            },
2048            |state: &mut NavState, result: PopResult| {
2049                state.results.push(result.take::<bool>());
2050            },
2051        );
2052        h.pass();
2053        h.settle();
2054        assert!(h.state.results.is_empty());
2055    }
2056}