Skip to main content

frust_material/overlay/
modal.rs

1// Ported from `frust-shadcn`'s `plugins/shadcn/src/overlay/modal.rs` (in-repo
2// sibling catalog; itself a port of shadcn/ui v4 rev
3// `d4fc45b1fbabfccb7a6a4333d8004cf19481caa9`, MIT © shadcn, over Radix's dialog
4// and vaul's drawer). A sibling port, never a dependency — see `super`'s header.
5// Porting decisions: the panel geometry, the staged-exit machine and the
6// drag/snap-point gesture are carried over verbatim; the chrome resolves
7// Material roles (32% scrim, `surfaceContainer*` fill, `shape.extra_large`
8// corners, the M3 32×4dp drag handle, the M3 `close` icon) and the motion
9// resolves `crate::tokens` presets. The one structural change is the back-press
10// tier: this host sits on `crate::sheet`'s `BackPolicy::DismissAnimated` +
11// `PushOptions::dismiss_signal` seam, and — unlike that sheet, which pops
12// immediately — routes a back press into the *same* staged exit as every other
13// dismiss gesture (`crate::dialog`'s simpler `push_transparent_for_result` path
14// has no back seam at all, which is why it is not the one reused here).
15
16//! The modal overlay host: the M3 32% scrim plus one panel, as a single widget
17//! the modal families (dialog, alert dialog, bottom sheet, side sheet,
18//! full-screen search, picker) configure rather than re-derive.
19//!
20//! Upstream these are wrappers over the *same* two elements — a full-area
21//! overlay and a positioned content panel — differing only in where the content
22//! is pinned, which corners it rounds, which edge it borders, and how it
23//! animates in. [`OverlayModalConfig`] is exactly that difference list, so a
24//! component module contributes its chrome config plus the composed content view
25//! and nothing else.
26//!
27//! # Architecture
28//!
29//! Mirrors [`mod@crate::dialog`]/[`crate::sheet`] end to end (see [`super`]'s
30//! mounting note): the scrim belongs to *this* widget rather than to the
31//! navigator, the panel is a modal barrier that swallows every input its content
32//! did not take, dismissal is the app's `on_dismiss` (wired to
33//! `controller.pop()` by [`show_overlay_modal`]), and an action inside the
34//! content pops with a value through the navigator's own pop-result machinery.
35//!
36//! Three pointer dismiss gestures, each independently disableable:
37//!
38//! * a **scrim tap** — a press *and* release outside the panel, when
39//!   [`OverlayModalConfig::scrim_dismiss`] is on (an alert dialog turns it off:
40//!   it demands an explicit choice);
41//! * the **close button** — the top-trailing M3 `close` icon, when
42//!   [`OverlayModalConfig::close_button`] is on;
43//! * the **drag handle** — the M3 32×4dp bar, when
44//!   [`OverlayModalConfig::handle`] is on. A press and release on it dismisses;
45//!   with [`OverlayModalConfig::drag`] on it is also the drag affordance
46//!   (below).
47//!
48//! plus **Escape**, once the modal holds focus — claimed on every `Down`, the
49//! [`mod@crate::dialog`]/[`crate::sheet`] opt-in, with the same documented gap:
50//! there is no auto-focus-on-appear hook in the framework, so a caller must
51//! complete one pointer interaction with the modal before Escape does anything.
52//!
53//! [`OverlayModalView::dismissable`] (default `true`) is the one flag over all
54//! of them: `false` disables the scrim tap, the close button, the handle, the
55//! drag commit, Escape *and* the back press together, leaving only an explicit
56//! control the app wires inside its own content.
57//!
58//! # The barrier swallows keys too
59//!
60//! The panel is a modal barrier for *every* input class, not just pointers: a
61//! key the content did not take is reported [`EventResult::Handled`] rather than
62//! falling through to whatever sits behind the modal. Content-first routing is
63//! unchanged — a focused field inside the panel sees every keystroke, and only
64//! what it declines reaches the barrier, where Escape dismisses and everything
65//! else is absorbed.
66//!
67//! # Motion
68//!
69//! [`OverlayEntrance::FadeScale`] composites the panel through a
70//! [`PaintScene::push_layer`] at the ramp's alpha, under a scale about its own
71//! centre. [`OverlayEntrance::Slide`] moves the panel's origin in **layout** so
72//! hit-testing follows the panel rather than lagging behind a paint-only
73//! transform — which is also why a running slide asks for `request_layout`, not
74//! a bare `request_frame`. The scrim fades with either.
75//!
76//! The source's Tailwind timings map onto `crate::tokens`' M3 presets:
77//!
78//! | Source class | Material preset |
79//! |---|---|
80//! | `duration-200` (dialog content) | [`MaterialMotion::SHORT_4`] (200ms) |
81//! | `duration-500` (sheet content) | [`MaterialMotion::LONG_2`] (500ms) |
82//! | `ease-out` (fade/zoom in) | [`MaterialMotion::EMPHASIZED_DECELERATE`] |
83//! | `ease-in-out` (slide in) | [`MaterialMotion::EMPHASIZED`] |
84//! | (either, on the way out) | [`MaterialMotion::EMPHASIZED_ACCELERATE`] — M3's curve for elements leaving |
85//! | `zoom-in-95` | [`SCALE_FROM`], carried over verbatim (a visual constant, not a token) |
86//!
87//! An exit runs the same ramp backwards over the same duration scaled by how
88//! much travel is left, so a half-open drawer closes in half the time.
89//!
90//! `Theme.motion.reduce_motion` collapses both to a jump: the ramp is stopped
91//! and progress snaps to its rest value on the first paint, one frame after the
92//! modal mounts (the pass that sets it is a paint, and the geometry it feeds is
93//! a layout) — so a reduced-motion modal appears whole on its second frame
94//! rather than animating on its first.
95//!
96//! # Exit motion, and why the close hook is state-free
97//!
98//! Every dismiss trigger — scrim tap, Escape, the close button, the handle, a
99//! drag past its threshold, **an Android back press** — stages an **exit**
100//! instead of firing the app's dismissal on the spot: the same `progress` ramp
101//! runs back down to `0.0`, the barrier keeps swallowing input the whole way, a
102//! second trigger mid-exit is a no-op, and only when the ramp settles is the
103//! dismissal fired — from `paint`, which is sound because
104//! [`NavigatorController::pop`] merely *enqueues* an op applied on the next
105//! rebuild (the [`crate::sheet`]/`frust_glyph::dialog` precedent; the same paint
106//! asks for one more frame so that rebuild is guaranteed to come).
107//!
108//! Firing from paint is why the staged path's callback is
109//! [`OverlayModalView::on_close`] — a plain `Fn()` — and not the
110//! `Fn(&mut State)` [`OverlayModalView::on_dismiss`] takes: `PaintCtx` carries
111//! no app state. [`show_overlay_modal`] wires `on_close` to `controller.pop()`
112//! for every component, so the navigator path animates out with no app
113//! involvement, and app state rides the navigator's own `on_result` (delivered
114//! with `&mut State` after the pop) exactly as before. **A modal with no
115//! `on_close` wired** — a `Stack` mount that only set `on_dismiss` — keeps the
116//! immediate, unstaged dismissal: there is no state-bearing pass to defer into,
117//! so staging one would leave an invisible barrier standing. `reduce_motion`
118//! collapses the exit the same way it collapses the entrance.
119//!
120//! Deferring the pop by a whole ramp is also why the hook is a [`StagedPop`]
121//! rather than a bare `Fn()`: a pop takes the *top* page, so a page pushed while
122//! the ramp runs would be popped instead of this one. The hook is armed with the
123//! stack's identity when the exit is staged and refuses to fire if it moved —
124//! and the refusal is reported, so a refused exit ramps back open instead of
125//! latching `closed` over a page it never removed.
126//!
127//! # App-initiated dismiss: the [`ModalDismiss`] handle
128//!
129//! The staged exit above is the *host's* to trigger — its own chrome (scrim,
130//! close button, handle, Escape, back) reaches it, the caller-built content
131//! inside the panel does not. A panel's own action button ("Apply", "Save",
132//! "Cancel") therefore used to have exactly one route open to it, a direct
133//! [`NavigatorController::pop`], which dismisses **unstaged**: the same panel
134//! closed two different ways played two different exit motions.
135//!
136//! [`ModalDismiss`] is that missing seam. An app mints one, installs it with
137//! [`OverlayModalView::dismiss_handle`] (or a family wrapper's own forward of
138//! it), clones it into whatever control it likes, and
139//! [`dismiss`](ModalDismiss::dismiss) then runs the **same** staged exit the
140//! chrome gestures run — [`stage_dismiss`](OverlayModalWidget::stage_dismiss)
141//! is the one staging core all of them share, so there is no second exit path
142//! to keep in step.
143//!
144//! Three decisions worth stating outright:
145//!
146//! * **An app dismiss is not a *user* dismissal, so it ignores
147//!   [`dismissable`](OverlayModalView::dismissable).** That flag exists to take
148//!   the *user's* exits away (a sheet that demands an explicit choice) —
149//!   leaving only "an explicit control the app wires inside its own content",
150//!   which is precisely this handle. Gating the handle on it would make the
151//!   one documented escape hatch the one thing that cannot close a
152//!   non-dismissable panel.
153//! * **It can carry the pop result.**
154//!   [`dismiss_with`](ModalDismiss::dismiss_with) hands a [`PopResult`] to the
155//!   deferred pop, so a staged "Apply" still reaches the pusher's `on_result`
156//!   — the payload rides the [`StagedPop`], which is the only thing that knows
157//!   whether a navigator is behind the hook at all (an app-wired
158//!   [`OverlayModalView::on_close`] takes no payload, and drops it).
159//! * **Mint it outside the page builder.** `show_*`'s `build` closure re-runs
160//!   on every navigator rebuild; a handle minted *inside* it would be a fresh
161//!   cell each pass. The widget defends against that anyway (it re-seeds on a
162//!   handle whose identity changed, see
163//!   [`OverlayModalView::rebuild`]) — but a handle the app cannot also hold
164//!   from outside is of no use to the control it was minted for.
165//!
166//! A request is observed in **both** passes, latched by generation so it acts
167//! exactly once: in `event`, right after the content routing that likely raised
168//! it (so the dismissal stages in the very pass that asked for it), and in
169//! `paint` as the backstop for a request raised anywhere else (an async
170//! completion, a timer).
171//!
172//! ## What a request consumes
173//!
174//! An observation *peeks* the generation, puts the request through
175//! [`stage_dismiss`](OverlayModalWidget::stage_dismiss), and only then decides
176//! what it consumed — four rules, all edge paths of the same seam:
177//!
178//! * **The generation is latched by whichever pass saw the request**, accepted
179//!   or refused, so it acts exactly once. A refusal ("already exiting, already
180//!   closed") is spent, not deferred: the ramp in flight *is* the dismissal,
181//!   and holding the request open to re-stage after that ramp ends would arm a
182//!   fresh pop against a stack the refusal already told us had moved.
183//! * **The payload is taken only by the observation that accepted the
184//!   dismissal.** A refused request leaves its [`PopResult`] with the handle,
185//!   so a shared handle's "OK" pressed during a "Cancel" ramp cannot retarget
186//!   that ramp's own pop (`show_date_picker` shares exactly one handle between
187//!   the two), and a payload-free `dismiss()` after an unobserved
188//!   `dismiss_with(..)` pops empty rather than delivering the stale value (the
189//!   handle stamps each payload with the generation that raised it — see
190//!   [`ModalDismiss`]). What the accepted request's payload *is* scoped to is
191//!   [`OverlayModalWidget::pending_result`]'s own contract: the exit it staged,
192//!   and no later one.
193//! * **The one pass that latches nothing is the `paint` backstop with no
194//!   `on_close` wired.** `PaintCtx` carries no app state, so that pass cannot
195//!   reach [`OverlayModalView::on_dismiss`] and has nothing else to fire;
196//!   swallowing the request there would make a handle on a `Stack`-mounted
197//!   panel silently inert. It leaves the request pending instead, for the
198//!   `event` pass's own `on_dismiss` fallback — which is what makes
199//!   `.dismiss_handle(..)` work identically on both mounts, and identically to
200//!   [`crate::sheet`]'s host, whose unstaged route already fell back this way.
201//! * **A request left pending that way still dismisses only once**, because the
202//!   unstaged `on_dismiss` route is itself one-shot: the first delivery latches
203//!   it ([`OverlayModalWidget::dismissed`], `closed`'s twin for a mount with no
204//!   staged pop) and every dismissal route — scrim tap, close affordance,
205//!   handle, `Escape`, back, the handle's own request — is refused afterwards.
206//!   Without it a pending request and a user dismissal landing before the app's
207//!   teardown are *two* dismissals: `Escape` over a standing request fires
208//!   `on_dismiss` in the observation and again in the key arm of the same event
209//!   pass, with no rebuild in between. [`crate::sheet`]'s host latches the
210//!   identical rule (`BottomSheetWidget::dismissed`, read by its
211//!   `refuses_dismiss` guard), so the two seams stay interchangeable here too.
212//!
213//! # Back-dismiss: the `DismissAnimated` tier
214//!
215//! [`show_overlay_modal`] pushes its transparent page with
216//! [`frust::BackPolicy::DismissAnimated`] when the modal is
217//! [`dismissable`](OverlayModalView::dismissable) and
218//! [`frust::BackPolicy::Veto`] when it is not — the same tier
219//! [`crate::sheet`] uses, and the reason this host does *not* reuse
220//! [`mod@crate::dialog`]'s plain `push_transparent_for_result` path (which has no
221//! back seam). A back press therefore never pops the page itself: the navigator
222//! bumps the shared [`PushOptions::dismiss_signal`] generation cell, this
223//! widget observes the bump on its next paint
224//! ([`observe_dismiss_signal`](OverlayModalWidget::observe_dismiss_signal)) and
225//! stages the ordinary exit ramp, which pops on settle. A `Veto` page is pushed
226//! with no cell at all, so a back press is consumed and does nothing.
227//!
228//! **The drain gotcha** (documented on [`crate::sheet`]'s own observer, and the
229//! reason both fire paths request a frame): `pop()` only *enqueues* a nav op and
230//! writes no tracked signal, so nothing else schedules the frame whose rebuild
231//! drains it. Without a [`PaintCtx::request_frame`] from the very paint that
232//! fires, a dirty-driven desktop shell (`ControlFlow::Wait`) idles and the
233//! mobile frame gate skips, leaving the dismissal dead until an unrelated later
234//! frame.
235//!
236//! # Drag-to-close and snap points
237//!
238//! [`OverlayModalConfig::drag`] turns an edge-pinned panel into a draggable
239//! sheet: a press on the handle — or anywhere on the panel the content did not
240//! take — captures, each move scrubs `progress` along the panel's own axis so
241//! the panel tracks the pointer (the scrim dims with it), and the release picks
242//! the nearest resting point, with a fast flick ([`OVERLAY_FLING_VELOCITY`])
243//! nudging to the next one in the direction of travel. Landing on `0.0`
244//! continues into the exit ramp above rather than snapping shut. A press that
245//! never passes [`TOUCH_SLOP`] is a *tap*: on the handle it dismisses, anywhere
246//! else it is swallowed like any other barrier press.
247//!
248//! [`OverlayModalView::snap_points`]: a value in `0..=1` is a fraction of the
249//! panel's own extent, a value above `1` is logical px. The panel opens to the
250//! **first** point (fully open with none given), and the resting set the release
251//! snaps to is those points plus `0.0` (closed) — so with no snap points at all
252//! the two candidates are `0.0` and `1.0` and "nearest" is exactly the
253//! conventional drag-past-the-midpoint commit ([`crate::sheet`]'s own
254//! `DRAG_DISMISS_FRACTION` of `0.5`, arrived at from the other direction). A
255//! first point that itself normalizes to `0.0` is the one exception:
256//! [`OverlayModalWidget::open_progress`] floors it to fully open rather than
257//! authoring a panel with nothing to open to. The panel is laid out at its full
258//! extent and translated to expose the active fraction, so a partially-open
259//! sheet carries a proportionally lighter scrim rather than a full-strength one.
260//!
261//! # Dragging a non-dismissable panel
262//!
263//! [`OverlayModalConfig::drag`] stays on whether or not the panel is
264//! dismissable — the *gesture* is never gated on whether a close callback
265//! exists, only the *outcome* is. A drag past the closing threshold on a panel
266//! that cannot dismiss springs back to
267//! [`open_progress`](OverlayModalWidget::open_progress) instead of continuing
268//! into the exit ramp ([`settle_drag`](OverlayModalWidget::settle_drag)) — the
269//! same reachability rule the Escape handler applies.
270//!
271//! # Clamp discipline
272//!
273//! `f64::clamp` panics if `min > max` or either bound is NaN — a hard abort
274//! under `panic = "abort"`, not a catchable error (see [`crate::slider`]'s own
275//! core for the same rule stated at length, which this one mirrors).
276//! [`OverlayModalView::snap_points`] is the one place caller input enters
277//! this module, and it drops any non-finite point right there — the rest of
278//! the module (`normalize_snap`, `snap_targets`, `open_progress`) trusts
279//! [`OverlayModalWidget::snap_points`] to already be all-finite rather than
280//! re-checking. That leaves two clamp shapes in the module tree:
281//!
282//! - **Bounds are literal `0.0`/`1.0`** (`normalize_snap`'s two clamps,
283//!   [`begin_ramp`](OverlayModalWidget::begin_ramp)'s fraction clamp) —
284//!   trivially ordered, never a hazard.
285//! - **A computed bound clamped with the ordering-visible `.max()`/`.min()`
286//!   shape instead of `.clamp()`** —
287//!   [`drag_move`](OverlayModalWidget::drag_move)'s scrub target against the
288//!   live `open` snap-target bound: `f64::max`/`f64::min` can't panic even if
289//!   a NaN somehow reached one side (IEEE `minNum`/`maxNum` semantics return
290//!   the other, non-NaN operand), which `clamp`'s internal
291//!   `assert!(min <= max)` would.
292//!
293//! A new clamp against a computed bound must fit one of these two shapes, not
294//! introduce a third.
295
296use std::cell::Cell;
297use std::rc::Rc;
298use std::time::Duration;
299
300use frust::authoring::{
301    Affine, AnyView, BezPath, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color,
302    CornerRadii, CursorIcon, ErasedCallback, EventCtx, EventResult, InputEvent, Key, LayoutCtx,
303    NamedKey, PaintCtx, PaintScene, Point, PointerPhase, Rect, Role, SemanticsCtx, Size, View,
304    Widget, any, build_child, erase_callback, rebuild_child, route_event_single, teardown_child,
305    visit_children,
306};
307use frust::input::{TOUCH_SLOP, VelocityTracker};
308use frust::{
309    AnimationController, BackPolicy, Curve, FrameTime, IconData, NavigatorController, PopResult,
310    PushOptions, Theme, TransitionSpec,
311};
312
313use super::{OverlayContainer, OverlayElevation, OverlaySide, finite_or_zero, reduce_motion};
314use crate::press::presses;
315use crate::tokens::{MaterialMotion, MaterialSpacing};
316
317/// Minimum dialog panel width, in logical px (M3 dialog spec; the same bound
318/// [`mod@crate::dialog`] clamps to).
319pub const OVERLAY_DIALOG_MIN_WIDTH: f64 = 280.0;
320/// Maximum dialog panel width, in logical px (M3 dialog spec).
321pub const OVERLAY_DIALOG_MAX_WIDTH: f64 = 560.0;
322/// The margin a centred panel keeps on *each* side of the viewport —
323/// [`MaterialSpacing::LG`] (16dp), where the source's own
324/// `max-w-[calc(100%-2rem)]` lands.
325const CENTERED_MARGIN: f64 = MaterialSpacing::LG;
326/// A side sheet's share of the viewport width (the source's `w-3/4`).
327pub const OVERLAY_EDGE_FRACTION: f64 = 0.75;
328/// Maximum side-sheet width, in logical px (M3 side sheets span 256–400dp).
329pub const OVERLAY_SIDE_SHEET_MAX_WIDTH: f64 = 400.0;
330/// A bottom sheet's share of the viewport height, for a config that caps it.
331pub const OVERLAY_SHEET_MAX_HEIGHT_FRACTION: f64 = 0.8;
332
333/// The scale a [`OverlayEntrance::FadeScale`] entrance starts from (the
334/// source's `zoom-in-95`, carried over verbatim — a visual constant with no
335/// Material token to resolve against; the same value
336/// [`super::ANCHORED_ENTER_SCALE`] carries for the anchored host).
337pub const SCALE_FROM: f64 = 0.95;
338/// Progress difference below which a ramp counts as settled.
339const PROGRESS_EPSILON: f64 = 1e-4;
340
341/// Release speed, in logical px/s along the drag axis, at or above which a drag
342/// commits in the direction it was flung rather than to whichever resting point
343/// is nearest.
344///
345/// **Community-approximate**: Material publishes no fling threshold for a sheet
346/// dismiss. 400 px/s (0.4 px/ms) is a deliberate flick — an order of magnitude
347/// above `frust::input::FLING_STOP`'s 30 px/s "this fling is over" floor, and
348/// well under the speed of a full-screen swipe.
349pub const OVERLAY_FLING_VELOCITY: f64 = 400.0;
350
351/// The close affordance's inset from the panel's top/trailing edges —
352/// [`MaterialSpacing::LG`] (16dp).
353const CLOSE_INSET: f64 = MaterialSpacing::LG;
354/// The close icon's painted size, in logical px (the M3 24dp icon box).
355const CLOSE_ICON: f64 = 24.0;
356/// The close affordance's **hit** box, in logical px: the M3 48dp minimum touch
357/// target centred on the 24dp icon. Chrome-only — it changes no layout and no
358/// painted pixel.
359const CLOSE_HIT: f64 = 48.0;
360
361/// Drag-handle visual indicator width, in logical px (M3 token
362/// `m3_comp_sheet_bottom_docked_drag_handle_width`; [`crate::sheet`]'s own).
363const HANDLE_WIDTH: f64 = 32.0;
364/// Drag-handle visual indicator height, in logical px (M3 token
365/// `m3_comp_sheet_bottom_docked_drag_handle_height`).
366const HANDLE_HEIGHT: f64 = 4.0;
367/// The M3 48dp minimum touch target the handle is hit-tested and laid out
368/// against ([`crate::sheet`]'s handle strip).
369const HANDLE_TOUCH_TARGET: f64 = 48.0;
370
371/// The vertical space a drag handle occupies at the top of a panel — the inset
372/// a sheet's own content reserves for it, since the handle is painted as panel
373/// chrome rather than laid out as a child.
374pub const OVERLAY_HANDLE_RESERVE: f64 = HANDLE_TOUCH_TARGET;
375
376/// How far outside the panel its elevation shadow may reach, in logical px —
377/// the bound the fade layer is inflated by so the shadow is not clipped out of
378/// it.
379const SHADOW_SPILL: f64 = 48.0;
380
381/// How much of an axis a panel takes.
382#[derive(Clone, Copy, Debug, PartialEq)]
383pub enum OverlayExtent {
384    /// A fraction of the area's own extent on that axis.
385    Fraction(f64),
386    /// Whatever the content asks for.
387    Content,
388}
389
390/// An upper bound on a resolved [`OverlayExtent`].
391#[derive(Clone, Copy, Debug, PartialEq)]
392pub enum OverlayLimit {
393    /// No cap beyond the area itself.
394    None,
395    /// A fixed cap in logical px.
396    Px(f64),
397    /// A cap as a fraction of the area's extent.
398    Fraction(f64),
399}
400
401impl OverlayLimit {
402    /// Apply this cap to `value`, given the area's extent on the same axis.
403    fn apply(self, value: f64, area: f64) -> f64 {
404        let cap = match self {
405            OverlayLimit::None => area,
406            OverlayLimit::Px(px) => px.min(area),
407            OverlayLimit::Fraction(f) => area * f,
408        };
409        value.min(cap)
410    }
411}
412
413/// Where the panel sits inside the host area.
414#[derive(Clone, Copy, Debug, PartialEq)]
415pub enum OverlayGeometry {
416    /// Centred both ways, content-tall, capped at `max_width` (the M3 dialog
417    /// shape).
418    Centered {
419        /// The width cap, in logical px.
420        max_width: f64,
421    },
422    /// Pinned to one edge, full-bleed on the cross axis (the M3 bottom-sheet
423    /// and side-sheet shapes).
424    Edge {
425        /// The edge the panel is pinned to (and slides in from).
426        side: OverlaySide,
427        /// The panel's extent *along* that edge's axis (its thickness).
428        extent: OverlayExtent,
429        /// The cap on that extent.
430        limit: OverlayLimit,
431    },
432}
433
434/// Which of the panel's corners are rounded (all at the theme's
435/// `shape.extra_large`).
436#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
437pub enum OverlayCorners {
438    /// All four — the M3 dialog.
439    #[default]
440    All,
441    /// Square — a full-height side sheet meeting three screen edges.
442    None,
443    /// The top two — an M3 bottom sheet.
444    Top,
445    /// The bottom two — a top sheet.
446    Bottom,
447    /// The leading two — a trailing-edge side sheet.
448    Start,
449    /// The trailing two — a leading-edge side sheet.
450    End,
451}
452
453impl OverlayCorners {
454    /// The per-corner radii for a `radius`-sized corner.
455    fn radii(self, radius: f64) -> CornerRadii {
456        let (tl, tr, br, bl) = match self {
457            OverlayCorners::All => (radius, radius, radius, radius),
458            OverlayCorners::None => (0.0, 0.0, 0.0, 0.0),
459            OverlayCorners::Top => (radius, radius, 0.0, 0.0),
460            OverlayCorners::Bottom => (0.0, 0.0, radius, radius),
461            OverlayCorners::Start => (radius, 0.0, 0.0, radius),
462            OverlayCorners::End => (0.0, radius, radius, 0.0),
463        };
464        CornerRadii::new(tl, tr, br, bl)
465    }
466}
467
468/// Which of the panel's edges carry a 1dp outline.
469///
470/// M3 gives neither a dialog nor a sheet a border by default (elevation and the
471/// container role separate them from the scrim), so both presets below leave
472/// this [`OverlayBorder::None`]; the arms exist for a component that needs a
473/// divider against an opaque background.
474#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
475pub enum OverlayBorder {
476    /// No border at all.
477    #[default]
478    None,
479    /// All four edges.
480    All,
481    /// One edge only — a side sheet's *inner* edge.
482    Edge(OverlaySide),
483}
484
485/// The 1dp hairline weight an [`OverlayBorder`] strokes at.
486const BORDER_WIDTH: f64 = 1.0;
487
488/// How the panel enters.
489#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
490pub enum OverlayEntrance {
491    /// Fade plus a scale about the panel's centre — paint-only.
492    #[default]
493    FadeScale,
494    /// Slide in off the geometry's own edge — driven in layout.
495    Slide,
496    /// No entrance: the panel is at rest on its first frame.
497    None,
498}
499
500impl OverlayEntrance {
501    /// This entrance's ramp duration (see the module docs' motion table).
502    fn duration(self) -> Duration {
503        match self {
504            OverlayEntrance::FadeScale => MaterialMotion::SHORT_4,
505            OverlayEntrance::Slide => MaterialMotion::LONG_2,
506            OverlayEntrance::None => Duration::ZERO,
507        }
508    }
509
510    /// This entrance's easing, per direction: M3's decelerate/standard curves
511    /// entering, its accelerate curve leaving.
512    fn curve(self, exiting: bool) -> Curve {
513        if exiting {
514            return MaterialMotion::EMPHASIZED_ACCELERATE;
515        }
516        match self {
517            OverlayEntrance::Slide => MaterialMotion::EMPHASIZED,
518            _ => MaterialMotion::EMPHASIZED_DECELERATE,
519        }
520    }
521
522    /// Whether the entrance moves the panel's *geometry* (and therefore needs a
523    /// relayout per frame rather than a repaint).
524    fn is_layout_affecting(self) -> bool {
525        matches!(self, OverlayEntrance::Slide)
526    }
527}
528
529/// The accessibility role the modal reports.
530#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
531pub enum OverlayRole {
532    /// A dialog box (`Role::Dialog`) — also the role
533    /// [`mod@crate::dialog`]/[`crate::sheet`] report, accesskit having no
534    /// dedicated sheet role.
535    #[default]
536    Dialog,
537    /// An alert dialog demanding a response (`Role::AlertDialog`).
538    AlertDialog,
539}
540
541impl OverlayRole {
542    /// The accesskit role.
543    fn role(self) -> Role {
544        match self {
545            OverlayRole::Dialog => Role::Dialog,
546            OverlayRole::AlertDialog => Role::AlertDialog,
547        }
548    }
549}
550
551/// The chrome differences between the modal families — see the [module
552/// docs](self).
553#[derive(Clone, Copy, Debug, PartialEq)]
554pub struct OverlayModalConfig {
555    /// Where the panel sits.
556    pub geometry: OverlayGeometry,
557    /// Which corners round.
558    pub corners: OverlayCorners,
559    /// Which edges carry an outline.
560    pub border: OverlayBorder,
561    /// The `surfaceContainer*` rung the panel fills with.
562    pub container: OverlayContainer,
563    /// The panel's elevation rung.
564    pub elevation: OverlayElevation,
565    /// How the panel enters.
566    pub entrance: OverlayEntrance,
567    /// Whether a press+release outside the panel dismisses.
568    pub scrim_dismiss: bool,
569    /// Whether the close affordance is shown.
570    pub close_button: bool,
571    /// Whether the drag-handle bar is shown (and dismisses on release).
572    pub handle: bool,
573    /// Whether the panel can be dragged along its own edge axis to close (see
574    /// the [module docs](self)). Meaningful only for an
575    /// [`OverlayGeometry::Edge`] panel.
576    pub drag: bool,
577    /// The accessibility role.
578    pub role: OverlayRole,
579}
580
581impl OverlayModalConfig {
582    /// The M3 dialog chrome: a centred, fully-rounded `surfaceContainerHigh`
583    /// panel at elevation 3, capped at `max_width`, fading and scaling in.
584    pub fn centered(max_width: f64) -> Self {
585        OverlayModalConfig {
586            geometry: OverlayGeometry::Centered { max_width },
587            corners: OverlayCorners::All,
588            border: OverlayBorder::None,
589            container: OverlayContainer::High,
590            elevation: OverlayElevation::Level3,
591            entrance: OverlayEntrance::FadeScale,
592            scrim_dismiss: true,
593            close_button: false,
594            handle: false,
595            drag: false,
596            role: OverlayRole::Dialog,
597        }
598    }
599
600    /// The M3 sheet chrome on `side`: a `surfaceContainerLow` panel at
601    /// elevation 1 pinned to that edge, rounded on the two corners facing into
602    /// the screen, sliding in.
603    ///
604    /// A left/right side sheet is [`OVERLAY_EDGE_FRACTION`] of the width capped
605    /// at [`OVERLAY_SIDE_SHEET_MAX_WIDTH`] and full height; a top/bottom sheet
606    /// is content-tall and full width.
607    pub fn edge(side: OverlaySide) -> Self {
608        let (extent, limit) = if side.is_vertical() {
609            (OverlayExtent::Content, OverlayLimit::None)
610        } else {
611            (
612                OverlayExtent::Fraction(OVERLAY_EDGE_FRACTION),
613                OverlayLimit::Px(OVERLAY_SIDE_SHEET_MAX_WIDTH),
614            )
615        };
616        let corners = match side {
617            OverlaySide::Bottom => OverlayCorners::Top,
618            OverlaySide::Top => OverlayCorners::Bottom,
619            OverlaySide::Left => OverlayCorners::End,
620            OverlaySide::Right => OverlayCorners::Start,
621        };
622        OverlayModalConfig {
623            geometry: OverlayGeometry::Edge {
624                side,
625                extent,
626                limit,
627            },
628            corners,
629            border: OverlayBorder::None,
630            container: OverlayContainer::Low,
631            elevation: OverlayElevation::Level1,
632            entrance: OverlayEntrance::Slide,
633            scrim_dismiss: true,
634            close_button: false,
635            handle: false,
636            drag: false,
637            role: OverlayRole::Dialog,
638        }
639    }
640
641    /// Replace the panel's extent along its own axis (an
642    /// [`OverlayGeometry::Edge`] config only; a no-op on a centred one).
643    pub fn extent(mut self, extent: OverlayExtent, limit: OverlayLimit) -> Self {
644        if let OverlayGeometry::Edge {
645            extent: e,
646            limit: l,
647            ..
648        } = &mut self.geometry
649        {
650            *e = extent;
651            *l = limit;
652        }
653        self
654    }
655
656    /// Set which corners round.
657    pub fn corners(mut self, corners: OverlayCorners) -> Self {
658        self.corners = corners;
659        self
660    }
661
662    /// Set which edges carry an outline.
663    pub fn border(mut self, border: OverlayBorder) -> Self {
664        self.border = border;
665        self
666    }
667
668    /// Set the panel's container rung.
669    pub fn container(mut self, container: OverlayContainer) -> Self {
670        self.container = container;
671        self
672    }
673
674    /// Set the panel's elevation rung.
675    pub fn elevation(mut self, elevation: OverlayElevation) -> Self {
676        self.elevation = elevation;
677        self
678    }
679
680    /// Set the entrance.
681    pub fn entrance(mut self, entrance: OverlayEntrance) -> Self {
682        self.entrance = entrance;
683        self
684    }
685
686    /// Enable or disable scrim-tap dismissal.
687    pub fn scrim_dismiss(mut self, scrim_dismiss: bool) -> Self {
688        self.scrim_dismiss = scrim_dismiss;
689        self
690    }
691
692    /// Show or hide the close affordance.
693    pub fn close_button(mut self, close_button: bool) -> Self {
694        self.close_button = close_button;
695        self
696    }
697
698    /// Show or hide the drag handle.
699    pub fn handle(mut self, handle: bool) -> Self {
700        self.handle = handle;
701        self
702    }
703
704    /// Enable or disable drag-to-close (see [`OverlayModalConfig::drag`]).
705    pub fn drag(mut self, drag: bool) -> Self {
706        self.drag = drag;
707        self
708    }
709
710    /// Set the accessibility role.
711    pub fn role(mut self, role: OverlayRole) -> Self {
712        self.role = role;
713        self
714    }
715}
716
717/// A view-held, typed dismiss callback (erased on build).
718type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
719
720/// A state-free close hook, fired from `paint` when an exit ramp settles (see
721/// the [module docs](self)) — a [`StagedPop`], so a deferred navigator pop can
722/// refuse to fire onto a stack that moved under it.
723pub type OnClose = Rc<StagedPop>;
724
725/// The state-free close a staged exit fires on settle, paired with the navigator
726/// stack identity the exit was staged against.
727///
728/// Staging puts a whole exit ramp — hundreds of milliseconds — between the
729/// *decision* to dismiss and the [`NavigatorController::pop`] that carries it
730/// out, and a pop always takes the **top** page. Anything pushed inside that
731/// window (a deep link, an async completion landing) therefore ends up popped
732/// *instead of* this panel — and since nav ops drain FIFO, the deferred pop
733/// queues up behind that push rather than racing it, so it loses every time.
734/// `arm` snapshots the stack the exit was staged against; `fire` re-reads it and
735/// refuses the pop if it moved, *reporting* the refusal so the widget can
736/// recover instead of latching itself closed over a page it never removed.
737///
738/// **Why depth, not `route_generation`.** Both are public identities on
739/// [`NavigatorController`], but what makes a deferred pop wrong here is
740/// precisely that a page was pushed *on top of* this one — and a push is exactly
741/// what moves [`NavigatorController::depth`]. Nothing else can change this
742/// widget's standing while it is alive to fire at all: a pop takes this very
743/// page and tears the widget down with it, and a page below cannot be removed
744/// out from under it.
745/// [`route_generation`](NavigatorController::route_generation) answers a
746/// different question — whether the stack's *route content* changed — which is a
747/// superset signal here (it also moves for a route edit that leaves this page on
748/// top). Both share the same advisory staleness: they publish at the last
749/// rebuild, so a push enqueued *after* the rebuild preceding the settling paint
750/// is still invisible. That narrows the exposure from the ramp's whole length to
751/// a single frame; it does not erase it.
752///
753/// Public only because it names the [`OnClose`] alias; it is built for a caller
754/// by [`show_overlay_modal`], by [`crate::sheet`]'s own `show_bottom_sheet`, or
755/// by [`OverlayModalView::on_close`] wrapping an app's hook — never directly.
756pub struct StagedPop {
757    /// Reads the navigator's live page-stack depth — `None` for a hook with no
758    /// navigator behind it (an app-wired [`OverlayModalView::on_close`] on a
759    /// `Stack` mount), whose close is always authoritative.
760    depth: Option<Box<dyn Fn() -> usize>>,
761    /// The close itself: `controller.pop_with_result(..)` on the navigator
762    /// path, which is `pop()` exactly when the payload is
763    /// [`PopResult::empty`]. A hook with no navigator behind it takes no
764    /// payload and drops it (see [`unguarded`](Self::unguarded)).
765    close: Box<dyn Fn(PopResult)>,
766    /// The depth [`arm`](Self::arm) last snapshotted; `None` until it runs.
767    staged_depth: Cell<Option<usize>>,
768}
769
770impl StagedPop {
771    /// A navigator pop, guarded by the stack identity above, carrying whatever
772    /// payload [`fire_with`](Self::fire_with) is handed (empty for every
773    /// gesture dismissal — only [`ModalDismiss::dismiss_with`] supplies one).
774    pub(crate) fn navigator_pop<State: 'static>(controller: &NavigatorController<State>) -> Self {
775        let depth_ctrl = controller.clone();
776        let pop_ctrl = controller.clone();
777        Self {
778            depth: Some(Box::new(move || depth_ctrl.depth())),
779            close: Box::new(move |result| pop_ctrl.pop_with_result(result)),
780            staged_depth: Cell::new(None),
781        }
782    }
783
784    /// A close hook with no navigator identity to check — an app's own
785    /// [`OverlayModalView::on_close`], which may be a signal write rather than a
786    /// pop at all and is not this module's to second-guess. It takes no
787    /// argument, so a [`ModalDismiss::dismiss_with`] payload has nowhere to go
788    /// on this path and is dropped: there is no pusher `on_result` behind a
789    /// `Stack` mount to deliver it to.
790    pub(crate) fn unguarded<F: Fn() + 'static>(close: F) -> Self {
791        Self {
792            depth: None,
793            close: Box::new(move |_result| close()),
794            staged_depth: Cell::new(None),
795        }
796    }
797
798    /// Snapshot the stack this exit is being staged against.
799    ///
800    /// Called by the widget the moment a trigger stages an exit — deliberately
801    /// **not** at construction: the page builder re-runs on every navigator
802    /// rebuild, so a construction-time snapshot would simply be re-taken after
803    /// the very push it exists to catch.
804    pub(crate) fn arm(&self) {
805        self.staged_depth
806            .set(self.depth.as_ref().map(|depth| depth()));
807    }
808
809    /// Fire the close, handing it `result`'s payload, unless the stack moved
810    /// since [`arm`](Self::arm) — returns whether it actually fired.
811    ///
812    /// An unarmed hook fires unconditionally: an unstaged close lands in the same
813    /// turn its trigger arrives, leaving no window for the stack to move.
814    ///
815    /// `result` is taken **only when the pop actually happens**: a refused pop
816    /// removed no page and delivered no result, so the payload is left with the
817    /// caller — which drops it, since it was scoped to the exit that did not
818    /// land (the panel ramps back open rather than closing; see
819    /// [`OverlayModalWidget::pending_result`]).
820    #[must_use]
821    pub(crate) fn fire_with(&self, result: &mut Option<PopResult>) -> bool {
822        if let (Some(depth), Some(staged)) = (&self.depth, self.staged_depth.get())
823            && depth() != staged
824        {
825            return false;
826        }
827        (self.close)(result.take().unwrap_or_else(PopResult::empty));
828        true
829    }
830}
831
832/// A cloneable handle an app triggers to dismiss a modal **through the host's
833/// own staged exit ramp** — the public half of a machine whose triggers were
834/// otherwise all host-chrome-internal (see the [module docs](self)'
835/// app-initiated dismiss section).
836///
837/// Mint one per presentation, install it on the modal view with
838/// [`OverlayModalView::dismiss_handle`] (or the family wrapper's forward of
839/// it), and clone it into the panel's own controls:
840///
841/// ```ignore
842/// let dismiss = ModalDismiss::new();
843/// let apply = dismiss.clone();
844/// show_side_sheet(
845///     &nav,
846///     move || {
847///         side_sheet(
848///             "Filters",
849///             body(),
850///             filled_button("Apply", move |_: &mut State| {
851///                 apply.dismiss_with(PopResult::of(Filters::current()))
852///             }),
853///         )
854///         .dismiss_handle(dismiss.clone())
855///     },
856///     |state: &mut State, result: PopResult| { /* ... */ },
857/// );
858/// ```
859///
860/// A request is a generation bump on a shared cell, not a callback: the widget
861/// latches the last generation it saw, so a trigger fires exactly once however
862/// many passes observe it, and a bump raised before the widget existed is not a
863/// request aimed at it. Triggering while an exit is already staging is a no-op
864/// — the ramp in flight is the dismissal, and it keeps its own payload (see the
865/// [module docs](self)' *What a request consumes*).
866#[derive(Clone, Default)]
867pub struct ModalDismiss(Rc<DismissRequest>);
868
869/// [`ModalDismiss`]' shared cell: the request counter, plus the payload the
870/// next staged pop carries — stamped with the generation that raised it.
871///
872/// **Why the stamp.** A payload belongs to *its own* request and to no other:
873/// an unobserved [`ModalDismiss::dismiss_with`] followed by a payload-free
874/// [`ModalDismiss::dismiss`] must pop empty rather than deliver the first
875/// one's value. [`ModalDismiss::request`] enforces that from the writing side
876/// (the newer request displaces the stash outright) and
877/// [`ModalDismiss::take_result`] from the reading side (an observer only takes
878/// a payload stamped with the very generation it is acting on).
879///
880/// **Why a [`Cell`], not a `RefCell`.** A payload is app-owned data whose own
881/// `Drop` may reach back into this handle (it can hold a clone of it); a
882/// `RefCell` would have a live borrow for such a re-entrant write to trip into
883/// a `BorrowMutError` panic. A `Cell` has no borrow flag at all, and every
884/// mutation below completes *before* the value it displaced is dropped.
885#[derive(Default)]
886struct DismissRequest {
887    generation: Cell<u64>,
888    result: Cell<Option<(u64, PopResult)>>,
889}
890
891impl ModalDismiss {
892    /// A fresh handle, with no request raised on it yet.
893    pub fn new() -> Self {
894        Self::default()
895    }
896
897    /// Ask the modal to dismiss itself, animating out, popping with an empty
898    /// result.
899    pub fn dismiss(&self) {
900        self.request(None);
901    }
902
903    /// Ask the modal to dismiss itself, animating out, popping with `result`
904    /// for the pusher's own `on_result` callback (`show_*`'s third argument).
905    ///
906    /// A payload only reaches an `on_result` where a navigator is behind the
907    /// modal; a `Stack`-mounted modal's app-wired
908    /// [`OverlayModalView::on_close`] takes none and drops it
909    /// ([`StagedPop::unguarded`]), and a mount with only
910    /// [`OverlayModalView::on_dismiss`] (no close hook at all) still dismisses
911    /// but leaves the payload undelivered — `&mut State` is that route's own
912    /// channel back to the app.
913    ///
914    /// Raised while an exit is already staging, this is the same no-op
915    /// [`Self::dismiss`] is: the in-flight ramp keeps whatever payload it was
916    /// staged with, and this one is not delivered (see the [module docs](self)'
917    /// *What a request consumes*).
918    pub fn dismiss_with(&self, result: PopResult) {
919        self.request(Some(result));
920    }
921
922    /// Stash any payload against the generation about to be raised, then bump
923    /// the generation the widget latches against. The bump is last so a paint
924    /// racing the write can never see the request without its payload.
925    ///
926    /// The stash is *replaced*, never merged into: a payload-free
927    /// [`Self::dismiss`] clears whatever an earlier, still-unobserved
928    /// [`Self::dismiss_with`] left there. The newest request is the live one,
929    /// and a plain dismissal asks to pop with nothing.
930    fn request(&self, result: Option<PopResult>) {
931        let next = self.0.generation.get().wrapping_add(1);
932        // Displace first, drop second: the superseded payload's own `Drop` runs
933        // with the cell already in its new state (see [`DismissRequest`]).
934        let superseded = self.0.result.replace(result.map(|result| (next, result)));
935        drop(superseded);
936        self.0.generation.set(next);
937    }
938
939    /// The live request generation.
940    ///
941    /// `pub(crate)` (not private) so [`crate::sheet`] can mirror this same
942    /// observe pattern against its own staged-exit path rather than minting
943    /// a second handle type (see that module's own `ModalDismiss` adoption).
944    pub(crate) fn generation(&self) -> u64 {
945        self.0.generation.get()
946    }
947
948    /// Take the payload `generation`'s own request stashed, if it carried one.
949    ///
950    /// Keyed on the generation so a payload can only ever ride the request that
951    /// raised it (see [`DismissRequest`]): an observation of a *later* request
952    /// finds nothing here rather than delivering a value the app attached to an
953    /// earlier one.
954    ///
955    /// `pub(crate)` for the same cross-module reason as [`Self::generation`].
956    pub(crate) fn take_result(&self, generation: u64) -> Option<PopResult> {
957        match self.0.result.take() {
958            Some((stamp, result)) if stamp == generation => Some(result),
959            // Not this request's payload: put it back exactly as found (the
960            // cell is empty right now, so nothing is dropped here either).
961            other => {
962                self.0.result.set(other);
963                None
964            }
965        }
966    }
967
968    /// Whether both handles share one cell — an *identity* check, not an
969    /// equality one (see [`OverlayModalView::rebuild`]'s re-seed).
970    ///
971    /// `pub(crate)` for the same cross-module reason as [`Self::generation`].
972    pub(crate) fn same_cell(&self, other: &Self) -> bool {
973        Rc::ptr_eq(&self.0, &other.0)
974    }
975}
976
977/// A modal component that [`show_overlay_modal`] can wire a navigator pop into.
978///
979/// A modal component implements it by storing the callback in its own
980/// `on_dismiss` slot and reporting its own `dismissable` flag; the trait exists
981/// so one push helper serves every family rather than each component
982/// re-deriving the same `push_with_options` call.
983///
984/// Its element is the shared [`OverlayModalWidget`] by definition — a modal
985/// component *is* a pre-configured modal host — which is what lets
986/// [`show_overlay_modal`] install the staged-exit close hook and the back-press
987/// dismiss signal on whatever component it was handed, with no per-component
988/// setter.
989pub trait OverlayModalContent<State: 'static>:
990    View<State, Element = OverlayModalWidget> + Sized + 'static
991{
992    /// Install the dismiss callback, replacing any the builder already set.
993    fn on_modal_dismiss(self, on_dismiss: OnDismiss<State>) -> Self;
994
995    /// Whether this modal may be dismissed by the user at all — read once, at
996    /// push time, to pick the page's [`BackPolicy`].
997    fn modal_dismissable(&self) -> bool;
998}
999
1000/// Build a modal host: `content` inside a panel shaped by `config`, over the M3
1001/// 32% scrim.
1002///
1003/// The content view owns all of the panel's padding and layout — the panel
1004/// itself contributes only chrome (fill, outline, corners, elevation) plus the
1005/// optional close affordance and drag handle, which sit *over* the content.
1006pub fn overlay_modal<State: 'static, V: View<State>>(
1007    content: V,
1008    config: OverlayModalConfig,
1009) -> OverlayModalView<State> {
1010    OverlayModalView {
1011        content: any(content),
1012        config,
1013        label: None,
1014        dismissable: true,
1015        on_dismiss: None,
1016        on_close: None,
1017        dismiss_handle: None,
1018        snap_points: Vec::new(),
1019    }
1020}
1021
1022/// A declarative modal host. See [`overlay_modal`].
1023///
1024/// The fields are crate-visible so the catalog's modal components can wrap one
1025/// and adjust its config in their own builders (a dialog *is* a pre-configured
1026/// modal host); an app outside the crate configures it through
1027/// [`overlay_modal`]'s `config` argument and the builders below.
1028pub struct OverlayModalView<State: 'static> {
1029    pub(crate) content: AnyView<State>,
1030    pub(crate) config: OverlayModalConfig,
1031    pub(crate) label: Option<String>,
1032    pub(crate) dismissable: bool,
1033    pub(crate) on_dismiss: Option<OnDismiss<State>>,
1034    pub(crate) on_close: Option<OnClose>,
1035    pub(crate) dismiss_handle: Option<ModalDismiss>,
1036    pub(crate) snap_points: Vec<f64>,
1037}
1038
1039impl<State: 'static> OverlayModalView<State> {
1040    /// Label the modal's accessibility node (its title text).
1041    pub fn label(mut self, label: impl Into<String>) -> Self {
1042        self.label = Some(label.into());
1043        self
1044    }
1045
1046    /// Whether the user can dismiss this modal at all — the scrim tap, the
1047    /// close affordance, the handle, a drag commit, `Escape`, and an Android
1048    /// back press (default `true`; see the [module docs](self)). `false`
1049    /// disables all six; only an explicit control the app wires through its own
1050    /// content still dismisses it.
1051    pub fn dismissable(mut self, dismissable: bool) -> Self {
1052        self.dismissable = dismissable;
1053        self
1054    }
1055
1056    /// Set the **unstaged** dismiss callback — delivered with `&mut State`
1057    /// during the event pass.
1058    ///
1059    /// Used only when no [`on_close`](Self::on_close) hook is wired: with one,
1060    /// the dismissal is staged behind the exit ramp and fired from `paint`,
1061    /// where no app state exists (see the [module docs](self)).
1062    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
1063        self.on_dismiss = Some(Rc::new(on_dismiss));
1064        self
1065    }
1066
1067    /// Set the state-free close hook fired when the exit ramp settles — the
1068    /// staged-dismissal path. [`show_overlay_modal`] wires this to
1069    /// `controller.pop()`; a `Stack`-mounted modal wires its own (a signal
1070    /// write, a navigator pop).
1071    ///
1072    /// Pinned at `build`: a directly constructed (non-staged) view's later
1073    /// rebuilds neither update nor clear this hook once installed — see
1074    /// [`OverlayModalWidget`]'s `on_close` field doc for why.
1075    ///
1076    /// An app hook is wrapped *unguarded*: only the navigator pop
1077    /// [`show_overlay_modal`] wires has a stack identity to check.
1078    pub fn on_close<F: Fn() + 'static>(mut self, on_close: F) -> Self {
1079        self.on_close = Some(Rc::new(StagedPop::unguarded(on_close)));
1080        self
1081    }
1082
1083    /// Install the app-triggered staged-dismiss handle: the panel's own
1084    /// content can then close it through the very ramp the host chrome uses,
1085    /// instead of the raw, unstaged `controller.pop()` that was its only route
1086    /// before (see the [module docs](self)' app-initiated dismiss section, and
1087    /// [`ModalDismiss`] for the worked example).
1088    ///
1089    /// Mint the handle *outside* a `show_*` page builder and clone it in — the
1090    /// builder re-runs on every navigator rebuild.
1091    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
1092        self.dismiss_handle = Some(dismiss);
1093        self
1094    }
1095
1096    /// Set the drag's snap points: `0..=1` a fraction of the panel's own
1097    /// extent, above `1` logical px (see the [module docs](self)). The panel
1098    /// opens to the first point — **except** a first point of `0.0` itself,
1099    /// which [`OverlayModalWidget::open_progress`] treats as absent (fully
1100    /// open) rather than authoring a panel that opens already closed.
1101    ///
1102    /// A non-finite point (NaN or infinite — never a meaningful fraction or
1103    /// px extent) is dropped here, at the boundary, rather than stored: this
1104    /// is the one place caller input enters the widget, and every downstream
1105    /// consumer (`normalize_snap`, `snap_targets`, `open_progress`) trusts
1106    /// [`OverlayModalWidget::snap_points`] to already be all-finite rather
1107    /// than re-checking (see the [module docs](self)'s Clamp Discipline
1108    /// section). A caller-authored NaN/∞ behaves as if that point were never
1109    /// passed at all.
1110    pub fn snap_points(mut self, points: &[f64]) -> Self {
1111        self.snap_points = points.iter().copied().filter(|p| p.is_finite()).collect();
1112        self
1113    }
1114}
1115
1116impl<State: 'static> OverlayModalContent<State> for OverlayModalView<State> {
1117    fn on_modal_dismiss(mut self, on_dismiss: OnDismiss<State>) -> Self {
1118        self.on_dismiss = Some(on_dismiss);
1119        self
1120    }
1121
1122    fn modal_dismissable(&self) -> bool {
1123        self.dismissable
1124    }
1125}
1126
1127/// Push `build`'s modal as a transparent navigator page and register
1128/// `on_result` for the value it pops with — the shared push behind every modal
1129/// component's `show_*` wrapper.
1130///
1131/// The modal's dismissal is wired to `controller.pop()` (an *empty*
1132/// [`PopResult`], overriding any `on_dismiss` the builder set); an action inside
1133/// the content pops with a value via `controller.pop_with_result(..)`. The
1134/// navigator transition is [`TransitionSpec::NONE`] on purpose: the modal stages
1135/// its own entrance *and exit* ([`OverlayEntrance`]), so a page transition on
1136/// top of it would animate the same thing twice.
1137///
1138/// The back-press policy is peeked once, here, from
1139/// [`OverlayModalContent::modal_dismissable`] — the push-time `PushOptions`
1140/// contract fixes it for the life of the page, exactly as
1141/// [`crate::sheet`]'s own `show_bottom_sheet` peek does, even though `build` is
1142/// re-invoked on every later navigator rebuild to diff the page's content.
1143///
1144/// The pop is wired **twice, by design**: as the staged
1145/// [`OverlayModalView::on_close`] hook the exit ramp fires on settle (the path
1146/// every dismissal actually takes here), and as the unstaged
1147/// [`OverlayModalContent::on_modal_dismiss`] callback, which only runs for a
1148/// config with no entrance to reverse. Exactly one of the two fires per
1149/// dismissal.
1150pub fn show_overlay_modal<State, V, B, R>(
1151    controller: &NavigatorController<State>,
1152    build: B,
1153    on_result: R,
1154) where
1155    State: 'static,
1156    V: OverlayModalContent<State>,
1157    B: Fn() -> V + 'static,
1158    R: Fn(&mut State, PopResult) + 'static,
1159{
1160    let dismiss_ctrl = controller.clone();
1161    // Built **once**, outside the page builder, and only cloned into each build:
1162    // the builder re-runs on every navigator rebuild, and a fresh hook per pass
1163    // would throw away the identity an in-flight exit armed (see [`StagedPop`]).
1164    let staged: OnClose = Rc::new(StagedPop::navigator_pop(controller));
1165    let dismissable = build().modal_dismissable();
1166    // A `Veto` page is pushed with no cell at all: the navigator consumes the
1167    // back press and fires nothing.
1168    let signal = dismissable.then(|| Rc::new(Cell::new(0u64)));
1169    let widget_signal = signal.clone();
1170    let mut options = PushOptions::transparent()
1171        .transition(TransitionSpec::NONE)
1172        .back(if dismissable {
1173            BackPolicy::DismissAnimated
1174        } else {
1175            BackPolicy::Veto
1176        })
1177        .on_result(on_result);
1178    if let Some(sig) = &signal {
1179        options = options.dismiss_signal(sig.clone());
1180    }
1181    controller.push_with_options(
1182        move || {
1183            let ctrl = dismiss_ctrl.clone();
1184            any::<State, _>(StagedExit {
1185                inner: build().on_modal_dismiss(Rc::new(move |_state: &mut State| ctrl.pop())),
1186                on_close: staged.clone(),
1187                dismiss_signal: widget_signal.clone(),
1188            })
1189        },
1190        options,
1191    );
1192}
1193
1194/// A modal component with the staged-exit close hook and the back-press
1195/// dismiss-signal cell installed on the [`OverlayModalWidget`] it builds.
1196///
1197/// [`show_overlay_modal`] is generic over every modal component, so it cannot
1198/// reach the [`OverlayModalView`] each of them wraps privately; every one of
1199/// them *does* build the shared [`OverlayModalWidget`]
1200/// ([`OverlayModalContent`]'s element bound), so this thin pass-through installs
1201/// both on the built widget instead — the same place [`OverlayModalView`]'s own
1202/// `on_close` lands. [`OverlayModalView::rebuild`] never writes either field
1203/// itself, so `StagedExit::rebuild`'s own reinstall below (which runs *after*
1204/// `inner.rebuild`) is the sole rebuild-time writer on this path — it always
1205/// wins rather than merely winning last.
1206struct StagedExit<V> {
1207    inner: V,
1208    on_close: OnClose,
1209    dismiss_signal: Option<Rc<Cell<u64>>>,
1210}
1211
1212impl<State: 'static, V: View<State, Element = OverlayModalWidget>> View<State> for StagedExit<V> {
1213    type Element = OverlayModalWidget;
1214
1215    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
1216        let mut widget = self.inner.build(ctx);
1217        widget.on_close = Some(self.on_close.clone());
1218        // Seeded from the cell's *current* generation: a bump that predates
1219        // this widget is not a back press aimed at it.
1220        widget.last_seen_dismiss = self.dismiss_signal.as_ref().map_or(0, |s| s.get());
1221        widget.dismiss_signal = self.dismiss_signal.clone();
1222        widget
1223    }
1224
1225    fn rebuild(
1226        &self,
1227        prev: &Self,
1228        element: &mut OverlayModalWidget,
1229        ctx: &mut BuildCtx<'_>,
1230    ) -> ChangeFlags {
1231        let flags = self.inner.rebuild(&prev.inner, element, ctx);
1232        element.on_close = Some(self.on_close.clone());
1233        // The cell's identity is fixed at push time, so reinstalling it never
1234        // disturbs `last_seen_dismiss`.
1235        element.dismiss_signal = self.dismiss_signal.clone();
1236        flags
1237    }
1238
1239    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
1240        self.inner.teardown(element, ctx);
1241    }
1242}
1243
1244/// Who asked for a dismissal, the one thing
1245/// [`OverlayModalWidget::stage_dismiss`] treats differently between triggers.
1246#[derive(Clone, Copy, PartialEq, Eq)]
1247enum DismissSource {
1248    /// A user gesture, or a back press — gated on
1249    /// [`OverlayModalView::dismissable`].
1250    User,
1251    /// The app's own [`ModalDismiss`] handle, which that flag does not gate
1252    /// (see the module docs' app-initiated dismiss section).
1253    App,
1254}
1255
1256/// What a [`OverlayModalWidget::stage_dismiss`] attempt left for its caller to
1257/// finish, since only the caller knows which pass it holds.
1258enum DismissStage {
1259    /// Nothing to do: already exiting, already closed, or not permitted.
1260    Refused,
1261    /// The exit ramp is running — ask for the pass its motion needs.
1262    Ramping,
1263    /// Nothing to stage (no close hook to fire from `paint`, or no entrance to
1264    /// reverse): the caller dismisses on the spot, however its own pass can.
1265    Unstaged,
1266}
1267
1268/// The retained widget for an [`OverlayModalView`].
1269pub struct OverlayModalWidget {
1270    content: ChildPod,
1271    config: OverlayModalConfig,
1272    label: Option<String>,
1273    dismissable: bool,
1274    on_dismiss: Option<ErasedCallback>,
1275    /// The state-free close hook the exit ramp fires on settle (see the module
1276    /// docs); `None` leaves dismissal unstaged.
1277    ///
1278    /// Installed at [`OverlayModalView::build`] and, on the staged navigator
1279    /// path, reinstalled after every rebuild by [`StagedExit`] — the sole
1280    /// rebuild-time writer. [`OverlayModalView::rebuild`] itself never touches
1281    /// this field: a directly constructed, non-staged view (one no `StagedExit`
1282    /// wraps) therefore has its `on_close` pinned at whatever `build` installed
1283    /// for the widget's whole mounted lifetime — a later rebuild neither updates
1284    /// nor clears it, since only the staged wrapper needs a live reinstall (its
1285    /// own hook is a fixed `controller.pop()` closure that never changes across
1286    /// rebuilds anyway).
1287    on_close: Option<OnClose>,
1288    /// The shared back-press dismiss-signal cell (the `DismissAnimated` seam;
1289    /// see [`Self::observe_dismiss_signal`]), installed by [`StagedExit`].
1290    dismiss_signal: Option<Rc<Cell<u64>>>,
1291    /// The last generation observed from `dismiss_signal` (0 with no signal
1292    /// wired — a `Stack` mount, or a `Veto` page).
1293    last_seen_dismiss: u64,
1294    /// The app's own staged-dismiss handle, installed by
1295    /// [`OverlayModalView::dismiss_handle`] (see the module docs'
1296    /// app-initiated dismiss section).
1297    dismiss_handle: Option<ModalDismiss>,
1298    /// The last request generation observed from `dismiss_handle` — seeded from
1299    /// the handle's own generation at build, so a request raised before this
1300    /// widget existed is not aimed at it.
1301    last_seen_request: u64,
1302    /// The payload a [`ModalDismiss::dismiss_with`] request armed, handed to
1303    /// the staged pop when the exit settles.
1304    ///
1305    /// **Scoped to the exit its own request staged, and to no other.** It is
1306    /// taken from the handle only by the observation that *accepted* the
1307    /// request ([`Self::accept_dismiss_request`]), and dropped by every leg
1308    /// that ends that exit without a landed pop: a refused staged pop (the
1309    /// stack moved — see [`StagedPop::fire_with`]), a drag that catches the
1310    /// closing panel ([`Self::scrub`]), any ramp aimed back open
1311    /// ([`Self::begin_ramp`]). A payload left standing across one of those
1312    /// would be delivered by whatever dismissal happened to land next — a
1313    /// scrim tap, `Escape`, a back press, a payload-free `dismiss()` — handing
1314    /// the pusher's `on_result` a value it was never dismissed with.
1315    pending_result: Option<PopResult>,
1316    /// The drag's resting points, as authored (see
1317    /// [`OverlayModalView::snap_points`]).
1318    snap_points: Vec<f64>,
1319    /// The panel rect in the widget's own coordinate space (computed at layout,
1320    /// hit-tested at event time).
1321    panel: Rect,
1322    /// The ramp driver, the endpoints it interpolates between, and the progress
1323    /// `layout` last used: `progress = from + (to − from) · anim.value()`.
1324    anim: AnimationController,
1325    ramp: (f64, f64),
1326    progress: f64,
1327    /// Whether the first paint has seeded the ramp (see the module docs'
1328    /// `reduce_motion` note).
1329    started: bool,
1330    /// Whether the running ramp ends in a dismissal.
1331    exiting: bool,
1332    /// One-shot latch set the instant `on_close` fires (settle, or the
1333    /// immediate no-ramp path in [`Self::request_dismiss`]). Terminal for this
1334    /// widget's mounted lifetime: the hook only *enqueues* a navigator pop, so
1335    /// the widget stays mounted and still a barrier for one or more frames
1336    /// after firing, during which a further dismiss trigger must not re-fire it
1337    /// — that would pop whatever page is now underneath this one.
1338    /// `request_dismiss` refuses every trigger once set; nothing ever clears it
1339    /// back to `false`.
1340    ///
1341    /// Set only when the hook actually closed: a [`StagedPop`] that *refused*
1342    /// its pop removed no page, so latching here would brick a still-live
1343    /// widget rather than record a finished dismissal.
1344    closed: bool,
1345    /// [`Self::closed`]'s twin for the *unstaged* route: the one-shot latch set
1346    /// the instant [`OverlayModalView::on_dismiss`] is actually invoked (see
1347    /// [`Self::fire_dismiss_now`]).
1348    ///
1349    /// A mount with no close hook has no `closed` to latch — its dismissal is
1350    /// the app callback itself — and the app tears the panel down from its own
1351    /// state, so this widget stays mounted and dismissable for the rebuild or
1352    /// two that takes. Without this latch a second trigger inside that window
1353    /// dismisses the same panel twice: a pending [`ModalDismiss`] request the
1354    /// `paint` backstop deliberately left standing (see the [module
1355    /// docs](self)' *What a request consumes*) plus the `Escape` that arrives
1356    /// before the next rebuild are two triggers of exactly that shape, one
1357    /// event pass apart or none. Terminal like `closed`, and for the same
1358    /// reason: nothing ever clears it back to `false`.
1359    dismissed: bool,
1360    /// A scrim/panel-background press is in flight (the modal barrier).
1361    scrim_captured: bool,
1362    /// Whether that press started outside the panel — only an outside press
1363    /// released outside dismisses.
1364    scrim_down_outside: bool,
1365    /// The close affordance's latched hover/press state.
1366    close_hovered: bool,
1367    close_captured: bool,
1368    /// The drag handle's latched hover/press state.
1369    handle_hovered: bool,
1370    handle_captured: bool,
1371    /// A drag along the panel's own axis is in flight, where it started (axis
1372    /// position and the progress it began from), whether it began on the handle,
1373    /// and whether it has passed [`TOUCH_SLOP`] into a real drag.
1374    drag_captured: bool,
1375    drag_start: f64,
1376    drag_from: f64,
1377    drag_on_handle: bool,
1378    drag_moved: bool,
1379    /// The release-velocity estimator, clocked from the last painted frame —
1380    /// pointer events carry no timestamp of their own (`frust_widgets::scroll`'s
1381    /// precedent).
1382    tracker: VelocityTracker,
1383    last_frame_time: FrameTime,
1384}
1385
1386impl OverlayModalWidget {
1387    /// The panel rect in the widget's own coordinate space.
1388    pub fn panel_rect(&self) -> Rect {
1389        self.panel
1390    }
1391
1392    /// The close affordance's hit box, when the config shows one.
1393    pub fn close_rect(&self) -> Option<Rect> {
1394        self.config.close_button.then(|| {
1395            let center = Point::new(
1396                self.panel.x1 - CLOSE_INSET - CLOSE_ICON / 2.0,
1397                self.panel.y0 + CLOSE_INSET + CLOSE_ICON / 2.0,
1398            );
1399            Rect::from_center_size(center, Size::new(CLOSE_HIT, CLOSE_HIT))
1400        })
1401    }
1402
1403    /// The drag handle's hit box, when the config shows one.
1404    pub fn handle_rect(&self) -> Option<Rect> {
1405        self.config
1406            .handle
1407            .then(|| Rect::from_center_size(self.handle_center(), Size::new(CLOSE_HIT, CLOSE_HIT)))
1408    }
1409
1410    /// The centre of the handle indicator, in the widget's own space.
1411    fn handle_center(&self) -> Point {
1412        Point::new(
1413            self.panel.center().x,
1414            self.panel.y0 + HANDLE_TOUCH_TARGET / 2.0,
1415        )
1416    }
1417
1418    /// The handle's painted bar (a subset of its hit box — see [`CLOSE_HIT`]).
1419    fn handle_bar(&self) -> Rect {
1420        Rect::from_center_size(self.handle_center(), Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
1421    }
1422
1423    /// The ramp's progress: `0.0` off-screen/transparent, `1.0` fully shown.
1424    pub fn progress(&self) -> f64 {
1425        self.progress
1426    }
1427
1428    /// Whether an exit ramp is running (the panel is on its way out, and the
1429    /// barrier is still swallowing input).
1430    pub fn is_exiting(&self) -> bool {
1431        self.exiting
1432    }
1433
1434    /// The progress an open panel rests at: the first snap point, or fully open
1435    /// with none authored.
1436    ///
1437    /// Floored above [`PROGRESS_EPSILON`]: a first point that normalizes to
1438    /// `0.0` (an explicit `snap_points(&[0.0])`, or a `0.0`-fraction point on an
1439    /// as-yet-unmeasured extent) is treated as **absent** rather than as "open
1440    /// to closed" — the entrance ramp always has a nonzero target to animate
1441    /// toward, matching [`Self::settle_drag`]'s own floor for a panel that
1442    /// cannot dismiss. There is no fallible surface here
1443    /// ([`OverlayModalView::snap_points`] is a plain builder call), so an
1444    /// unreachable configuration silently falls back to fully open instead of
1445    /// panicking.
1446    pub fn open_progress(&self) -> f64 {
1447        self.snap_points
1448            .first()
1449            .map(|p| self.normalize_snap(*p))
1450            .filter(|p| *p > PROGRESS_EPSILON)
1451            .unwrap_or(1.0)
1452    }
1453
1454    /// A snap point in progress space: `0..=1` is already a fraction of the
1455    /// panel's extent, anything larger is logical px against that extent.
1456    /// Clamped into `0..=1`, and treated as fully open while no extent has been
1457    /// laid out yet.
1458    fn normalize_snap(&self, point: f64) -> f64 {
1459        if point <= 1.0 {
1460            return point.clamp(0.0, 1.0);
1461        }
1462        let extent = self.extent();
1463        if extent <= 0.0 {
1464            return 1.0;
1465        }
1466        (point / extent).clamp(0.0, 1.0)
1467    }
1468
1469    /// The resting points a release snaps to, ascending: every snap point plus
1470    /// `0.0` (closed). With none authored the set is `{0.0, 1.0}`, so "nearest"
1471    /// is the drag-past-the-midpoint commit.
1472    fn snap_targets(&self) -> Vec<f64> {
1473        let mut targets: Vec<f64> = std::iter::once(0.0)
1474            .chain(self.snap_points.iter().map(|p| self.normalize_snap(*p)))
1475            .collect();
1476        if targets.len() == 1 {
1477            targets.push(1.0);
1478        }
1479        targets.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1480        targets
1481    }
1482
1483    /// The panel's extent along its own slide axis (its thickness).
1484    fn extent(&self) -> f64 {
1485        match self.config.geometry {
1486            OverlayGeometry::Edge { side, .. } if side.is_vertical() => self.panel.height(),
1487            OverlayGeometry::Edge { .. } => self.panel.width(),
1488            OverlayGeometry::Centered { .. } => 0.0,
1489        }
1490    }
1491
1492    /// The panel's slide axis, and which way along it closes: `+1.0` when the
1493    /// panel leaves toward growing coordinates (bottom/right), `-1.0` otherwise.
1494    fn close_sign(&self) -> f64 {
1495        match self.config.geometry {
1496            OverlayGeometry::Edge {
1497                side: OverlaySide::Bottom | OverlaySide::Right,
1498                ..
1499            } => 1.0,
1500            _ => -1.0,
1501        }
1502    }
1503
1504    /// `position` projected onto the panel's slide axis.
1505    fn axis_pos(&self, position: Point) -> f64 {
1506        match self.config.geometry {
1507            OverlayGeometry::Edge { side, .. } if side.is_vertical() => position.y,
1508            _ => position.x,
1509        }
1510    }
1511
1512    /// The last painted frame time in milliseconds — the event pass's clock,
1513    /// since a pointer event carries none.
1514    fn event_time_ms(&self) -> f64 {
1515        self.last_frame_time.as_secs_f64() * 1000.0
1516    }
1517
1518    /// Whether a dismissal can actually be delivered: the user-facing
1519    /// [`dismissable`](OverlayModalView::dismissable) flag *and* a hook to fire.
1520    fn can_dismiss(&self) -> bool {
1521        self.dismissable && (self.on_dismiss.is_some() || self.on_close.is_some())
1522    }
1523
1524    /// Start a ramp from the current progress to `to`, over the entrance's own
1525    /// duration scaled by how much of the travel is left, eased by its own
1526    /// direction's curve.
1527    ///
1528    /// The one choke point every staging path goes through, so it is also where
1529    /// the staged pop is armed against the stack as it stands right now (see
1530    /// [`StagedPop`]); a leg aimed anywhere but closed is not a dismissal and
1531    /// arms nothing — it *ends* whatever exit was staged, and the payload that
1532    /// exit was carrying goes with it (see [`Self::pending_result`]).
1533    fn begin_ramp(&mut self, to: f64, exiting: bool) {
1534        let from = self.progress;
1535        self.ramp = (from, to);
1536        self.exiting = exiting;
1537        if exiting {
1538            if let Some(on_close) = &self.on_close {
1539                on_close.arm();
1540            }
1541        } else {
1542            self.pending_result = None;
1543        }
1544        let fraction = (to - from).abs().clamp(0.0, 1.0);
1545        let duration = self.config.entrance.duration().mul_f64(fraction);
1546        self.anim =
1547            AnimationController::new(duration).with_curve(self.config.entrance.curve(exiting));
1548        self.anim.forward();
1549        self.started = true;
1550    }
1551
1552    /// The progress the running ramp is at.
1553    fn ramp_value(&self) -> f64 {
1554        let (from, to) = self.ramp;
1555        from + (to - from) * self.anim.value_clamped()
1556    }
1557
1558    /// Ask for the pass a moving ramp needs: a relayout when the motion moves
1559    /// the panel's geometry (`request_layout` implies a frame), a bare frame
1560    /// otherwise.
1561    fn request_continuation(&self, ctx: &mut PaintCtx) {
1562        if self.config.entrance.is_layout_affecting() {
1563            ctx.request_layout();
1564        } else {
1565            ctx.request_frame();
1566        }
1567    }
1568
1569    /// Whether a staged exit is possible at all: something to fire from `paint`,
1570    /// and an entrance to reverse.
1571    fn stageable(&self) -> bool {
1572        self.on_close.is_some() && self.config.entrance != OverlayEntrance::None
1573    }
1574
1575    /// Stage the dismissal — the one staging core **every** trigger goes
1576    /// through: the pointer/key gestures ([`Self::request_dismiss`]), the back
1577    /// press ([`Self::observe_dismiss_signal`]), and the app's own
1578    /// [`ModalDismiss`] handle ([`Self::observe_dismiss_request`]).
1579    ///
1580    /// A trigger arriving while an exit is already running, or after this
1581    /// widget has dismissed once — `on_close` fired ([`Self::closed`]) or
1582    /// `on_dismiss` did ([`Self::dismissed`]) — is a no-op. Both latches guard
1583    /// the same window: the dismissal is enqueued (a navigator pop) or
1584    /// delegated (the app's own state), and the widget stays mounted across the
1585    /// rebuild or two that takes, where a second trigger would otherwise
1586    /// dismiss it twice. A *user* trigger on a non-dismissable modal is a no-op
1587    /// too; an app one is not (the module docs' app-initiated dismiss section
1588    /// states why).
1589    ///
1590    /// What it cannot do is finish an unstaged dismissal, since only the caller
1591    /// knows which pass it holds — hence [`DismissStage::Unstaged`].
1592    fn stage_dismiss(&mut self, source: DismissSource) -> DismissStage {
1593        if self.exiting || self.closed || self.dismissed {
1594            return DismissStage::Refused;
1595        }
1596        if source == DismissSource::User && !self.dismissable {
1597            return DismissStage::Refused;
1598        }
1599        if !self.stageable() {
1600            return DismissStage::Unstaged;
1601        }
1602        self.begin_ramp(0.0, true);
1603        DismissStage::Ramping
1604    }
1605
1606    /// Fire the state-free close hook now, with any payload a
1607    /// [`ModalDismiss::dismiss_with`] request armed — the unstaged close, in
1608    /// the same turn its trigger arrived, so the hook is unarmed and always
1609    /// closes (see [`StagedPop::fire_with`]). Returns whether it fired.
1610    fn fire_close_now(&mut self) -> bool {
1611        let Some(on_close) = self.on_close.clone() else {
1612            return false;
1613        };
1614        let fired = on_close.fire_with(&mut self.pending_result);
1615        self.closed = fired;
1616        fired
1617    }
1618
1619    /// Fire the state-bearing [`OverlayModalView::on_dismiss`] now — the
1620    /// unstaged close's twin for a mount that wired no `on_close` at all — and
1621    /// latch the route shut behind it ([`Self::dismissed`]), so however many
1622    /// triggers land before the app's own teardown lands, exactly one
1623    /// dismissal reaches the app. Returns whether it fired.
1624    fn fire_dismiss_now(&mut self, ctx: &mut EventCtx) -> bool {
1625        let Some(on_dismiss) = self.on_dismiss.as_mut() else {
1626            return false;
1627        };
1628        on_dismiss(ctx);
1629        self.dismissed = true;
1630        true
1631    }
1632
1633    /// Stage the dismissal from a user gesture (a scrim tap, the close
1634    /// affordance, the handle, `Escape`), falling back to firing
1635    /// [`OverlayModalView::on_dismiss`] on the spot when there is nothing to
1636    /// stage — no close hook to fire from `paint`, or no entrance to reverse.
1637    fn request_dismiss(&mut self, ctx: &mut EventCtx) {
1638        match self.stage_dismiss(DismissSource::User) {
1639            DismissStage::Refused => {}
1640            DismissStage::Ramping => ctx.request_redraw(),
1641            DismissStage::Unstaged => {
1642                if !self.fire_dismiss_now(ctx) {
1643                    self.fire_close_now();
1644                }
1645            }
1646        }
1647    }
1648
1649    /// The [`ModalDismiss`] handle's live request generation, when a request
1650    /// this widget has not seen before is pending — the *peek* half of the
1651    /// observe pattern (see the [module docs](self)' *What a request
1652    /// consumes*).
1653    ///
1654    /// Peeking is deliberately separate from latching: only the caller, having
1655    /// put the request through [`Self::stage_dismiss`], knows what became of
1656    /// it, and a payload must never be taken by an observation that did not
1657    /// accept the dismissal it belongs to.
1658    fn pending_dismiss_request(&self) -> Option<u64> {
1659        let handle = self.dismiss_handle.as_ref()?;
1660        let current = handle.generation();
1661        (current != self.last_seen_request).then_some(current)
1662    }
1663
1664    /// Record `generation` as seen — so the request acts exactly once however
1665    /// many passes observe it — **without** taking its payload.
1666    ///
1667    /// This is what a *refused* request gets (an exit already in flight, or a
1668    /// widget already closed): the trigger is spent, since the ramp in flight
1669    /// is the dismissal and re-staging it later would arm a pop against a stack
1670    /// that has moved on. Its payload stays with the handle rather than
1671    /// retargeting an exit that was never asked to carry it — a shared handle's
1672    /// "OK" pressed during a "Cancel" ramp must not make the cancelled panel
1673    /// pop confirmed.
1674    fn latch_dismiss_request(&mut self, generation: u64) {
1675        self.last_seen_request = generation;
1676    }
1677
1678    /// Latch `generation` *and* take the payload it carried — the accepted
1679    /// request's own value, handed to whichever close this dismissal fires
1680    /// (see [`Self::pending_result`]).
1681    ///
1682    /// The payload is taken here, at acceptance, rather than at the settle that
1683    /// consumes it: the handle is the app's and may be re-armed for a later
1684    /// presentation, while this exit's payload belongs to this exit.
1685    fn accept_dismiss_request(&mut self, generation: u64) {
1686        self.latch_dismiss_request(generation);
1687        // Assigned wholesale: an accepted request's payload *is* this exit's
1688        // payload, and nothing else may still be pending here (every leg that
1689        // ends a staged exit without a landed pop clears it).
1690        self.pending_result = self
1691            .dismiss_handle
1692            .as_ref()
1693            .and_then(|handle| handle.take_result(generation));
1694    }
1695
1696    /// Observe the app's [`ModalDismiss`] handle in the *event* pass, right
1697    /// after the content routing that most likely raised the request (a panel
1698    /// action button), so an app dismissal stages in the very pass that asked
1699    /// for it rather than waiting on a paint nothing scheduled.
1700    fn observe_dismiss_request(&mut self, ctx: &mut EventCtx) {
1701        let Some(request) = self.pending_dismiss_request() else {
1702            return;
1703        };
1704        match self.stage_dismiss(DismissSource::App) {
1705            DismissStage::Refused => self.latch_dismiss_request(request),
1706            DismissStage::Ramping => {
1707                self.accept_dismiss_request(request);
1708                ctx.request_redraw();
1709            }
1710            DismissStage::Unstaged => {
1711                // `on_close` first: it is the only unstaged route that can
1712                // carry this request's payload, so it is the only one that
1713                // takes it. `on_dismiss` is the fallback for a mount that wired
1714                // no close hook at all — a `Stack`-mounted dialog or side
1715                // sheet, whose handle would otherwise be inert (the same
1716                // fallback [`crate::sheet`]'s host applies, so both hosts
1717                // behave identically here) — and it carries no payload, which
1718                // therefore stays with the handle, undelivered.
1719                if self.on_close.is_some() {
1720                    self.accept_dismiss_request(request);
1721                    self.fire_close_now();
1722                } else {
1723                    self.latch_dismiss_request(request);
1724                    self.fire_dismiss_now(ctx);
1725                }
1726            }
1727        }
1728    }
1729
1730    /// The paint-pass twin of [`Self::observe_dismiss_request`] — the backstop
1731    /// for a request raised outside an event (an async completion, a timer).
1732    /// Firing an unstaged close from here must itself request the frame whose
1733    /// rebuild drains the enqueued pop (the drain gotcha the module docs spell
1734    /// out).
1735    fn observe_paint_dismiss_request(&mut self, ctx: &mut PaintCtx) {
1736        let Some(request) = self.pending_dismiss_request() else {
1737            return;
1738        };
1739        match self.stage_dismiss(DismissSource::App) {
1740            DismissStage::Refused => self.latch_dismiss_request(request),
1741            DismissStage::Ramping => {
1742                self.accept_dismiss_request(request);
1743                self.request_continuation(ctx);
1744            }
1745            // Only the state-free `on_close` is reachable from a paint, the
1746            // same limit [`Self::observe_dismiss_signal`] works under. With one
1747            // wired this fires it; with none, this pass can do *nothing* at all
1748            // with the request, so it is deliberately left unlatched for the
1749            // event pass's `on_dismiss` fallback rather than swallowed here.
1750            // Nothing was staged either way, so a request left pending costs a
1751            // peek per frame and no state.
1752            DismissStage::Unstaged => {
1753                if self.on_close.is_some() {
1754                    self.accept_dismiss_request(request);
1755                    if self.fire_close_now() {
1756                        ctx.request_frame();
1757                    }
1758                }
1759            }
1760        }
1761    }
1762
1763    /// Observe the shared back-press dismiss-signal cell and stage the exit
1764    /// exactly once per bump — the [`BackPolicy::DismissAnimated`] seam's
1765    /// widget-side half (see the module docs' back-dismiss section). A back
1766    /// request flags `PAINT`, so this always runs before the next frame is
1767    /// shown.
1768    ///
1769    /// The paint-pass twin of [`Self::request_dismiss`]: the same
1770    /// [`Self::stage_dismiss`] core, except that the unstaged fallback can only
1771    /// reach the state-free `on_close` (no `&mut State` exists here), and
1772    /// firing it must itself request the frame whose rebuild drains the
1773    /// enqueued pop — the drain gotcha the module docs spell out.
1774    fn observe_dismiss_signal(&mut self, ctx: &mut PaintCtx) {
1775        let Some(signal) = &self.dismiss_signal else {
1776            return;
1777        };
1778        let current = signal.get();
1779        if current == self.last_seen_dismiss {
1780            return;
1781        }
1782        self.last_seen_dismiss = current;
1783        match self.stage_dismiss(DismissSource::User) {
1784            DismissStage::Refused => {}
1785            DismissStage::Ramping => self.request_continuation(ctx),
1786            DismissStage::Unstaged => {
1787                if self.fire_close_now() {
1788                    ctx.request_frame();
1789                }
1790            }
1791        }
1792    }
1793
1794    /// Move the panel to `progress` without animating — the drag scrub. The
1795    /// panel keeps the extent layout gave it and only its origin moves, so no
1796    /// relayout is owed.
1797    ///
1798    /// A live drag takes the panel over from whatever ramp was running,
1799    /// including an exit: catching a closing sheet cancels its dismissal —
1800    /// payload included, the same rule [`Self::begin_ramp`]'s reopening legs
1801    /// apply — and the release decides again from where the finger left it.
1802    fn scrub(&mut self, ctx: &mut EventCtx, progress: f64) {
1803        if (progress - self.progress).abs() < PROGRESS_EPSILON {
1804            return;
1805        }
1806        self.anim.stop();
1807        self.exiting = false;
1808        self.pending_result = None;
1809        self.progress = progress;
1810        self.ramp = (progress, progress);
1811        self.reposition(ctx.size());
1812        ctx.request_redraw();
1813    }
1814
1815    /// Re-place an edge-pinned panel (and its content) for the current progress,
1816    /// inside an area of `area`.
1817    fn reposition(&mut self, area: Size) {
1818        let OverlayGeometry::Edge { side, .. } = self.config.geometry else {
1819            return;
1820        };
1821        let size = self.panel.size();
1822        let origin = edge_origin(side, area, size, self.progress);
1823        self.panel = Rect::from_origin_size(origin, size);
1824        self.content.set_origin(origin);
1825    }
1826
1827    /// Open a drag from `position`, remembering where along the axis it started
1828    /// and what progress it started from.
1829    fn begin_drag(&mut self, ctx: &mut EventCtx, position: Point, on_handle: bool) {
1830        self.drag_captured = true;
1831        self.drag_on_handle = on_handle;
1832        self.drag_moved = false;
1833        self.drag_start = self.axis_pos(position);
1834        self.drag_from = self.progress;
1835        self.tracker.clear();
1836        self.tracker.record(self.event_time_ms(), self.drag_start);
1837        ctx.capture_pointer();
1838    }
1839
1840    /// Track a captured drag: sample the velocity, and once the gesture has
1841    /// passed [`TOUCH_SLOP`] scrub the panel to follow the pointer.
1842    ///
1843    /// The scrub measures from the `Down`, slop included — unlike
1844    /// `frust_widgets::scroll`, which re-baselines when it *takes the gesture
1845    /// over* from a child. Nothing is taken over here (the panel captured the
1846    /// press outright; the slop only separates a tap from a drag), so
1847    /// re-baselining would leave the panel trailing the finger for the rest of
1848    /// the gesture.
1849    fn drag_move(&mut self, ctx: &mut EventCtx, position: Point) {
1850        let axis = self.axis_pos(position);
1851        self.tracker.record(self.event_time_ms(), axis);
1852        let delta = axis - self.drag_start;
1853        if !self.drag_moved && delta.abs() > TOUCH_SLOP {
1854            self.drag_moved = true;
1855        }
1856        if !self.drag_moved {
1857            return;
1858        }
1859        let extent = self.extent();
1860        if extent <= 0.0 {
1861            return;
1862        }
1863        // Dragging toward the closing edge lowers progress; the panel can be
1864        // pushed no further open than its own furthest resting point.
1865        let open = self.snap_targets().last().copied().unwrap_or(1.0);
1866        let target = self.drag_from - self.close_sign() * delta / extent;
1867        // Ordered explicitly rather than `clamp`: `open` is computed from the
1868        // live snap targets, and clamp bounds must be provably ordered and
1869        // finite (see the [module docs](self)'s Clamp Discipline section).
1870        // `f64::max`/`f64::min` also can't panic the way `clamp`'s internal
1871        // `assert!(min <= max)` would if a NaN ever reached `open`.
1872        self.scrub(ctx, target.max(0.0).min(open));
1873    }
1874
1875    /// The resting point a release settles to: the nearest one, or — past
1876    /// [`OVERLAY_FLING_VELOCITY`] — the next one along the direction of travel.
1877    fn release_target(&self, velocity: f64) -> f64 {
1878        let targets = self.snap_targets();
1879        let nearest = |p: f64| {
1880            targets
1881                .iter()
1882                .copied()
1883                .min_by(|a, b| {
1884                    (a - p)
1885                        .abs()
1886                        .partial_cmp(&(b - p).abs())
1887                        .unwrap_or(std::cmp::Ordering::Equal)
1888                })
1889                .unwrap_or(0.0)
1890        };
1891        if velocity.abs() >= OVERLAY_FLING_VELOCITY {
1892            // A flick toward the closing edge takes the next point down, one
1893            // away from it the next point up.
1894            let opening = velocity * self.close_sign() < 0.0;
1895            let next = if opening {
1896                targets.iter().copied().find(|t| *t > self.progress)
1897            } else {
1898                targets.iter().rev().copied().find(|t| *t < self.progress)
1899            };
1900            if let Some(next) = next {
1901                return next;
1902            }
1903        }
1904        nearest(self.progress)
1905    }
1906
1907    /// Settle a released drag: continue into the exit ramp when it landed
1908    /// closed, otherwise ramp back to the point it snapped to.
1909    ///
1910    /// A closed landing on a panel that cannot dismiss springs back to
1911    /// [`Self::open_progress`] instead of staging an exit:
1912    /// [`Self::request_dismiss`] no-ops there, and by the time a drag settles
1913    /// `progress` has already tracked the pointer down near `0.0` (unlike the
1914    /// tap-to-dismiss paths, which never move it) — a bare no-op would leave the
1915    /// panel resting there, an invisible full-window input barrier with nothing
1916    /// to recover it.
1917    fn settle_drag(&mut self, ctx: &mut EventCtx, target: f64) {
1918        if target <= PROGRESS_EPSILON {
1919            if self.can_dismiss() {
1920                self.request_dismiss(ctx);
1921            } else {
1922                self.begin_ramp(self.open_progress(), false);
1923                ctx.request_redraw();
1924            }
1925        } else {
1926            self.begin_ramp(target, false);
1927            ctx.request_redraw();
1928        }
1929    }
1930}
1931
1932impl<State: 'static> View<State> for OverlayModalView<State> {
1933    type Element = OverlayModalWidget;
1934
1935    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
1936        let settled = self.config.entrance == OverlayEntrance::None;
1937        OverlayModalWidget {
1938            content: build_child(&self.content, ctx),
1939            config: self.config,
1940            label: self.label.clone(),
1941            dismissable: self.dismissable,
1942            on_dismiss: self.on_dismiss.as_ref().map(erase_callback),
1943            on_close: self.on_close.clone(),
1944            dismiss_signal: None,
1945            last_seen_dismiss: 0,
1946            // Seeded from the handle's *current* generation: a request raised
1947            // before this widget existed is not aimed at it (the same rule
1948            // `StagedExit` applies to the back-press cell).
1949            last_seen_request: self
1950                .dismiss_handle
1951                .as_ref()
1952                .map_or(0, ModalDismiss::generation),
1953            dismiss_handle: self.dismiss_handle.clone(),
1954            pending_result: None,
1955            snap_points: self.snap_points.clone(),
1956            panel: Rect::ZERO,
1957            anim: AnimationController::new(self.config.entrance.duration())
1958                .with_curve(self.config.entrance.curve(false)),
1959            // The entrance's target resolves on the first paint, not here: a
1960            // snap point in logical px needs the extent layout has yet to
1961            // measure.
1962            ramp: (0.0, 1.0),
1963            progress: if settled { 1.0 } else { 0.0 },
1964            started: settled,
1965            exiting: false,
1966            closed: false,
1967            dismissed: false,
1968            scrim_captured: false,
1969            scrim_down_outside: false,
1970            close_hovered: false,
1971            close_captured: false,
1972            handle_hovered: false,
1973            handle_captured: false,
1974            drag_captured: false,
1975            drag_start: 0.0,
1976            drag_from: 0.0,
1977            drag_on_handle: false,
1978            drag_moved: false,
1979            tracker: VelocityTracker::new(),
1980            last_frame_time: FrameTime::ZERO,
1981        }
1982    }
1983
1984    fn rebuild(
1985        &self,
1986        prev: &Self,
1987        element: &mut OverlayModalWidget,
1988        ctx: &mut BuildCtx<'_>,
1989    ) -> ChangeFlags {
1990        let mut flags = rebuild_child(&prev.content, &self.content, &mut element.content, ctx);
1991        if element.config != self.config {
1992            element.config = self.config;
1993            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1994        }
1995        if element.label != self.label {
1996            element.label = self.label.clone();
1997            flags |= ChangeFlags::PAINT;
1998        }
1999        if element.dismissable != self.dismissable {
2000            element.dismissable = self.dismissable;
2001        }
2002        if element.snap_points != self.snap_points {
2003            element.snap_points = self.snap_points.clone();
2004            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2005        }
2006        // The app's dismiss handle follows the view whenever the view still
2007        // carries one: an app that mints one per page-builder pass (rather than
2008        // once, outside it — see `ModalDismiss`) would otherwise leave the
2009        // widget latched to the first pass's dead cell while its buttons bump
2010        // the newest one. Re-seeding is gated on *identity*, so the live
2011        // request generation survives every rebuild that hands the same handle
2012        // back; a view that stopped carrying one leaves the installed handle
2013        // standing, the same pinning `on_close` documents.
2014        if let Some(handle) = &self.dismiss_handle
2015            && !element
2016                .dismiss_handle
2017                .as_ref()
2018                .is_some_and(|installed| installed.same_cell(handle))
2019        {
2020            element.last_seen_request = handle.generation();
2021            element.dismiss_handle = Some(handle.clone());
2022        }
2023        // `on_dismiss` is reinstalled unconditionally (cheap — closures aren't
2024        // comparable, so this is what every interactive widget does).
2025        // `on_close` and `dismiss_signal` are deliberately left untouched here —
2026        // see `OverlayModalWidget::on_close`'s field doc for why.
2027        element.on_dismiss = self.on_dismiss.as_ref().map(erase_callback);
2028        flags
2029    }
2030
2031    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
2032        teardown_child(&self.content, &mut element.content, ctx);
2033    }
2034}
2035
2036/// Lay the panel out and place the content inside it.
2037///
2038/// Split out of [`Widget::layout`] so the geometry is one readable expression
2039/// per [`OverlayGeometry`] arm; returns the panel rect (already offset by the
2040/// slide progress, if any).
2041fn layout_panel(
2042    content: &mut ChildPod,
2043    ctx: &mut LayoutCtx,
2044    config: OverlayModalConfig,
2045    area: Size,
2046    progress: f64,
2047) -> Rect {
2048    match config.geometry {
2049        OverlayGeometry::Centered { max_width } => {
2050            let available = (area.width - 2.0 * CENTERED_MARGIN).max(0.0);
2051            // The M3 [280, 560] clamp, with the viewport margin winning on a
2052            // window too narrow for the minimum.
2053            let width = max_width
2054                .min(available)
2055                .max(OVERLAY_DIALOG_MIN_WIDTH.min(available));
2056            let max_height = (area.height - 2.0 * CENTERED_MARGIN).max(0.0);
2057            // A tight width, a loose height: the panel is as tall as its
2058            // content, up to the area.
2059            let measured = content.layout_child(
2060                ctx,
2061                &BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height)),
2062            );
2063            let height = measured.height.min(max_height);
2064            let origin = Point::new(
2065                ((area.width - width) / 2.0).max(0.0),
2066                ((area.height - height) / 2.0).max(0.0),
2067            );
2068            let panel = Rect::from_origin_size(origin, Size::new(width, height));
2069            content.set_origin(panel.origin());
2070            panel
2071        }
2072        OverlayGeometry::Edge {
2073            side,
2074            extent,
2075            limit,
2076        } => {
2077            let (axis_area, cross) = if side.is_vertical() {
2078                (area.height, area.width)
2079            } else {
2080                (area.width, area.height)
2081            };
2082            let cap = limit.apply(axis_area, axis_area);
2083            let thickness = match extent {
2084                OverlayExtent::Fraction(f) => limit.apply(axis_area * f, axis_area),
2085                OverlayExtent::Content => {
2086                    let bc = if side.is_vertical() {
2087                        BoxConstraints::new(Size::new(cross, 0.0), Size::new(cross, cap))
2088                    } else {
2089                        BoxConstraints::new(Size::new(0.0, cross), Size::new(cap, cross))
2090                    };
2091                    let measured = content.layout_child(ctx, &bc);
2092                    let along = if side.is_vertical() {
2093                        measured.height
2094                    } else {
2095                        measured.width
2096                    };
2097                    along.min(cap)
2098                }
2099            };
2100            let size = if side.is_vertical() {
2101                Size::new(cross, thickness)
2102            } else {
2103                Size::new(thickness, cross)
2104            };
2105            if extent != OverlayExtent::Content {
2106                content.layout_child(ctx, &BoxConstraints::tight(size));
2107            }
2108            let origin = edge_origin(side, area, size, progress);
2109            let panel = Rect::from_origin_size(origin, size);
2110            content.set_origin(panel.origin());
2111            panel
2112        }
2113    }
2114}
2115
2116/// Where an edge-pinned panel of `size` sits inside `area` at `progress`: at
2117/// `0.0` entirely outside its own edge, at `1.0` flush against it, in between
2118/// the fraction of it the slide (or a drag) has brought in.
2119///
2120/// Shared by [`layout_panel`] and the drag scrub, which moves the panel between
2121/// layout passes and must place it identically.
2122fn edge_origin(side: OverlaySide, area: Size, size: Size, progress: f64) -> Point {
2123    let thickness = if side.is_vertical() {
2124        size.height
2125    } else {
2126        size.width
2127    };
2128    let out = thickness * (1.0 - progress);
2129    match side {
2130        OverlaySide::Top => Point::new(0.0, -out),
2131        OverlaySide::Bottom => Point::new(0.0, area.height - thickness + out),
2132        OverlaySide::Left => Point::new(-out, 0.0),
2133        OverlaySide::Right => Point::new(area.width - thickness + out, 0.0),
2134    }
2135}
2136
2137/// Stroke the panel's outline per [`OverlayBorder`].
2138fn paint_border(
2139    scene: &mut dyn PaintScene,
2140    origin: Point,
2141    size: Size,
2142    border: OverlayBorder,
2143    color: Color,
2144) {
2145    let half = BORDER_WIDTH / 2.0;
2146    let (from, to) = match border {
2147        OverlayBorder::None => return,
2148        OverlayBorder::All => {
2149            // Inset by half the stroke width so the hairline paints fully
2150            // inside the panel's own bounds (a stroke is centred on its path) —
2151            // the `crate::card` outlined precedent.
2152            let mut path = BezPath::new();
2153            path.move_to(Point::new(half, half));
2154            path.line_to(Point::new(size.width - half, half));
2155            path.line_to(Point::new(size.width - half, size.height - half));
2156            path.line_to(Point::new(half, size.height - half));
2157            path.close_path();
2158            scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(color));
2159            return;
2160        }
2161        OverlayBorder::Edge(OverlaySide::Top) => {
2162            (Point::new(0.0, half), Point::new(size.width, half))
2163        }
2164        OverlayBorder::Edge(OverlaySide::Bottom) => (
2165            Point::new(0.0, size.height - half),
2166            Point::new(size.width, size.height - half),
2167        ),
2168        OverlayBorder::Edge(OverlaySide::Left) => {
2169            (Point::new(half, 0.0), Point::new(half, size.height))
2170        }
2171        OverlayBorder::Edge(OverlaySide::Right) => (
2172            Point::new(size.width - half, 0.0),
2173            Point::new(size.width - half, size.height),
2174        ),
2175    };
2176    let mut path = BezPath::new();
2177    path.move_to(from);
2178    path.line_to(to);
2179    scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(color));
2180}
2181
2182/// Return `color` with its own alpha scaled by `factor` — how the scrim rides
2183/// the ramp.
2184fn scale_alpha(color: Color, factor: f32) -> Color {
2185    let c = color.components;
2186    Color::new([c[0], c[1], c[2], c[3] * factor])
2187}
2188
2189/// Whether `pos` falls inside an optional hit box (`false` when the box is not
2190/// shown at all).
2191fn hit(rect: Option<Rect>, pos: Point) -> bool {
2192    rect.is_some_and(|r| r.contains(pos))
2193}
2194
2195impl Widget for OverlayModalWidget {
2196    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2197        let area = Size::new(
2198            finite_or_zero(bc.max().width),
2199            finite_or_zero(bc.max().height),
2200        );
2201        self.panel = layout_panel(&mut self.content, ctx, self.config, area, self.progress);
2202        bc.constrain(area)
2203    }
2204
2205    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2206        // The back-press seam, first: a bump staged here rides the very same
2207        // ramp the pointer/key gestures below drive. The app's own handle is
2208        // observed alongside it, as the backstop for a request raised outside
2209        // an event pass.
2210        self.observe_dismiss_signal(ctx);
2211        self.observe_paint_dismiss_request(ctx);
2212        // Authoritative hover read: a pointer that left this widget sends it no
2213        // event at all, so the chrome's latched flags are cleared whenever the
2214        // hover path no longer runs through here. It cannot *set* them — the
2215        // path is true for a hovered content child too — which is what the
2216        // per-region latches in `event` are for.
2217        if !ctx.is_hovered() {
2218            self.close_hovered = false;
2219            self.handle_hovered = false;
2220        }
2221        let (origin, size) = (ctx.origin(), ctx.size());
2222        let now = ctx.frame_time();
2223        // Every theme read in one scope: the `&Theme` borrows the context, and
2224        // the child paint plus the frame requests below need it mutably.
2225        let (reduce, radius, scrim, fill, border, ink, handle_color, shadow) = {
2226            let theme = Theme::from_paint_ctx(ctx);
2227            (
2228                reduce_motion(theme),
2229                super::radius(theme),
2230                super::scrim(theme),
2231                super::container(theme, self.config.container),
2232                super::outline_variant(theme),
2233                super::on_surface(theme),
2234                super::on_surface_variant(theme),
2235                super::shadow(theme, self.config.elevation),
2236            )
2237        };
2238
2239        // The event pass has no clock of its own; this is the one it reads.
2240        self.last_frame_time = now;
2241
2242        // Advance (or collapse) the running ramp. The entrance's target
2243        // resolves here, on the first paint, because a snap point in logical px
2244        // needs the extent the layout before it measured.
2245        if !self.started {
2246            self.started = true;
2247            self.ramp = (0.0, self.open_progress());
2248            if !reduce {
2249                self.anim.forward();
2250            }
2251        }
2252        let next = if reduce {
2253            if self.anim.is_animating() {
2254                self.anim.stop();
2255            }
2256            self.ramp.1
2257        } else if self.anim.is_animating() {
2258            // `advance`'s first call after `forward()` only seeds the clock
2259            // (zero delta, but still truthy) — the continuation has to be
2260            // requested on every truthy advance, not just the ones that moved
2261            // `progress`, or a slide's seeding paint never schedules the frame
2262            // that would carry it off zero.
2263            if self.anim.advance(now) {
2264                self.request_continuation(ctx);
2265            }
2266            self.ramp_value()
2267        } else {
2268            self.progress
2269        };
2270        if (next - self.progress).abs() > PROGRESS_EPSILON {
2271            self.progress = next;
2272            self.request_continuation(ctx);
2273        }
2274        // A settled exit is where the deferred dismissal is finally fired. The
2275        // hook only *enqueues* a navigator pop (it writes no tracked signal), so
2276        // this paint asks for the frame whose rebuild drains it — otherwise a
2277        // `ControlFlow::Wait` desktop shell idles and the modal never leaves.
2278        if self.exiting && !self.anim.is_animating() && self.progress <= PROGRESS_EPSILON {
2279            self.exiting = false;
2280            self.progress = 0.0;
2281            // Cloned out of `self` so the recovery arm below can take `&mut
2282            // self`; it is an `Rc`, and this runs once per exit.
2283            if let Some(on_close) = self.on_close.clone() {
2284                // With whatever payload an app dismissal armed.
2285                if on_close.fire_with(&mut self.pending_result) {
2286                    self.closed = true;
2287                    ctx.request_frame();
2288                } else {
2289                    // The stack moved under the staged pop, so it was refused
2290                    // ([`StagedPop`]). Do **not** latch `closed`: that latch is
2291                    // terminal and would leave an invisible, undismissable
2292                    // barrier standing over a page this widget never removed.
2293                    // Ramp back open instead — the panel is recoverable, and a
2294                    // later dismiss stages a fresh, correctly-armed exit. That
2295                    // reopening leg also drops the payload this exit was
2296                    // carrying (`begin_ramp`): it was scoped to a pop that
2297                    // never happened, and must not ride the next dismissal.
2298                    self.begin_ramp(self.open_progress(), false);
2299                    self.request_continuation(ctx);
2300                }
2301            }
2302        }
2303        let progress = self.progress;
2304
2305        // The scrim fades with the panel, and covers the whole area.
2306        scene.fill_rect(origin, size, scale_alpha(scrim, progress as f32));
2307
2308        let panel_origin = Point::new(origin.x + self.panel.x0, origin.y + self.panel.y0);
2309        let panel_size = self.panel.size();
2310        let radii = self.config.corners.radii(radius);
2311        let fade_scale = self.config.entrance == OverlayEntrance::FadeScale && progress < 1.0;
2312        if fade_scale {
2313            // Composite the panel at the ramp's alpha. The layer rect is
2314            // inflated so the elevation shadow, which reaches outside the panel,
2315            // is not clipped out of it.
2316            let layer = Rect::from_origin_size(panel_origin, panel_size)
2317                .inflate(SHADOW_SPILL, SHADOW_SPILL);
2318            scene.push_layer(layer.origin(), layer.size(), progress as f32);
2319            // …and scale about the panel's own centre.
2320            let c = Rect::from_origin_size(panel_origin, panel_size).center();
2321            let scale = SCALE_FROM + (1.0 - SCALE_FROM) * progress;
2322            scene.push_transform(
2323                Affine::translate(c.to_vec2())
2324                    * Affine::scale(scale)
2325                    * Affine::translate(-c.to_vec2()),
2326            );
2327        }
2328
2329        if let Some((blur, y_offset, color)) = shadow {
2330            scene.draw_shadow(
2331                Point::new(panel_origin.x, panel_origin.y + y_offset),
2332                panel_size,
2333                radii.largest(),
2334                blur,
2335                color,
2336            );
2337        }
2338        scene.fill_rounded_rect_radii(panel_origin, panel_size, radii, fill);
2339
2340        // The content is clipped to the panel: a scrolling panel needs it, and
2341        // it is what keeps a too-tall panel's content inside the window.
2342        scene.push_clip_rounded_radii(panel_origin, panel_size, radii);
2343        self.content.paint_child(ctx, scene);
2344        scene.pop_clip();
2345
2346        paint_border(scene, panel_origin, panel_size, self.config.border, border);
2347
2348        if self.config.close_button {
2349            // M3 has no rest-state opacity ramp on an icon (a state layer does
2350            // the hover work): the affordance sits in `onSurfaceVariant` and
2351            // steps up to `onSurface` when hovered — the source's
2352            // `opacity-70 hover:opacity-100`, re-expressed in roles.
2353            let color = if self.close_hovered {
2354                ink
2355            } else {
2356                handle_color
2357            };
2358            let (path, design) = IconData::from(crate::icons::CLOSE).resolve();
2359            let scale = if design > 0.0 {
2360                CLOSE_ICON / design
2361            } else {
2362                1.0
2363            };
2364            let icon_origin = Point::new(
2365                origin.x + self.panel.x1 - CLOSE_INSET - CLOSE_ICON,
2366                origin.y + self.panel.y0 + CLOSE_INSET,
2367            );
2368            scene.fill_path(
2369                icon_origin,
2370                &(Affine::scale(scale) * path),
2371                &Brush::Solid(color),
2372            );
2373        }
2374        if self.config.handle {
2375            let bar = self.handle_bar();
2376            scene.fill_rounded_rect(
2377                Point::new(origin.x + bar.x0, origin.y + bar.y0),
2378                bar.size(),
2379                bar.height() / 2.0,
2380                handle_color,
2381            );
2382        }
2383
2384        if fade_scale {
2385            scene.pop_transform();
2386            scene.pop_layer();
2387        }
2388    }
2389
2390    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2391        // Claim focus on every `Down` — the `crate::dialog`/`crate::sheet`
2392        // opt-in that makes Escape reachable, and what keeps the root's focus
2393        // session alive while the modal is up (re-claiming while focused is a
2394        // no-op; a claim-once guard would kill the session on the second tap).
2395        if matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Down) {
2396            ctx.request_focus();
2397        }
2398        // Content first: an action inside the panel owns its own events, and
2399        // this widget's own hover claims come after the routing — *unless* this
2400        // widget already holds the gesture. A capture recorded here (a barrier
2401        // press, a live drag) means the content declined the `Down` that opened
2402        // it, so re-routing its `Move`s would let a control the pointer happens
2403        // to travel over steal a drag mid-flight. Broadcasts and focus-routed
2404        // events are never short-circuited.
2405        let captured = self.close_captured
2406            || self.handle_captured
2407            || self.scrim_captured
2408            || self.drag_captured;
2409        let own_gesture = captured && matches!(event, InputEvent::Pointer(_));
2410        if !own_gesture {
2411            let handled = route_event_single(&mut self.content, ctx, event) == EventResult::Handled;
2412            // An action the content just ran may have asked to dismiss through
2413            // the app's own handle: stage it in this pass rather than leaving
2414            // it to a paint the content did not necessarily schedule.
2415            //
2416            // Observed whatever the routing reported, not only on `Handled`:
2417            // this is also the *state-bearing* pass a request raised anywhere
2418            // else (an async completion, a timer) waits for when the panel has
2419            // no staged close hook to fire from `paint` — see the module docs'
2420            // *What a request consumes*. [`crate::sheet`]'s host observes as
2421            // broadly, which is what keeps the two seams interchangeable.
2422            self.observe_dismiss_request(ctx);
2423            if handled {
2424                return EventResult::Handled;
2425            }
2426        }
2427        if let InputEvent::Key(key) = event {
2428            if key.key == Key::Named(NamedKey::Escape) {
2429                self.request_dismiss(ctx);
2430            }
2431            // The barrier swallows every other key too: nothing behind a modal
2432            // may act on a keystroke its content declined.
2433            return EventResult::Handled;
2434        }
2435        let InputEvent::Pointer(p) = event else {
2436            return EventResult::Ignored;
2437        };
2438        // A secondary press is swallowed like every other one — a modal blocks
2439        // the page behind it whatever button pressed — but it operates nothing:
2440        // no close press, no drag, no scrim-dismiss arming. Only the primary
2441        // button does any of that (the catalog's press rule), and the panel's
2442        // own content, routed above, has already had its chance at the event (a
2443        // context menu inside the panel still opens).
2444        if !presses(p) && p.phase != PointerPhase::Move {
2445            return EventResult::Handled;
2446        }
2447        let (close, handle) = (self.close_rect(), self.handle_rect());
2448        match p.phase {
2449            PointerPhase::Move => {
2450                if self.drag_captured {
2451                    // A captured drag re-asks for its cursor from its own arm,
2452                    // so the shape survives the pointer leaving the panel.
2453                    ctx.set_cursor(CursorIcon::Grabbing);
2454                    self.drag_move(ctx, p.position);
2455                    return EventResult::Handled;
2456                }
2457                if self.close_captured || self.handle_captured || self.scrim_captured {
2458                    if self.close_captured || self.handle_captured {
2459                        ctx.set_cursor(CursorIcon::Pointer);
2460                    }
2461                    return EventResult::Handled;
2462                }
2463                let over_close = hit(close, p.position);
2464                let over_handle = hit(handle, p.position);
2465                if over_close || over_handle {
2466                    // Claimed *after* the content routing above.
2467                    ctx.claim_hover();
2468                    if over_handle && self.config.drag {
2469                        // A draggable handle advertises the gesture.
2470                        ctx.set_cursor(CursorIcon::Grab);
2471                    } else {
2472                        ctx.set_cursor(CursorIcon::Pointer);
2473                    }
2474                }
2475                if self.close_hovered != over_close {
2476                    self.close_hovered = over_close;
2477                    ctx.request_redraw();
2478                }
2479                if self.handle_hovered != over_handle {
2480                    self.handle_hovered = over_handle;
2481                    ctx.request_redraw();
2482                }
2483                EventResult::Ignored
2484            }
2485            PointerPhase::Down => {
2486                if hit(close, p.position) {
2487                    self.close_captured = true;
2488                    ctx.capture_pointer();
2489                    return EventResult::Handled;
2490                }
2491                // A draggable panel takes the press as a drag — from the handle
2492                // or from the panel's own surface — and still resolves it as a
2493                // tap if it never moves.
2494                if self.config.drag && (hit(handle, p.position) || self.panel.contains(p.position))
2495                {
2496                    self.begin_drag(ctx, p.position, hit(handle, p.position));
2497                    return EventResult::Handled;
2498                }
2499                if hit(handle, p.position) {
2500                    self.handle_captured = true;
2501                    ctx.capture_pointer();
2502                    return EventResult::Handled;
2503                }
2504                // An invisible panel absorbs nothing — `super::anchored`'s own
2505                // closed-state gate, ported: a widget still mounted behind an
2506                // enqueued pop (or one whose entrance has not drawn its first
2507                // frame) paints no scrim and no panel, and a barrier that
2508                // swallows what it does not show is an invisible input trap.
2509                if self.progress <= PROGRESS_EPSILON {
2510                    return EventResult::Ignored;
2511                }
2512                // The modal barrier: swallow, and remember whether the press
2513                // started outside the panel.
2514                self.scrim_captured = true;
2515                self.scrim_down_outside = !self.panel.contains(p.position);
2516                ctx.capture_pointer();
2517                EventResult::Handled
2518            }
2519            PointerPhase::Up => {
2520                if self.close_captured {
2521                    self.close_captured = false;
2522                    if hit(close, p.position) {
2523                        self.request_dismiss(ctx);
2524                    }
2525                    return EventResult::Handled;
2526                }
2527                if self.handle_captured {
2528                    self.handle_captured = false;
2529                    if hit(handle, p.position) {
2530                        self.request_dismiss(ctx);
2531                    }
2532                    return EventResult::Handled;
2533                }
2534                if self.drag_captured {
2535                    self.drag_captured = false;
2536                    if !self.drag_moved {
2537                        // A tap, not a drag: on the handle it dismisses, on the
2538                        // panel it is an ordinary swallowed barrier press.
2539                        if self.drag_on_handle && hit(handle, p.position) {
2540                            self.request_dismiss(ctx);
2541                        }
2542                        return EventResult::Handled;
2543                    }
2544                    let target = self.release_target(self.tracker.velocity());
2545                    self.settle_drag(ctx, target);
2546                    return EventResult::Handled;
2547                }
2548                if !self.scrim_captured {
2549                    return EventResult::Ignored;
2550                }
2551                self.scrim_captured = false;
2552                let released_outside = !self.panel.contains(p.position);
2553                if self.config.scrim_dismiss && self.scrim_down_outside && released_outside {
2554                    self.request_dismiss(ctx);
2555                }
2556                EventResult::Handled
2557            }
2558            PointerPhase::Cancel => {
2559                // A `Cancel` arm never touches app state — flags only, plus the
2560                // ramp back to where the drag started.
2561                self.close_captured = false;
2562                self.handle_captured = false;
2563                self.scrim_captured = false;
2564                if self.drag_captured {
2565                    self.drag_captured = false;
2566                    if self.drag_moved {
2567                        self.begin_ramp(self.drag_from, false);
2568                        ctx.request_redraw();
2569                    }
2570                }
2571                EventResult::Handled
2572            }
2573        }
2574    }
2575
2576    fn semantics(&self, ctx: &mut SemanticsCtx) {
2577        let label = self.label.clone();
2578        ctx.push_container(
2579            self.config.role.role(),
2580            |node| {
2581                node.set_modal();
2582                if let Some(label) = &label {
2583                    node.set_label(label.as_str());
2584                }
2585            },
2586            |ctx| self.content.semantics_child(ctx),
2587        );
2588    }
2589
2590    visit_children!(content);
2591}
2592
2593#[cfg(test)]
2594mod tests {
2595    use super::*;
2596    use frust::authoring::text::TextContext;
2597    use frust::authoring::{
2598        KeyEvent, Modifiers, PointerButton, PointerEvent, Shape, any as core_any,
2599    };
2600    use frust::{Brightness, NavigatorView};
2601    use frust_core::RenderRoot;
2602    use frust_widgets::navigator;
2603    use std::any::Any;
2604
2605    /// The window every modal test lays out in.
2606    const WINDOW: Size = Size::new(400.0, 600.0);
2607
2608    /// A recording `PaintScene`: the fills, paths, strokes, clips, shadows and
2609    /// fade layers a modal emits.
2610    #[derive(Default)]
2611    struct Recorder {
2612        /// `fill_rect` calls — the scrim is the first of them.
2613        rects: Vec<(Point, Size, Color)>,
2614        /// `fill_rounded_rect_radii` calls — the panel.
2615        panels: Vec<(Point, Size, CornerRadii, Color)>,
2616        /// `fill_rounded_rect` calls — the drag handle.
2617        rrects: Vec<(Point, Size, f64, Color)>,
2618        /// `fill_path` calls, as `(origin, bounding box, color)` — the close
2619        /// icon.
2620        paths: Vec<(Point, Rect, Color)>,
2621        /// `stroke_path` calls, as `(bounding box, width, color)`.
2622        strokes: Vec<(Rect, f64, Color)>,
2623        /// `draw_shadow` calls.
2624        shadows: Vec<(Point, Size, f64, f64, Color)>,
2625        /// `push_clip*` calls.
2626        clips: Vec<(Point, Size)>,
2627        /// `push_layer` alphas — the fade.
2628        layers: Vec<f32>,
2629        /// `push_transform` affines — the scale.
2630        transforms: Vec<Affine>,
2631    }
2632
2633    impl PaintScene for Recorder {
2634        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
2635            self.rects.push((origin, size, color));
2636        }
2637        fn draw_text(&mut self, _origin: Point, _text: &str) {}
2638        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
2639            self.rrects.push((origin, size, radius, color));
2640        }
2641        fn fill_rounded_rect_radii(
2642            &mut self,
2643            origin: Point,
2644            size: Size,
2645            radii: CornerRadii,
2646            color: Color,
2647        ) {
2648            self.panels.push((origin, size, radii, color));
2649        }
2650        fn fill_path(&mut self, origin: Point, path: &BezPath, brush: &Brush) {
2651            let color = match brush {
2652                Brush::Solid(c) => *c,
2653                _ => Color::TRANSPARENT,
2654            };
2655            self.paths.push((origin, path.bounding_box(), color));
2656        }
2657        fn stroke_path(&mut self, origin: Point, path: &BezPath, width: f64, brush: &Brush) {
2658            let color = match brush {
2659                Brush::Solid(c) => *c,
2660                _ => Color::TRANSPARENT,
2661            };
2662            self.strokes
2663                .push((path.bounding_box() + origin.to_vec2(), width, color));
2664        }
2665        fn draw_shadow(
2666            &mut self,
2667            origin: Point,
2668            size: Size,
2669            radius: f64,
2670            std_dev: f64,
2671            color: Color,
2672        ) {
2673            self.shadows.push((origin, size, radius, std_dev, color));
2674        }
2675        fn push_clip(&mut self, origin: Point, size: Size) {
2676            self.clips.push((origin, size));
2677        }
2678        fn push_clip_rounded(&mut self, origin: Point, size: Size, _radius: f64) {
2679            self.clips.push((origin, size));
2680        }
2681        fn push_clip_rounded_radii(&mut self, origin: Point, size: Size, _radii: CornerRadii) {
2682            self.clips.push((origin, size));
2683        }
2684        fn pop_clip(&mut self) {}
2685        fn push_layer(&mut self, _origin: Point, _size: Size, alpha: f32) {
2686            self.layers.push(alpha);
2687        }
2688        fn pop_layer(&mut self) {}
2689        fn push_transform(&mut self, transform: Affine) {
2690            self.transforms.push(transform);
2691        }
2692        fn pop_transform(&mut self) {}
2693    }
2694
2695    /// A fixed-size leaf generic over the app state, for a modal's content.
2696    struct Block(Size);
2697
2698    /// The retained half of [`Block`].
2699    struct BlockWidget(Size);
2700
2701    impl<S: 'static> View<S> for Block {
2702        type Element = BlockWidget;
2703        fn build(&self, _ctx: &mut BuildCtx<'_>) -> BlockWidget {
2704            BlockWidget(self.0)
2705        }
2706        fn rebuild(
2707            &self,
2708            _prev: &Self,
2709            element: &mut BlockWidget,
2710            _ctx: &mut BuildCtx<'_>,
2711        ) -> ChangeFlags {
2712            element.0 = self.0;
2713            ChangeFlags::NONE
2714        }
2715    }
2716
2717    impl Widget for BlockWidget {
2718        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2719            bc.constrain(self.0)
2720        }
2721        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2722    }
2723
2724    /// A frame time `ms` milliseconds in.
2725    fn ft_ms(ms: f64) -> FrameTime {
2726        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
2727    }
2728
2729    /// A pointer event at `(x, y)`.
2730    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2731        InputEvent::Pointer(PointerEvent {
2732            phase,
2733            position: Point::new(x, y),
2734            button: PointerButton::Primary,
2735        })
2736    }
2737
2738    /// An Escape key event.
2739    fn escape() -> InputEvent {
2740        InputEvent::Key(KeyEvent {
2741            key: Key::Named(NamedKey::Escape),
2742            modifiers: Modifiers::default(),
2743            repeat: false,
2744        })
2745    }
2746
2747    #[derive(Default)]
2748    struct Flags {
2749        dismissed: u32,
2750    }
2751
2752    fn build(view: &OverlayModalView<Flags>) -> OverlayModalWidget {
2753        let mut counter = 0u64;
2754        View::<Flags>::build(view, &mut BuildCtx::new(&mut counter))
2755    }
2756
2757    fn rebuild(
2758        view: &OverlayModalView<Flags>,
2759        prev: &OverlayModalView<Flags>,
2760        w: &mut OverlayModalWidget,
2761    ) {
2762        let mut counter = 0u64;
2763        View::<Flags>::rebuild(view, prev, w, &mut BuildCtx::new(&mut counter));
2764    }
2765
2766    fn layout(w: &mut OverlayModalWidget) -> Size {
2767        let mut tcx = TextContext::new();
2768        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2769        w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
2770    }
2771
2772    fn dispatch(w: &mut OverlayModalWidget, state: &mut Flags, event: &InputEvent) -> EventResult {
2773        let state_any: &mut dyn Any = state;
2774        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
2775        w.event(&mut ctx, event)
2776    }
2777
2778    fn view(config: OverlayModalConfig) -> OverlayModalView<Flags> {
2779        overlay_modal(Block(Size::new(200.0, 120.0)), config)
2780            .on_dismiss(|s: &mut Flags| s.dismissed += 1)
2781    }
2782
2783    /// A modal wired the way [`show_overlay_modal`] wires one: a state-free
2784    /// close hook (here a counter) alongside the unstaged `on_dismiss`, so the
2785    /// staged exit path is the one taken.
2786    fn staged(config: OverlayModalConfig) -> (OverlayModalView<Flags>, Rc<Cell<u32>>) {
2787        let closed = Rc::new(Cell::new(0u32));
2788        let hook = closed.clone();
2789        let view = view(config).on_close(move || hook.set(hook.get() + 1));
2790        (view, closed)
2791    }
2792
2793    /// Paint one frame at `ms` and return what it drew.
2794    fn frame(w: &mut OverlayModalWidget, ms: f64) -> Recorder {
2795        let mut rec = Recorder::default();
2796        let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(ms));
2797        w.paint(&mut ctx, &mut rec);
2798        rec
2799    }
2800
2801    /// Paint (and relay out) until the running ramp is over.
2802    fn run_ramp(w: &mut OverlayModalWidget, from_ms: f64) {
2803        frame(w, from_ms);
2804        layout(w);
2805        frame(w, from_ms + 2000.0);
2806        layout(w);
2807    }
2808
2809    fn settled(config: OverlayModalConfig) -> OverlayModalConfig {
2810        config.entrance(OverlayEntrance::None)
2811    }
2812
2813    // ---- geometry -----------------------------------------------------------
2814
2815    #[test]
2816    fn a_centered_panel_is_capped_centred_and_content_tall() {
2817        let mut w = build(&view(settled(OverlayModalConfig::centered(
2818            OVERLAY_DIALOG_MAX_WIDTH,
2819        ))));
2820        let size = layout(&mut w);
2821        assert_eq!(size, WINDOW, "the host fills its area (the scrim)");
2822        let panel = w.panel_rect();
2823        // The viewport margin bites before the 560dp cap in a 400px window.
2824        assert_eq!(panel.width(), WINDOW.width - 2.0 * CENTERED_MARGIN);
2825        assert_eq!(panel.height(), 120.0, "content-tall");
2826        assert!((panel.center().x - WINDOW.width / 2.0).abs() < 1e-9);
2827        assert!((panel.center().y - WINDOW.height / 2.0).abs() < 1e-9);
2828        assert_eq!(w.content.origin(), panel.origin());
2829    }
2830
2831    #[test]
2832    fn a_centered_panel_holds_the_m3_minimum_width_where_it_fits() {
2833        // A cap below the M3 minimum still yields a 280dp panel — the clamp is
2834        // `[280, max]`, not "whatever the cap says".
2835        let mut w = build(&view(settled(OverlayModalConfig::centered(120.0))));
2836        layout(&mut w);
2837        assert_eq!(w.panel_rect().width(), OVERLAY_DIALOG_MIN_WIDTH);
2838    }
2839
2840    #[test]
2841    fn an_edge_panel_pins_to_its_side_at_three_quarters_capped_at_the_side_sheet_max() {
2842        for side in [OverlaySide::Left, OverlaySide::Right] {
2843            let mut w = build(&view(settled(OverlayModalConfig::edge(side))));
2844            layout(&mut w);
2845            let panel = w.panel_rect();
2846            assert_eq!(
2847                panel.width(),
2848                (WINDOW.width * OVERLAY_EDGE_FRACTION).min(OVERLAY_SIDE_SHEET_MAX_WIDTH)
2849            );
2850            assert_eq!(panel.height(), WINDOW.height, "full height");
2851            match side {
2852                OverlaySide::Left => assert_eq!(panel.x0, 0.0),
2853                _ => assert_eq!(panel.x1, WINDOW.width),
2854            }
2855        }
2856        for side in [OverlaySide::Top, OverlaySide::Bottom] {
2857            let mut w = build(&view(settled(OverlayModalConfig::edge(side))));
2858            layout(&mut w);
2859            let panel = w.panel_rect();
2860            assert_eq!(panel.width(), WINDOW.width, "full width");
2861            assert_eq!(panel.height(), 120.0, "content-tall");
2862            match side {
2863                OverlaySide::Top => assert_eq!(panel.y0, 0.0),
2864                _ => assert_eq!(panel.y1, WINDOW.height),
2865            }
2866        }
2867    }
2868
2869    #[test]
2870    fn each_edge_preset_rounds_only_the_corners_facing_the_screen() {
2871        let r = 28.0;
2872        assert_eq!(
2873            OverlayModalConfig::edge(OverlaySide::Bottom)
2874                .corners
2875                .radii(r),
2876            CornerRadii::new(r, r, 0.0, 0.0)
2877        );
2878        assert_eq!(
2879            OverlayModalConfig::edge(OverlaySide::Right)
2880                .corners
2881                .radii(r),
2882            CornerRadii::new(r, 0.0, 0.0, r)
2883        );
2884        assert_eq!(
2885            OverlayCorners::None.radii(r),
2886            CornerRadii::new(0.0, 0.0, 0.0, 0.0)
2887        );
2888    }
2889
2890    #[test]
2891    fn a_limit_caps_an_edge_panel() {
2892        let config = settled(OverlayModalConfig::edge(OverlaySide::Bottom).extent(
2893            OverlayExtent::Content,
2894            OverlayLimit::Fraction(OVERLAY_SHEET_MAX_HEIGHT_FRACTION),
2895        ));
2896        let mut w = build(&overlay_modal(Block(Size::new(200.0, 5000.0)), config));
2897        layout(&mut w);
2898        assert_eq!(
2899            w.panel_rect().height(),
2900            WINDOW.height * OVERLAY_SHEET_MAX_HEIGHT_FRACTION
2901        );
2902    }
2903
2904    #[test]
2905    fn an_unbounded_height_collapses_the_host_and_its_panel_to_nothing() {
2906        // The documented mounts (a navigator page, a full-area `Stack`) are
2907        // bounded; a host put inside a scroll view is not, and this is what
2908        // that costs — pinned, not fixed here: the coercion cannot invent an
2909        // extent nobody offered, so the fix belongs at the mount site.
2910        let mut w = build(&view(settled(OverlayModalConfig::centered(
2911            OVERLAY_DIALOG_MAX_WIDTH,
2912        ))));
2913        let mut tcx = TextContext::new();
2914        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2915        let scrolled = BoxConstraints::new(
2916            Size::new(WINDOW.width, 0.0),
2917            Size::new(WINDOW.width, f64::INFINITY),
2918        );
2919        let size = w.layout(&mut lctx, &scrolled);
2920        assert_eq!(size.height, 0.0, "no vertical area to fill");
2921        assert_eq!(
2922            w.panel_rect().height(),
2923            0.0,
2924            "the panel is a hairline, while the barrier still takes presses"
2925        );
2926        // The same host under the contract's own constraints is unharmed.
2927        let bounded = w.layout(&mut lctx, &BoxConstraints::tight(WINDOW));
2928        assert_eq!(bounded, WINDOW);
2929        assert_eq!(w.panel_rect().height(), 120.0);
2930    }
2931
2932    // ---- chrome -------------------------------------------------------------
2933
2934    #[test]
2935    fn the_themed_chrome_is_material_roles_end_to_end() {
2936        let theme = crate::baseline().with_brightness(Brightness::Light);
2937        let scheme = theme.scheme();
2938        let mut w = build(&view(
2939            settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
2940                .close_button(true)
2941                .border(OverlayBorder::All),
2942        ));
2943        let size = layout(&mut w);
2944        let mut rec = Recorder::default();
2945        let mut ctx =
2946            PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
2947        w.paint(&mut ctx, &mut rec);
2948
2949        assert_eq!(rec.rects[0].1, size, "the scrim covers the area");
2950        assert_eq!(
2951            rec.rects[0].2,
2952            super::super::with_alpha(scheme.scrim, super::super::OVERLAY_SCRIM_ALPHA),
2953            "the M3 32% scrim"
2954        );
2955        assert_eq!(
2956            rec.panels[0].3, scheme.surface_container_high,
2957            "the dialog container role"
2958        );
2959        assert_eq!(
2960            rec.panels[0].2,
2961            CornerRadii::uniform(theme.shape.extra_large),
2962            "`shape.extra_large` on all four corners"
2963        );
2964        let level3 = theme.elevation.level3.shadow(theme.brightness);
2965        assert_eq!(rec.shadows[0].3, level3.blur_std_dev, "the level-3 rung");
2966        assert_eq!(rec.strokes[0].2, scheme.outline_variant, "the hairline");
2967        assert_eq!(
2968            rec.paths[0].2, scheme.on_surface_variant,
2969            "the close icon's rest role"
2970        );
2971        assert_eq!(rec.clips.len(), 1, "the content is clipped to the panel");
2972    }
2973
2974    #[test]
2975    fn the_unthemed_chrome_falls_back_to_the_m3_baseline_light_values() {
2976        let mut w = build(&view(
2977            settled(OverlayModalConfig::edge(OverlaySide::Bottom)).handle(true),
2978        ));
2979        layout(&mut w);
2980        let rec = frame(&mut w, 0.0);
2981        assert_eq!(rec.rects[0].2, super::super::scrim(None));
2982        assert_eq!(
2983            rec.panels[0].3,
2984            super::super::container(None, OverlayContainer::Low)
2985        );
2986        // The M3 32×4dp handle indicator, fully rounded, in the drag-handle
2987        // color role.
2988        let handle = rec
2989            .rrects
2990            .iter()
2991            .find(|(_, s, _, _)| *s == Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
2992            .expect("the drag handle is painted");
2993        assert_eq!(handle.2, HANDLE_HEIGHT / 2.0);
2994        assert_eq!(handle.3, super::super::on_surface_variant(None));
2995        assert!(rec.strokes.is_empty(), "no border by default");
2996    }
2997
2998    #[test]
2999    fn the_close_and_handle_hit_boxes_are_the_m3_touch_target() {
3000        let mut w = build(&view(
3001            settled(OverlayModalConfig::edge(OverlaySide::Bottom))
3002                .close_button(true)
3003                .handle(true),
3004        ));
3005        layout(&mut w);
3006        let close = w.close_rect().expect("a close box");
3007        assert_eq!(close.size(), Size::new(CLOSE_HIT, CLOSE_HIT));
3008        assert_eq!(
3009            close.center().x,
3010            w.panel_rect().x1 - CLOSE_INSET - CLOSE_ICON / 2.0
3011        );
3012        let handle = w.handle_rect().expect("a handle box");
3013        assert_eq!(handle.size(), Size::new(CLOSE_HIT, CLOSE_HIT));
3014        assert_eq!(handle.center().x, w.panel_rect().center().x);
3015        assert_eq!(OVERLAY_HANDLE_RESERVE, HANDLE_TOUCH_TARGET);
3016    }
3017
3018    // ---- dismissal ----------------------------------------------------------
3019
3020    #[test]
3021    fn a_scrim_press_and_release_outside_the_panel_dismisses() {
3022        let mut w = build(&view(settled(OverlayModalConfig::centered(
3023            OVERLAY_DIALOG_MAX_WIDTH,
3024        ))));
3025        layout(&mut w);
3026        let mut state = Flags::default();
3027        assert_eq!(
3028            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0)),
3029            EventResult::Handled,
3030            "the barrier swallows the press"
3031        );
3032        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
3033        assert_eq!(state.dismissed, 1);
3034    }
3035
3036    #[test]
3037    fn a_press_on_the_panel_background_is_swallowed_without_dismissing() {
3038        let mut w = build(&view(settled(OverlayModalConfig::centered(
3039            OVERLAY_DIALOG_MAX_WIDTH,
3040        ))));
3041        layout(&mut w);
3042        let c = w.panel_rect().center();
3043        let mut state = Flags::default();
3044        assert_eq!(
3045            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
3046            EventResult::Handled
3047        );
3048        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
3049        assert_eq!(state.dismissed, 0);
3050    }
3051
3052    #[test]
3053    fn scrim_dismiss_off_ignores_the_same_gesture_but_escape_still_works() {
3054        let mut w = build(&view(
3055            settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).scrim_dismiss(false),
3056        ));
3057        layout(&mut w);
3058        let mut state = Flags::default();
3059        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
3060        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
3061        assert_eq!(state.dismissed, 0);
3062        // Escape still works — the alert dialog's own escape hatch.
3063        dispatch(&mut w, &mut state, &escape());
3064        assert_eq!(state.dismissed, 1);
3065    }
3066
3067    #[test]
3068    fn dismissable_false_gates_every_gesture_at_once() {
3069        let mut w = build(
3070            &view(
3071                settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true),
3072            )
3073            .dismissable(false),
3074        );
3075        layout(&mut w);
3076        let mut state = Flags::default();
3077        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
3078        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
3079        dispatch(&mut w, &mut state, &escape());
3080        let close = w.close_rect().expect("a close box").center();
3081        dispatch(
3082            &mut w,
3083            &mut state,
3084            &pointer(PointerPhase::Down, close.x, close.y),
3085        );
3086        dispatch(
3087            &mut w,
3088            &mut state,
3089            &pointer(PointerPhase::Up, close.x, close.y),
3090        );
3091        assert_eq!(
3092            state.dismissed, 0,
3093            "the scrim tap, Escape and the close affordance are all gated together"
3094        );
3095    }
3096
3097    #[test]
3098    fn the_close_affordance_fires_on_release_inside_and_hovers() {
3099        let mut w = build(&view(
3100            settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true),
3101        ));
3102        layout(&mut w);
3103        let close = w.close_rect().expect("a close box");
3104        let c = close.center();
3105        let mut state = Flags::default();
3106        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
3107        assert!(w.close_hovered, "the latch follows the hit test");
3108        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
3109        assert_eq!(state.dismissed, 0, "never on down");
3110        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
3111        assert_eq!(state.dismissed, 1);
3112
3113        // A release outside the box fires nothing, and the press is not a scrim
3114        // press either.
3115        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
3116        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
3117        assert_eq!(state.dismissed, 1);
3118    }
3119
3120    #[test]
3121    fn a_hovered_close_affordance_steps_up_to_the_on_surface_role() {
3122        // A real `RenderRoot` is the only harness that can exercise hover at
3123        // all: the link is recorded by the root's event pass and read back
3124        // through `PaintCtx::is_hovered`, which the paint arm treats as
3125        // authoritative (a bare paint reads `false` and clears the latch, by
3126        // design). The `crate::list_item` hover-harness precedent.
3127        let config =
3128            settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true);
3129        // The geometry is deterministic, so a throwaway widget answers where
3130        // the close box lands.
3131        let mut probe = build(&view(config));
3132        layout(&mut probe);
3133        let c = probe.close_rect().expect("a close box").center();
3134
3135        let mut root: RenderRoot<Flags, OverlayModalView<Flags>> = RenderRoot::new();
3136        let mut state = Flags::default();
3137        let mut tcx = TextContext::new();
3138        let mut app = move |_s: &mut Flags| overlay_modal(Block(Size::new(200.0, 120.0)), config);
3139        let theme = crate::baseline().with_brightness(Brightness::Light);
3140        root.set_theme(Box::new(theme.clone()));
3141        root.rebuild(&mut app, &mut state);
3142        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
3143
3144        let mut rest = Recorder::default();
3145        root.paint(&mut rest, FrameTime::ZERO);
3146        assert_eq!(
3147            rest.paths[0].2,
3148            theme.scheme().on_surface_variant,
3149            "at rest the affordance sits in the muted ink role"
3150        );
3151
3152        root.event(&mut state, &pointer(PointerPhase::Move, c.x, c.y));
3153        let mut hovered = Recorder::default();
3154        root.paint(&mut hovered, FrameTime::ZERO);
3155        assert_eq!(
3156            hovered.paths[0].2,
3157            theme.scheme().on_surface,
3158            "a hovered affordance steps up to the panel's own ink"
3159        );
3160    }
3161
3162    #[test]
3163    fn the_drag_handle_dismisses_on_release() {
3164        let mut w = build(&view(
3165            settled(OverlayModalConfig::edge(OverlaySide::Bottom)).handle(true),
3166        ));
3167        layout(&mut w);
3168        let handle = w.handle_rect().expect("a handle box");
3169        let c = handle.center();
3170        let mut state = Flags::default();
3171        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
3172        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
3173        assert_eq!(state.dismissed, 1);
3174    }
3175
3176    #[test]
3177    fn a_cancelled_press_clears_every_latch_without_dismissing() {
3178        let mut w = build(&view(
3179            settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).close_button(true),
3180        ));
3181        layout(&mut w);
3182        let c = w.close_rect().unwrap().center();
3183        let mut state = Flags::default();
3184        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
3185        dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y));
3186        assert_eq!(state.dismissed, 0);
3187        assert!(!w.close_captured && !w.scrim_captured);
3188    }
3189
3190    #[test]
3191    fn the_barrier_swallows_a_key_the_content_did_not_take() {
3192        let mut w = build(&view(settled(OverlayModalConfig::centered(
3193            OVERLAY_DIALOG_MAX_WIDTH,
3194        ))));
3195        layout(&mut w);
3196        let mut state = Flags::default();
3197        let tab = InputEvent::Key(KeyEvent {
3198            key: Key::Named(NamedKey::Tab),
3199            modifiers: Modifiers::default(),
3200            repeat: false,
3201        });
3202        assert_eq!(
3203            dispatch(&mut w, &mut state, &tab),
3204            EventResult::Handled,
3205            "a modal barrier absorbs keys as well as pointers"
3206        );
3207        assert_eq!(state.dismissed, 0, "and only Escape dismisses");
3208    }
3209
3210    // ---- motion -------------------------------------------------------------
3211
3212    #[test]
3213    fn the_fade_scale_entrance_ramps_alpha_and_scale_then_settles() {
3214        let mut w = build(&view(OverlayModalConfig::centered(
3215            OVERLAY_DIALOG_MAX_WIDTH,
3216        )));
3217        layout(&mut w);
3218        let first = frame(&mut w, 0.0);
3219        assert_eq!(first.layers, vec![0.0], "starts transparent");
3220        assert_eq!(first.rects[0].2.components[3], 0.0, "so does the scrim");
3221        let mid = frame(&mut w, MaterialMotion::SHORT_4.as_millis() as f64 / 2.0);
3222        let alpha = mid.layers[0];
3223        assert!(alpha > 0.0 && alpha < 1.0, "mid-fade: {alpha}");
3224        assert_eq!(mid.transforms.len(), 1, "and a scale transform");
3225        let done = frame(&mut w, 1000.0);
3226        assert!(
3227            done.layers.is_empty() && done.transforms.is_empty(),
3228            "a settled panel composites plainly"
3229        );
3230        assert!((w.progress() - 1.0).abs() < 1e-9);
3231    }
3232
3233    #[test]
3234    fn the_entrance_durations_are_the_mapped_material_presets() {
3235        assert_eq!(
3236            OverlayEntrance::FadeScale.duration(),
3237            MaterialMotion::SHORT_4
3238        );
3239        assert_eq!(OverlayEntrance::Slide.duration(), MaterialMotion::LONG_2);
3240        assert_eq!(OverlayEntrance::None.duration(), Duration::ZERO);
3241        assert_eq!(
3242            OverlayEntrance::FadeScale.curve(false),
3243            MaterialMotion::EMPHASIZED_DECELERATE
3244        );
3245        assert_eq!(
3246            OverlayEntrance::Slide.curve(false),
3247            MaterialMotion::EMPHASIZED
3248        );
3249        assert_eq!(
3250            OverlayEntrance::Slide.curve(true),
3251            MaterialMotion::EMPHASIZED_ACCELERATE,
3252            "either entrance leaves on M3's accelerate curve"
3253        );
3254    }
3255
3256    #[test]
3257    fn reduce_motion_collapses_the_entrance_to_a_jump() {
3258        let mut theme = crate::baseline().with_brightness(Brightness::Light);
3259        theme.motion.reduce_motion = true;
3260        let mut w = build(&view(OverlayModalConfig::centered(
3261            OVERLAY_DIALOG_MAX_WIDTH,
3262        )));
3263        let size = layout(&mut w);
3264        let mut rec = Recorder::default();
3265        let mut ctx =
3266            PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
3267        w.paint(&mut ctx, &mut rec);
3268        assert!((w.progress() - 1.0).abs() < 1e-9, "settled on the spot");
3269        assert!(rec.layers.is_empty(), "no fade layer");
3270        assert_eq!(
3271            rec.rects[0].2,
3272            super::super::with_alpha(theme.scheme().scrim, super::super::OVERLAY_SCRIM_ALPHA),
3273            "a full-strength scrim"
3274        );
3275    }
3276
3277    #[test]
3278    fn the_slide_entrance_moves_the_panel_in_layout() {
3279        let mut w = build(&view(OverlayModalConfig::edge(OverlaySide::Right)));
3280        layout(&mut w);
3281        assert_eq!(
3282            w.panel_rect().x0,
3283            WINDOW.width,
3284            "starts entirely off the right edge"
3285        );
3286        let step = |w: &mut OverlayModalWidget, ms: f64| {
3287            frame(w, ms);
3288            layout(w);
3289        };
3290        step(&mut w, 0.0);
3291        step(&mut w, MaterialMotion::LONG_2.as_millis() as f64 / 2.0);
3292        let mid = w.panel_rect();
3293        assert!(
3294            mid.x0 > WINDOW.width - mid.width() && mid.x0 < WINDOW.width,
3295            "mid-slide: {}",
3296            mid.x0
3297        );
3298        step(&mut w, 2000.0);
3299        assert_eq!(w.panel_rect().x1, WINDOW.width, "flush at rest");
3300    }
3301
3302    #[test]
3303    fn each_entrances_seeding_paint_asks_for_the_pass_its_own_motion_needs() {
3304        // `AnimationController::advance`'s first truthy call after `forward()`
3305        // only seeds the clock — `progress` is still 0.0 after it. The
3306        // continuation has to be requested on that seeding paint too, or a
3307        // layout-affecting entrance never schedules the frame that would carry
3308        // it off zero, and the panel sits permanently off-screen under a
3309        // `ControlFlow::Wait` shell.
3310        for (config, layout_affecting) in [
3311            (OverlayModalConfig::edge(OverlaySide::Right), true),
3312            (
3313                OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
3314                false,
3315            ),
3316        ] {
3317            let mut w = build(&view(config));
3318            let size = layout(&mut w);
3319            let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, ft_ms(0.0));
3320            w.paint(&mut ctx, &mut Recorder::default());
3321            assert!(
3322                w.progress().abs() < 1e-9,
3323                "the seeding paint leaves progress at zero"
3324            );
3325            assert!(
3326                ctx.needs_frame(),
3327                "the seeding paint must schedule the frame that ramps progress off zero"
3328            );
3329            assert_eq!(
3330                ctx.needs_layout(),
3331                layout_affecting,
3332                "only a layout-affecting entrance asks for a layout pass"
3333            );
3334        }
3335    }
3336
3337    // ---- the staged exit ----------------------------------------------------
3338
3339    #[test]
3340    fn a_dismiss_ramps_the_panel_out_and_closes_only_when_it_settles() {
3341        let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3342        let mut w = build(&view);
3343        layout(&mut w);
3344        run_ramp(&mut w, 0.0);
3345        assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
3346
3347        let mut state = Flags::default();
3348        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
3349        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
3350        assert_eq!(state.dismissed, 0, "the unstaged callback stays unused");
3351        assert_eq!(closed.get(), 0, "and the close is deferred");
3352        assert!(w.is_exiting());
3353
3354        // Mid-ramp: still visible, still nothing fired.
3355        frame(&mut w, 3000.0);
3356        let mid = frame(
3357            &mut w,
3358            3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
3359        );
3360        assert!(w.progress() > 0.0 && w.progress() < 1.0, "{}", w.progress());
3361        let alpha = mid.layers[0];
3362        assert!(alpha > 0.0 && alpha < 1.0, "the panel fades out: {alpha}");
3363        assert!(
3364            mid.rects[0].2.components[3] < super::super::OVERLAY_SCRIM_ALPHA,
3365            "and the scrim rides the ramp down"
3366        );
3367        assert_eq!(closed.get(), 0);
3368
3369        // Settled: progress at zero, and the close fires exactly once.
3370        frame(&mut w, 5000.0);
3371        assert_eq!(w.progress(), 0.0);
3372        assert_eq!(closed.get(), 1);
3373        assert!(!w.is_exiting());
3374        assert_eq!(state.dismissed, 0);
3375    }
3376
3377    /// A panel that has finished its exit paints nothing — the scrim's alpha
3378    /// rides `progress` and a `FadeScale` panel composites at it — but stays
3379    /// mounted until the enqueued pop drains. It must not go on swallowing
3380    /// presses there: an invisible barrier is an input trap with nothing on
3381    /// screen to explain it (the `super::anchored` gate, ported).
3382    #[test]
3383    fn a_panel_that_has_finished_its_exit_swallows_no_press() {
3384        let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3385        let mut w = build(&view);
3386        layout(&mut w);
3387        run_ramp(&mut w, 0.0);
3388        let over_panel = w.panel_rect().center();
3389
3390        let mut state = Flags::default();
3391        dispatch(&mut w, &mut state, &escape());
3392        frame(&mut w, 3000.0);
3393        frame(&mut w, 5000.0);
3394        assert_eq!(closed.get(), 1, "the staged close fired");
3395        assert_eq!(w.progress(), 0.0, "and the panel paints nothing");
3396
3397        let result = dispatch(
3398            &mut w,
3399            &mut state,
3400            &pointer(PointerPhase::Down, over_panel.x, over_panel.y),
3401        );
3402        assert_eq!(
3403            result,
3404            EventResult::Ignored,
3405            "an invisible panel absorbs nothing"
3406        );
3407        assert!(!w.scrim_captured, "and arms no scrim dismiss either");
3408    }
3409
3410    #[test]
3411    fn the_staged_close_hook_survives_a_rebuild_and_still_fires_exactly_once() {
3412        // `OverlayModalView::rebuild` never writes `element.on_close` at all
3413        // (see its doc comment); `StagedExit::rebuild` — which runs
3414        // `inner.rebuild` first and reinstalls its own hook after — must still
3415        // be the writer that wins on the staged (`show_overlay_modal`) path,
3416        // through as many rebuilds as the page sees before it's popped.
3417        let closed = Rc::new(Cell::new(0u32));
3418        let config = OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH);
3419        let hook1 = closed.clone();
3420        let v1 = StagedExit {
3421            inner: view(config),
3422            on_close: Rc::new(StagedPop::unguarded(move || hook1.set(hook1.get() + 1))),
3423            dismiss_signal: None,
3424        };
3425        let mut counter = 0u64;
3426        let mut w = View::<Flags>::build(&v1, &mut BuildCtx::new(&mut counter));
3427        layout(&mut w);
3428        run_ramp(&mut w, 0.0);
3429
3430        // A second rebuild pass through the wrapper, as the navigator's pushed
3431        // page sees on every frame it stays mounted.
3432        let hook2 = closed.clone();
3433        let v2 = StagedExit {
3434            inner: view(config),
3435            on_close: Rc::new(StagedPop::unguarded(move || hook2.set(hook2.get() + 1))),
3436            dismiss_signal: None,
3437        };
3438        View::<Flags>::rebuild(&v2, &v1, &mut w, &mut BuildCtx::new(&mut counter));
3439
3440        let mut state = Flags::default();
3441        dispatch(&mut w, &mut state, &escape());
3442        frame(&mut w, 3000.0);
3443        frame(&mut w, 5000.0);
3444        assert_eq!(
3445            closed.get(),
3446            1,
3447            "the reinstalled hook still fires exactly once"
3448        );
3449    }
3450
3451    #[test]
3452    fn a_rebuild_never_resurrects_or_clears_a_bare_views_on_close_hook() {
3453        // Pinned behavior, not last-writer-wins: a directly constructed,
3454        // non-staged view's `on_close` is installed once at `build` and never
3455        // touched by a later `rebuild`. A rebuild carrying `None` must not clear
3456        // the hook the widget already has installed.
3457        let closed = Rc::new(Cell::new(0u32));
3458        let hook = closed.clone();
3459        let v1 = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
3460            .on_close(move || hook.set(hook.get() + 1));
3461        let mut w = build(&v1);
3462        layout(&mut w);
3463        run_ramp(&mut w, 0.0);
3464
3465        // Rebuild with a view whose own `on_close` is unset.
3466        let v2 = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3467        rebuild(&v2, &v1, &mut w);
3468
3469        let mut state = Flags::default();
3470        dispatch(&mut w, &mut state, &escape());
3471        frame(&mut w, 3000.0);
3472        frame(&mut w, 5000.0);
3473        assert_eq!(
3474            closed.get(),
3475            1,
3476            "the build-time hook is still installed and still fires — pinned, not cleared"
3477        );
3478    }
3479
3480    #[test]
3481    fn the_exit_keeps_asking_for_the_pass_its_own_motion_needs() {
3482        for (config, layout_affecting) in [
3483            (OverlayModalConfig::edge(OverlaySide::Bottom), true),
3484            (
3485                OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
3486                false,
3487            ),
3488        ] {
3489            let (view, _closed) = staged(config);
3490            let mut w = build(&view);
3491            layout(&mut w);
3492            run_ramp(&mut w, 0.0);
3493            let mut state = Flags::default();
3494            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, 5.0));
3495            dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 5.0, 5.0));
3496
3497            let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(3000.0));
3498            w.paint(&mut ctx, &mut Recorder::default());
3499            assert!(
3500                ctx.needs_frame(),
3501                "the exit's seeding paint schedules its own continuation"
3502            );
3503            assert_eq!(
3504                ctx.needs_layout(),
3505                layout_affecting,
3506                "only a layout-affecting exit asks for a layout pass"
3507            );
3508        }
3509    }
3510
3511    #[test]
3512    fn reduce_motion_closes_on_the_spot_with_no_ramp() {
3513        let mut theme = crate::baseline().with_brightness(Brightness::Light);
3514        theme.motion.reduce_motion = true;
3515        let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3516        let mut w = build(&view);
3517        let size = layout(&mut w);
3518        let paint = |w: &mut OverlayModalWidget, ms: f64| {
3519            let mut ctx =
3520                PaintCtx::for_test(Point::ORIGIN, size, ft_ms(ms)).with_theme(&theme as &dyn Any);
3521            w.paint(&mut ctx, &mut Recorder::default());
3522        };
3523        paint(&mut w, 0.0);
3524        assert!((w.progress() - 1.0).abs() < 1e-9, "no entrance ramp");
3525
3526        let mut state = Flags::default();
3527        dispatch(&mut w, &mut state, &escape());
3528        paint(&mut w, 16.0);
3529        assert_eq!(w.progress(), 0.0, "the exit is a jump, not a ramp");
3530        assert_eq!(closed.get(), 1);
3531    }
3532
3533    #[test]
3534    fn a_second_dismiss_while_exiting_is_a_no_op() {
3535        let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3536        let mut w = build(&view);
3537        layout(&mut w);
3538        run_ramp(&mut w, 0.0);
3539        let mut state = Flags::default();
3540        dispatch(&mut w, &mut state, &escape());
3541        let first = w.progress();
3542        frame(&mut w, 3000.0);
3543        frame(
3544            &mut w,
3545            3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
3546        );
3547        let mid = w.progress();
3548        assert!(mid < first, "the ramp is running");
3549
3550        // A second trigger neither restarts the ramp nor fires anything.
3551        dispatch(&mut w, &mut state, &escape());
3552        assert_eq!(w.progress(), mid, "the ramp is untouched");
3553        frame(&mut w, 5000.0);
3554        assert_eq!(closed.get(), 1, "one close for two triggers");
3555        assert_eq!(state.dismissed, 0);
3556    }
3557
3558    #[test]
3559    fn a_dismiss_trigger_after_settle_does_not_fire_on_close_again() {
3560        // The hazard: `on_close` only *enqueues* `controller.pop()`, so the
3561        // widget stays mounted — and still a barrier — for at least one frame
3562        // after the exit ramp settles. A trigger landing in that window would
3563        // otherwise re-enter `request_dismiss` with `progress` already at zero:
3564        // a zero-duration ramp settling on its very next paint, firing
3565        // `on_close` a second time and popping the page underneath this modal.
3566        let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3567        let mut w = build(&view);
3568        layout(&mut w);
3569        run_ramp(&mut w, 0.0);
3570        let mut state = Flags::default();
3571        dispatch(&mut w, &mut state, &escape());
3572        frame(&mut w, 3000.0);
3573        frame(&mut w, 5000.0);
3574        assert_eq!(w.progress(), 0.0, "settled closed");
3575        assert_eq!(closed.get(), 1, "on_close fired once on settle");
3576        assert!(!w.is_exiting());
3577
3578        // The enqueued pop hasn't drained yet, so the widget is still mounted. A
3579        // further dismiss trigger must be a pure no-op.
3580        dispatch(&mut w, &mut state, &escape());
3581        frame(&mut w, 7000.0);
3582        assert_eq!(closed.get(), 1, "on_close does not fire a second time");
3583        assert_eq!(state.dismissed, 0);
3584    }
3585
3586    #[test]
3587    fn without_a_close_hook_the_dismissal_stays_unstaged() {
3588        // A `Stack`-mounted modal that only wired `on_dismiss` has no
3589        // state-bearing pass to defer into, so it keeps the immediate path
3590        // rather than leaving an invisible barrier standing.
3591        let mut w = build(&view(OverlayModalConfig::centered(
3592            OVERLAY_DIALOG_MAX_WIDTH,
3593        )));
3594        layout(&mut w);
3595        run_ramp(&mut w, 0.0);
3596        let mut state = Flags::default();
3597        dispatch(&mut w, &mut state, &escape());
3598        assert_eq!(state.dismissed, 1);
3599        assert!(!w.is_exiting());
3600        assert!((w.progress() - 1.0).abs() < 1e-9, "nothing animates out");
3601    }
3602
3603    // ---- the back-press (`DismissAnimated`) tier ----------------------------
3604
3605    /// A staged modal wired the way [`show_overlay_modal`] wires one, plus the
3606    /// dismiss-signal cell the navigator bumps on a back press.
3607    fn back_wired(
3608        config: OverlayModalConfig,
3609    ) -> (OverlayModalWidget, Rc<Cell<u64>>, Rc<Cell<u32>>) {
3610        let closed = Rc::new(Cell::new(0u32));
3611        let hook = closed.clone();
3612        let signal = Rc::new(Cell::new(0u64));
3613        let staged = StagedExit {
3614            inner: view(config),
3615            on_close: Rc::new(StagedPop::unguarded(move || hook.set(hook.get() + 1))),
3616            dismiss_signal: Some(signal.clone()),
3617        };
3618        let mut counter = 0u64;
3619        let mut w = View::<Flags>::build(&staged, &mut BuildCtx::new(&mut counter));
3620        layout(&mut w);
3621        run_ramp(&mut w, 0.0);
3622        (w, signal, closed)
3623    }
3624
3625    #[test]
3626    fn a_dismiss_signal_bump_stages_the_same_exit_ramp_every_gesture_takes() {
3627        let (mut w, signal, closed) =
3628            back_wired(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3629        assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
3630
3631        // The navigator's back press: bump the shared generation cell.
3632        signal.set(signal.get() + 1);
3633        frame(&mut w, 3000.0);
3634        assert!(w.is_exiting(), "the back press stages the exit, not a pop");
3635        assert_eq!(closed.get(), 0, "and fires nothing yet");
3636        frame(
3637            &mut w,
3638            3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
3639        );
3640        assert!(w.progress() > 0.0 && w.progress() < 1.0, "mid-exit");
3641        frame(&mut w, 5000.0);
3642        assert_eq!(closed.get(), 1, "the pop fires when the ramp settles");
3643
3644        // A stale re-observation of the same generation changes nothing.
3645        frame(&mut w, 6000.0);
3646        assert_eq!(closed.get(), 1);
3647    }
3648
3649    #[test]
3650    fn a_back_press_on_an_unstageable_modal_fires_the_pop_and_schedules_its_drain() {
3651        // No entrance to reverse: the close fires on the spot, and the very
3652        // paint that fires it must request the frame whose rebuild drains the
3653        // enqueued pop (the drain gotcha).
3654        let (mut w, signal, closed) = back_wired(settled(OverlayModalConfig::centered(
3655            OVERLAY_DIALOG_MAX_WIDTH,
3656        )));
3657        signal.set(signal.get() + 1);
3658        let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(3000.0));
3659        w.paint(&mut ctx, &mut Recorder::default());
3660        assert_eq!(closed.get(), 1);
3661        assert!(
3662            ctx.needs_frame(),
3663            "the firing paint schedules the draining frame"
3664        );
3665    }
3666
3667    #[test]
3668    fn a_non_dismissable_modal_ignores_a_dismiss_signal_bump() {
3669        // `show_overlay_modal` pushes such a page with `BackPolicy::Veto` and no
3670        // cell at all; the widget-side guard is belt-and-braces for a modal
3671        // whose flag flips after the push.
3672        let closed = Rc::new(Cell::new(0u32));
3673        let hook = closed.clone();
3674        let signal = Rc::new(Cell::new(0u64));
3675        let staged = StagedExit {
3676            inner: view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)).dismissable(false),
3677            on_close: Rc::new(StagedPop::unguarded(move || hook.set(hook.get() + 1))),
3678            dismiss_signal: Some(signal.clone()),
3679        };
3680        let mut counter = 0u64;
3681        let mut w = View::<Flags>::build(&staged, &mut BuildCtx::new(&mut counter));
3682        layout(&mut w);
3683        run_ramp(&mut w, 0.0);
3684        signal.set(signal.get() + 1);
3685        frame(&mut w, 3000.0);
3686        assert!(!w.is_exiting());
3687        frame(&mut w, 5000.0);
3688        assert_eq!(closed.get(), 0, "a Veto-shaped modal never self-pops");
3689    }
3690
3691    #[test]
3692    fn a_bump_that_predates_the_widget_is_not_a_back_press_aimed_at_it() {
3693        let closed = Rc::new(Cell::new(0u32));
3694        let hook = closed.clone();
3695        let signal = Rc::new(Cell::new(7u64));
3696        let staged = StagedExit {
3697            inner: view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH)),
3698            on_close: Rc::new(StagedPop::unguarded(move || hook.set(hook.get() + 1))),
3699            dismiss_signal: Some(signal.clone()),
3700        };
3701        let mut counter = 0u64;
3702        let mut w = View::<Flags>::build(&staged, &mut BuildCtx::new(&mut counter));
3703        layout(&mut w);
3704        run_ramp(&mut w, 0.0);
3705        assert!(
3706            !w.is_exiting(),
3707            "the pre-existing generation is the baseline"
3708        );
3709        assert_eq!(closed.get(), 0);
3710    }
3711
3712    // ---- the app-initiated dismiss (`ModalDismiss`) seam --------------------
3713
3714    /// A staged modal (wired the way [`show_overlay_modal`] wires one) with an
3715    /// app dismiss handle installed, already fully entered — plus that handle
3716    /// and the close counter.
3717    fn app_dismissable(
3718        config: OverlayModalConfig,
3719        dismissable: bool,
3720    ) -> (OverlayModalWidget, ModalDismiss, Rc<Cell<u32>>) {
3721        let (view, closed) = staged(config);
3722        let dismiss = ModalDismiss::new();
3723        let view = view
3724            .dismissable(dismissable)
3725            .dismiss_handle(dismiss.clone());
3726        let mut w = build(&view);
3727        layout(&mut w);
3728        run_ramp(&mut w, 0.0);
3729        (w, dismiss, closed)
3730    }
3731
3732    #[test]
3733    fn a_dismiss_request_stages_the_same_exit_ramp_every_gesture_takes() {
3734        let (mut w, dismiss, closed) =
3735            app_dismissable(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH), true);
3736        assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
3737
3738        dismiss.dismiss();
3739        frame(&mut w, 3000.0);
3740        assert!(w.is_exiting(), "the request stages the exit, not a pop");
3741        assert_eq!(closed.get(), 0, "and fires nothing yet");
3742        frame(
3743            &mut w,
3744            3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
3745        );
3746        assert!(w.progress() > 0.0 && w.progress() < 1.0, "mid-exit");
3747        frame(&mut w, 5000.0);
3748        assert_eq!(closed.get(), 1, "the pop fires when the ramp settles");
3749
3750        // A stale re-observation of the same generation changes nothing.
3751        frame(&mut w, 6000.0);
3752        assert_eq!(closed.get(), 1);
3753    }
3754
3755    #[test]
3756    fn a_second_dismiss_request_while_exiting_is_a_no_op() {
3757        let (mut w, dismiss, closed) =
3758            app_dismissable(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH), true);
3759        dismiss.dismiss();
3760        frame(&mut w, 3000.0);
3761        frame(
3762            &mut w,
3763            3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
3764        );
3765        let mid = w.progress();
3766        assert!(mid < 1.0, "the ramp is running");
3767
3768        // A double-tapped action button: the second request neither restarts
3769        // the ramp nor fires a second pop — nor swaps its own payload into the
3770        // exit already in flight, which was staged to pop with something else.
3771        dismiss.dismiss_with(PopResult::of(5));
3772        frame(
3773            &mut w,
3774            3000.0 + MaterialMotion::SHORT_4.as_millis() as f64 / 2.0,
3775        );
3776        assert!(w.is_exiting(), "still the one exit");
3777        assert!(
3778            dismiss.take_result(dismiss.generation()).is_some(),
3779            "the refused request's payload stays with the handle"
3780        );
3781        frame(&mut w, 5000.0);
3782        assert_eq!(closed.get(), 1, "one close for two requests");
3783
3784        // …and one more after the settle, in the window before the enqueued pop
3785        // drains, is refused by the `closed` latch like every other trigger.
3786        dismiss.dismiss();
3787        frame(&mut w, 7000.0);
3788        assert_eq!(closed.get(), 1);
3789    }
3790
3791    #[test]
3792    fn a_dismiss_request_closes_a_non_dismissable_modal() {
3793        // The decision the module docs state: `dismissable(false)` takes the
3794        // *user's* exits away and leaves "an explicit control the app wires
3795        // inside its own content" — which is exactly this handle. A back-press
3796        // bump on the same modal stays refused (see the back-press tier).
3797        let (mut w, dismiss, closed) = app_dismissable(
3798            OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
3799            false,
3800        );
3801        let mut state = Flags::default();
3802        dispatch(&mut w, &mut state, &escape());
3803        assert!(!w.is_exiting(), "the user's own exits are still gone");
3804
3805        dismiss.dismiss();
3806        frame(&mut w, 3000.0);
3807        assert!(w.is_exiting(), "but the app's own control still closes it");
3808        frame(&mut w, 5000.0);
3809        assert_eq!(closed.get(), 1);
3810        assert_eq!(state.dismissed, 0);
3811    }
3812
3813    #[test]
3814    fn a_request_that_predates_the_widget_is_not_aimed_at_it() {
3815        let (view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
3816        let dismiss = ModalDismiss::new();
3817        // A handle re-used from an earlier presentation: its generation is
3818        // already non-zero when this modal mounts.
3819        dismiss.dismiss();
3820        dismiss.dismiss();
3821        let mut w = build(&view.dismiss_handle(dismiss.clone()));
3822        layout(&mut w);
3823        run_ramp(&mut w, 0.0);
3824        assert!(!w.is_exiting(), "the standing generation is the baseline");
3825        assert_eq!(closed.get(), 0);
3826
3827        // The next request is, though.
3828        dismiss.dismiss();
3829        frame(&mut w, 3000.0);
3830        assert!(w.is_exiting());
3831    }
3832
3833    #[test]
3834    fn an_unstageable_modal_fires_the_request_at_once_and_schedules_its_drain() {
3835        // No entrance to reverse: the close fires on the spot, and the very
3836        // paint that fires it must request the frame whose rebuild drains the
3837        // enqueued pop (the drain gotcha).
3838        let (view, closed) = staged(settled(OverlayModalConfig::centered(
3839            OVERLAY_DIALOG_MAX_WIDTH,
3840        )));
3841        let dismiss = ModalDismiss::new();
3842        let mut w = build(&view.dismiss_handle(dismiss.clone()));
3843        layout(&mut w);
3844        dismiss.dismiss();
3845        let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(3000.0));
3846        w.paint(&mut ctx, &mut Recorder::default());
3847        assert_eq!(closed.get(), 1);
3848        assert!(
3849            ctx.needs_frame(),
3850            "the firing paint schedules the draining frame"
3851        );
3852    }
3853
3854    /// Panel content that dismisses through the app handle when pressed — an
3855    /// action button's shape, minus the button (it captures the `Down` and
3856    /// acts on the `Up`, so the host never takes the gesture for itself).
3857    struct Trigger(ModalDismiss);
3858
3859    /// The retained half of [`Trigger`].
3860    struct TriggerWidget(ModalDismiss);
3861
3862    impl<S: 'static> View<S> for Trigger {
3863        type Element = TriggerWidget;
3864        fn build(&self, _ctx: &mut BuildCtx<'_>) -> TriggerWidget {
3865            TriggerWidget(self.0.clone())
3866        }
3867        fn rebuild(
3868            &self,
3869            _prev: &Self,
3870            element: &mut TriggerWidget,
3871            _ctx: &mut BuildCtx<'_>,
3872        ) -> ChangeFlags {
3873            element.0 = self.0.clone();
3874            ChangeFlags::NONE
3875        }
3876    }
3877
3878    impl Widget for TriggerWidget {
3879        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3880            bc.constrain(Size::new(200.0, 120.0))
3881        }
3882        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
3883        fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
3884            let InputEvent::Pointer(p) = event else {
3885                return EventResult::Ignored;
3886            };
3887            match p.phase {
3888                PointerPhase::Down => EventResult::Handled,
3889                PointerPhase::Up => {
3890                    self.0.dismiss();
3891                    EventResult::Handled
3892                }
3893                _ => EventResult::Ignored,
3894            }
3895        }
3896    }
3897
3898    #[test]
3899    fn a_content_action_stages_its_dismiss_in_the_same_event_pass() {
3900        // The event-pass observation: the content handled the press, so no
3901        // paint is owed by anything else — the host must still see the request
3902        // and ask for the frame its ramp needs.
3903        let closed = Rc::new(Cell::new(0u32));
3904        let hook = closed.clone();
3905        let dismiss = ModalDismiss::new();
3906        let view = overlay_modal::<Flags, _>(
3907            Trigger(dismiss.clone()),
3908            OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
3909        )
3910        .on_close(move || hook.set(hook.get() + 1))
3911        .dismiss_handle(dismiss);
3912        let mut w = build(&view);
3913        layout(&mut w);
3914        run_ramp(&mut w, 0.0);
3915
3916        let hit = w.panel_rect().center();
3917        let mut state = Flags::default();
3918        dispatch(
3919            &mut w,
3920            &mut state,
3921            &pointer(PointerPhase::Down, hit.x, hit.y),
3922        );
3923        let state_any: &mut dyn Any = &mut state;
3924        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
3925        let result = w.event(&mut ctx, &pointer(PointerPhase::Up, hit.x, hit.y));
3926        assert_eq!(result, EventResult::Handled);
3927        assert!(w.is_exiting(), "staged inside the pass that asked for it");
3928        assert!(
3929            ctx.needs_redraw(),
3930            "and the ramp's first frame is asked for"
3931        );
3932        assert_eq!(closed.get(), 0, "nothing pops mid-ramp");
3933
3934        frame(&mut w, 3000.0);
3935        frame(&mut w, 5000.0);
3936        assert_eq!(closed.get(), 1);
3937        assert_eq!(state.dismissed, 0, "the staged path, not the unstaged one");
3938    }
3939
3940    #[test]
3941    fn a_stack_mounted_panels_handle_reaches_the_unstaged_on_dismiss() {
3942        // The shape a `Stack`-mounted `DialogView`/`SideSheetView` builds: an
3943        // `on_dismiss` and no staged close hook at all. The handle must still
3944        // dismiss the panel, through the one route such a mount has — and it
3945        // is the *event* pass that has it, since `PaintCtx` carries no state.
3946        let dismiss = ModalDismiss::new();
3947        let view = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
3948            .dismiss_handle(dismiss.clone());
3949        let mut w = build(&view);
3950        layout(&mut w);
3951        run_ramp(&mut w, 0.0);
3952
3953        dismiss.dismiss();
3954        // The paint backstop can reach neither `on_dismiss` (no app state) nor
3955        // an `on_close` (none wired): it must leave the request pending rather
3956        // than consume it, or the handle is silently inert on this mount.
3957        frame(&mut w, 3000.0);
3958        let mut state = Flags::default();
3959        assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
3960
3961        // …so the next event pass is what delivers it.
3962        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 5.0, 5.0));
3963        assert_eq!(
3964            state.dismissed, 1,
3965            "a `Stack` mount's handle is not inert — it dismisses unstaged"
3966        );
3967        assert!(!w.is_exiting(), "there was nothing to stage");
3968
3969        // Exactly once, however many further passes observe the same request.
3970        frame(&mut w, 3100.0);
3971        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 6.0, 6.0));
3972        assert_eq!(state.dismissed, 1);
3973    }
3974
3975    #[test]
3976    fn escape_with_a_pending_unobserved_request_dismisses_exactly_once() {
3977        // Two dismissal routes meeting inside one event pass: an async
3978        // `dismiss()` the paint backstop deliberately left pending (it can
3979        // reach neither hook on this mount), and the `Escape` that arrives
3980        // before any rebuild. The event pass observes the request first and
3981        // dismisses unstaged; the keypress that follows must find that route
3982        // already spent rather than dismiss the same panel a second time.
3983        let dismiss = ModalDismiss::new();
3984        let view = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
3985            .dismiss_handle(dismiss.clone());
3986        let mut w = build(&view);
3987        layout(&mut w);
3988        run_ramp(&mut w, 0.0);
3989
3990        dismiss.dismiss();
3991        frame(&mut w, 3000.0);
3992        let mut state = Flags::default();
3993        assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
3994
3995        assert_eq!(
3996            dispatch(&mut w, &mut state, &escape()),
3997            EventResult::Handled,
3998            "the barrier still swallows the key"
3999        );
4000        assert_eq!(
4001            state.dismissed, 1,
4002            "one keypress over a pending request is one dismissal, not two"
4003        );
4004    }
4005
4006    #[test]
4007    fn a_stack_mounted_dialog_view_dismisses_through_its_installed_handle() {
4008        // The same gap through the public API that reaches it: every dialog
4009        // variant forwards `.dismiss_handle(..)` to this host and composes no
4010        // `on_close`, so a `Stack`-mounted one had no dismissal route at all.
4011        let dismiss = ModalDismiss::new();
4012        let view = crate::dialog::dialog::<Flags>()
4013            .title("Discard draft?")
4014            .on_dismiss(|s: &mut Flags| s.dismissed += 1)
4015            .dismiss_handle(dismiss.clone());
4016        let mut counter = 0u64;
4017        let mut w = View::<Flags>::build(&view, &mut BuildCtx::new(&mut counter));
4018        layout(&mut w);
4019        run_ramp(&mut w, 0.0);
4020
4021        dismiss.dismiss();
4022        let mut state = Flags::default();
4023        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 5.0, 5.0));
4024        assert_eq!(state.dismissed, 1);
4025    }
4026
4027    #[test]
4028    fn a_rebuild_that_swaps_the_handle_relatches_against_the_new_cell() {
4029        // An app that mints its handle *inside* the page builder hands the
4030        // widget a fresh cell every rebuild; the widget follows the newest one
4031        // rather than latching onto a dead cell (and re-seeds, so the swap
4032        // itself is not read as a request).
4033        let (first_view, closed) = staged(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH));
4034        let first = ModalDismiss::new();
4035        let first_view = first_view.dismiss_handle(first.clone());
4036        let mut w = build(&first_view);
4037        layout(&mut w);
4038        run_ramp(&mut w, 0.0);
4039
4040        let second = ModalDismiss::new();
4041        second.dismiss();
4042        let second_view = view(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
4043            .dismiss_handle(second.clone());
4044        rebuild(&second_view, &first_view, &mut w);
4045        frame(&mut w, 3000.0);
4046        assert!(!w.is_exiting(), "the swap itself is not a request");
4047
4048        // The stale handle is inert…
4049        first.dismiss();
4050        frame(&mut w, 3100.0);
4051        assert!(!w.is_exiting());
4052        // …and the live one drives the exit.
4053        second.dismiss();
4054        frame(&mut w, 3200.0);
4055        assert!(w.is_exiting());
4056        frame(&mut w, 6000.0);
4057        assert_eq!(closed.get(), 1);
4058    }
4059
4060    // ---- navigator integration ---------------------------------------------
4061
4062    #[derive(Default)]
4063    struct NavState {
4064        results: Vec<Option<i32>>,
4065    }
4066
4067    /// A minimal opaque page for the navigator integration tests.
4068    struct BgPage(Size);
4069
4070    /// The retained half of [`BgPage`].
4071    struct BgPageWidget(Size);
4072
4073    impl View<NavState> for BgPage {
4074        type Element = BgPageWidget;
4075        fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageWidget {
4076            BgPageWidget(self.0)
4077        }
4078        fn rebuild(&self, _p: &Self, _e: &mut BgPageWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
4079            ChangeFlags::NONE
4080        }
4081    }
4082
4083    impl Widget for BgPageWidget {
4084        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
4085            bc.constrain(self.0)
4086        }
4087        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
4088            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
4089        }
4090    }
4091
4092    /// A navigator rooted on an opaque page, plus the controller driving it.
4093    struct NavHarness {
4094        controller: NavigatorController<NavState>,
4095        root: RenderRoot<NavState, NavigatorView<NavState>>,
4096        state: NavState,
4097        tcx: TextContext,
4098    }
4099
4100    impl NavHarness {
4101        fn new() -> Self {
4102            let controller: NavigatorController<NavState> = NavigatorController::new();
4103            let mut h = NavHarness {
4104                controller,
4105                root: RenderRoot::new(),
4106                state: NavState::default(),
4107                tcx: TextContext::new(),
4108            };
4109            h.pass();
4110            h
4111        }
4112
4113        fn app(&self) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> + use<> {
4114            let ctrl = self.controller.clone();
4115            move |_: &mut NavState| navigator(&ctrl, || core_any::<NavState, _>(BgPage(WINDOW)))
4116        }
4117
4118        fn pass(&mut self) {
4119            let mut app = self.app();
4120            self.root.rebuild(&mut app, &mut self.state);
4121            self.root
4122                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
4123        }
4124
4125        fn paint(&mut self, ms: u64) -> bool {
4126            self.root
4127                .paint(&mut Recorder::default(), ft_ms(ms as f64))
4128                .needs_frame
4129        }
4130
4131        /// Drive rebuild → layout → paint until nothing asks for another frame
4132        /// (bounded), the shape a real shell's loop takes.
4133        fn drive(&mut self, from_ms: u64) {
4134            for i in 0..20u64 {
4135                self.pass();
4136                if !self.paint(from_ms + i * 100) {
4137                    return;
4138                }
4139            }
4140            panic!("the frame loop never settled");
4141        }
4142    }
4143
4144    #[test]
4145    fn show_overlay_modal_pushes_a_transparent_page_that_pops_on_a_staged_dismiss() {
4146        let mut h = NavHarness::new();
4147        show_overlay_modal(
4148            &h.controller,
4149            || {
4150                overlay_modal(
4151                    Block(Size::new(200.0, 120.0)),
4152                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4153                )
4154            },
4155            |state: &mut NavState, result: PopResult| {
4156                state.results.push(result.take::<i32>());
4157            },
4158        );
4159        h.pass();
4160        h.drive(0);
4161        assert_eq!(h.controller.depth(), 2, "the modal is up");
4162
4163        // A scrim tap stages the exit; the pop lands once it settles.
4164        h.root
4165            .event(&mut h.state, &pointer(PointerPhase::Down, 5.0, 5.0));
4166        h.root
4167            .event(&mut h.state, &pointer(PointerPhase::Up, 5.0, 5.0));
4168        assert_eq!(
4169            h.controller.depth(),
4170            2,
4171            "nothing pops during the exit ramp itself"
4172        );
4173        h.drive(3000);
4174        assert_eq!(h.state.results, vec![None], "popped with an empty result");
4175        assert_eq!(h.controller.depth(), 1);
4176    }
4177
4178    /// The staged pop waits out a whole exit ramp, and a navigator pop always
4179    /// takes the *top* page: a page the app pushes inside that window must not
4180    /// be popped in the modal's place, and the modal must stay dismissible
4181    /// afterwards rather than latching `closed` over a page it never removed.
4182    #[test]
4183    fn a_page_pushed_during_the_exit_ramp_is_not_popped_in_the_modals_place() {
4184        let mut h = NavHarness::new();
4185        show_overlay_modal(
4186            &h.controller,
4187            || {
4188                overlay_modal(
4189                    Block(Size::new(200.0, 120.0)),
4190                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4191                )
4192            },
4193            |state: &mut NavState, result: PopResult| {
4194                state.results.push(result.take::<i32>());
4195            },
4196        );
4197        h.pass();
4198        h.drive(0);
4199
4200        // A scrim tap stages the exit…
4201        h.root
4202            .event(&mut h.state, &pointer(PointerPhase::Down, 5.0, 5.0));
4203        h.root
4204            .event(&mut h.state, &pointer(PointerPhase::Up, 5.0, 5.0));
4205
4206        // …and one frame into the ramp the app pushes a page of its own (a deep
4207        // link, an async completion landing). Transparent, so the modal keeps
4208        // painting underneath it and its ramp really does reach the settle that
4209        // fires the staged pop; an opaque page would cull the modal's paint and
4210        // the pop would never fire at all.
4211        h.pass();
4212        h.paint(3000);
4213        h.controller
4214            .push_transparent(|| core_any::<NavState, _>(BgPage(Size::new(400.0, 100.0))));
4215        h.drive(3100);
4216
4217        assert_eq!(
4218            h.controller.depth(),
4219            3,
4220            "the pushed page survives — the staged pop was refused, not aimed at it"
4221        );
4222        assert!(
4223            h.state.results.is_empty(),
4224            "and nothing reported a dismissal"
4225        );
4226
4227        // Recoverable, not bricked: back out of the pushed page, then dismiss
4228        // the modal again — the fresh exit is staged against the stack it
4229        // actually pops.
4230        h.controller.request_back();
4231        h.drive(6000);
4232        assert_eq!(
4233            h.controller.depth(),
4234            2,
4235            "the pushed page backs out normally"
4236        );
4237        h.controller.request_back();
4238        h.drive(9000);
4239        assert_eq!(
4240            h.state.results,
4241            vec![None],
4242            "the modal is still dismissible after the refused pop"
4243        );
4244        assert_eq!(h.controller.depth(), 1);
4245    }
4246
4247    #[test]
4248    fn a_back_request_dismisses_a_dismissable_modal_through_the_navigator() {
4249        let mut h = NavHarness::new();
4250        show_overlay_modal(
4251            &h.controller,
4252            || {
4253                overlay_modal(
4254                    Block(Size::new(200.0, 120.0)),
4255                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4256                )
4257            },
4258            |state: &mut NavState, result: PopResult| {
4259                state.results.push(result.take::<i32>());
4260            },
4261        );
4262        h.pass();
4263        h.drive(0);
4264        assert!(
4265            h.controller.back_interest(),
4266            "a dismissable modal claims back interest"
4267        );
4268
4269        h.controller.request_back();
4270        h.pass();
4271        assert_eq!(
4272            h.controller.depth(),
4273            2,
4274            "DismissAnimated: the stack is unchanged immediately"
4275        );
4276        // The back-press rebuild flagged PAINT; this paint observes the bumped
4277        // signal and stages the exit — which pops only once it settles.
4278        assert!(
4279            h.paint(3000),
4280            "the staging paint schedules its own continuation"
4281        );
4282        assert_eq!(h.controller.depth(), 2, "still up, mid-ramp");
4283        h.drive(4000);
4284        assert_eq!(
4285            h.state.results,
4286            vec![None],
4287            "a back request dismisses the modal"
4288        );
4289        assert_eq!(h.controller.depth(), 1);
4290    }
4291
4292    #[test]
4293    fn a_back_request_on_a_non_dismissable_modal_is_vetoed() {
4294        let mut h = NavHarness::new();
4295        show_overlay_modal(
4296            &h.controller,
4297            || {
4298                overlay_modal(
4299                    Block(Size::new(200.0, 120.0)),
4300                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4301                )
4302                .dismissable(false)
4303            },
4304            |state: &mut NavState, result: PopResult| {
4305                state.results.push(result.take::<i32>());
4306            },
4307        );
4308        h.pass();
4309        h.drive(0);
4310        assert!(
4311            h.controller.back_interest(),
4312            "a Veto modal still claims the back press"
4313        );
4314
4315        h.controller.request_back();
4316        h.pass();
4317        h.paint(3000);
4318        h.paint(6000);
4319        assert!(
4320            h.state.results.is_empty(),
4321            "a non-dismissable modal never pops on a back request"
4322        );
4323        assert_eq!(h.controller.depth(), 2);
4324    }
4325
4326    #[test]
4327    fn a_dismiss_request_pops_with_its_result_only_once_the_ramp_settles() {
4328        let mut h = NavHarness::new();
4329        let dismiss = ModalDismiss::new();
4330        let installed = dismiss.clone();
4331        show_overlay_modal(
4332            &h.controller,
4333            move || {
4334                overlay_modal(
4335                    Block(Size::new(200.0, 120.0)),
4336                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4337                )
4338                .dismiss_handle(installed.clone())
4339            },
4340            |state: &mut NavState, result: PopResult| {
4341                state.results.push(result.take::<i32>());
4342            },
4343        );
4344        h.pass();
4345        h.drive(0);
4346        assert_eq!(h.controller.depth(), 2, "the modal is up");
4347
4348        // The panel's own "Apply": a staged dismissal carrying a value.
4349        dismiss.dismiss_with(PopResult::of(7));
4350        h.pass();
4351        h.paint(3000);
4352        assert_eq!(
4353            h.controller.depth(),
4354            2,
4355            "nothing pops during the exit ramp itself"
4356        );
4357        h.drive(3100);
4358        assert_eq!(
4359            h.state.results,
4360            vec![Some(7)],
4361            "the payload rides the staged pop to the pusher's on_result"
4362        );
4363        assert_eq!(h.controller.depth(), 1);
4364    }
4365
4366    /// One handle shared by two panel actions is `show_date_picker`'s exact
4367    /// shape: "Cancel" dismisses empty, "OK" dismisses with the pick. An OK
4368    /// pressed *during* the cancel ramp is the documented no-op — it must not
4369    /// retarget the exit already in flight, or the cancelled panel pops
4370    /// confirmed.
4371    #[test]
4372    fn a_request_refused_mid_ramp_never_retargets_the_exit_it_found() {
4373        let mut h = NavHarness::new();
4374        let dismiss = ModalDismiss::new();
4375        let installed = dismiss.clone();
4376        show_overlay_modal(
4377            &h.controller,
4378            move || {
4379                overlay_modal(
4380                    Block(Size::new(200.0, 120.0)),
4381                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4382                )
4383                .dismiss_handle(installed.clone())
4384            },
4385            |state: &mut NavState, result: PopResult| {
4386                state.results.push(result.take::<i32>());
4387            },
4388        );
4389        h.pass();
4390        h.drive(0);
4391
4392        // "Cancel": an empty staged dismissal.
4393        dismiss.dismiss();
4394        h.pass();
4395        h.paint(3000);
4396        // …then "OK" lands one frame into its ramp.
4397        dismiss.dismiss_with(PopResult::of(7));
4398        h.drive(3100);
4399        assert_eq!(
4400            h.state.results,
4401            vec![None],
4402            "the ramp in flight is the dismissal, and it keeps its own empty result"
4403        );
4404        assert_eq!(h.controller.depth(), 1);
4405
4406        // The refused request left its payload with the handle rather than in
4407        // a widget that has since gone — and a re-presentation neither
4408        // inherits it nor is wedged by it: a fresh request supersedes the
4409        // stashed value and pops empty again.
4410        let re_presented = dismiss.clone();
4411        show_overlay_modal(
4412            &h.controller,
4413            move || {
4414                overlay_modal(
4415                    Block(Size::new(200.0, 120.0)),
4416                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4417                )
4418                .dismiss_handle(re_presented.clone())
4419            },
4420            |state: &mut NavState, result: PopResult| {
4421                state.results.push(result.take::<i32>());
4422            },
4423        );
4424        h.pass();
4425        h.drive(6000);
4426        assert_eq!(h.controller.depth(), 2, "the modal is up again");
4427        dismiss.dismiss();
4428        h.pass();
4429        h.drive(9000);
4430        assert_eq!(
4431            h.state.results,
4432            vec![None, None],
4433            "the re-presented modal observes a fresh request, with no stale payload"
4434        );
4435        assert_eq!(h.controller.depth(), 1);
4436    }
4437
4438    /// A payload is scoped to the exit its own request staged. A staged pop
4439    /// **refused** at fire time (the stack moved — see [`StagedPop`]) delivered
4440    /// nothing and ramps the panel back open, so the payload dies with it
4441    /// rather than riding whichever unrelated dismissal lands next.
4442    #[test]
4443    fn a_refused_staged_pop_drops_the_payload_it_was_carrying() {
4444        let mut h = NavHarness::new();
4445        let dismiss = ModalDismiss::new();
4446        let installed = dismiss.clone();
4447        show_overlay_modal(
4448            &h.controller,
4449            move || {
4450                overlay_modal(
4451                    Block(Size::new(200.0, 120.0)),
4452                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4453                )
4454                .dismiss_handle(installed.clone())
4455            },
4456            |state: &mut NavState, result: PopResult| {
4457                state.results.push(result.take::<i32>());
4458            },
4459        );
4460        h.pass();
4461        h.drive(0);
4462
4463        // The panel's own "Apply", carrying its value…
4464        dismiss.dismiss_with(PopResult::of(7));
4465        h.pass();
4466        h.paint(3000);
4467        // …and a page lands on top before the ramp settles, so the pop is
4468        // refused and the panel ramps back open.
4469        h.controller
4470            .push_transparent(|| core_any::<NavState, _>(BgPage(Size::new(400.0, 100.0))));
4471        h.drive(3100);
4472        assert_eq!(h.controller.depth(), 3, "the pushed page survives");
4473        assert!(h.state.results.is_empty(), "nothing was delivered");
4474
4475        // Back out of the pushed page, then dismiss the modal the ordinary
4476        // way: the payload of the exit that never landed must not ride it.
4477        h.controller.request_back();
4478        h.drive(6000);
4479        assert_eq!(h.controller.depth(), 2);
4480        h.controller.request_back();
4481        h.drive(9000);
4482        assert_eq!(
4483            h.state.results,
4484            vec![None],
4485            "a later, unrelated dismissal pops with its own empty result"
4486        );
4487        assert_eq!(h.controller.depth(), 1);
4488    }
4489
4490    /// A payload-free `dismiss()` pops empty even when an earlier
4491    /// `dismiss_with(..)` was never observed: the newer request supersedes the
4492    /// stash, so the handle cannot deliver a value the app has moved on from.
4493    #[test]
4494    fn a_plain_dismiss_after_an_unobserved_dismiss_with_pops_empty() {
4495        let mut h = NavHarness::new();
4496        let dismiss = ModalDismiss::new();
4497        let installed = dismiss.clone();
4498        show_overlay_modal(
4499            &h.controller,
4500            move || {
4501                overlay_modal(
4502                    Block(Size::new(200.0, 120.0)),
4503                    OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH),
4504                )
4505                .dismiss_handle(installed.clone())
4506            },
4507            |state: &mut NavState, result: PopResult| {
4508                state.results.push(result.take::<i32>());
4509            },
4510        );
4511        h.pass();
4512        h.drive(0);
4513
4514        // Both raised before any pass observes either.
4515        dismiss.dismiss_with(PopResult::of(7));
4516        dismiss.dismiss();
4517        h.pass();
4518        h.drive(3000);
4519        assert_eq!(
4520            h.state.results,
4521            vec![None],
4522            "the plain dismissal pops with nothing, not with the stale payload"
4523        );
4524        assert_eq!(h.controller.depth(), 1);
4525    }
4526
4527    // ---- drag-to-close and snap points --------------------------------------
4528
4529    /// A bottom sheet's chrome: content-tall, draggable, handled.
4530    fn sheet_config() -> OverlayModalConfig {
4531        OverlayModalConfig::edge(OverlaySide::Bottom)
4532            .handle(true)
4533            .drag(true)
4534    }
4535
4536    /// Build an entered, draggable bottom panel plus its close counter.
4537    fn dragged() -> (OverlayModalWidget, Rc<Cell<u32>>) {
4538        let (view, closed) = staged(sheet_config());
4539        let mut w = build(&view);
4540        layout(&mut w);
4541        run_ramp(&mut w, 0.0);
4542        (w, closed)
4543    }
4544
4545    #[test]
4546    fn a_drag_past_the_midpoint_closes_and_a_shorter_one_springs_back() {
4547        for (travel, closes) in [(100.0, true), (30.0, false)] {
4548            let (mut w, closed) = dragged();
4549            let extent = w.panel_rect().height();
4550            let start = w.handle_rect().unwrap().center();
4551            let mut state = Flags::default();
4552            dispatch(
4553                &mut w,
4554                &mut state,
4555                &pointer(PointerPhase::Down, start.x, start.y),
4556            );
4557            dispatch(
4558                &mut w,
4559                &mut state,
4560                &pointer(PointerPhase::Move, start.x, start.y + travel),
4561            );
4562            // The panel tracks the pointer: progress falls by the fraction of
4563            // its own extent the drag covered.
4564            let expected = 1.0 - travel / extent;
4565            assert!(
4566                (w.progress() - expected).abs() < 1e-9,
4567                "{travel} → {}",
4568                w.progress()
4569            );
4570            assert!(
4571                w.panel_rect().y0 > 0.0 && w.panel_rect().y1 > WINDOW.height,
4572                "the panel followed it down"
4573            );
4574            dispatch(
4575                &mut w,
4576                &mut state,
4577                &pointer(PointerPhase::Up, start.x, start.y + travel),
4578            );
4579            run_ramp(&mut w, 3000.0);
4580            if closes {
4581                assert_eq!(closed.get(), 1, "past the midpoint it commits");
4582                assert_eq!(w.progress(), 0.0);
4583            } else {
4584                assert_eq!(closed.get(), 0, "short of it, it settles back open");
4585                assert!((w.progress() - 1.0).abs() < 1e-9);
4586            }
4587        }
4588    }
4589
4590    #[test]
4591    fn a_non_dismissable_sheet_springs_back_from_a_drag_past_the_midpoint() {
4592        // Neither `on_dismiss` nor `on_close` wired: `overlay_modal` alone, not
4593        // the `view`/`staged` helpers above (both wire `on_dismiss`) — the shape
4594        // a `Stack`-mounted sheet with no dismiss hook is left in.
4595        let mut w = build(&overlay_modal(
4596            Block(Size::new(200.0, 120.0)),
4597            sheet_config(),
4598        ));
4599        layout(&mut w);
4600        run_ramp(&mut w, 0.0);
4601        let extent = w.panel_rect().height();
4602        let start = w.handle_rect().unwrap().center();
4603        let mut state = Flags::default();
4604        dispatch(
4605            &mut w,
4606            &mut state,
4607            &pointer(PointerPhase::Down, start.x, start.y),
4608        );
4609        dispatch(
4610            &mut w,
4611            &mut state,
4612            &pointer(PointerPhase::Move, start.x, start.y + extent * 0.9),
4613        );
4614        assert!(
4615            w.progress() < 0.2,
4616            "the panel still tracks the pointer while dragging: {}",
4617            w.progress()
4618        );
4619        dispatch(
4620            &mut w,
4621            &mut state,
4622            &pointer(PointerPhase::Up, start.x, start.y + extent * 0.9),
4623        );
4624        run_ramp(&mut w, 3000.0);
4625        assert!(
4626            (w.progress() - 1.0).abs() < 1e-9,
4627            "no dismiss hook: springs back to open_progress() instead of \
4628             sticking near zero as an invisible full-window barrier — {}",
4629            w.progress()
4630        );
4631        // The panel is unambiguously open and hittable where it rests, not a
4632        // residual off-screen sliver.
4633        assert!(w.panel_rect().contains(w.panel_rect().center()));
4634        assert!(w.panel_rect().y0 < WINDOW.height && w.panel_rect().y1 == WINDOW.height);
4635        assert_eq!(state.dismissed, 0, "no dismiss hook ever fired");
4636    }
4637
4638    #[test]
4639    fn snap_points_zero_is_treated_as_absent_and_opens_fully() {
4640        let (view, _closed) = staged(sheet_config());
4641        let mut w = build(&view.snap_points(&[0.0]));
4642        assert!(
4643            w.open_progress() > PROGRESS_EPSILON,
4644            "a first point of 0.0 is treated as absent: {}",
4645            w.open_progress()
4646        );
4647        assert_eq!(w.open_progress(), 1.0, "falls back to fully open");
4648
4649        layout(&mut w);
4650        run_ramp(&mut w, 0.0);
4651        assert!(
4652            (w.progress() - 1.0).abs() < 1e-9,
4653            "the sheet opens fully rather than opening already closed: {}",
4654            w.progress()
4655        );
4656    }
4657
4658    #[test]
4659    fn a_flick_toward_the_edge_closes_from_anywhere() {
4660        // A tall panel, so the flick's own travel stays a small fraction of the
4661        // extent and the *speed* is what decides.
4662        let closed = Rc::new(Cell::new(0u32));
4663        let hook = closed.clone();
4664        let mut w = build(
4665            &overlay_modal(Block(Size::new(200.0, 400.0)), sheet_config())
4666                .on_close(move || hook.set(hook.get() + 1)),
4667        );
4668        layout(&mut w);
4669        run_ramp(&mut w, 0.0);
4670        let start = w.handle_rect().unwrap().center();
4671        let mut state = Flags::default();
4672        dispatch(
4673            &mut w,
4674            &mut state,
4675            &pointer(PointerPhase::Down, start.x, start.y),
4676        );
4677        // A short, fast flick: 40px between two frames 16ms apart is 2500 px/s,
4678        // well past `OVERLAY_FLING_VELOCITY`, while the panel is still nearly
4679        // open.
4680        frame(&mut w, 3000.0);
4681        dispatch(
4682            &mut w,
4683            &mut state,
4684            &pointer(PointerPhase::Move, start.x, start.y + 40.0),
4685        );
4686        frame(&mut w, 3016.0);
4687        dispatch(
4688            &mut w,
4689            &mut state,
4690            &pointer(PointerPhase::Move, start.x, start.y + 80.0),
4691        );
4692        assert!(w.progress() > 0.5, "still mostly open: {}", w.progress());
4693        dispatch(
4694            &mut w,
4695            &mut state,
4696            &pointer(PointerPhase::Up, start.x, start.y + 80.0),
4697        );
4698        run_ramp(&mut w, 4000.0);
4699        assert_eq!(closed.get(), 1, "the flick committed to the close");
4700    }
4701
4702    #[test]
4703    fn catching_a_closing_sheet_cancels_its_dismissal() {
4704        let (mut w, closed) = dragged();
4705        let mut state = Flags::default();
4706        let handle = w.handle_rect().unwrap().center();
4707        dispatch(&mut w, &mut state, &escape());
4708        frame(&mut w, 3000.0);
4709        frame(
4710            &mut w,
4711            3000.0 + MaterialMotion::LONG_2.as_millis() as f64 / 4.0,
4712        );
4713        assert!(w.is_exiting() && w.progress() < 1.0);
4714
4715        // Grabbing the panel mid-exit and pulling it back open wins.
4716        dispatch(
4717            &mut w,
4718            &mut state,
4719            &pointer(PointerPhase::Down, handle.x, handle.y),
4720        );
4721        dispatch(
4722            &mut w,
4723            &mut state,
4724            &pointer(PointerPhase::Move, handle.x, handle.y - 60.0),
4725        );
4726        assert!(!w.is_exiting(), "the drag owns the panel now");
4727        dispatch(
4728            &mut w,
4729            &mut state,
4730            &pointer(PointerPhase::Up, handle.x, handle.y - 60.0),
4731        );
4732        run_ramp(&mut w, 4000.0);
4733        assert_eq!(closed.get(), 0, "nothing closed");
4734        assert!((w.progress() - 1.0).abs() < 1e-9, "back open");
4735    }
4736
4737    #[test]
4738    fn a_cancelled_drag_returns_to_where_it_started() {
4739        let (mut w, closed) = dragged();
4740        let start = w.handle_rect().unwrap().center();
4741        let mut state = Flags::default();
4742        dispatch(
4743            &mut w,
4744            &mut state,
4745            &pointer(PointerPhase::Down, start.x, start.y),
4746        );
4747        dispatch(
4748            &mut w,
4749            &mut state,
4750            &pointer(PointerPhase::Move, start.x, start.y + 90.0),
4751        );
4752        assert!(w.progress() < 1.0);
4753        dispatch(
4754            &mut w,
4755            &mut state,
4756            &pointer(PointerPhase::Cancel, start.x, start.y + 90.0),
4757        );
4758        run_ramp(&mut w, 3000.0);
4759        assert!((w.progress() - 1.0).abs() < 1e-9, "restored");
4760        assert_eq!(closed.get(), 0);
4761    }
4762
4763    #[test]
4764    fn a_tap_on_a_draggable_handle_still_dismisses_but_one_on_the_panel_does_not() {
4765        let (mut w, closed) = dragged();
4766        let mut state = Flags::default();
4767        let handle = w.handle_rect().unwrap().center();
4768        dispatch(
4769            &mut w,
4770            &mut state,
4771            &pointer(PointerPhase::Down, handle.x, handle.y),
4772        );
4773        dispatch(
4774            &mut w,
4775            &mut state,
4776            &pointer(PointerPhase::Up, handle.x, handle.y),
4777        );
4778        run_ramp(&mut w, 3000.0);
4779        assert_eq!(closed.get(), 1);
4780
4781        let (mut w, closed) = dragged();
4782        let body = w.panel_rect().center();
4783        dispatch(
4784            &mut w,
4785            &mut state,
4786            &pointer(PointerPhase::Down, body.x, body.y),
4787        );
4788        dispatch(
4789            &mut w,
4790            &mut state,
4791            &pointer(PointerPhase::Up, body.x, body.y),
4792        );
4793        run_ramp(&mut w, 3000.0);
4794        assert_eq!(
4795            closed.get(),
4796            0,
4797            "the panel surface is a barrier, not a button"
4798        );
4799        assert!((w.progress() - 1.0).abs() < 1e-9);
4800    }
4801
4802    #[test]
4803    fn snap_points_open_at_the_first_and_the_release_takes_the_nearest() {
4804        let (view, closed) = staged(sheet_config());
4805        let mut w = build(&view.snap_points(&[0.5, 1.0]));
4806        layout(&mut w);
4807        run_ramp(&mut w, 0.0);
4808        assert!(
4809            (w.progress() - 0.5).abs() < 1e-9,
4810            "opens at the first point: {}",
4811            w.progress()
4812        );
4813        let extent = w.panel_rect().height();
4814        assert!(
4815            (w.panel_rect().y0 - (WINDOW.height - extent * 0.5)).abs() < 1e-9,
4816            "and shows exactly that fraction of itself"
4817        );
4818
4819        // Dragged up most of the way: the release snaps to the point above.
4820        let start = w.handle_rect().unwrap().center();
4821        let mut state = Flags::default();
4822        dispatch(
4823            &mut w,
4824            &mut state,
4825            &pointer(PointerPhase::Down, start.x, start.y),
4826        );
4827        dispatch(
4828            &mut w,
4829            &mut state,
4830            &pointer(PointerPhase::Move, start.x, start.y - extent * 0.4),
4831        );
4832        dispatch(
4833            &mut w,
4834            &mut state,
4835            &pointer(PointerPhase::Up, start.x, start.y - extent * 0.4),
4836        );
4837        run_ramp(&mut w, 3000.0);
4838        assert!((w.progress() - 1.0).abs() < 1e-9, "{}", w.progress());
4839        assert_eq!(closed.get(), 0);
4840
4841        // Below the lowest point, `0.0` is the nearest: it closes.
4842        dispatch(
4843            &mut w,
4844            &mut state,
4845            &pointer(PointerPhase::Down, start.x, start.y),
4846        );
4847        dispatch(
4848            &mut w,
4849            &mut state,
4850            &pointer(PointerPhase::Move, start.x, start.y + extent * 0.85),
4851        );
4852        dispatch(
4853            &mut w,
4854            &mut state,
4855            &pointer(PointerPhase::Up, start.x, start.y + extent * 0.85),
4856        );
4857        run_ramp(&mut w, 6000.0);
4858        assert_eq!(closed.get(), 1);
4859    }
4860
4861    #[test]
4862    fn a_snap_point_above_one_is_logical_px_against_the_extent() {
4863        let (view, _closed) = staged(sheet_config());
4864        let mut w = build(&view.snap_points(&[60.0]));
4865        layout(&mut w);
4866        run_ramp(&mut w, 0.0);
4867        let extent = w.panel_rect().height();
4868        assert!(
4869            (w.progress() - 60.0 / extent).abs() < 1e-9,
4870            "{}",
4871            w.progress()
4872        );
4873        assert!(
4874            (w.panel_rect().y0 - (WINDOW.height - 60.0)).abs() < 1e-9,
4875            "60 logical px of the sheet are on screen"
4876        );
4877    }
4878
4879    #[test]
4880    fn snap_points_drops_non_finite_values_at_the_boundary() {
4881        let (view, _closed) = staged(sheet_config());
4882        let view = view.snap_points(&[f64::NAN, f64::INFINITY]);
4883        assert!(
4884            view.snap_points.is_empty(),
4885            "NaN and infinity are both non-finite: dropped by the builder, \
4886             never stored"
4887        );
4888    }
4889
4890    #[test]
4891    fn non_finite_snap_points_behave_as_absent_through_a_drag_and_never_panic() {
4892        // Same shape as `a_drag_past_the_midpoint_closes_and_a_shorter_one_
4893        // springs_back`, but with degenerate snap points: NaN/∞ are dropped
4894        // at the boundary (`OverlayModalView::snap_points`), so
4895        // `snap_targets()` is exactly the no-snap-points default `{0.0, 1.0}`
4896        // and the drag clamp's `open` bound is never NaN — the crash this
4897        // guards against is `f64::clamp`'s internal `assert!(min <= max)`
4898        // panicking on a NaN bound, an abort under `panic = "abort"`.
4899        let (view, closed) = staged(sheet_config());
4900        let mut w = build(&view.snap_points(&[f64::NAN, f64::INFINITY]));
4901        layout(&mut w);
4902        run_ramp(&mut w, 0.0);
4903        assert!(
4904            (w.progress() - 1.0).abs() < 1e-9,
4905            "opens fully, same as no snap points at all: {}",
4906            w.progress()
4907        );
4908
4909        let extent = w.panel_rect().height();
4910        let start = w.handle_rect().unwrap().center();
4911        let mut state = Flags::default();
4912        dispatch(
4913            &mut w,
4914            &mut state,
4915            &pointer(PointerPhase::Down, start.x, start.y),
4916        );
4917        dispatch(
4918            &mut w,
4919            &mut state,
4920            &pointer(PointerPhase::Move, start.x, start.y + extent * 0.9),
4921        );
4922        assert!(
4923            w.progress().is_finite(),
4924            "no NaN reached progress mid-drag: {}",
4925            w.progress()
4926        );
4927        dispatch(
4928            &mut w,
4929            &mut state,
4930            &pointer(PointerPhase::Up, start.x, start.y + extent * 0.9),
4931        );
4932        run_ramp(&mut w, 3000.0);
4933        assert!(w.progress().is_finite());
4934        assert_eq!(
4935            closed.get(),
4936            1,
4937            "past the midpoint it commits, same as the no-snap-points default"
4938        );
4939    }
4940
4941    #[test]
4942    fn degenerate_snap_points_dont_strand_the_barrier_on_a_tap_dismiss() {
4943        // A handle tap (not a drag) still routes through the same exit-ramp
4944        // settle check (`settled_exit`'s `progress <= PROGRESS_EPSILON`).
4945        // Degenerate snap points must not leave a NaN in `progress` that
4946        // would never satisfy that comparison and strand the barrier open
4947        // forever.
4948        let (view, closed) = staged(sheet_config());
4949        let mut w = build(&view.snap_points(&[f64::NAN, f64::INFINITY]));
4950        layout(&mut w);
4951        run_ramp(&mut w, 0.0);
4952        let handle = w.handle_rect().unwrap().center();
4953        let mut state = Flags::default();
4954        dispatch(
4955            &mut w,
4956            &mut state,
4957            &pointer(PointerPhase::Down, handle.x, handle.y),
4958        );
4959        dispatch(
4960            &mut w,
4961            &mut state,
4962            &pointer(PointerPhase::Up, handle.x, handle.y),
4963        );
4964        run_ramp(&mut w, 3000.0);
4965        assert!(
4966            w.progress().is_finite(),
4967            "no NaN reached progress: {}",
4968            w.progress()
4969        );
4970        assert_eq!(w.progress(), 0.0, "settled fully closed, not stranded");
4971        assert_eq!(
4972            closed.get(),
4973            1,
4974            "the barrier settled and fired on_close, not left stuck open"
4975        );
4976    }
4977
4978    // ---- semantics ----------------------------------------------------------
4979
4980    #[test]
4981    fn semantics_is_a_modal_node_labelled_by_its_title() {
4982        fn logic(_s: &mut Flags) -> OverlayModalView<Flags> {
4983            overlay_modal(
4984                Block(Size::new(100.0, 50.0)),
4985                settled(OverlayModalConfig::centered(OVERLAY_DIALOG_MAX_WIDTH))
4986                    .role(OverlayRole::AlertDialog),
4987            )
4988            .label("Are you sure?")
4989        }
4990        let mut root: RenderRoot<Flags, OverlayModalView<Flags>> = RenderRoot::new();
4991        let mut state = Flags::default();
4992        root.rebuild(&mut logic, &mut state);
4993        let mut tcx = TextContext::new();
4994        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
4995        let update = root.semantics();
4996        let (_, node) = update
4997            .nodes
4998            .iter()
4999            .find(|(_, n)| n.role() == Role::AlertDialog)
5000            .expect("an AlertDialog node");
5001        assert!(node.is_modal());
5002        assert_eq!(node.label(), Some("Are you sure?"));
5003    }
5004
5005    #[test]
5006    fn visit_children_publishes_the_content() {
5007        let w = build(&view(settled(OverlayModalConfig::centered(
5008            OVERLAY_DIALOG_MAX_WIDTH,
5009        ))));
5010        let mut seen = 0usize;
5011        Widget::visit_children(&w, &mut |_pod| seen += 1);
5012        assert_eq!(seen, 1);
5013    }
5014}