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