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}