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