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 is still plain `AnyView<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, Widget,
108    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(mut self, actions: Vec<AnyView<State>>) -> Self {
606        self.actions = actions.into_iter().map(Rc::new).collect();
607        self
608    }
609
610    /// Append one action button to the trailing action row.
611    pub fn action(mut self, action: AnyView<State>) -> Self {
612        self.actions.push(Rc::new(action));
613        self
614    }
615
616    /// Whether the user can dismiss this dialog at all — the scrim tap,
617    /// `Escape`, and an Android back press (default `true`; see
618    /// [`crate::overlay::modal::OverlayModalView::dismissable`]). `false`
619    /// disables all three; only an app-provided action still dismisses it.
620    pub fn dismissable(mut self, dismissable: bool) -> Self {
621        self.dismissable = dismissable;
622        self
623    }
624
625    /// Observe a scrim-tap/Escape/back dismissal. [`show_dialog`] already
626    /// wires the pop for you (via [`OverlayModalContent::on_modal_dismiss`]);
627    /// this is for a caller that wants to react to it too (or that mounts a
628    /// `DialogView` directly in a `Stack`, with no navigator involved).
629    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
630        self.on_dismiss = Some(Rc::new(on_dismiss));
631        self
632    }
633
634    /// Install the app-triggered staged-dismiss handle, so a Cancel/Confirm
635    /// action closes the dialog through the **same** exit ramp the scrim tap,
636    /// `Escape`, and a back press take — rather than a raw
637    /// `controller.pop()`, which dismisses on the spot with no ramp. See
638    /// [`ModalDismiss`]; mint the handle outside [`show_dialog`]'s page
639    /// builder.
640    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
641        self.dismiss_handle = Some(dismiss);
642        self
643    }
644
645    /// Compose the host-facing [`OverlayModalView`] fresh from the current
646    /// fields (see the module docs' *Why actions are `Rc`-wrapped
647    /// internally*).
648    fn compose(&self) -> OverlayModalView<State> {
649        let panel = DialogPanelView {
650            title: self.title.clone(),
651            content: self.body.as_ref().map(|s| Rc::new(body_view::<State>(s))),
652            actions: self.actions.clone(),
653            top_divider: false,
654            bottom_divider: false,
655            content_full_bleed: false,
656        };
657        let mut view = overlay_modal(
658            panel,
659            OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
660        )
661        .dismissable(self.dismissable);
662        if let Some(title) = &self.title {
663            view = view.label(title.clone());
664        }
665        if let Some(on_dismiss) = &self.on_dismiss {
666            let on_dismiss = on_dismiss.clone();
667            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
668        }
669        if let Some(dismiss) = &self.dismiss_handle {
670            view = view.dismiss_handle(dismiss.clone());
671        }
672        view
673    }
674}
675
676impl<State: 'static> View<State> for DialogView<State> {
677    type Element = OverlayModalWidget;
678
679    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
680        View::build(&self.compose(), ctx)
681    }
682
683    fn rebuild(
684        &self,
685        prev: &Self,
686        element: &mut OverlayModalWidget,
687        ctx: &mut BuildCtx<'_>,
688    ) -> ChangeFlags {
689        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
690    }
691
692    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
693        View::teardown(&self.compose(), element, ctx);
694    }
695}
696
697impl<State: 'static> OverlayModalContent<State> for DialogView<State> {
698    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
699        self.on_dismiss = Some(on_dismiss);
700        self
701    }
702
703    fn modal_dismissable(&self) -> bool {
704        self.dismissable
705    }
706}
707
708/// Push `build`'s dialog as a transparent navigator page over the host's
709/// scrim, and register `on_result` for the value it pops with.
710///
711/// Dismissal (scrim tap, `Escape`, Android back) is wired to
712/// `controller.pop()` for you, staged behind the host's exit ramp; an action
713/// button pops with a value via `controller.pop_with_result(..)`, immediately
714/// (see the module docs).
715///
716/// ```ignore
717/// show_dialog(
718///     &state.nav,
719///     || dialog().title("Delete?").body("This can't be undone.").action(confirm_button),
720///     |state: &mut State, result: PopResult| {
721///         state.confirmed = result.take::<bool>().unwrap_or(false);
722///     },
723/// );
724/// ```
725pub fn show_dialog<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
726where
727    State: 'static,
728    B: Fn() -> DialogView<State> + 'static,
729    R: Fn(&mut State, PopResult) + 'static,
730{
731    show_overlay_modal(controller, build, on_result);
732}
733
734// ============================================================================
735// selection_dialog() / SelectionDialogView / show_selection_dialog() — the
736// centered dialog with a scrollable(-ish; see the module docs) option list.
737// ============================================================================
738
739/// A view-held, typed per-option toggle callback (erased on build).
740type OnToggle<State> = Rc<dyn Fn(&mut State, String)>;
741
742/// A declarative M3 selection dialog: [`dialog`]'s centered panel, both
743/// dividers on, and a radio/checkbox option list for content. See the
744/// [module docs](self)' controlled-component note.
745pub struct SelectionDialogView<State: 'static> {
746    title: String,
747    options: Vec<String>,
748    multi_select: bool,
749    selected: Vec<String>,
750    on_toggle: Option<OnToggle<State>>,
751    cancel_label: String,
752    confirm_label: String,
753    on_confirm: Option<OnDismiss<State>>,
754    dismissable: bool,
755    on_dismiss: Option<OnDismiss<State>>,
756    dismiss_handle: Option<ModalDismiss>,
757}
758
759/// Create a selection dialog titled `title`, offering `options` (single-select
760/// by default — see [`SelectionDialogView::multi_select`]).
761pub fn selection_dialog<State: 'static>(
762    title: impl Into<String>,
763    options: Vec<String>,
764) -> SelectionDialogView<State> {
765    SelectionDialogView {
766        title: title.into(),
767        options,
768        multi_select: false,
769        selected: Vec::new(),
770        on_toggle: None,
771        cancel_label: DEFAULT_CANCEL_LABEL.to_string(),
772        confirm_label: DEFAULT_CONFIRM_LABEL.to_string(),
773        on_confirm: None,
774        dismissable: true,
775        on_dismiss: None,
776        dismiss_handle: None,
777    }
778}
779
780impl<State: 'static> SelectionDialogView<State> {
781    /// Allow more than one option selected at once (checkbox rows instead of
782    /// radio rows) — the reference's `multiSelect`.
783    pub fn multi_select(mut self, multi_select: bool) -> Self {
784        self.multi_select = multi_select;
785        self
786    }
787
788    /// The currently-selected options (controlled — see the [module
789    /// docs](self)). Single-select reads only the first entry.
790    pub fn selected(mut self, selected: Vec<String>) -> Self {
791        self.selected = selected;
792        self
793    }
794
795    /// Fired with the tapped option's value on every row press — a
796    /// single-select tap reports the newly-chosen option; a multi-select tap
797    /// reports whichever option should flip membership. The caller updates
798    /// its own `selected` set and re-renders.
799    pub fn on_toggle<F: Fn(&mut State, String) + 'static>(mut self, on_toggle: F) -> Self {
800        self.on_toggle = Some(Rc::new(on_toggle));
801        self
802    }
803
804    /// Override the cancel action's label (`_cancelLabel`, default "Cancel").
805    pub fn cancel_label(mut self, label: impl Into<String>) -> Self {
806        self.cancel_label = label.into();
807        self
808    }
809
810    /// Override the confirm action's label (`_confirmLabel`, default "OK").
811    pub fn confirm_label(mut self, label: impl Into<String>) -> Self {
812        self.confirm_label = label.into();
813        self
814    }
815
816    /// Fired on a confirm tap — disabled (per the reference) until at least
817    /// one option is selected. The caller decides what confirming means
818    /// (typically `controller.pop_with_result(..)` with its own `selected`),
819    /// exactly like the basic dialog's `.action(..)` contract.
820    pub fn on_confirm<F: Fn(&mut State) + 'static>(mut self, on_confirm: F) -> Self {
821        self.on_confirm = Some(Rc::new(on_confirm));
822        self
823    }
824
825    /// Whether the user can dismiss this dialog via the scrim tap, `Escape`,
826    /// or an Android back press (default `true`) — see
827    /// [`DialogView::dismissable`].
828    pub fn dismissable(mut self, dismissable: bool) -> Self {
829        self.dismissable = dismissable;
830        self
831    }
832
833    /// Observe a scrim-tap/Escape/back/cancel dismissal — see
834    /// [`DialogView::on_dismiss`].
835    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
836        self.on_dismiss = Some(Rc::new(on_dismiss));
837        self
838    }
839
840    /// Install the app-triggered staged-dismiss handle, so the confirm action
841    /// closes the dialog through the host's exit ramp instead of a raw,
842    /// unstaged `controller.pop()` — see [`DialogView::dismiss_handle`].
843    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
844        self.dismiss_handle = Some(dismiss);
845        self
846    }
847
848    fn compose(&self) -> OverlayModalView<State> {
849        let list = SelectionListView {
850            options: self.options.clone(),
851            multi_select: self.multi_select,
852            selected: self.selected.clone(),
853            on_toggle: self.on_toggle.clone().unwrap_or_else(|| Rc::new(|_, _| {})),
854        };
855
856        let on_dismiss = self.on_dismiss.clone();
857        let cancel_action: AnyView<State> = any(text_button(
858            self.cancel_label.clone(),
859            move |state: &mut State| {
860                if let Some(on_dismiss) = &on_dismiss {
861                    on_dismiss(state);
862                }
863            },
864        ));
865        let on_confirm = self.on_confirm.clone();
866        let confirm_action: AnyView<State> = any(button(
867            self.confirm_label.clone(),
868            move |state: &mut State| {
869                if let Some(on_confirm) = &on_confirm {
870                    on_confirm(state);
871                }
872            },
873        )
874        .enabled(!self.selected.is_empty()));
875
876        let panel = DialogPanelView {
877            title: Some(self.title.clone()),
878            content: Some(Rc::new(any(list))),
879            actions: vec![Rc::new(cancel_action), Rc::new(confirm_action)],
880            top_divider: true,
881            bottom_divider: true,
882            content_full_bleed: true,
883        };
884        let mut view = overlay_modal(
885            panel,
886            OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
887        )
888        .dismissable(self.dismissable)
889        .label(self.title.clone());
890        if let Some(on_dismiss) = &self.on_dismiss {
891            let on_dismiss = on_dismiss.clone();
892            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
893        }
894        if let Some(dismiss) = &self.dismiss_handle {
895            view = view.dismiss_handle(dismiss.clone());
896        }
897        view
898    }
899}
900
901impl<State: 'static> View<State> for SelectionDialogView<State> {
902    type Element = OverlayModalWidget;
903
904    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
905        View::build(&self.compose(), ctx)
906    }
907
908    fn rebuild(
909        &self,
910        prev: &Self,
911        element: &mut OverlayModalWidget,
912        ctx: &mut BuildCtx<'_>,
913    ) -> ChangeFlags {
914        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
915    }
916
917    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
918        View::teardown(&self.compose(), element, ctx);
919    }
920}
921
922impl<State: 'static> OverlayModalContent<State> for SelectionDialogView<State> {
923    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
924        self.on_dismiss = Some(on_dismiss);
925        self
926    }
927
928    fn modal_dismissable(&self) -> bool {
929        self.dismissable
930    }
931}
932
933/// Push `build`'s selection dialog as a transparent navigator page, and
934/// register `on_result` for the value it pops with. See [`show_dialog`].
935pub fn show_selection_dialog<State, B, R>(
936    controller: &NavigatorController<State>,
937    build: B,
938    on_result: R,
939) where
940    State: 'static,
941    B: Fn() -> SelectionDialogView<State> + 'static,
942    R: Fn(&mut State, PopResult) + 'static,
943{
944    show_overlay_modal(controller, build, on_result);
945}
946
947/// The option-list content view built fresh, each pass, from primitive owned
948/// data (never `Rc`-wrapped — see the module docs).
949struct SelectionListView<State: 'static> {
950    options: Vec<String>,
951    multi_select: bool,
952    selected: Vec<String>,
953    on_toggle: OnToggle<State>,
954}
955
956/// One selection row: the control ([`crate::radio`]/[`crate::checkbox`],
957/// reused read-only) plus an optional separate label pod (multi-select only —
958/// [`crate::checkbox`] has no built-in label slot, unlike [`crate::radio`]).
959struct SelectionRow {
960    control: ChildPod,
961    label: Option<ChildPod>,
962}
963
964/// The retained widget for a [`SelectionListView`].
965struct SelectionListWidget {
966    rows: Vec<SelectionRow>,
967}
968
969/// Build one row's control (+ label, if multi-select) view pair.
970fn selection_row_views<State: 'static>(
971    option: &str,
972    multi_select: bool,
973    selected: &[String],
974    on_toggle: &OnToggle<State>,
975) -> (AnyView<State>, Option<AnyView<State>>) {
976    if multi_select {
977        let checked = selected.iter().any(|s| s == option);
978        let toggle = on_toggle.clone();
979        let value = option.to_string();
980        let control = any(checkbox(checked, move |state: &mut State, _next: bool| {
981            toggle(state, value.clone())
982        }));
983        let label = any(text(option.to_string())
984            .themed_role(ThemeTextColor::OnSurface)
985            .themed_family(ThemeTextType::BodyLarge));
986        (control, Some(label))
987    } else {
988        let group_value = selected.first().cloned();
989        let toggle = on_toggle.clone();
990        let control = any(
991            radio::<State, Option<String>>(Some(option.to_string()), group_value)
992                .on_changed(move |state: &mut State, value: Option<String>| {
993                    if let Some(value) = value {
994                        toggle(state, value);
995                    }
996                })
997                .label(option.to_string()),
998        );
999        (control, None)
1000    }
1001}
1002
1003impl<State: 'static> View<State> for SelectionListView<State> {
1004    type Element = SelectionListWidget;
1005
1006    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionListWidget {
1007        let rows = self
1008            .options
1009            .iter()
1010            .map(|option| {
1011                let (control, label) = selection_row_views::<State>(
1012                    option,
1013                    self.multi_select,
1014                    &self.selected,
1015                    &self.on_toggle,
1016                );
1017                SelectionRow {
1018                    control: build_child(&control, ctx),
1019                    label: label.map(|l| build_child(&l, ctx)),
1020                }
1021            })
1022            .collect();
1023        SelectionListWidget { rows }
1024    }
1025
1026    fn rebuild(
1027        &self,
1028        prev: &Self,
1029        element: &mut SelectionListWidget,
1030        ctx: &mut BuildCtx<'_>,
1031    ) -> ChangeFlags {
1032        let shape_changed = prev.options != self.options
1033            || prev.multi_select != self.multi_select
1034            || element.rows.len() != self.options.len();
1035        if shape_changed {
1036            for (view, row) in prev.options.iter().zip(element.rows.iter_mut()) {
1037                let (control, label) = selection_row_views::<State>(
1038                    view,
1039                    prev.multi_select,
1040                    &prev.selected,
1041                    &prev.on_toggle,
1042                );
1043                teardown_child(&control, &mut row.control, ctx);
1044                if let (Some(label_view), Some(pod)) = (label, row.label.as_mut()) {
1045                    teardown_child(&label_view, pod, ctx);
1046                }
1047            }
1048            element.rows = self
1049                .options
1050                .iter()
1051                .map(|option| {
1052                    let (control, label) = selection_row_views::<State>(
1053                        option,
1054                        self.multi_select,
1055                        &self.selected,
1056                        &self.on_toggle,
1057                    );
1058                    SelectionRow {
1059                        control: build_child(&control, ctx),
1060                        label: label.map(|l| build_child(&l, ctx)),
1061                    }
1062                })
1063                .collect();
1064            return ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1065        }
1066
1067        let mut flags = ChangeFlags::NONE;
1068        for (i, (option, row)) in self.options.iter().zip(element.rows.iter_mut()).enumerate() {
1069            let (prev_control, prev_label) = selection_row_views::<State>(
1070                &prev.options[i],
1071                prev.multi_select,
1072                &prev.selected,
1073                &prev.on_toggle,
1074            );
1075            let (next_control, next_label) = selection_row_views::<State>(
1076                option,
1077                self.multi_select,
1078                &self.selected,
1079                &self.on_toggle,
1080            );
1081            flags |= rebuild_child(&prev_control, &next_control, &mut row.control, ctx);
1082            if let (Some(prev_l), Some(next_l), Some(pod)) =
1083                (prev_label, next_label, row.label.as_mut())
1084            {
1085                flags |= rebuild_child(&prev_l, &next_l, pod, ctx);
1086            }
1087        }
1088        flags
1089    }
1090
1091    fn teardown(&self, element: &mut SelectionListWidget, ctx: &mut BuildCtx<'_>) {
1092        for (option, row) in self.options.iter().zip(element.rows.iter_mut()) {
1093            let (control, label) = selection_row_views::<State>(
1094                option,
1095                self.multi_select,
1096                &self.selected,
1097                &self.on_toggle,
1098            );
1099            teardown_child(&control, &mut row.control, ctx);
1100            if let (Some(label_view), Some(pod)) = (label, row.label.as_mut()) {
1101                teardown_child(&label_view, pod, ctx);
1102            }
1103        }
1104    }
1105}
1106
1107impl Widget for SelectionListWidget {
1108    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1109        let width = bc.max().width.max(bc.min().width);
1110        let mut y = SELECTION_LIST_INSET;
1111        let row_w = (width - 2.0 * DIALOG_PADDING).max(0.0);
1112        let row_bc = BoxConstraints::loose(Size::new(row_w, SELECTION_ITEM_HEIGHT));
1113
1114        for row in &mut self.rows {
1115            let control_size = row.control.layout_child(ctx, &row_bc);
1116            let mut row_h = control_size.height;
1117            let label_size = row.label.as_mut().map(|l| l.layout_child(ctx, &row_bc));
1118            if let Some(s) = label_size {
1119                row_h = row_h.max(s.height);
1120            }
1121            let band_h = row_h.max(SELECTION_ITEM_HEIGHT);
1122            let control_y = y + (band_h - control_size.height) / 2.0;
1123            row.control
1124                .set_origin(Point::new(DIALOG_PADDING, control_y));
1125            if let (Some(label), Some(s)) = (row.label.as_mut(), label_size) {
1126                let label_x = DIALOG_PADDING + control_size.width + CHECKBOX_LABEL_GAP;
1127                let label_y = y + (band_h - s.height) / 2.0;
1128                label.set_origin(Point::new(label_x, label_y));
1129            }
1130            y += band_h.max(SELECTION_ITEM_HEIGHT);
1131        }
1132        y += SELECTION_LIST_INSET;
1133
1134        bc.constrain(Size::new(width, y))
1135    }
1136
1137    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1138        for row in &mut self.rows {
1139            row.control.paint_child(ctx, scene);
1140            if let Some(label) = row.label.as_mut() {
1141                label.paint_child(ctx, scene);
1142            }
1143        }
1144    }
1145
1146    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1147        for row in &mut self.rows {
1148            if route_event_single(&mut row.control, ctx, event) == EventResult::Handled {
1149                return EventResult::Handled;
1150            }
1151        }
1152        EventResult::Ignored
1153    }
1154
1155    fn semantics(&self, ctx: &mut SemanticsCtx) {
1156        for row in &self.rows {
1157            row.control.semantics_child(ctx);
1158            if let Some(label) = &row.label {
1159                label.semantics_child(ctx);
1160            }
1161        }
1162    }
1163
1164    fn visit_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
1165        for row in &self.rows {
1166            visitor(&row.control);
1167            if let Some(label) = &row.label {
1168                visitor(label);
1169            }
1170        }
1171    }
1172}
1173
1174// ============================================================================
1175// full_screen_dialog() / FullScreenDialogView / show_full_screen_dialog() —
1176// the edge-to-edge dialog: header (close, title, optional action) + divider +
1177// body, sliding up from the bottom.
1178// ============================================================================
1179
1180/// A declarative M3 full-screen dialog: an app-bar-like header over a body
1181/// slot, filling the whole area. See the [module docs](self).
1182pub struct FullScreenDialogView<State: 'static> {
1183    title: String,
1184    body: Rc<AnyView<State>>,
1185    action: Option<Rc<AnyView<State>>>,
1186    dismissable: bool,
1187    on_dismiss: Option<OnDismiss<State>>,
1188    dismiss_handle: Option<ModalDismiss>,
1189}
1190
1191/// Create a full-screen dialog titled `title`, filling its body slot with
1192/// `body`. Chain [`FullScreenDialogView::action`] for a trailing header
1193/// action.
1194pub fn full_screen_dialog<State: 'static, V: View<State>>(
1195    title: impl Into<String>,
1196    body: V,
1197) -> FullScreenDialogView<State> {
1198    FullScreenDialogView {
1199        title: title.into(),
1200        body: Rc::new(any(body)),
1201        action: None,
1202        dismissable: true,
1203        on_dismiss: None,
1204        dismiss_handle: None,
1205    }
1206}
1207
1208impl<State: 'static> FullScreenDialogView<State> {
1209    /// Set the header's trailing action (e.g. a "Save" button) — the
1210    /// reference's `action`.
1211    pub fn action(mut self, action: AnyView<State>) -> Self {
1212        self.action = Some(Rc::new(action));
1213        self
1214    }
1215
1216    /// Whether `Escape`/an Android back press dismiss this dialog (default
1217    /// `true`) — see [`DialogView::dismissable`]. There is no scrim to tap
1218    /// (the panel is full-bleed) and the header's own close affordance
1219    /// always dismisses regardless of this flag (matching the reference,
1220    /// where the header close is the dialog's one unconditional exit).
1221    pub fn dismissable(mut self, dismissable: bool) -> Self {
1222        self.dismissable = dismissable;
1223        self
1224    }
1225
1226    /// Observe a dismissal (`Escape`, back, or the header close) — see
1227    /// [`DialogView::on_dismiss`].
1228    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
1229        self.on_dismiss = Some(Rc::new(on_dismiss));
1230        self
1231    }
1232
1233    /// Install the app-triggered staged-dismiss handle, so the header's own
1234    /// trailing action (a "Save") closes the dialog through the host's exit
1235    /// ramp instead of a raw, unstaged `controller.pop()` — see
1236    /// [`DialogView::dismiss_handle`]. The header's *close* affordance is not
1237    /// routed through it: it fires `on_dismiss` directly (see
1238    /// [`Self::dismissable`]), which is this family's own unstaged exit.
1239    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
1240        self.dismiss_handle = Some(dismiss);
1241        self
1242    }
1243
1244    fn config() -> OverlayModalConfig {
1245        OverlayModalConfig::edge(OverlaySide::Bottom)
1246            .extent(OverlayExtent::Fraction(1.0), OverlayLimit::None)
1247            .corners(OverlayCorners::None)
1248            .close_button(false)
1249            .scrim_dismiss(false)
1250            .elevation(OverlayElevation::None)
1251    }
1252
1253    fn compose(&self) -> OverlayModalView<State> {
1254        let panel = FullScreenPanelView {
1255            title: self.title.clone(),
1256            body: self.body.clone(),
1257            action: self.action.clone(),
1258            on_dismiss: self.on_dismiss.clone(),
1259        };
1260        let mut view = overlay_modal(panel, Self::config())
1261            .dismissable(self.dismissable)
1262            .label(self.title.clone());
1263        if let Some(on_dismiss) = &self.on_dismiss {
1264            let on_dismiss = on_dismiss.clone();
1265            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
1266        }
1267        if let Some(dismiss) = &self.dismiss_handle {
1268            view = view.dismiss_handle(dismiss.clone());
1269        }
1270        view
1271    }
1272}
1273
1274impl<State: 'static> View<State> for FullScreenDialogView<State> {
1275    type Element = OverlayModalWidget;
1276
1277    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
1278        View::build(&self.compose(), ctx)
1279    }
1280
1281    fn rebuild(
1282        &self,
1283        prev: &Self,
1284        element: &mut OverlayModalWidget,
1285        ctx: &mut BuildCtx<'_>,
1286    ) -> ChangeFlags {
1287        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
1288    }
1289
1290    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
1291        View::teardown(&self.compose(), element, ctx);
1292    }
1293}
1294
1295impl<State: 'static> OverlayModalContent<State> for FullScreenDialogView<State> {
1296    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
1297        self.on_dismiss = Some(on_dismiss);
1298        self
1299    }
1300
1301    fn modal_dismissable(&self) -> bool {
1302        // The header's own close affordance always dismisses (see
1303        // `FullScreenDialogView::dismissable`'s doc) — but that reaches
1304        // `on_dismiss` directly rather than through the host's staged
1305        // request-dismiss/back-signal path, so `dismissable` here still
1306        // governs only Escape/back, matching every other variant.
1307        self.dismissable
1308    }
1309}
1310
1311/// Push `build`'s full-screen dialog as a transparent navigator page, and
1312/// register `on_result` for the value it pops with. See [`show_dialog`].
1313pub fn show_full_screen_dialog<State, B, R>(
1314    controller: &NavigatorController<State>,
1315    build: B,
1316    on_result: R,
1317) where
1318    State: 'static,
1319    B: Fn() -> FullScreenDialogView<State> + 'static,
1320    R: Fn(&mut State, PopResult) + 'static,
1321{
1322    show_overlay_modal(controller, build, on_result);
1323}
1324
1325/// The full-screen panel's content view: header (close, title, optional
1326/// action) + divider + body.
1327struct FullScreenPanelView<State: 'static> {
1328    title: String,
1329    body: Rc<AnyView<State>>,
1330    action: Option<Rc<AnyView<State>>>,
1331    on_dismiss: Option<OnDismiss<State>>,
1332}
1333
1334/// The retained widget for a [`FullScreenPanelView`].
1335struct FullScreenPanelWidget {
1336    close: ChildPod,
1337    title: ChildPod,
1338    action: Option<ChildPod>,
1339    divider: ChildPod,
1340    body: ChildPod,
1341}
1342
1343/// Build the header close affordance's view — [`icon_button`] over
1344/// [`crate::icons::CLOSE`], firing `on_dismiss` directly (see the module
1345/// docs' *Deliberate v1 scope cuts*).
1346fn close_view<State: 'static>(on_dismiss: Option<OnDismiss<State>>) -> AnyView<State> {
1347    any(
1348        icon_button(any(icon(crate::icons::CLOSE)), move |state: &mut State| {
1349            if let Some(on_dismiss) = &on_dismiss {
1350                on_dismiss(state);
1351            }
1352        })
1353        .semantic_label(CLOSE_SEMANTIC_LABEL),
1354    )
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) -> AnyView<NavState> {
1707        any(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(any(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(any(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}