Skip to main content

frust_material/
sheet.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/bottom_sheets/` tree is `m3e_bottom_sheets.dart` +
3// `styles/m3e_bottom_sheet_theme.dart`.
4// Porting decision: the reference's own drag handling is velocity-only
5// (`_handleDragEnd` fires past a fling threshold with no live position
6// tracking) and its entrance is a plain duration+curve `SlideTransition`
7// (`M3EMotion.long1` + `emphasizedDecelerate`). This port instead tracks the
8// drag interactively (the panel follows the finger) and drives the
9// entrance/exit/drag-settle with a physical spring
10// (`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`), matching the
11// spring machinery the rest of this crate's M3E-flavored motion already uses
12// (`crate::button::motion`, `crate::loading_indicator`) rather than the
13// reference's simpler duration+curve ramp.
14
15//! The modal M3 `BottomSheet`: a full-area widget = **scrim** (32%
16//! scrim-color fill, fading with the panel's own spring) + a **bottom-anchored
17//! panel** (top corners extra-large 28dp, `surfaceContainerLow`, a centered
18//! drag handle **32dp wide × 4dp tall** in a 48dp touch target), pushed as a
19//! transparent navigator page via
20//! [`NavigatorController::push_transparent_for_result`]. [`show_bottom_sheet`]
21//! wraps the push.
22//!
23//! # Navigator-modal architecture
24//!
25//! Like [`mod@crate::dialog`], the scrim is part of *this* widget (the
26//! navigator provides none and routes input only to the top page), and the
27//! sheet is designed to be pushed as a transparent page over a static
28//! background. Dismissal is always `controller.pop()` — staged behind the exit
29//! spring on that path (see *Who owns the exit*) — wired to three gestures:
30//!
31//! * a **scrim tap** (a press+release outside the panel) pops with an *empty*
32//!   [`PopResult`];
33//! * a **drag-release on the handle**, decided by position and velocity (see
34//!   below);
35//! * **Escape**, once the sheet holds focus (see *Keyboard operability*).
36//!
37//! # Interactive drag + spring settle
38//!
39//! Unlike the reference, the panel **follows the finger**: a press in the
40//! 48dp handle strip captures the pointer, and once the gesture passes
41//! [`frust::input::TOUCH_SLOP`] every `Move` scrubs [`BottomSheetWidget`]'s
42//! internal `progress` (`0.0` fully closed/off-screen, `1.0` at rest) by the
43//! drag's own fraction of the panel's height — the panel translates with the
44//! pointer, clamped to `0.0..=1.0`. A release below [`TOUCH_SLOP`] is a *tap*:
45//! unchanged from v1, nothing dismisses (only a real drag can).
46//!
47//! On release, the panel's fate is decided by **position and velocity**,
48//! mirroring [`overlay::modal`](crate::overlay::modal)'s own drag-settle: a
49//! release speed at or above [`FLING_VELOCITY`] commits in the direction it
50//! was flung (a fast downward flick dismisses even from near the top; a fast
51//! upward one stays open even from near the bottom); otherwise the position
52//! alone decides — past half the sheet's own height ([`DRAG_DISMISS_FRACTION`])
53//! dismisses, short of it springs back open. Either way, the panel
54//! **springs** to its resting point (open or closed) rather than snapping or
55//! easing on a fixed duration — [`BottomSheetWidget::spring_to`] launches a
56//! fresh leg of [`SHEET_SPRING`] (`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`)
57//! from wherever `progress` currently reads, at the release's own measured
58//! speed (boosted to at least [`SPRING_KICK`] in the decided direction when
59//! the raw reading is too weak or the wrong sign to read as a deliberate
60//! commit).
61//!
62//! # Spring entrance/exit
63//!
64//! The sheet no longer rides the navigator's own page-transition slide
65//! ([`show_bottom_sheet`] pushes with [`TransitionSpec::NONE`] — the widget
66//! stages its own motion, the [`crate::overlay::modal`] precedent): on its
67//! first `paint`, [`BottomSheetWidget`] kicks [`SHEET_SPRING`] from `progress
68//! = 0.0` toward `1.0`, so **the first frame is invisible** (the panel is off
69//! -screen) and the slide-up becomes visible only from the second frame
70//! onward — the same one-frame lag [`crate::overlay::modal`]'s own ramp
71//! documents. A dismiss trigger with no drag behind it (a scrim tap, Escape,
72//! an Android back press) kicks the same spring the other way, at
73//! [`SPRING_KICK`]. `Theme.motion.reduce_motion` collapses both to a jump:
74//! `paint` stops the spring and snaps `progress` straight to its target.
75//!
76//! # Who owns the exit
77//!
78//! **The widget's own spring owns it, on every mount, and on the navigator
79//! path the pop waits for that spring to settle** — the same
80//! [`crate::overlay::modal`] staging shape, reached here without a single new
81//! public builder method.
82//!
83//! The alternative was letting the navigator animate the pop, by pushing with
84//! a slide-up page transition instead of [`TransitionSpec::NONE`]. It **is**
85//! expressible, and was rejected on its merits rather than for lack of a seam:
86//! a [`TransitionSpec`] is one spec per page, applied to the push *and* the pop
87//! alike, so a *preset* pop-side slide-down necessarily brings its push-side
88//! slide-up back with it — double-animating the entrance the widget's own spring
89//! already runs — but [`frust::PageTransition::Custom`] is handed `is_pop` and
90//! maps it onto the `(entering, leaving)` layer pair itself, so an asymmetric,
91//! pop-only spec is a plain use of that public seam, with no `frust-widgets`
92//! edit involved. What decides it is ownership: the entrance is already a spring
93//! *inside* this widget, and a navigator-driven exit starts from the page
94//! transition's own `0..=1`, knowing nothing of the `progress` a live handle drag
95//! left behind — the drag-release settle could no longer pick up where the finger
96//! stopped, and the sheet's motion would live in two places at once. So both
97//! halves stay under one owner, the widget, and the navigator contributes no page
98//! offset in either direction.
99//!
100//! Staging costs no public surface because the pop it defers is **state-free**:
101//! [`show_bottom_sheet`] wires an internal `on_close` (a [`StagedPop`] around
102//! `controller.pop()`) onto the view the same private way it already wires the
103//! back-press [`dismiss_signal`](PushOptions::dismiss_signal) cell — neither is
104//! a builder method, and neither is reachable from outside this module. Every
105//! dismiss trigger (scrim tap, handle-drag release, Escape, Android back) then
106//! takes one shape: kick the closing spring, mark the widget `exiting`, and let
107//! `paint` fire the pop exactly once the spring has settled at `progress == 0`
108//! (a `closed` latch makes that firing terminal, so a trigger landing in the
109//! frames between the enqueued pop and the rebuild that drains it cannot pop
110//! the page underneath). Symmetrically, a leg aimed back *open* — a drag caught
111//! mid-exit, a release short of the dismiss threshold, a `Cancel` — clears
112//! `exiting` again, since a leg targeting `1.0` is by definition not a
113//! dismissal; leaving it set would strand the sheet at rest with a staged exit
114//! that can only fire at the closed end, refusing every later trigger.
115//! `reduce_motion` collapses the wait rather than skipping it: the exit snaps to
116//! `0` and fires on the next paint.
117//!
118//! The deferred pop is identity-guarded, not blind. It takes the navigator's
119//! *top* page, and the ramp it waits out is long enough for the app to push
120//! another one (a deep link, an async completion) — which a bare `pop()` would
121//! then take instead of this sheet. The hook is therefore a
122//! [`StagedPop`]: armed with the stack's identity when the exit is staged, and
123//! refusing to fire if that identity moved. A refused pop is *reported* rather
124//! than swallowed — the sheet springs back open and stays dismissible, instead
125//! of latching `closed` over a page it never removed.
126//!
127//! **A mount with no `on_close` — a standalone [`frust::Stack`] mount, or any
128//! sheet built by [`bottom_sheet`] alone — keeps the immediate, state-touching
129//! v1 dismissal**: [`BottomSheetView::on_dismiss`] (a `Fn(&mut State)`) fires
130//! in the same event as the spring kick, exactly as before, because there is no
131//! state-free hook to defer into. Exactly one of the two fires per dismissal.
132//!
133//! # Convergence with `overlay::modal`
134//!
135//! [`crate::overlay::modal`] is the merged host built on top of *this*
136//! module's own `BackPolicy`/`dismiss_signal` mechanisms, and now carries a
137//! more general drag-to-close-with-snap-points machine of its own. This
138//! module does **not** rebuild itself on top of that host — that host
139//! generalizes to arbitrary snap points via a duration+curve ramp, where this
140//! sheet's motion is a spring throughout — but it now shares the host's
141//! **staged-exit shape**: a state-free close hook fired from `paint` on settle,
142//! guarded by an `exiting`/`closed` pair, with the state-bearing `on_dismiss`
143//! kept as the unstaged fallback for a mount that wires no hook. The
144//! theme-role resolution converges too, reading [`crate::overlay`]'s shared
145//! accessors (`scrim`/`container`/`on_surface_variant`/`radius`) rather than
146//! duplicating them locally.
147//!
148//! # Panel anatomy
149//!
150//! The panel spans the full width, anchored to the bottom edge, with its **top**
151//! two corners rounded to the extra-large (28dp) shape and its bottom corners
152//! square (they sit at the screen edge). It sizes to its content plus the 48dp
153//! handle strip at the top; the visual drag-handle indicator is 32×4dp, centered
154//! horizontally within that strip. The panel's own extent never changes with
155//! `progress` — only its Y offset does, so a drag scrub or a spring-advanced
156//! frame repositions it (`BottomSheetWidget::reposition`) without a relayout.
157//!
158//! # Standalone use
159//!
160//! A `BottomSheet` is a plain widget: it works inside a [`frust::Stack`] too
161//! (the scrim is still its own). The navigator path is the primary, documented
162//! one. This module does **not** edit any nav file.
163//!
164//! # Semantics
165//!
166//! The sheet contributes one [`Role::Dialog`] container node with the accesskit
167//! **modal** flag set — a documented choice: accesskit has no dedicated
168//! "bottom sheet" role, and a modal sheet is, to assistive tech, a modal
169//! dialog-ish surface (the same role the [`mod@crate::dialog`] uses). Its
170//! content becomes the node's accesskit children.
171//!
172//! # Keyboard operability
173//!
174//! **Escape-to-dismiss now works, once the sheet has focus.** A `Down`
175//! anywhere in the sheet (scrim, handle, or panel background) claims focus
176//! via `EventCtx::request_focus` — the sheet already captures its whole area,
177//! so this is a pure opt-in with no new hit-testing. Once focused, a
178//! focus-routed `Key(Escape)` invokes the *same* dismiss path as a scrim tap
179//! or handle drag (`on_dismiss`) — unless the sheet's `content` itself holds
180//! the deeper focus path, in which case content gets first crack at the key
181//! (mirroring how an action consumes an event before the dialog's modal
182//! barrier does). **There is still no hook to focus the sheet on appear**
183//! (auto-focus-on-appear) — a caller must complete one pointer interaction
184//! with the sheet before Escape does anything; that gap is deferred to a
185//! future focus-manager work item.
186//!
187//! The accesskit **modal** flag set above is nonetheless **kept deliberately**,
188//! not dropped. No platform `accesskit_*` adapter is wired yet (see
189//! `docs/ARCHITECTURE.md`'s Semantics pass), so there is no live assistive-tech
190//! audience the flag could currently mislead.
191//!
192//! # State layer
193//!
194//! This sheet has no `StateLayer` surface of its own (the scrim, panel, and
195//! drag handle are plain fills, not an M3 interactive surface) — there is
196//! nothing here for `StateLayer::set_focused` to wire into.
197//!
198//! # `dismissable(bool)` + back-dismiss
199//!
200//! [`BottomSheetView::dismissable`] (default `true`) is Flutter's
201//! `isDismissible`+`enableDrag` collapsed into one v1 flag (spec parity):
202//! `false` disables the scrim tap, the handle drag (a drag past the
203//! dismiss threshold instead **springs back open** — the panel already
204//! tracked the finger down toward closed, so a bare no-op would leave it
205//! resting there as an invisible full-window barrier), and `Escape`, and
206//! [`show_bottom_sheet`] pushes the page with [`frust::BackPolicy::Veto`] so a
207//! back press is consumed with no effect. `true` pushes
208//! [`frust::BackPolicy::DismissAnimated`]: a back press bumps the shared
209//! dismiss-signal cell [`NavigatorController::request_back`] writes,
210//! observed from `paint` (`observe_dismiss_signal`) — which stages exactly the
211//! reverse ramp every other dismiss trigger stages (see *Who owns the exit*),
212//! so back is animated rather than instant. Because the pop that ramp
213//! eventually fires only *enqueues* a `NavOp::Pop` (it writes no tracked
214//! signal), the paint that fires it requests the next frame
215//! ([`PaintCtx::request_frame`]) so the enqueued pop is guaranteed a draining
216//! rebuild — otherwise a dirty-driven desktop shell idles and the mobile frame
217//! gate skips, leaving the back press dead until an unrelated later frame. The
218//! in-flight ramp keeps its own frames coming the same way.
219//!
220//! # App-initiated dismiss: adopting [`ModalDismiss`]
221//!
222//! [`BottomSheetView::dismiss_handle`] installs [`crate::overlay::modal`]'s own
223//! [`ModalDismiss`] handle — the same public type [`crate::side_sheet`] installs,
224//! never a second one minted for this module. The sheet's content (a row tap,
225//! an "Apply" button) triggers it, and the widget observes the request through
226//! exactly the shape [`mod@crate::overlay::modal`]'s own host does: a
227//! generation cell latched at `build`, re-seeded on `rebuild` only when the
228//! handle's *identity* changed, and observed in **both** the `event` pass
229//! (right after the content routing that most likely raised it) and the
230//! `paint` pass (the backstop for a request raised outside an event). Neither
231//! observation stages a *second* exit path — both route straight into this
232//! module's own [`BottomSheetWidget::request_dismiss`]/`stage_exit`/
233//! `fire_staged_close` machinery, the same one every gesture dismissal
234//! already takes, so an app dismiss plays the identical spring settle and can
235//! carry a [`PopResult`] the same way [`ModalDismiss::dismiss_with`] does for
236//! [`crate::side_sheet`].
237//!
238//! An app dismiss ignores [`BottomSheetView::dismissable`] the same way
239//! [`mod@crate::overlay::modal`]'s own handle does (that flag exists to take
240//! the *user's* exits away, and a `ModalDismiss` trigger is not one) — free
241//! here, since [`BottomSheetWidget::request_dismiss`] already applies no such
242//! gate itself; every *gesture* caller (the scrim tap, the handle-drag
243//! release, `Escape`) gates it at its own call site instead.
244//!
245//! What an observation *consumes* mirrors that host too, rule for rule (see
246//! its own *What a request consumes*): the generation is latched by whichever
247//! pass saw the request, accepted or refused, so it acts exactly once; the
248//! payload is taken only where a dismissal was actually accepted **and** has a
249//! staged pop to carry it, never by a refusal (which would retarget the ramp
250//! already in flight) and never by the unstaged `on_dismiss` route (which
251//! carries no [`PopResult`] at all); and the one pass that latches nothing is
252//! the `paint` backstop on a `Stack` mount, which can deliver neither and
253//! leaves the request for the event pass rather than swallowing it.
254//!
255//! A request left pending that way still dismisses only **once**, by the same
256//! rule the other host applies: the unstaged `on_dismiss` route is one-shot
257//! ([`BottomSheetWidget::dismissed`], `closed`'s twin for a mount with no
258//! staged pop), and every dismissal route reads it through
259//! [`BottomSheetWidget::refuses_dismiss`]. Without it a scrim tap and a
260//! standing request are two dismissals rather than one: the tap fires
261//! `on_dismiss` (neither the `Down` barrier arm nor the `scrim_captured` arm
262//! observes the handle), and the next `Move` that does observe it fires again.
263//!
264//! # Public API
265//!
266//! [`bottom_sheet`], [`show_bottom_sheet`], and every [`BottomSheetView`]
267//! builder method except [`dismiss_handle`](BottomSheetView::dismiss_handle)
268//! are unchanged from v1 — the staged exit above is wired through private
269//! view/widget fields only, so this module's public surface is the two
270//! functions plus [`BottomSheetView::on_dismiss`]/
271//! [`BottomSheetView::dismissable`]/[`BottomSheetView::dismiss_handle`].
272
273use std::cell::Cell;
274use std::rc::Rc;
275use std::time::Duration;
276
277use frust::Theme;
278use frust::authoring::Role;
279use frust::authoring::{
280    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
281    Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
282};
283use frust::input::{TOUCH_SLOP, VelocityTracker};
284use kurbo::{Point, Rect, RoundedRect, RoundedRectRadii, Shape, Size};
285use peniko::{Brush, Color};
286
287use frust::{
288    AnimationController, BackPolicy, FrameTime, NavigatorController, PopResult, PushOptions,
289    SpringDesc, TransitionSpec,
290};
291
292use super::press::presses;
293use crate::overlay::modal::{ModalDismiss, StagedPop};
294use crate::overlay::{OverlayContainer, container, on_surface_variant, radius, scrim};
295use crate::tokens::MaterialSpring;
296
297/// Drag-handle visual indicator width, in logical px (M3 token
298/// `m3_comp_sheet_bottom_docked_drag_handle_width`).
299const HANDLE_WIDTH: f64 = 32.0;
300/// Drag-handle visual indicator height, in logical px (M3 token
301/// `m3_comp_sheet_bottom_docked_drag_handle_height`).
302const HANDLE_HEIGHT: f64 = 4.0;
303/// Drag-handle interactive touch-target strip height, in logical px (M3
304/// enforces a 48dp minimum touch target on the handle). The whole
305/// full-width strip at the top of the sheet is the drag zone.
306const HANDLE_TOUCH_TARGET: f64 = 48.0;
307/// Flattening tolerance for the top-rounded panel path (a visually-lossless
308/// value for on-screen corner radii — the [`crate::card`] precedent).
309const PATH_TOLERANCE: f64 = 0.1;
310
311/// Fraction of the sheet's own height a handle-drag must have closed past to
312/// dismiss on release, when the release speed is below [`FLING_VELOCITY`]
313/// (i.e. `progress < DRAG_DISMISS_FRACTION` dismisses,
314/// `progress >= DRAG_DISMISS_FRACTION` springs back open) — see the [module
315/// docs](self)' Interactive drag section.
316///
317/// **Community-approximate**: Material's bottom-sheet dismiss threshold is a
318/// fling/drag heuristic with no single published constant; half the sheet
319/// height is the conventional drag-past-the-midpoint commit point (the same
320/// 0.5 the navigator's edge-swipe uses, and the same value
321/// [`crate::overlay::modal`]'s own no-snap-points default reduces to).
322const DRAG_DISMISS_FRACTION: f64 = 0.5;
323
324/// Release speed (logical px/s along the drag axis) at or above which a
325/// handle drag commits in the direction it was flung, overriding the
326/// position-based decision above.
327///
328/// **Community-approximate**: Material publishes no fling threshold for a
329/// sheet dismiss — mirrors [`crate::overlay::modal::OVERLAY_FLING_VELOCITY`],
330/// an order of magnitude above `frust::input::FLING_STOP`'s 30 px/s "this
331/// fling is over" floor.
332const FLING_VELOCITY: f64 = 400.0;
333
334/// Launch velocity (`progress`-units/sec, `0..=1` scale) for a spring leg
335/// with no real pointer velocity behind it — the entrance, and every non-drag
336/// dismiss (scrim tap, Escape, an Android back press) — and the floor a drag
337/// release's own measured velocity is boosted to when it is too weak, or the
338/// wrong sign, to read as a deliberate commit in the decided direction. The
339/// same modest-kick convention `crate::button::motion::FLING_VELOCITY` uses
340/// for its own `AnimationController::fling`.
341const SPRING_KICK: f64 = 4.0;
342
343/// How close to fully-closed a settled exit spring must have landed for the
344/// staged pop to fire (`progress` units) — a sub-pixel slack on any sheet
345/// shorter than a thousand logical px, so it reads as "arrived", never as a
346/// spring stopped somewhere visible.
347const SETTLED_EPSILON: f64 = 1e-3;
348
349/// The spring driving the sheet's entrance, its dismiss, and a drag settle —
350/// M3E's `expressiveSpatialDefault` preset
351/// ([`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`], stiffness 380 / damping
352/// ratio 0.8): the same spatial-motion default this crate already uses
353/// elsewhere for a position/shape change (`crate::loading_indicator`'s morph,
354/// `crate::carousel`'s snap).
355const SHEET_SPRING: SpringDesc = SpringDesc {
356    mass: 1.0,
357    stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
358    damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio,
359};
360
361/// Coerce a possibly-infinite constraint dimension to a finite value (a sheet
362/// expects bounded constraints — a navigator page or a full-screen `Stack`).
363fn finite_or_zero(v: f64) -> f64 {
364    if v.is_finite() { v } else { 0.0 }
365}
366
367/// Return `color` with its own alpha scaled by `factor` — how the scrim rides
368/// the spring (`crate::overlay::modal`'s own `scale_alpha`, ported in rather
369/// than shared: a two-line pure function, not worth a `pub(crate)` seam for).
370fn scale_alpha(color: Color, factor: f32) -> Color {
371    let c = color.components;
372    Color::new([c[0], c[1], c[2], c[3] * factor])
373}
374
375/// A view-held, typed scrim/drag-dismiss callback (erased on build).
376type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
377
378/// The state-free navigator pop the exit spring stages, fired from `paint` on
379/// settle — see the [module docs](self)' *Who owns the exit*. State-free is
380/// what makes staging possible at all: `paint` carries no app state.
381///
382/// The [`StagedPop`] shared with [`crate::overlay::modal`] (the same convergence
383/// as this module's theme accessors — one implementation of the guard, so both
384/// hosts provably behave the same): it carries the stack identity the exit was
385/// staged against and refuses a pop the stack moved out from under.
386type OnClose = Rc<StagedPop>;
387
388/// A declarative modal M3 bottom sheet wrapping a single content child. See the
389/// [module docs](self).
390pub struct BottomSheetView<State: 'static> {
391    content: AnyView<State>,
392    on_dismiss: Option<OnDismiss<State>>,
393    dismissable: bool,
394    /// The staged, state-free pop — wired internally by [`show_bottom_sheet`],
395    /// never part of the public builder surface (see the [module docs](self)).
396    on_close: Option<OnClose>,
397    /// The shared back-press dismiss-signal cell (the `DismissAnimated` seam),
398    /// wired internally by [`show_bottom_sheet`] alongside `on_close`.
399    dismiss_signal: Option<Rc<Cell<u64>>>,
400    /// The app-triggered staged-dismiss handle, installed by
401    /// [`BottomSheetView::dismiss_handle`] — unlike `on_close`/
402    /// `dismiss_signal`, never wired automatically; an app mints and installs
403    /// it itself (see the [module docs](self)' app-initiated dismiss
404    /// section).
405    dismiss_handle: Option<ModalDismiss>,
406}
407
408/// Wrap `content` in a modal bottom sheet. Chain
409/// [`BottomSheetView::on_dismiss`] to handle a dismiss gesture (usually a
410/// `controller.pop()` — [`show_bottom_sheet`] wires this for you).
411pub fn bottom_sheet<State: 'static, V: View<State>>(content: V) -> BottomSheetView<State> {
412    BottomSheetView {
413        content: any(content),
414        on_dismiss: None,
415        dismissable: true,
416        on_close: None,
417        dismiss_signal: None,
418        dismiss_handle: None,
419    }
420}
421
422impl<State: 'static> BottomSheetView<State> {
423    /// Set the dismiss callback — invoked with `&mut State` on a scrim tap or a
424    /// handle drag release decided closed. [`show_bottom_sheet`] wires this to
425    /// `controller.pop()` automatically.
426    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
427        self.on_dismiss = Some(Rc::new(on_dismiss));
428        self
429    }
430
431    /// Whether this sheet can be dismissed by the user at all — the scrim
432    /// tap, the handle drag, `Escape`, and an Android back press (default
433    /// `true`; see the [module docs](self)). `false` disables all four; only
434    /// an explicit close control the app wires through its own content still
435    /// dismisses it.
436    pub fn dismissable(mut self, dismissable: bool) -> Self {
437        self.dismissable = dismissable;
438        self
439    }
440
441    /// Install the app-triggered staged-dismiss handle: the sheet's own
442    /// content can then close it through the very spring exit the scrim tap,
443    /// the handle drag, `Escape`, and an Android back press all take —
444    /// instead of popping the navigator directly, which would drop the page
445    /// out from under the sheet mid-slide. Reuses
446    /// [`crate::overlay::modal::ModalDismiss`] — the same handle type
447    /// [`crate::side_sheet::SideSheetView::dismiss_handle`] installs — so an
448    /// app mixing sheet families never juggles two handle types. See the
449    /// [module docs](self)' app-initiated dismiss section.
450    ///
451    /// Mint the handle *outside* [`show_bottom_sheet`]'s page builder and
452    /// clone it in — the builder re-runs on every navigator rebuild.
453    ///
454    /// ```ignore
455    /// let dismiss = ModalDismiss::new();
456    /// let select = dismiss.clone();
457    /// show_bottom_sheet(
458    ///     &nav,
459    ///     move || {
460    ///         bottom_sheet(menu_panel(rows(), move |state: &mut State, sel| {
461    ///             on_select(state, sel);
462    ///             select.dismiss();
463    ///         }))
464    ///         .dismiss_handle(dismiss.clone())
465    ///     },
466    ///     |state: &mut State, result: PopResult| { /* ... */ },
467    /// );
468    /// ```
469    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
470        self.dismiss_handle = Some(dismiss);
471        self
472    }
473}
474
475/// Push `build`'s sheet as a transparent navigator page and register
476/// `on_result` for the value the sheet pops with. The sheet owns both halves of
477/// its own spring motion ([module docs](self)' *Who owns the exit*), so the page
478/// transition itself is [`TransitionSpec::NONE`] — a page-level transition on
479/// top would animate the same slide twice, in both directions.
480///
481/// The sheet's dismiss (scrim tap + handle drag + Escape + Android back) is
482/// wired to `controller.pop()` for you (dismiss pops with an *empty*
483/// [`PopResult`], overriding any [`BottomSheetView::on_dismiss`] the builder
484/// set).
485///
486/// The pop is wired **twice, by design** — as the state-free `on_close` hook
487/// the exit spring fires on settle (the path every dismissal takes here), and
488/// as the unstaged [`BottomSheetView::on_dismiss`] callback, which this path
489/// only reaches if there were no spring to reverse. Exactly one of the two
490/// fires per dismissal.
491///
492/// ```ignore
493/// show_bottom_sheet(
494///     &state.nav,
495///     || bottom_sheet(sheet_content()),
496///     |state: &mut State, result: PopResult| { /* ... */ },
497/// );
498/// ```
499pub fn show_bottom_sheet<State, B, R>(
500    controller: &NavigatorController<State>,
501    build: B,
502    on_result: R,
503) where
504    State: 'static,
505    B: Fn() -> BottomSheetView<State> + 'static,
506    R: Fn(&mut State, PopResult) + 'static,
507{
508    let dismiss_ctrl = controller.clone();
509    // Built **once**, outside the page builder below, and only cloned into each
510    // build: that builder re-runs on every navigator rebuild, and a fresh hook
511    // per pass would throw away the stack identity an in-flight staged exit
512    // armed (see [`StagedPop`]).
513    let staged: OnClose = Rc::new(StagedPop::navigator_pop(controller));
514    // Peeked once, at show-time: the back policy/dismiss-signal wiring is
515    // fixed for the life of this pushed page (mirrors the navigator's
516    // push-time `PushOptions` contract, and
517    // `frust_glyph::dialog::show_glyph_dialog`'s identical peek), even
518    // though `build` is re-invoked on every later
519    // navigator rebuild to diff the page's content.
520    let dismissable = build().dismissable;
521    let signal = dismissable.then(|| Rc::new(Cell::new(0u64)));
522    let widget_signal = signal.clone();
523    let mut options = PushOptions::transparent()
524        .transition(TransitionSpec::NONE)
525        .back(if dismissable {
526            BackPolicy::DismissAnimated
527        } else {
528            BackPolicy::Veto
529        })
530        .on_result(on_result);
531    if let Some(sig) = &signal {
532        options = options.dismiss_signal(sig.clone());
533    }
534    controller.push_with_options(
535        move || {
536            let ctrl = dismiss_ctrl.clone();
537            let mut view = build().on_dismiss(move |_state: &mut State| ctrl.pop());
538            view.on_close = Some(staged.clone());
539            view.dismiss_signal = widget_signal.clone();
540            any::<State, _>(view)
541        },
542        options,
543    );
544}
545
546/// The retained widget for a [`BottomSheetView`]. See the [module docs](self).
547pub struct BottomSheetWidget {
548    content: ChildPod,
549    on_dismiss: Option<frust::authoring::ErasedCallback>,
550    dismissable: bool,
551    /// The state-free pop staged behind the exit spring, fired from `paint` on
552    /// settle — `None` on a standalone mount, which keeps the immediate,
553    /// state-touching `on_dismiss` instead (see the [module docs](self)' *Who
554    /// owns the exit*).
555    on_close: Option<OnClose>,
556    /// The shared back-press dismiss-signal cell (the `DismissAnimated`
557    /// seam) — see
558    /// [`observe_dismiss_signal`](Self::observe_dismiss_signal).
559    dismiss_signal: Option<Rc<Cell<u64>>>,
560    /// The last generation observed from `dismiss_signal` (0 with no signal
561    /// wired, or a `Veto`/non-dismissable sheet).
562    last_seen_dismiss: u64,
563    /// The app's own staged-dismiss handle, installed by
564    /// [`BottomSheetView::dismiss_handle`] (see the [module docs](self)'
565    /// app-initiated dismiss section).
566    dismiss_handle: Option<ModalDismiss>,
567    /// The last request generation observed from `dismiss_handle` — seeded
568    /// from the handle's own generation at build, so a request raised before
569    /// this widget existed is not aimed at it
570    /// (`crate::overlay::modal::OverlayModalWidget`'s own `last_seen_request`
571    /// field, mirrored).
572    last_seen_request: u64,
573    /// The payload a [`ModalDismiss::dismiss_with`] request armed, handed to
574    /// the staged pop when the spring settles closed.
575    ///
576    /// **Scoped to the exit its own request staged, and to no other** — the
577    /// rule `crate::overlay::modal::OverlayModalWidget`'s own `pending_result`
578    /// states at length. It is taken from the handle only by the observation
579    /// that *accepted* the request ([`Self::accept_dismiss_request`]), and
580    /// dropped by every leg that ends that exit without a landed pop: a
581    /// refused pop (the stack moved — see [`StagedPop::fire_with`]), a drag
582    /// that catches the closing sheet, any leg aimed back open
583    /// ([`Self::spring_open`]). Left standing across one of those, it would be
584    /// delivered by whatever dismissal happened to land next.
585    pending_result: Option<PopResult>,
586    /// Whether the running spring leg ends in a dismissal — set the moment a
587    /// trigger stages one ([`Self::stage_exit`]), cleared by the settle that
588    /// fires the pop and by every leg aimed back open: a drag that catches the
589    /// closing panel, a release short of the dismiss threshold, a `Cancel`
590    /// ([`Self::spring_open`]).
591    exiting: bool,
592    /// One-shot latch set the instant the staged `on_close` actually pops.
593    /// Terminal for this widget's mounted lifetime: the hook only *enqueues* a
594    /// navigator pop, so the sheet stays mounted (and still a modal barrier) for
595    /// one or more frames after firing, during which a further dismiss trigger
596    /// must not re-fire it — that would pop whatever page is now underneath.
597    /// Nothing ever clears it back to `false`, which is exactly why a
598    /// [`StagedPop`] that *refused* its pop must not set it: no page was
599    /// removed, so latching would brick a still-live sheet instead of recording
600    /// a finished dismissal.
601    closed: bool,
602    /// [`Self::closed`]'s twin for the *unstaged* route: the one-shot latch set
603    /// the instant [`BottomSheetView::on_dismiss`] is actually invoked (see
604    /// [`Self::request_dismiss`]).
605    ///
606    /// A `Stack` mount has no staged pop to latch `closed` on — its dismissal
607    /// *is* the app callback — and the app tears the sheet down from its own
608    /// state, so this widget stays mounted and dismissable for the rebuild or
609    /// two that takes. Without this latch a second trigger inside that window
610    /// dismisses the same sheet twice: a [`ModalDismiss`] request the `paint`
611    /// backstop deliberately left standing (see
612    /// [`Self::observe_paint_dismiss_request`]) plus the scrim tap that lands
613    /// before the next rebuild are two triggers of exactly that shape — the tap
614    /// fires `on_dismiss`, and the very next `Move` observes the request and
615    /// fires it again. Terminal like `closed`, and for the same reason: nothing
616    /// ever clears it back to `false`.
617    ///
618    /// `crate::overlay::modal::OverlayModalWidget`'s own `dismissed` field,
619    /// mirrored — one rule across both hosts (see that module's *What a request
620    /// consumes*).
621    dismissed: bool,
622
623    /// The content's measured size from the last real `layout` — cached so a
624    /// drag scrub or a spring-advanced paint frame can reposition the panel
625    /// without a full relayout (the panel's own extent never changes with
626    /// `progress`, only its Y offset does).
627    content_size: Size,
628    /// The bottom-anchored panel rect in the widget's own local coordinate
629    /// space, at its full (unclamped) extent — translated by `progress`, not
630    /// resized by it (computed by [`Self::reposition`], read for scrim/handle
631    /// hit-testing at event time).
632    panel: Rect,
633    /// The full-width 48dp drag-handle touch strip at the top of the panel
634    /// (local coords, tracks the panel's own translation).
635    handle_target: Rect,
636
637    /// `0.0` fully closed/off-screen, `1.0` at rest (open) — may transiently
638    /// read outside that range mid-spring (an under-damped spring's overshoot
639    /// past its target is real, intended motion; see
640    /// `crates/frust-core/src/anim.rs`'s `AnimationController` docs).
641    progress: f64,
642    /// The spring engine driving `progress` between legs — freshly re-seeded
643    /// at the start of every new leg ([`Self::spring_to`]) from whatever
644    /// `progress` currently reads, and left untouched (not advanced) while a
645    /// drag is scrubbing `progress` directly.
646    anim: AnimationController,
647    /// Whether the first paint has kicked off the entrance spring.
648    started: bool,
649
650    /// A handle-strip press is in flight (the sheet captured the pointer on a
651    /// `Down` in the handle strip).
652    drag_captured: bool,
653    /// The `Down` y the drag distance is measured from.
654    drag_start_y: f64,
655    /// `progress` at the start of this drag — the scrub's own baseline.
656    drag_from: f64,
657    /// Whether the gesture has passed [`TOUCH_SLOP`] into a real drag; below
658    /// it a release is a tap and nothing dismisses (unchanged from v1).
659    drag_moved: bool,
660    /// The release-velocity estimator, clocked from the last painted frame —
661    /// pointer events carry no timestamp of their own (`frust_widgets::scroll`'s
662    /// precedent, also `crate::overlay::modal`'s).
663    tracker: VelocityTracker,
664    last_frame_time: FrameTime,
665
666    /// A scrim/panel-background press is in flight (the modal barrier).
667    scrim_captured: bool,
668    /// Whether that press started outside the panel (only an outside press
669    /// released outside dismisses).
670    scrim_down_outside: bool,
671}
672
673impl<State: 'static> View<State> for BottomSheetView<State> {
674    type Element = BottomSheetWidget;
675
676    fn build(&self, ctx: &mut BuildCtx<'_>) -> BottomSheetWidget {
677        BottomSheetWidget {
678            content: frust::authoring::build_child(&self.content, ctx),
679            on_dismiss: self
680                .on_dismiss
681                .as_ref()
682                .map(frust::authoring::erase_callback),
683            dismissable: self.dismissable,
684            on_close: self.on_close.clone(),
685            dismiss_signal: self.dismiss_signal.clone(),
686            // Seeded from the cell's *current* generation: a bump that predates
687            // this widget is not a back press aimed at it.
688            last_seen_dismiss: self.dismiss_signal.as_ref().map_or(0, |sig| sig.get()),
689            dismiss_handle: self.dismiss_handle.clone(),
690            // Seeded from the handle's *current* generation, the same rule as
691            // `last_seen_dismiss` above: a request raised before this widget
692            // existed is not aimed at it.
693            last_seen_request: self
694                .dismiss_handle
695                .as_ref()
696                .map_or(0, ModalDismiss::generation),
697            pending_result: None,
698            exiting: false,
699            closed: false,
700            dismissed: false,
701            content_size: Size::ZERO,
702            panel: Rect::ZERO,
703            handle_target: Rect::ZERO,
704            progress: 0.0,
705            anim: AnimationController::new(Duration::ZERO),
706            started: false,
707            drag_captured: false,
708            drag_start_y: 0.0,
709            drag_from: 0.0,
710            drag_moved: false,
711            tracker: VelocityTracker::new(),
712            last_frame_time: FrameTime::ZERO,
713            scrim_captured: false,
714            scrim_down_outside: false,
715        }
716    }
717
718    fn rebuild(
719        &self,
720        prev: &Self,
721        element: &mut BottomSheetWidget,
722        ctx: &mut BuildCtx<'_>,
723    ) -> ChangeFlags {
724        let flags = frust::authoring::rebuild_child(
725            &prev.content,
726            &self.content,
727            &mut element.content,
728            ctx,
729        );
730        // Closures aren't comparable — reinstall the dismiss adapter cheaply.
731        element.on_dismiss = self
732            .on_dismiss
733            .as_ref()
734            .map(frust::authoring::erase_callback);
735        element.dismissable = self.dismissable;
736        // Both staging seams are reinstalled the same cheap way: the close
737        // hook is a closure (never comparable), and the dismiss-signal cell's
738        // identity is fixed at push time (see `show_bottom_sheet`), so
739        // reinstalling it here never disturbs `last_seen_dismiss` — nor do
740        // either of them disturb the in-flight `exiting`/`closed` state.
741        element.on_close = self.on_close.clone();
742        element.dismiss_signal = self.dismiss_signal.clone();
743        // The app's dismiss handle follows the view whenever the view still
744        // carries one, gated on *identity* — exactly
745        // `crate::overlay::modal::OverlayModalView::rebuild`'s own re-seed: a
746        // view handing back the same cell keeps the live request generation
747        // (a button's own pending tap must not be forgotten), while a view
748        // that stopped carrying one leaves the installed handle standing,
749        // the same pinning `on_close`/`dismiss_signal` get above.
750        if let Some(handle) = &self.dismiss_handle
751            && !element
752                .dismiss_handle
753                .as_ref()
754                .is_some_and(|installed| installed.same_cell(handle))
755        {
756            element.last_seen_request = handle.generation();
757            element.dismiss_handle = Some(handle.clone());
758        }
759        flags
760    }
761
762    fn teardown(&self, element: &mut BottomSheetWidget, ctx: &mut BuildCtx<'_>) {
763        frust::authoring::teardown_child(&self.content, &mut element.content, ctx);
764    }
765}
766
767impl BottomSheetWidget {
768    /// Observe the shared back-press dismiss-signal cell (see the
769    /// `dismiss_signal` field docs) and stage the exit exactly once per bump —
770    /// the `BackPolicy::DismissAnimated` seam's widget-side half
771    /// (`nav::navigator::BackPolicy`'s documented observation seam).
772    ///
773    /// The paint-pass twin of [`Self::request_dismiss`]: identical guards, and
774    /// the same closing spring, so an Android back press animates out exactly
775    /// like a scrim tap. It requests the frame the fresh spring leg needs; the
776    /// pop itself is fired later, by [`Self::fire_staged_close`] on settle.
777    fn observe_dismiss_signal(&mut self, ctx: &mut PaintCtx) {
778        let Some(signal) = &self.dismiss_signal else {
779            return;
780        };
781        let current = signal.get();
782        if current == self.last_seen_dismiss {
783            return;
784        }
785        self.last_seen_dismiss = current;
786        if self.refuses_dismiss() || self.on_close.is_none() {
787            return;
788        }
789        self.stage_exit(-SPRING_KICK);
790        ctx.request_frame();
791    }
792
793    /// The app's [`ModalDismiss`] handle's live request generation, when a
794    /// request this widget has not seen before is pending — the *peek* half of
795    /// the observe pattern.
796    ///
797    /// Mirrors `crate::overlay::modal::OverlayModalWidget`'s own
798    /// `pending_dismiss_request` exactly, and for the same reason: only the
799    /// caller knows what became of the request, so nothing is consumed until
800    /// it does (see that module's *What a request consumes*).
801    fn pending_dismiss_request(&self) -> Option<u64> {
802        let handle = self.dismiss_handle.as_ref()?;
803        let current = handle.generation();
804        (current != self.last_seen_request).then_some(current)
805    }
806
807    /// Record `generation` as seen — so the request acts exactly once however
808    /// many passes observe it — **without** taking its payload.
809    ///
810    /// What a *refused* request gets (an exit already in flight, or a sheet
811    /// already closed), and what the unstaged [`BottomSheetView::on_dismiss`]
812    /// route gets too: neither delivers a [`PopResult`], so the payload stays
813    /// with the handle instead of retargeting an exit that was never asked to
814    /// carry it.
815    fn latch_dismiss_request(&mut self, generation: u64) {
816        self.last_seen_request = generation;
817    }
818
819    /// Latch `generation` *and* take the payload it carried, for the staged
820    /// pop to fire with on settle (see [`Self::pending_result`]).
821    ///
822    /// The payload is taken here, at acceptance, rather than at the settle
823    /// that consumes it: the handle is the app's and may be re-armed for a
824    /// later presentation, while this exit's payload belongs to this exit.
825    fn accept_dismiss_request(&mut self, generation: u64) {
826        self.latch_dismiss_request(generation);
827        self.pending_result = self
828            .dismiss_handle
829            .as_ref()
830            .and_then(|handle| handle.take_result(generation));
831    }
832
833    /// Whether a dismiss trigger would be refused outright right now — the
834    /// guard [`Self::request_dismiss`] applies, peeked so an app request can be
835    /// refused *without* being mistaken for one that was acted on.
836    ///
837    /// One dismissal per mounted sheet, whichever route delivered it: an exit
838    /// already in flight, a staged pop already fired ([`Self::closed`]), or an
839    /// unstaged `on_dismiss` already delivered ([`Self::dismissed`]).
840    fn refuses_dismiss(&self) -> bool {
841        self.exiting || self.closed || self.dismissed
842    }
843
844    /// Observe the app's [`ModalDismiss`] handle in the *event* pass, right
845    /// after the content routing that most likely raised the request (a row
846    /// tap, an action button), so an app dismiss stages in the very pass that
847    /// asked for it.
848    ///
849    /// Routed straight through [`Self::request_dismiss`] — the same staged
850    /// path every gesture dismissal already takes, never a second staging
851    /// core. `request_dismiss` itself applies no `dismissable` gate (every
852    /// *gesture* caller gates it at its own call site instead), so an app
853    /// dismiss is correctly ungated here for free — matching
854    /// [`mod@crate::overlay::modal`]'s own documented rule that an app
855    /// dismiss ignores [`BottomSheetView::dismissable`].
856    ///
857    /// The staged route is the only one that can carry the request's payload;
858    /// with no `on_close` wired, `request_dismiss` falls back to the unstaged,
859    /// state-bearing [`BottomSheetView::on_dismiss`] — the fallback that keeps
860    /// a handle live on a `Stack` mount, which
861    /// [`mod@crate::overlay::modal`]'s host now applies identically.
862    fn observe_dismiss_request(&mut self, ctx: &mut EventCtx) {
863        let Some(request) = self.pending_dismiss_request() else {
864            return;
865        };
866        if self.refuses_dismiss() {
867            self.latch_dismiss_request(request);
868            return;
869        }
870        if self.on_close.is_some() {
871            self.accept_dismiss_request(request);
872        } else {
873            self.latch_dismiss_request(request);
874        }
875        self.request_dismiss(ctx, -SPRING_KICK);
876    }
877
878    /// The paint-pass twin of [`Self::observe_dismiss_request`] — the
879    /// backstop for a request raised outside an event (an async completion, a
880    /// timer). Mirrors [`Self::observe_dismiss_signal`]'s shape exactly,
881    /// keyed off the handle's own generation instead of the back-press cell.
882    ///
883    /// No unstaged fallback exists here, by design: `PaintCtx` carries no app
884    /// state to invoke [`BottomSheetView::on_dismiss`] with — the same limit
885    /// [`mod@crate::overlay::modal`]'s own paint-pass twin works under. A
886    /// request arriving here with no `on_close` wired (a `Stack` mount with no
887    /// navigator behind it) is therefore left **pending**, not swallowed: the
888    /// event pass can still deliver it through `on_dismiss`, and consuming it
889    /// here would make the handle silently inert on that mount.
890    fn observe_paint_dismiss_request(&mut self, ctx: &mut PaintCtx) {
891        let Some(request) = self.pending_dismiss_request() else {
892            return;
893        };
894        if self.refuses_dismiss() {
895            self.latch_dismiss_request(request);
896            return;
897        }
898        if self.on_close.is_none() {
899            return;
900        }
901        self.accept_dismiss_request(request);
902        self.stage_exit(-SPRING_KICK);
903        ctx.request_frame();
904    }
905
906    /// Fire the staged, state-free pop once the closing spring has settled at
907    /// the fully-closed end — the one place the navigator path's dismissal
908    /// actually happens (see the [module docs](self)' *Who owns the exit*).
909    ///
910    /// **Scheduling the draining frame.** `on_close` only *enqueues* a
911    /// `NavOp::Pop` on the controller; it writes no tracked reactive signal, so
912    /// nothing else schedules the frame that drains it. The paint that fires it
913    /// therefore must itself request the next frame ([`PaintCtx::request_frame`])
914    /// — exactly as `frust_glyph::dialog` requests a frame past its `Dismissed`
915    /// phase so the rebuild that applies the pop runs. Without this, a
916    /// dirty-driven desktop shell (`ControlFlow::Wait`) idles and the mobile
917    /// frame gate `Skip`s the next tick, so the enqueued pop never drains until
918    /// an unrelated later frame.
919    ///
920    /// **The refused pop.** The hook may decline to pop at all, when the stack
921    /// moved out from under the staged exit (see [`StagedPop`]) — a page pushed
922    /// while the ramp ran would otherwise be popped in this sheet's place. A
923    /// refusal must not latch [`Self::closed`]: that latch is terminal, and
924    /// setting it over a page this widget never removed would leave an
925    /// invisible, permanently undismissable barrier. The sheet springs back
926    /// open instead (`reduce_motion` snaps it), and a later dismiss stages a
927    /// fresh, correctly-armed exit.
928    ///
929    /// **The carried payload.** [`Self::pending_result`], armed by a
930    /// [`ModalDismiss::dismiss_with`] request, rides this fire via
931    /// [`StagedPop::fire_with`] — taken only when the pop actually happens.
932    /// A refused pop drops it instead of holding it: it was scoped to *this*
933    /// exit's pop, which never happened, and a payload left standing would be
934    /// delivered by whatever dismissal landed next (a scrim tap, `Escape`, a
935    /// back press) to a pusher that was never handed it.
936    fn fire_staged_close(&mut self, ctx: &mut PaintCtx, reduce: bool) {
937        if !self.exiting || self.anim.is_animating() || self.progress > SETTLED_EPSILON {
938            return;
939        }
940        self.exiting = false;
941        self.progress = 0.0;
942        // Cloned out of `self` so the recovery arm below can take `&mut self`;
943        // it is an `Rc`, and this runs once per staged exit.
944        let Some(on_close) = self.on_close.clone() else {
945            return;
946        };
947        if on_close.fire_with(&mut self.pending_result) {
948            self.closed = true;
949            // Guarantee the enqueued pop one draining rebuild — see the
950            // `Scheduling the draining frame` note above.
951            ctx.request_frame();
952            return;
953        }
954        // Refused: the payload goes with the exit that did not land (see the
955        // `The carried payload` note above; `spring_open` clears it on the
956        // animated leg, this covers the `reduce_motion` snap too).
957        self.pending_result = None;
958        if reduce {
959            self.progress = 1.0;
960        } else {
961            self.spring_open(SPRING_KICK);
962        }
963        ctx.request_frame();
964    }
965
966    /// Start a fresh spring leg, launched with `velocity`
967    /// (`progress`-units/sec — positive opens, negative closes; see
968    /// [`AnimationController::fling`]), always picking up from wherever
969    /// `progress` currently reads — even when that was just set directly by a
970    /// drag scrub rather than by this engine's own last leg.
971    ///
972    /// `AnimationController` exposes no direct value setter; a zero-duration
973    /// `animate_to` is the sanctioned snap instead — with `duration <= 0.0`,
974    /// `start_duration` assigns the value immediately, with no motion in
975    /// between (`crates/frust-core/src/anim.rs`).
976    fn spring_to(&mut self, velocity: f64) {
977        let mut seed = AnimationController::new(Duration::ZERO);
978        seed.animate_to(self.progress);
979        self.anim = seed;
980        self.anim.fling(velocity, SHEET_SPRING);
981    }
982
983    /// Launch a fresh leg aimed back **open** (`velocity` positive), cancelling
984    /// any staged exit it overtakes.
985    ///
986    /// Clearing `exiting` is the point: a leg targeting `1.0` is by definition
987    /// not a dismissal, and a staged exit left flagged across one would settle at
988    /// rest where [`Self::fire_staged_close`] can never fire it, while
989    /// [`Self::request_dismiss`] and [`Self::observe_dismiss_signal`] both
990    /// hard-return on the flag — every later scrim tap, `Escape` and back press
991    /// dead, with the sheet still a modal barrier.
992    ///
993    /// A cancelled exit takes its payload with it: [`Self::pending_result`] is
994    /// scoped to the pop *that* exit was staged for, and must not be left
995    /// standing for whichever dismissal eventually lands.
996    fn spring_open(&mut self, velocity: f64) {
997        self.exiting = false;
998        self.pending_result = None;
999        self.spring_to(velocity);
1000    }
1001
1002    /// Stage an exit: launch the closing leg at `velocity`, flag it, and arm the
1003    /// staged pop against the navigator stack as it stands right now (see
1004    /// [`StagedPop`]). The one choke point every staging path goes through.
1005    fn stage_exit(&mut self, velocity: f64) {
1006        self.spring_to(velocity);
1007        self.exiting = true;
1008        if let Some(on_close) = &self.on_close {
1009            on_close.arm();
1010        }
1011    }
1012
1013    /// Take a dismiss trigger (scrim tap, Escape, a handle-drag release
1014    /// decided closed, or — via [`Self::observe_dismiss_request`] — the app's
1015    /// own [`ModalDismiss`] handle): kick the closing spring at `velocity`,
1016    /// then either **stage** the navigator pop behind it (the
1017    /// [`show_bottom_sheet`] path, which wires a state-free `on_close`) or
1018    /// fire the state-touching [`BottomSheetView::on_dismiss`] on the spot (a
1019    /// standalone mount, which has nothing to defer into) — see the [module
1020    /// docs](self)' *Who owns the exit*.
1021    ///
1022    /// A trigger arriving while a staged exit is already running, or after this
1023    /// sheet has dismissed once — the staged pop fired ([`Self::closed`]) or
1024    /// the unstaged `on_dismiss` did ([`Self::dismissed`]) — is a no-op
1025    /// ([`Self::refuses_dismiss`]): the sheet stays mounted for a frame or more
1026    /// past the enqueued pop (or past the app's own teardown), and a second
1027    /// dismissal there would take the page underneath with it. It is also what
1028    /// makes an app dismiss through the handle idempotent (a second `dismiss()`
1029    /// mid-exit changes nothing here, on top of the generation latch already
1030    /// refusing to observe one request twice).
1031    ///
1032    /// No `dismissable` gate lives here — every *gesture* caller applies it at
1033    /// its own call site, and [`Self::observe_dismiss_request`] deliberately
1034    /// does not, so an app dismiss reaches this unconditionally (see the
1035    /// [module docs](self)' app-initiated dismiss section).
1036    fn request_dismiss(&mut self, ctx: &mut EventCtx, velocity: f64) {
1037        if self.refuses_dismiss() {
1038            return;
1039        }
1040        if self.on_close.is_some() {
1041            self.stage_exit(velocity);
1042            // The spring's own frames come from `paint`, but only once one runs
1043            // — ask for that first one here.
1044            ctx.request_redraw();
1045            return;
1046        }
1047        self.spring_to(velocity);
1048        if let Some(on_dismiss) = self.on_dismiss.as_mut() {
1049            on_dismiss(ctx);
1050            // The unstaged route's own one-shot latch, `closed`'s twin: this
1051            // sheet has dismissed, and every route above refuses from here
1052            // ([`Self::refuses_dismiss`]).
1053            self.dismissed = true;
1054        }
1055    }
1056
1057    /// The last painted frame time in milliseconds — the event pass's clock,
1058    /// since a pointer event carries none.
1059    fn event_time_ms(&self) -> f64 {
1060        self.last_frame_time.as_secs_f64() * 1000.0
1061    }
1062
1063    /// Recompute the panel/handle-strip rects and the content's origin for the
1064    /// current `progress`, without a relayout — the panel's own extent (fixed
1065    /// by the last real layout) never changes with `progress`, only its Y
1066    /// offset does. Used by `layout`, by every spring-advanced `paint` frame,
1067    /// and by the drag scrub (`event`, which has no `request_layout` to reach
1068    /// for — `EventCtx` carries none).
1069    fn reposition(&mut self, area: Size) {
1070        let panel_h = (HANDLE_TOUCH_TARGET + self.content_size.height).min(area.height);
1071        let panel_y = area.height - panel_h * self.progress;
1072        self.panel =
1073            Rect::from_origin_size(Point::new(0.0, panel_y), Size::new(area.width, panel_h));
1074        self.handle_target = Rect::new(0.0, panel_y, area.width, panel_y + HANDLE_TOUCH_TARGET);
1075        let content_x = ((area.width - self.content_size.width) / 2.0).max(0.0);
1076        self.content
1077            .set_origin(Point::new(content_x, panel_y + HANDLE_TOUCH_TARGET));
1078    }
1079}
1080
1081impl Widget for BottomSheetWidget {
1082    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1083        let area = Size::new(
1084            finite_or_zero(bc.max().width),
1085            finite_or_zero(bc.max().height),
1086        );
1087
1088        // Content lays out full-width, below the handle strip, in the room left
1089        // under it.
1090        let content_max_h = (area.height - HANDLE_TOUCH_TARGET).max(0.0);
1091        let content_bc = BoxConstraints::loose(Size::new(area.width, content_max_h));
1092        self.content_size = self.content.layout_child(ctx, &content_bc);
1093
1094        self.reposition(area);
1095
1096        bc.constrain(area)
1097    }
1098
1099    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1100        // The back-press seam, first: a bump staged here rides the very same
1101        // spring the pointer/key gestures below drive. The app's own handle
1102        // is observed alongside it, as the backstop for a request raised
1103        // outside an event pass (`mod@crate::overlay::modal`'s own paint
1104        // ordering, mirrored).
1105        self.observe_dismiss_signal(ctx);
1106        self.observe_paint_dismiss_request(ctx);
1107        self.last_frame_time = ctx.frame_time();
1108        // Every theme read in one scope: `Theme::from_paint_ctx` ties its
1109        // returned reference to *this* borrow of `ctx`, and the frame-request/
1110        // reposition calls below need `ctx`/`self` mutably (the
1111        // `crate::overlay::modal` precedent for the same shape).
1112        let (reduce, panel_radius, scrim_color, container_color, handle_color) = {
1113            let theme = Theme::from_paint_ctx(ctx);
1114            (
1115                theme.is_some_and(|t| t.motion.reduce_motion),
1116                radius(theme),
1117                scrim(theme),
1118                container(theme, OverlayContainer::Low),
1119                on_surface_variant(theme),
1120            )
1121        };
1122
1123        if !self.started {
1124            self.started = true;
1125            if reduce {
1126                self.progress = 1.0;
1127            } else if !self.exiting {
1128                // …unless a dismiss beat the first paint here (nothing has been
1129                // shown yet, so there is nothing to slide *in*): re-kicking the
1130                // entrance would fight the exit leg already running and strand
1131                // a staged pop that only fires at the closed end.
1132                self.spring_to(SPRING_KICK);
1133            }
1134        }
1135        if reduce {
1136            if self.anim.is_animating() {
1137                self.anim.stop();
1138            }
1139            // `reduce_motion` collapses the staged exit rather than skipping
1140            // it: the panel lands closed on this frame and the pop fires just
1141            // below, instead of waiting out a spring that will never run.
1142            if self.exiting {
1143                self.progress = 0.0;
1144            }
1145        } else if self.anim.is_animating() {
1146            if self.anim.advance(self.last_frame_time) {
1147                ctx.request_frame();
1148            }
1149            self.progress = self.anim.value();
1150        }
1151        // A settled exit is where the staged pop is finally fired.
1152        self.fire_staged_close(ctx, reduce);
1153        // Keep the geometry in step with whatever `progress` this frame
1154        // settled on — the spring above (and `observe_dismiss_signal`) only
1155        // ever touches `progress` itself, so re-derive the panel/handle/
1156        // content placement here rather than waiting a frame for the next
1157        // `layout`.
1158        self.reposition(ctx.size());
1159
1160        // Scrim over the whole area, fading with the panel's own progress.
1161        scene.fill_rect(
1162            ctx.origin(),
1163            ctx.size(),
1164            scale_alpha(scrim_color, self.progress.clamp(0.0, 1.0) as f32),
1165        );
1166
1167        // Panel: full-width, bottom-anchored, top corners rounded and bottom
1168        // corners square (the bottom sits at the screen edge). No stroked/
1169        // per-corner rounded-rect primitive exists on `PaintScene`, so build the
1170        // path with `kurbo` and fill it — the `card` module's precedent.
1171        let panel_local = Rect::new(self.panel.x0, self.panel.y0, self.panel.x1, self.panel.y1);
1172        let radii = RoundedRectRadii::new(panel_radius, panel_radius, 0.0, 0.0);
1173        let path = RoundedRect::from_rect(panel_local, radii).to_path(PATH_TOLERANCE);
1174        scene.fill_path(ctx.origin(), &path, &Brush::Solid(container_color));
1175
1176        // Drag handle: 32×4dp indicator, centered horizontally within the top
1177        // strip, with fully-rounded ends.
1178        let handle_x = self.panel.x0 + (self.panel.width() - HANDLE_WIDTH) / 2.0;
1179        let handle_y = self.panel.y0 + (HANDLE_TOUCH_TARGET - HANDLE_HEIGHT) / 2.0;
1180        scene.fill_rounded_rect(
1181            Point::new(ctx.origin().x + handle_x, ctx.origin().y + handle_y),
1182            Size::new(HANDLE_WIDTH, HANDLE_HEIGHT),
1183            HANDLE_HEIGHT / 2.0,
1184            handle_color,
1185        );
1186
1187        self.content.paint_child(ctx, scene);
1188    }
1189
1190    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1191        // 0. A broadcast is not user input, so it belongs to neither the drag nor
1192        //    the scrim arm below: forward it to the content unconditionally and
1193        //    consume nothing, so a deferred callback queued inside the sheet still
1194        //    flushes while a finger is down.
1195        if event.is_broadcast() {
1196            frust::authoring::route_event_single(&mut self.content, ctx, event);
1197            return EventResult::Ignored;
1198        }
1199        // 1. An in-flight handle drag owns the pointer stream.
1200        if self.drag_captured {
1201            let InputEvent::Pointer(p) = event else {
1202                return EventResult::Handled;
1203            };
1204            match p.phase {
1205                PointerPhase::Move => {
1206                    self.tracker.record(self.event_time_ms(), p.position.y);
1207                    let dy = p.position.y - self.drag_start_y;
1208                    if !self.drag_moved && dy.abs() > TOUCH_SLOP {
1209                        self.drag_moved = true;
1210                    }
1211                    if self.drag_moved {
1212                        let extent = self.panel.height().max(1.0);
1213                        let target = (self.drag_from - dy / extent).clamp(0.0, 1.0);
1214                        self.progress = target;
1215                        // A live drag takes the panel over from whatever leg was
1216                        // running, a staged exit included: catching a closing
1217                        // sheet cancels its dismissal — payload and all, the
1218                        // same scoping rule `spring_open` applies — and the
1219                        // release below decides again from where the finger
1220                        // left it. (Once the pop has actually fired, `closed`
1221                        // is terminal and no drag resurrects the sheet.)
1222                        self.exiting = false;
1223                        self.pending_result = None;
1224                        self.reposition(ctx.size());
1225                        ctx.request_redraw();
1226                    }
1227                    EventResult::Handled
1228                }
1229                PointerPhase::Up => {
1230                    self.drag_captured = false;
1231                    if !self.drag_moved {
1232                        // A tap on the handle with no real movement: unchanged
1233                        // from v1 — nothing dismisses (only a real drag past
1234                        // the threshold, or a fling, can).
1235                        return EventResult::Handled;
1236                    }
1237                    let velocity = self.tracker.velocity();
1238                    let extent = self.panel.height().max(1.0);
1239                    let stay_open = if velocity.abs() >= FLING_VELOCITY {
1240                        // A flick toward the closing edge (positive velocity,
1241                        // moving down) commits closed; away from it, open.
1242                        velocity < 0.0
1243                    } else {
1244                        self.progress >= DRAG_DISMISS_FRACTION
1245                    };
1246                    // Reparametrize the release speed (px/s along the drag
1247                    // axis) into `progress`-units/sec, then clamp its sign to
1248                    // match the decided direction — a raw reading pointing
1249                    // the other way (a slow release with a stray twitch) must
1250                    // not fling the spring toward the wrong target, since
1251                    // `fling`'s own target is derived purely from the sign it
1252                    // is handed.
1253                    let raw = -velocity / extent;
1254                    if stay_open || !self.dismissable {
1255                        // `dismissable(false)`: a past-threshold drag springs
1256                        // back open instead of firing — see the module docs.
1257                        // `spring_open` (not `spring_to`) because a back press
1258                        // may have staged an exit *under* this live drag: this
1259                        // leg overrules it, and a staged exit left flagged here
1260                        // would settle at rest and refuse every later dismiss.
1261                        self.spring_open(raw.max(SPRING_KICK));
1262                    } else {
1263                        self.request_dismiss(ctx, raw.min(-SPRING_KICK));
1264                    }
1265                    EventResult::Handled
1266                }
1267                PointerPhase::Cancel => {
1268                    // A `Cancel` arm never touches state — clear the drag flag
1269                    // and, if it had actually moved, spring back open (a leg
1270                    // that also cancels any exit staged under the drag, exactly
1271                    // as the `Up` reopening leg above does; an Android back
1272                    // *gesture* delivers this `Cancel` itself).
1273                    self.drag_captured = false;
1274                    if self.drag_moved {
1275                        self.spring_open(SPRING_KICK);
1276                    }
1277                    EventResult::Handled
1278                }
1279                PointerPhase::Down => EventResult::Handled,
1280            }
1281        }
1282        // 2. An in-flight scrim/panel-background press owns the stream.
1283        else if self.scrim_captured {
1284            let InputEvent::Pointer(p) = event else {
1285                return EventResult::Handled;
1286            };
1287            match p.phase {
1288                PointerPhase::Up => {
1289                    let released_outside = !self.panel.contains(p.position);
1290                    if self.dismissable && self.scrim_down_outside && released_outside {
1291                        self.request_dismiss(ctx, -SPRING_KICK);
1292                    }
1293                    self.scrim_captured = false;
1294                    EventResult::Handled
1295                }
1296                PointerPhase::Cancel => {
1297                    self.scrim_captured = false;
1298                    EventResult::Handled
1299                }
1300                _ => EventResult::Handled,
1301            }
1302        }
1303        // 3. Fresh events.
1304        else {
1305            let InputEvent::Pointer(p) = event else {
1306                // Escape (when the sheet itself, not deeper content, holds the
1307                // focus path) dismisses through the same path as a scrim/drag
1308                // dismiss — content gets first crack at it if it holds the
1309                // deeper focus path (mirrors an action's own routing
1310                // precedence on `material::dialog`).
1311                if let InputEvent::Key(key_event) = event
1312                    && self.dismissable
1313                    && key_event.key == Key::Named(NamedKey::Escape)
1314                    && !self.content.is_focused()
1315                    && self.on_dismiss.is_some()
1316                {
1317                    self.request_dismiss(ctx, -SPRING_KICK);
1318                    return EventResult::Handled;
1319                }
1320                // Everything else focus-routed goes to the content.
1321                let result = frust::authoring::route_event_single(&mut self.content, ctx, event);
1322                // The content may have just fired the app's own `ModalDismiss`
1323                // handle (a keyboard-driven action) — stage it in this pass.
1324                self.observe_dismiss_request(ctx);
1325                return result;
1326            };
1327            match p.phase {
1328                PointerPhase::Down => {
1329                    // Claim focus on every Down anywhere in the sheet.
1330                    // Load-bearing: the root treats a Down that bubbles no
1331                    // claim as a blur (`release_focus_session` drops focus +
1332                    // IME state), so the re-claim is what keeps the session
1333                    // alive while this sheet is up. Re-claiming while already
1334                    // focused is a change-guarded no-op (no generation bump)
1335                    // — do not add a claim-once guard, it kills the session
1336                    // on the second tap (claim-once-hygiene review,
1337                    // 2026-08-06).
1338                    ctx.request_focus();
1339                    // A Down in the handle strip begins a drag — primary only:
1340                    // a secondary press drags nothing.
1341                    if presses(p) && self.handle_target.contains(p.position) {
1342                        self.drag_captured = true;
1343                        self.drag_start_y = p.position.y;
1344                        self.drag_from = self.progress;
1345                        self.drag_moved = false;
1346                        self.tracker.clear();
1347                        self.tracker.record(self.event_time_ms(), p.position.y);
1348                        ctx.capture_pointer();
1349                        return EventResult::Handled;
1350                    }
1351                    // Otherwise give the content a chance to consume it.
1352                    if frust::authoring::route_event_single(&mut self.content, ctx, event)
1353                        == EventResult::Handled
1354                    {
1355                        // An action the content just ran (e.g. a row that
1356                        // fires on `Down`) may have asked to dismiss through
1357                        // the app's own handle: stage it in this very pass.
1358                        self.observe_dismiss_request(ctx);
1359                        return EventResult::Handled;
1360                    }
1361                    // An invisible sheet absorbs nothing — the anchored
1362                    // overlay host's own closed-state gate, ported: at
1363                    // `progress == 0` the scrim is fully transparent and the
1364                    // panel sits off-screen, and a widget still mounted behind
1365                    // an enqueued pop (or one whose entrance has not drawn its
1366                    // first frame) paints exactly that. A barrier that swallows
1367                    // what it does not show is an invisible input trap.
1368                    if self.progress <= SETTLED_EPSILON {
1369                        return EventResult::Ignored;
1370                    }
1371                    // Falls to the modal barrier: swallow, arming a scrim dismiss
1372                    // when the press started outside the panel. A secondary
1373                    // press is swallowed the same way but arms nothing, so a
1374                    // right-click can never dismiss the sheet.
1375                    if !presses(p) {
1376                        return EventResult::Handled;
1377                    }
1378                    self.scrim_captured = true;
1379                    self.scrim_down_outside = !self.panel.contains(p.position);
1380                    ctx.capture_pointer();
1381                    EventResult::Handled
1382                }
1383                // A captured content child keeps receiving Move/Up/Cancel via the
1384                // route helper's active-path fast lane.
1385                _ => {
1386                    let result =
1387                        frust::authoring::route_event_single(&mut self.content, ctx, event);
1388                    // Fire-on-up-inside (`docs/CODE_STANDARDS.md`'s Interaction
1389                    // Semantics) means a row's own action most often lands on
1390                    // this very `Up` — observe the app's handle right after,
1391                    // the same pass the content just ran in.
1392                    self.observe_dismiss_request(ctx);
1393                    result
1394                }
1395            }
1396        }
1397    }
1398
1399    fn semantics(&self, ctx: &mut SemanticsCtx) {
1400        ctx.push_container(
1401            Role::Dialog,
1402            |node| node.set_modal(),
1403            |ctx| {
1404                self.content.semantics_child(ctx);
1405            },
1406        );
1407    }
1408
1409    frust::authoring::visit_children!(content);
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415    use frust::NavigatorView;
1416    use frust::TransitionSpec;
1417    use frust::authoring::text::TextContext;
1418    use frust::authoring::{
1419        KeyEvent, Modifiers, NamedKey, PointerButton, PointerEvent, any as core_any,
1420    };
1421    use frust_core::RenderRoot;
1422    use frust_widgets::navigator;
1423    use frust_widgets::test_support::leaf_any;
1424    use std::any::Any;
1425
1426    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1427        InputEvent::Pointer(PointerEvent {
1428            phase,
1429            position: Point::new(x, y),
1430            button: PointerButton::Primary,
1431        })
1432    }
1433
1434    fn escape_event() -> InputEvent {
1435        InputEvent::Key(KeyEvent {
1436            key: Key::Named(NamedKey::Escape),
1437            modifiers: Modifiers::default(),
1438            repeat: false,
1439        })
1440    }
1441
1442    fn ft(ms: u64) -> FrameTime {
1443        FrameTime::from_nanos(ms * 1_000_000)
1444    }
1445
1446    fn build(view: &BottomSheetView<()>) -> BottomSheetWidget {
1447        let mut counter = 0u64;
1448        View::<()>::build(view, &mut BuildCtx::new(&mut counter))
1449    }
1450
1451    /// Records filled rects, rounded rects, and filled paths (as a brush color
1452    /// plus the path's window-space bounding box).
1453    #[derive(Default)]
1454    struct Recorder {
1455        rects: Vec<(Point, Size, Color)>,
1456        rrects: Vec<(Point, Size, f64, Color)>,
1457        paths: Vec<Color>,
1458        path_boxes: Vec<Rect>,
1459    }
1460    impl PaintScene for Recorder {
1461        fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
1462            self.rects.push((o, s, c));
1463        }
1464        fn draw_text(&mut self, _o: Point, _t: &str) {}
1465        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1466            self.rrects.push((o, s, radius, color));
1467        }
1468        fn fill_path(&mut self, origin: Point, path: &kurbo::BezPath, brush: &Brush) {
1469            if let Brush::Solid(color) = brush {
1470                self.paths.push(*color);
1471            }
1472            let b = path.bounding_box();
1473            self.path_boxes.push(Rect::new(
1474                b.x0 + origin.x,
1475                b.y0 + origin.y,
1476                b.x1 + origin.x,
1477                b.y1 + origin.y,
1478            ));
1479        }
1480    }
1481    impl Recorder {
1482        /// The window-space top edge of the sheet panel this frame — the panel
1483        /// is the only filled *path* either navigator test below paints, so its
1484        /// bounding box is the panel's own rect, and its `y0` is what the exit
1485        /// and entrance ramps move.
1486        fn panel_top(&self) -> Option<f64> {
1487            self.path_boxes.first().map(|b| b.y0)
1488        }
1489    }
1490
1491    /// Drive `w` through a generous, bounded schedule of `paint` calls (each
1492    /// seeded with an advancing [`FrameTime`] via [`PaintCtx::for_test`])
1493    /// until its spring settles — every test below that needs the sheet at
1494    /// rest calls this first, since `layout`/`paint` position the panel by
1495    /// `progress`, and `progress` only reaches its target once enough spring
1496    /// frames have run (the [module docs](super)' "first frame is invisible"
1497    /// contract).
1498    fn settle(w: &mut BottomSheetWidget, area: Size) {
1499        let mut lctx = LayoutCtx::new();
1500        w.layout(&mut lctx, &BoxConstraints::tight(area));
1501        for i in 0..40u64 {
1502            let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(i * 20));
1503            w.paint(&mut pctx, &mut Recorder::default());
1504            if !w.anim.is_animating() {
1505                return;
1506            }
1507        }
1508        panic!("sheet spring failed to settle within the test bound");
1509    }
1510
1511    /// Advance `w`'s own clock by `delta_ms` from wherever it currently sits
1512    /// (`w.last_frame_time`, most recently stamped by `settle`) via a no-op
1513    /// `paint` — the velocity tests below need at least one clock tick
1514    /// between a `Down` and a `Move` for `VelocityTracker` to read a real,
1515    /// non-zero speed (a pointer event carries no timestamp of its own, and a
1516    /// hardcoded absolute `FrameTime` here would regress *behind*
1517    /// `settle`'s own advancing clock, corrupting the tracker's window).
1518    fn tick(w: &mut BottomSheetWidget, area: Size, delta_ms: u64) {
1519        let next_ms = w.last_frame_time.as_nanos() / 1_000_000 + delta_ms;
1520        let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(next_ms));
1521        w.paint(&mut pctx, &mut Recorder::default());
1522    }
1523
1524    #[test]
1525    fn layout_starts_fully_offscreen_before_the_entrance_settles() {
1526        let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
1527        let mut w = build(&view);
1528        let mut lctx = LayoutCtx::new();
1529        let area = Size::new(400.0, 600.0);
1530        let size = w.layout(&mut lctx, &BoxConstraints::tight(area));
1531        assert_eq!(size, area, "the sheet fills the whole area (scrim)");
1532        // `progress` starts at 0.0 (build default) — the panel sits fully
1533        // below the fold until the first `paint` kicks the entrance spring.
1534        assert_eq!(w.panel.y0, area.height);
1535    }
1536
1537    #[test]
1538    fn layout_anchors_panel_to_bottom_with_handle_strip_once_settled() {
1539        let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
1540        let mut w = build(&view);
1541        let area = Size::new(400.0, 600.0);
1542        settle(&mut w, area);
1543
1544        // Panel: full width, bottom-anchored, sized to content + handle strip.
1545        assert_eq!(w.panel.x0, 0.0);
1546        assert_eq!(w.panel.x1, area.width);
1547        assert!(
1548            (w.panel.y1 - area.height).abs() < 1e-6,
1549            "panel bottom is the screen edge once at rest"
1550        );
1551        assert!((w.panel.height() - (HANDLE_TOUCH_TARGET + 200.0)).abs() < 1e-6);
1552        // The handle strip is the top 48dp of the panel.
1553        assert_eq!(w.handle_target.y0, w.panel.y0);
1554        assert_eq!(w.handle_target.height(), HANDLE_TOUCH_TARGET);
1555        // Content sits below the handle strip.
1556        assert!((w.content.origin().y - (w.panel.y0 + HANDLE_TOUCH_TARGET)).abs() < 1e-6);
1557    }
1558
1559    #[test]
1560    fn unthemed_paint_draws_scrim_panel_and_handle_once_settled() {
1561        let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
1562        let mut w = build(&view);
1563        let area = Size::new(400.0, 600.0);
1564        settle(&mut w, area);
1565
1566        let mut rec = Recorder::default();
1567        let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(1000));
1568        w.paint(&mut pctx, &mut rec);
1569
1570        // Scrim: full-area fill at 32% of the fallback scrim, fully faded in
1571        // now that the sheet is at rest.
1572        assert_eq!(rec.rects[0].1, area);
1573        assert_eq!(rec.rects[0].2, scrim(None));
1574        // Panel: a filled top-rounded path in the fallback container color.
1575        assert_eq!(rec.paths, vec![container(None, OverlayContainer::Low)]);
1576        // Handle: a 32×4 rounded indicator in the fallback handle color.
1577        let handle = rec
1578            .rrects
1579            .iter()
1580            .find(|(_, s, _, _)| *s == Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
1581            .expect("the drag handle is painted");
1582        assert_eq!(handle.3, on_surface_variant(None));
1583    }
1584
1585    #[test]
1586    fn themed_paint_resolves_tokens_once_settled() {
1587        let theme = crate::baseline();
1588        let scheme = theme.scheme();
1589        let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
1590        let mut w = build(&view);
1591        let area = Size::new(400.0, 600.0);
1592        settle(&mut w, area);
1593
1594        let mut rec = Recorder::default();
1595        let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(1000)).with_theme(&theme);
1596        w.paint(&mut pctx, &mut rec);
1597
1598        assert_eq!(rec.rects[0].2, scrim(Some(&theme)));
1599        assert_eq!(rec.paths, vec![scheme.surface_container_low]);
1600        let handle = rec
1601            .rrects
1602            .iter()
1603            .find(|(_, s, _, _)| *s == Size::new(HANDLE_WIDTH, HANDLE_HEIGHT))
1604            .expect("the drag handle is painted");
1605        assert_eq!(handle.3, scheme.on_surface_variant);
1606    }
1607
1608    // --- Spring entrance/exit pinned. ---
1609
1610    #[test]
1611    fn the_entrance_progresses_smoothly_rather_than_jumping() {
1612        let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
1613        let mut w = build(&view);
1614        let area = Size::new(400.0, 600.0);
1615        let mut lctx = LayoutCtx::new();
1616        w.layout(&mut lctx, &BoxConstraints::tight(area));
1617
1618        // First paint: seeds the spring, but only seeds the clock too (zero
1619        // delta) — progress stays exactly where it started.
1620        let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(0));
1621        w.paint(&mut pctx, &mut Recorder::default());
1622        assert_eq!(w.progress, 0.0, "the first frame is invisible");
1623        assert!(w.anim.is_animating());
1624
1625        // A handful of frames in, the panel has moved but not yet arrived —
1626        // a real (spring) ramp, not an instant snap.
1627        let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(80));
1628        w.paint(&mut pctx, &mut Recorder::default());
1629        assert!(
1630            w.progress > 0.0 && w.progress < 1.2,
1631            "mid-flight progress {} should sit between the endpoints (allowing \
1632             a modest expressive overshoot)",
1633            w.progress
1634        );
1635
1636        settle(&mut w, area);
1637        assert!((w.progress - 1.0).abs() < 1e-6, "settles exactly at rest");
1638    }
1639
1640    #[test]
1641    fn a_dismiss_trigger_springs_the_panel_closed_over_several_frames() {
1642        let view: BottomSheetView<Flag> =
1643            bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
1644        let area = Size::new(400.0, 600.0);
1645        let mut counter = 0u64;
1646        let mut w = View::<Flag>::build(&view, &mut BuildCtx::new(&mut counter));
1647        settle(&mut w, area);
1648        assert_eq!(w.progress, 1.0);
1649
1650        // A scrim tap outside the panel dismisses immediately (state-touching,
1651        // per the module docs) but also kicks a real closing spring — the
1652        // widget doesn't just vanish or snap to 0 on the spot.
1653        let mut state = Flag::default();
1654        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1655        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1656        assert_eq!(state.dismissed, 1);
1657        // The closing spring is kicked (`self.anim`), but `progress` itself
1658        // only moves once a `paint` advances it — the same "first frame is
1659        // invisible" contract the entrance carries (`event` has no clock of
1660        // its own to advance against).
1661        assert!(w.anim.is_animating());
1662        assert_eq!(w.progress, 1.0, "progress hasn't moved yet — no paint ran");
1663
1664        // Left mounted (the standalone/`Stack` use case), it settles fully
1665        // closed rather than snapping — and visibly moves along the way.
1666        let mut saw_partial_progress = false;
1667        for i in 0..40u64 {
1668            let mut pctx = PaintCtx::for_test(Point::ZERO, area, ft(i * 20));
1669            w.paint(&mut pctx, &mut Recorder::default());
1670            if w.progress > 1e-3 && w.progress < 1.0 - 1e-3 {
1671                saw_partial_progress = true;
1672            }
1673            if !w.anim.is_animating() {
1674                break;
1675            }
1676        }
1677        assert!(
1678            saw_partial_progress,
1679            "the exit is a real ramp, not an instant snap to 0"
1680        );
1681        assert!((w.progress - 0.0).abs() < 1e-6);
1682    }
1683
1684    // --- Drag-to-dismiss (interactive, position + velocity). ---
1685
1686    #[derive(Default)]
1687    struct Flag {
1688        dismissed: u32,
1689    }
1690
1691    fn build_flag(view: &BottomSheetView<Flag>) -> BottomSheetWidget {
1692        let mut counter = 0u64;
1693        View::<Flag>::build(view, &mut BuildCtx::new(&mut counter))
1694    }
1695
1696    fn dispatch(w: &mut BottomSheetWidget, state: &mut Flag, event: &InputEvent) -> EventResult {
1697        let state_any: &mut dyn Any = state;
1698        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
1699        w.event(&mut ctx, event)
1700    }
1701
1702    fn laid_out_flag_sheet() -> BottomSheetWidget {
1703        let view: BottomSheetView<Flag> =
1704            bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
1705        let mut w = build_flag(&view);
1706        let mut lctx = LayoutCtx::new();
1707        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
1708        w
1709    }
1710
1711    /// The same fixture, already settled at rest (`progress == 1.0`) — the
1712    /// tests below that assert *live position-tracking* need a clean open
1713    /// baseline to scrub down from.
1714    fn settled_flag_sheet() -> BottomSheetWidget {
1715        let view: BottomSheetView<Flag> =
1716            bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
1717        let mut counter = 0u64;
1718        let mut w = View::<Flag>::build(&view, &mut BuildCtx::new(&mut counter));
1719        settle(&mut w, Size::new(400.0, 600.0));
1720        w
1721    }
1722
1723    // A `()`-erased leaf is `AnyView<()>`; the Flag-state sheet needs an
1724    // `AnyView<Flag>`, so wrap a fixed-size content view generic over state.
1725    fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
1726        core_any::<Flag, _>(FixedLeaf {
1727            size: Size::new(w, h),
1728        })
1729    }
1730    struct FixedLeaf {
1731        size: Size,
1732    }
1733    struct FixedLeafW {
1734        size: Size,
1735    }
1736    impl View<Flag> for FixedLeaf {
1737        type Element = FixedLeafW;
1738        fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
1739            FixedLeafW { size: self.size }
1740        }
1741        fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1742            ChangeFlags::NONE
1743        }
1744    }
1745    impl Widget for FixedLeafW {
1746        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1747            bc.constrain(self.size)
1748        }
1749        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1750    }
1751
1752    #[test]
1753    fn mid_drag_progress_tracks_the_pointer() {
1754        let mut w = settled_flag_sheet();
1755        let mut state = Flag::default();
1756        let handle_y = w.panel.y0 + 10.0;
1757        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1758        assert_eq!(w.progress, 1.0, "no movement yet");
1759
1760        // Past `TOUCH_SLOP` (18px): the panel starts following the finger.
1761        let extent = w.panel.height();
1762        dispatch(
1763            &mut w,
1764            &mut state,
1765            &ev(PointerPhase::Move, 200.0, handle_y + 40.0),
1766        );
1767        let expected = (1.0 - 40.0 / extent).clamp(0.0, 1.0);
1768        assert!(
1769            (w.progress - expected).abs() < 1e-6,
1770            "progress {} should track the 40px drag exactly ({} expected)",
1771            w.progress,
1772            expected
1773        );
1774        assert_eq!(state.dismissed, 0, "still mid-drag — nothing decided yet");
1775
1776        // Moving back up retraces the same live mapping.
1777        dispatch(
1778            &mut w,
1779            &mut state,
1780            &ev(PointerPhase::Move, 200.0, handle_y + 10.0),
1781        );
1782        let expected = (1.0 - 10.0 / extent).clamp(0.0, 1.0);
1783        assert!((w.progress - expected).abs() < 1e-6);
1784    }
1785
1786    #[test]
1787    fn a_sub_slop_move_does_not_move_the_panel() {
1788        let mut w = settled_flag_sheet();
1789        let mut state = Flag::default();
1790        let handle_y = w.panel.y0 + 10.0;
1791        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1792        dispatch(
1793            &mut w,
1794            &mut state,
1795            &ev(PointerPhase::Move, 200.0, handle_y + 10.0), // < TOUCH_SLOP (18)
1796        );
1797        assert_eq!(w.progress, 1.0, "below the slop the panel doesn't move yet");
1798        dispatch(
1799            &mut w,
1800            &mut state,
1801            &ev(PointerPhase::Up, 200.0, handle_y + 10.0),
1802        );
1803        assert_eq!(state.dismissed, 0, "a tap-shaped release does not dismiss");
1804    }
1805
1806    #[test]
1807    fn handle_drag_past_threshold_dismisses() {
1808        let mut w = laid_out_flag_sheet();
1809        // panel height = 48 + 200 = 248; threshold = 124.
1810        let handle_y = w.panel.y0 + 10.0; // inside the 48dp handle strip
1811        let mut state = Flag::default();
1812        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1813        assert!(w.drag_captured);
1814        dispatch(
1815            &mut w,
1816            &mut state,
1817            &ev(PointerPhase::Move, 200.0, handle_y + 200.0),
1818        );
1819        dispatch(
1820            &mut w,
1821            &mut state,
1822            &ev(PointerPhase::Up, 200.0, handle_y + 200.0),
1823        );
1824        assert_eq!(state.dismissed, 1, "a drag past half the sheet height pops");
1825    }
1826
1827    #[test]
1828    fn short_handle_drag_does_not_dismiss() {
1829        let mut w = laid_out_flag_sheet();
1830        let handle_y = w.panel.y0 + 10.0;
1831        let mut state = Flag::default();
1832        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1833        dispatch(
1834            &mut w,
1835            &mut state,
1836            &ev(PointerPhase::Up, 200.0, handle_y + 20.0),
1837        );
1838        assert_eq!(state.dismissed, 0, "a short drag does not dismiss");
1839    }
1840
1841    #[test]
1842    fn a_drag_released_under_the_dismiss_threshold_springs_back_open() {
1843        let mut w = settled_flag_sheet();
1844        let mut state = Flag::default();
1845        let handle_y = w.panel.y0 + 10.0;
1846        let extent = w.panel.height();
1847        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1848        // 30px is past TOUCH_SLOP but well short of the 50%-of-extent
1849        // dismiss threshold (124px on a 248px-tall panel).
1850        dispatch(
1851            &mut w,
1852            &mut state,
1853            &ev(PointerPhase::Move, 200.0, handle_y + 30.0),
1854        );
1855        assert!(w.progress < 1.0 && w.progress > DRAG_DISMISS_FRACTION);
1856        dispatch(
1857            &mut w,
1858            &mut state,
1859            &ev(PointerPhase::Up, 200.0, handle_y + 30.0),
1860        );
1861        assert_eq!(state.dismissed, 0, "under the threshold — stays open");
1862        assert!(
1863            w.anim.is_animating(),
1864            "a real spring leg back toward 1.0 is running, not an instant snap"
1865        );
1866        let _ = extent;
1867    }
1868
1869    #[test]
1870    fn a_fast_downward_flick_dismisses_regardless_of_position() {
1871        let mut w = settled_flag_sheet();
1872        let mut state = Flag::default();
1873        let handle_y = w.panel.y0 + 10.0;
1874        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1875        // Advance the clock 16ms between samples so the velocity tracker has
1876        // a real, non-zero reading — a small (30px) but *fast* downward move.
1877        tick(&mut w, Size::new(400.0, 600.0), 16);
1878        dispatch(
1879            &mut w,
1880            &mut state,
1881            &ev(PointerPhase::Move, 200.0, handle_y + 30.0),
1882        );
1883        // Position alone (30px of a 248px extent) would stay open, but the
1884        // ~1875px/s downward flick overrides it.
1885        assert!(w.progress > DRAG_DISMISS_FRACTION);
1886        dispatch(
1887            &mut w,
1888            &mut state,
1889            &ev(PointerPhase::Up, 200.0, handle_y + 30.0),
1890        );
1891        assert_eq!(state.dismissed, 1, "a fast downward flick dismisses");
1892    }
1893
1894    #[test]
1895    fn a_fast_upward_flick_stays_open_regardless_of_position() {
1896        let mut w = settled_flag_sheet();
1897        let mut state = Flag::default();
1898        let handle_y = w.panel.y0 + 10.0;
1899        let area = Size::new(400.0, 600.0);
1900        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1901        // Drag well past the dismiss threshold first…
1902        dispatch(
1903            &mut w,
1904            &mut state,
1905            &ev(PointerPhase::Move, 200.0, handle_y + 200.0),
1906        );
1907        assert!(w.progress < DRAG_DISMISS_FRACTION);
1908        // …then flick back upward. `VelocityTracker` reads off a trailing
1909        // window (`VELOCITY_WINDOW_MS`, 100ms), not an instantaneous
1910        // derivative, so age the downward sample out of that window first —
1911        // otherwise the still-in-window `Down`→+200 leg would dominate the
1912        // net reading despite the recent upward motion.
1913        tick(&mut w, area, 150);
1914        dispatch(
1915            &mut w,
1916            &mut state,
1917            &ev(PointerPhase::Move, 200.0, handle_y + 150.0),
1918        );
1919        tick(&mut w, area, 16);
1920        dispatch(
1921            &mut w,
1922            &mut state,
1923            &ev(PointerPhase::Move, 200.0, handle_y + 100.0),
1924        );
1925        dispatch(
1926            &mut w,
1927            &mut state,
1928            &ev(PointerPhase::Up, 200.0, handle_y + 100.0),
1929        );
1930        assert_eq!(state.dismissed, 0, "a fast upward flick stays open");
1931    }
1932
1933    // --- `dismissable(false)` gates the scrim + drag together. ---
1934
1935    fn laid_out_non_dismissable_flag_sheet() -> BottomSheetWidget {
1936        let view: BottomSheetView<Flag> = bottom_sheet(leaf_any_flag(300.0, 200.0))
1937            .on_dismiss(|s: &mut Flag| s.dismissed += 1)
1938            .dismissable(false);
1939        let mut w = build_flag(&view);
1940        let mut lctx = LayoutCtx::new();
1941        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
1942        w
1943    }
1944
1945    #[test]
1946    fn dismissable_false_gates_handle_drag_dismiss() {
1947        let mut w = laid_out_non_dismissable_flag_sheet();
1948        // panel height = 48 + 200 = 248; threshold = 124 — well past it.
1949        let handle_y = w.panel.y0 + 10.0;
1950        let mut state = Flag::default();
1951        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
1952        dispatch(
1953            &mut w,
1954            &mut state,
1955            &ev(PointerPhase::Move, 200.0, handle_y + 200.0),
1956        );
1957        dispatch(
1958            &mut w,
1959            &mut state,
1960            &ev(PointerPhase::Up, 200.0, handle_y + 200.0),
1961        );
1962        assert_eq!(
1963            state.dismissed, 0,
1964            "dismissable(false): a past-threshold drag settles back without firing"
1965        );
1966        assert!(
1967            w.anim.is_animating(),
1968            "it springs back open rather than staying stuck where the drag left it"
1969        );
1970    }
1971
1972    #[test]
1973    fn dismissable_false_gates_scrim_tap() {
1974        let mut w = laid_out_non_dismissable_flag_sheet();
1975        let mut state = Flag::default();
1976        assert!(!w.panel.contains(Point::new(5.0, 5.0)));
1977        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1978        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1979        assert_eq!(state.dismissed, 0, "dismissable(false) gates the scrim tap");
1980    }
1981
1982    #[test]
1983    fn scrim_tap_outside_panel_dismisses() {
1984        // Settled, not merely laid out: the barrier only swallows what it
1985        // shows, so a sheet whose entrance has not drawn a frame yet
1986        // (`progress == 0`) is input-transparent by design — see the Down arm's
1987        // visibility gate.
1988        let mut w = settled_flag_sheet();
1989        let mut state = Flag::default();
1990        // (5, 5) is in the top scrim, above the bottom-anchored panel.
1991        assert!(!w.panel.contains(Point::new(5.0, 5.0)));
1992        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1993        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1994        assert_eq!(state.dismissed, 1);
1995    }
1996
1997    // --- Navigator integration: scrim tap pops with an empty result. ---
1998
1999    #[derive(Default)]
2000    struct NavState {
2001        results: Vec<Option<i32>>,
2002    }
2003
2004    #[test]
2005    fn scrim_tap_pops_sheet_via_navigator() {
2006        let controller: NavigatorController<NavState> = NavigatorController::new();
2007        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2008        let mut app = {
2009            let ctrl = controller.clone();
2010            move |_: &mut NavState| {
2011                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2012            }
2013        };
2014        let mut state = NavState::default();
2015
2016        root.rebuild(&mut app, &mut state);
2017        let mut tcx = TextContext::new();
2018        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2019
2020        // Push the sheet with an instant transition (no slide to settle),
2021        // wiring the dismiss to a plain pop.
2022        {
2023            let dismiss = controller.clone();
2024            controller.push_transparent_for_result(
2025                move || {
2026                    let d = dismiss.clone();
2027                    core_any::<NavState, _>(
2028                        bottom_sheet(bg_page(400.0, 200.0))
2029                            .on_dismiss(move |_s: &mut NavState| d.pop()),
2030                    )
2031                },
2032                TransitionSpec::NONE,
2033                |state: &mut NavState, result: PopResult| {
2034                    state.results.push(result.take::<i32>());
2035                },
2036            );
2037        }
2038        root.rebuild(&mut app, &mut state);
2039        // Settle the entrance before tapping: the barrier only swallows what it
2040        // shows, so a sheet that has not drawn a frame yet is input-transparent
2041        // by design (the Down arm's visibility gate).
2042        settle_navigator_frame(&mut root, Size::new(400.0, 600.0));
2043
2044        // Tap the top scrim, above the bottom-anchored sheet.
2045        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
2046        root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
2047        root.rebuild(&mut app, &mut state);
2048        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2049        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2050
2051        assert_eq!(
2052            state.results,
2053            vec![None],
2054            "scrim tap pops with an empty result"
2055        );
2056    }
2057
2058    // --- Focus + Escape opt-in. ---
2059
2060    /// Settle the navigator's own frame loop (a bounded pump of `paint`
2061    /// calls) so the sheet's internal entrance spring is fully at rest before
2062    /// a test relies on rest-position geometry (`handle_y` computed from the
2063    /// sheet's fixed panel height) — the entrance now lives inside the widget
2064    /// rather than riding the navigator's own page transition, so it needs
2065    /// its own settle pass here too.
2066    fn settle_navigator_frame<State, V>(root: &mut RenderRoot<State, V>, area: Size)
2067    where
2068        V: frust::authoring::View<State>,
2069    {
2070        let mut tcx = TextContext::new();
2071        for i in 0..40u64 {
2072            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2073            root.paint(&mut Recorder::default(), ft(i * 20));
2074        }
2075    }
2076
2077    #[test]
2078    fn escape_after_a_short_handle_press_claims_focus_and_dismisses_via_navigator() {
2079        let controller: NavigatorController<NavState> = NavigatorController::new();
2080        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2081        let mut app = {
2082            let ctrl = controller.clone();
2083            move |_: &mut NavState| {
2084                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2085            }
2086        };
2087        let mut state = NavState::default();
2088        let area = Size::new(400.0, 600.0);
2089
2090        root.rebuild(&mut app, &mut state);
2091        let mut tcx = TextContext::new();
2092        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2093
2094        {
2095            let dismiss = controller.clone();
2096            controller.push_transparent_for_result(
2097                move || {
2098                    let d = dismiss.clone();
2099                    core_any::<NavState, _>(
2100                        bottom_sheet(bg_page(400.0, 200.0))
2101                            .on_dismiss(move |_s: &mut NavState| d.pop()),
2102                    )
2103                },
2104                TransitionSpec::NONE,
2105                |state: &mut NavState, result: PopResult| {
2106                    state.results.push(result.take::<i32>());
2107                },
2108            );
2109        }
2110        root.rebuild(&mut app, &mut state);
2111        settle_navigator_frame(&mut root, area);
2112
2113        // panel height = 48 (handle strip) + 200 (content) = 248; panel_y = 352.
2114        // A short handle-strip drag (well under the 124px dismiss threshold)
2115        // claims focus without itself dismissing (mirrors
2116        // `short_handle_drag_does_not_dismiss`).
2117        let handle_y = area.height - 248.0 + 10.0;
2118        root.event(&mut state, &ev(PointerPhase::Down, 200.0, handle_y));
2119        root.event(&mut state, &ev(PointerPhase::Up, 200.0, handle_y + 10.0));
2120        root.rebuild(&mut app, &mut state);
2121        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2122        assert!(
2123            state.results.is_empty(),
2124            "a short handle drag does not dismiss"
2125        );
2126
2127        // Escape now reaches the focused sheet and dismisses it, the same
2128        // dismiss path as a scrim tap / long handle drag.
2129        root.event(&mut state, &escape_event());
2130        root.rebuild(&mut app, &mut state);
2131        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2132        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2133
2134        assert_eq!(
2135            state.results,
2136            vec![None],
2137            "Escape dismisses the focused sheet"
2138        );
2139    }
2140
2141    #[test]
2142    fn escape_without_a_prior_press_does_nothing() {
2143        let controller: NavigatorController<NavState> = NavigatorController::new();
2144        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2145        let mut app = {
2146            let ctrl = controller.clone();
2147            move |_: &mut NavState| {
2148                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2149            }
2150        };
2151        let mut state = NavState::default();
2152        let area = Size::new(400.0, 600.0);
2153
2154        root.rebuild(&mut app, &mut state);
2155        let mut tcx = TextContext::new();
2156        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2157
2158        {
2159            let dismiss = controller.clone();
2160            controller.push_transparent_for_result(
2161                move || {
2162                    let d = dismiss.clone();
2163                    core_any::<NavState, _>(
2164                        bottom_sheet(bg_page(400.0, 200.0))
2165                            .on_dismiss(move |_s: &mut NavState| d.pop()),
2166                    )
2167                },
2168                TransitionSpec::NONE,
2169                |state: &mut NavState, result: PopResult| {
2170                    state.results.push(result.take::<i32>());
2171                },
2172            );
2173        }
2174        root.rebuild(&mut app, &mut state);
2175        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2176
2177        // No press yet — the sheet never claimed focus, so Escape has no focus
2178        // chain to travel and is dropped.
2179        root.event(&mut state, &escape_event());
2180        root.rebuild(&mut app, &mut state);
2181        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2182        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2183
2184        assert!(
2185            state.results.is_empty(),
2186            "Escape without prior focus is a no-op"
2187        );
2188    }
2189
2190    #[test]
2191    fn a_down_reclaims_focus_after_an_external_blur() {
2192        use frust::authoring::ChildPod;
2193
2194        let view: BottomSheetView<()> = bottom_sheet(leaf_any(300.0, 200.0));
2195        let area = Size::new(400.0, 600.0);
2196        let w = build(&view);
2197        let mut pod = ChildPod::new(Box::new(w));
2198        let mut lctx = LayoutCtx::new();
2199        pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
2200
2201        let mut dummy = ();
2202        // A scrim press well above the bottom-anchored panel (outside
2203        // content/handle) claims focus, then releases outside — dismissing
2204        // (mirrors `scrim_tap_outside_panel_dismisses`), which clears
2205        // `scrim_captured` regardless, returning to the "fresh events" arm.
2206        {
2207            let s: &mut dyn Any = &mut dummy;
2208            let mut ctx = EventCtx::new(s, Point::ZERO, area);
2209            pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
2210        }
2211        assert!(pod.is_focused(), "the first Down claims focus");
2212        {
2213            let s: &mut dyn Any = &mut dummy;
2214            let mut ctx = EventCtx::new(s, Point::ZERO, area);
2215            pod.event_child(&mut ctx, &ev(PointerPhase::Up, 5.0, 5.0));
2216        }
2217
2218        // Simulate an external blur (mirrors the root's own `Down`-with-no-
2219        // claim release path) so the second Down's own re-claim is what's
2220        // under test, not a leftover flag.
2221        pod.set_focused(false);
2222        {
2223            let s: &mut dyn Any = &mut dummy;
2224            let mut ctx = EventCtx::new(s, Point::ZERO, area);
2225            pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
2226        }
2227        assert!(
2228            pod.is_focused(),
2229            "a second Down must re-claim focus after an external blur — this is what \
2230             keeps the root's focus/IME session alive while the sheet is up"
2231        );
2232    }
2233
2234    // --- Root-level regression: an unclaimed Down must not blur a live focus/IME
2235    //     session (claim-once-hygiene fix-2a). ---
2236    //
2237    // The h1 claim-once guard broke exactly this: a second Down landing on the
2238    // sheet's own handle strip (a region the sheet's `event` handles itself,
2239    // *before* routing reaches `content` — see the "fresh events" Down arm
2240    // above) skipped `ctx.request_focus()` because the guard had already fired
2241    // once. With nothing else on that dispatch's path claiming focus either
2242    // (the handle-strip branch returns before `content` is ever routed to),
2243    // `RenderRoot`'s own Down arm (`crates/frust-core/src/app.rs`) saw no claim
2244    // bubble up at all and took its blur branch — `release_focus_session`,
2245    // dropping `focus_active` **and** `ime_state` together — even though a
2246    // `TextInput` inside the sheet's content was still mid-edit.
2247
2248    #[derive(Default)]
2249    struct FieldState {
2250        value: String,
2251    }
2252
2253    // A fixed-height (200dp) content slot wrapping a real `TextInput`, giving
2254    // the sheet the exact same panel geometry (`48 + 200 = 248dp`) the
2255    // `bg_page(400.0, 200.0)`-based focus tests above already rely on, so
2256    // `handle_y` below is derived the same way
2257    // `escape_after_a_short_handle_press_claims_focus_and_dismisses_via_navigator`
2258    // computes it.
2259    fn field_sheet_logic(state: &mut FieldState) -> BottomSheetView<FieldState> {
2260        bottom_sheet(frust::SizedBox(None, Some(200.0)).child(frust::text_input(
2261            state.value.clone(),
2262            |s: &mut FieldState, v: String| s.value = v,
2263        )))
2264    }
2265
2266    #[test]
2267    fn a_second_down_on_chrome_keeps_the_root_focus_session() {
2268        let mut root: RenderRoot<FieldState, BottomSheetView<FieldState>> = RenderRoot::new();
2269        let mut state = FieldState::default();
2270        let area = Size::new(400.0, 600.0);
2271
2272        root.rebuild(&mut field_sheet_logic, &mut state);
2273        settle_navigator_frame(&mut root, area);
2274
2275        // Focus the field with a tap inside the content slot (panel height
2276        // 248, so content spans y in [400, 600] — well clear of the handle
2277        // strip at the panel's top).
2278        root.event(&mut state, &ev(PointerPhase::Down, 200.0, 500.0));
2279        root.event(&mut state, &ev(PointerPhase::Up, 200.0, 500.0));
2280        assert!(root.is_focus_active(), "tap inside the field focuses it");
2281        assert!(
2282            root.ime_state().is_some(),
2283            "focusing the field publishes an IME surface"
2284        );
2285
2286        // A second, fresh Down on the handle strip (panel_y = 600 - 248 =
2287        // 352; strip is the top 48dp of the panel) — a region the sheet's
2288        // own `event` claims before `content` is ever routed to. Pre-fix,
2289        // this is exactly the dispatch the claim-once guard broke: no widget
2290        // on this path called `request_focus`, so the root's Down arm took
2291        // its blur branch and released the whole session.
2292        let handle_y = area.height - 248.0 + 10.0;
2293        root.event(&mut state, &ev(PointerPhase::Down, 200.0, handle_y));
2294
2295        assert!(
2296            root.is_focus_active(),
2297            "a second Down on the sheet's own chrome must not blur the root's \
2298             live focus session"
2299        );
2300        assert!(
2301            root.ime_state().is_some(),
2302            "the field's IME surface must survive a second Down on chrome that \
2303             never itself routed to the field"
2304        );
2305    }
2306
2307    // --- `dismissable(false)` gates Escape too (via the navigator). ---
2308
2309    #[test]
2310    fn dismissable_false_gates_escape_via_navigator() {
2311        let controller: NavigatorController<NavState> = NavigatorController::new();
2312        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2313        let mut app = {
2314            let ctrl = controller.clone();
2315            move |_: &mut NavState| {
2316                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2317            }
2318        };
2319        let mut state = NavState::default();
2320        let area = Size::new(400.0, 600.0);
2321
2322        root.rebuild(&mut app, &mut state);
2323        let mut tcx = TextContext::new();
2324        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2325
2326        {
2327            let dismiss = controller.clone();
2328            controller.push_transparent_for_result(
2329                move || {
2330                    let d = dismiss.clone();
2331                    core_any::<NavState, _>(
2332                        bottom_sheet(bg_page(400.0, 200.0))
2333                            .dismissable(false)
2334                            .on_dismiss(move |_s: &mut NavState| d.pop()),
2335                    )
2336                },
2337                TransitionSpec::NONE,
2338                |state: &mut NavState, result: PopResult| {
2339                    state.results.push(result.take::<i32>());
2340                },
2341            );
2342        }
2343        root.rebuild(&mut app, &mut state);
2344        settle_navigator_frame(&mut root, area);
2345
2346        // A short handle-strip press claims focus (unaffected by
2347        // `dismissable`), but the subsequent Escape is gated.
2348        let handle_y = area.height - 248.0 + 10.0;
2349        root.event(&mut state, &ev(PointerPhase::Down, 200.0, handle_y));
2350        root.event(&mut state, &ev(PointerPhase::Up, 200.0, handle_y + 10.0));
2351        root.rebuild(&mut app, &mut state);
2352        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2353
2354        root.event(&mut state, &escape_event());
2355        root.rebuild(&mut app, &mut state);
2356        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2357        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2358
2359        assert!(
2360            state.results.is_empty(),
2361            "dismissable(false) gates Escape too"
2362        );
2363    }
2364
2365    // --- Back request routes through dismissable/BackPolicy. ---
2366
2367    #[test]
2368    fn back_request_dismissable_true_dismisses_via_navigator() {
2369        let controller: NavigatorController<NavState> = NavigatorController::new();
2370        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2371        let mut app = {
2372            let ctrl = controller.clone();
2373            move |_: &mut NavState| {
2374                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2375            }
2376        };
2377        let mut state = NavState::default();
2378        let area = Size::new(400.0, 600.0);
2379
2380        root.rebuild(&mut app, &mut state);
2381        let mut tcx = TextContext::new();
2382        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2383
2384        show_bottom_sheet(
2385            &controller,
2386            || bottom_sheet(bg_page(400.0, 200.0)),
2387            |state: &mut NavState, result: PopResult| {
2388                state.results.push(result.take::<i32>());
2389            },
2390        );
2391        root.rebuild(&mut app, &mut state);
2392        // Settle the sheet's own internal spring entrance (it no longer rides
2393        // a navigator page transition — the entrance moved inside the widget,
2394        // see the module docs' *Spring entrance/exit* section).
2395        settle_navigator_frame(&mut root, area);
2396
2397        assert!(
2398            controller.back_interest(),
2399            "a dismissable sheet claims back interest"
2400        );
2401
2402        controller.request_back();
2403        root.rebuild(&mut app, &mut state);
2404        assert_eq!(
2405            controller.depth(),
2406            2,
2407            "DismissAnimated: the stack is unchanged immediately"
2408        );
2409
2410        // The back-press rebuild flagged PAINT; this paint observes the bumped
2411        // signal and *stages* the exit — the pop itself waits for the closing
2412        // spring to settle (the module docs' *Who owns the exit*; the staging
2413        // is what the assertions below changed for). Either way this same paint
2414        // MUST schedule the next frame, asserted here BEFORE any further
2415        // rebuild (the honest contract; the old test hand-called `rebuild()`
2416        // here, hiding exactly this gap).
2417        let outcome = root.paint(&mut Recorder::default(), ft(900));
2418        assert!(
2419            outcome.needs_frame,
2420            "the back-dismiss paint schedules the frames its staged exit needs"
2421        );
2422        assert_eq!(
2423            controller.depth(),
2424            2,
2425            "the exit ramp is only started at paint — the pop comes later"
2426        );
2427
2428        // Drive the loop ONLY via outcome-honoring frames (rebuild + layout +
2429        // paint, continuing while the outcome asks for another, bounded): no
2430        // manual extra rebuild. The exit ramp runs and the pop drains through
2431        // the shell contract alone.
2432        let mut frames = 0u64;
2433        loop {
2434            root.rebuild(&mut app, &mut state);
2435            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2436            let outcome = root.paint(&mut Recorder::default(), ft(1000 + frames * 100));
2437            frames += 1;
2438            assert!(
2439                frames < 20,
2440                "the exit ramp settles and the pop drains within a bounded number of frames"
2441            );
2442            if !outcome.needs_frame {
2443                break;
2444            }
2445        }
2446        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2447
2448        assert_eq!(
2449            state.results,
2450            vec![None],
2451            "a back request dismisses the sheet"
2452        );
2453        assert_eq!(controller.depth(), 1);
2454    }
2455
2456    // --- The staged exit, through the real `show_bottom_sheet` navigator
2457    //     path (not a hand-mounted widget the pop never reaches). ---
2458
2459    /// The whole point of staging: on the navigator path the panel must be seen
2460    /// walking off-screen across several frames *while the page is still
2461    /// mounted*, and only then does the pop land. A sheet whose pop drains a
2462    /// frame into its exit spring would show one or two positions here, not a
2463    /// ramp that reaches the bottom edge.
2464    #[test]
2465    fn a_scrim_tap_animates_the_panel_out_before_the_navigator_pop_lands() {
2466        let controller: NavigatorController<NavState> = NavigatorController::new();
2467        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2468        let mut app = {
2469            let ctrl = controller.clone();
2470            move |_: &mut NavState| {
2471                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2472            }
2473        };
2474        let mut state = NavState::default();
2475        let area = Size::new(400.0, 600.0);
2476
2477        root.rebuild(&mut app, &mut state);
2478        let mut tcx = TextContext::new();
2479        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2480
2481        show_bottom_sheet(
2482            &controller,
2483            || bottom_sheet(bg_page(400.0, 200.0)),
2484            |state: &mut NavState, result: PopResult| {
2485                state.results.push(result.take::<i32>());
2486            },
2487        );
2488        root.rebuild(&mut app, &mut state);
2489        settle_navigator_frame(&mut root, area);
2490
2491        // Panel height = 48 (handle strip) + 200 (content) = 248, so at rest
2492        // its top edge sits at 600 - 248 = 352.
2493        let rest_top = area.height - 248.0;
2494
2495        // A scrim tap above the panel.
2496        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
2497        root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
2498        assert_eq!(
2499            controller.depth(),
2500            2,
2501            "the tap stages the exit — it does not pop on the spot"
2502        );
2503
2504        // Drive shell-shaped frames (rebuild + layout + paint) until the pop has
2505        // drained, recording where the panel's top edge sits on each one.
2506        let mut tops: Vec<f64> = Vec::new();
2507        let mut frames = 0u64;
2508        while controller.depth() == 2 {
2509            frames += 1;
2510            assert!(
2511                frames < 60,
2512                "the staged exit settles and pops within a bounded number of frames"
2513            );
2514            root.rebuild(&mut app, &mut state);
2515            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2516            let mut rec = Recorder::default();
2517            root.paint(&mut rec, ft(1000 + frames * 20));
2518            if let Some(top) = rec.panel_top() {
2519                tops.push(top);
2520            }
2521        }
2522
2523        // A real ramp: several *distinct* intermediate positions between rest
2524        // and fully off-screen, all painted while the page was still mounted.
2525        let mid: Vec<f64> = tops
2526            .iter()
2527            .copied()
2528            .filter(|t| *t > rest_top + 1.0 && *t < area.height - 1.0)
2529            .collect();
2530        assert!(
2531            mid.len() >= 3,
2532            "the exit is a visible ramp, not a snap: intermediate panel tops {mid:?} \
2533             (all frames: {tops:?})"
2534        );
2535        assert!(
2536            mid.windows(2).all(|w| w[1] > w[0]),
2537            "the panel only ever travels downward on the way out: {mid:?}"
2538        );
2539        assert!(
2540            tops.last().is_some_and(|t| (t - area.height).abs() < 1.0),
2541            "the panel is fully off-screen on the frame the pop finally fires: {tops:?}"
2542        );
2543
2544        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2545        assert_eq!(
2546            state.results,
2547            vec![None],
2548            "and the sheet really did pop, with an empty result"
2549        );
2550        assert_eq!(controller.depth(), 1);
2551    }
2552
2553    /// `reduce_motion` must *collapse* the staged exit, never leave it waiting:
2554    /// a pop deferred behind a spring that is never allowed to run would strand
2555    /// the sheet on screen forever.
2556    #[test]
2557    fn reduce_motion_collapses_the_staged_exit_and_still_pops() {
2558        let mut theme = crate::baseline();
2559        theme.motion.reduce_motion = true;
2560
2561        let controller: NavigatorController<NavState> = NavigatorController::new();
2562        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2563        root.set_theme(Box::new(theme));
2564        let mut app = {
2565            let ctrl = controller.clone();
2566            move |_: &mut NavState| {
2567                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2568            }
2569        };
2570        let mut state = NavState::default();
2571        let area = Size::new(400.0, 600.0);
2572
2573        root.rebuild(&mut app, &mut state);
2574        let mut tcx = TextContext::new();
2575        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2576
2577        show_bottom_sheet(
2578            &controller,
2579            || bottom_sheet(bg_page(400.0, 200.0)),
2580            |state: &mut NavState, result: PopResult| {
2581                state.results.push(result.take::<i32>());
2582            },
2583        );
2584        root.rebuild(&mut app, &mut state);
2585        settle_navigator_frame(&mut root, area);
2586
2587        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
2588        root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
2589
2590        // One paint to collapse the exit and fire the pop, one rebuild to drain
2591        // it — no ramp to wait out.
2592        for i in 0..3u64 {
2593            root.rebuild(&mut app, &mut state);
2594            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2595            root.paint(&mut Recorder::default(), ft(1000 + i * 20));
2596        }
2597        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2598
2599        assert_eq!(
2600            state.results,
2601            vec![None],
2602            "reduce_motion still dismisses — it only skips the ramp"
2603        );
2604        assert_eq!(controller.depth(), 1);
2605    }
2606
2607    /// The staged pop waits out a whole exit ramp, and a navigator pop always
2608    /// takes the *top* page: a page the app pushes inside that window must not
2609    /// be popped in the sheet's place — and the sheet must stay dismissible
2610    /// afterwards rather than latching itself closed over a page it never
2611    /// removed.
2612    #[test]
2613    fn a_page_pushed_during_the_exit_ramp_survives_and_the_sheet_stays_dismissable() {
2614        let controller: NavigatorController<NavState> = NavigatorController::new();
2615        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2616        let mut app = {
2617            let ctrl = controller.clone();
2618            move |_: &mut NavState| {
2619                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2620            }
2621        };
2622        let mut state = NavState::default();
2623        let area = Size::new(400.0, 600.0);
2624
2625        root.rebuild(&mut app, &mut state);
2626        let mut tcx = TextContext::new();
2627        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2628
2629        show_bottom_sheet(
2630            &controller,
2631            || bottom_sheet(bg_page(400.0, 200.0)),
2632            |state: &mut NavState, result: PopResult| {
2633                state.results.push(result.take::<i32>());
2634            },
2635        );
2636        root.rebuild(&mut app, &mut state);
2637        settle_navigator_frame(&mut root, area);
2638
2639        // A scrim tap stages the exit…
2640        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
2641        root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
2642        assert_eq!(controller.depth(), 2, "the tap only stages");
2643
2644        // …and one frame into the ramp the app pushes a page of its own (a deep
2645        // link, an async completion landing — it cannot come from the user,
2646        // since the sheet is a barrier). Transparent, so the sheet keeps
2647        // painting underneath it and its ramp really does reach the settle that
2648        // fires the staged pop; an opaque page would cull the sheet's paint and
2649        // the pop would simply never fire.
2650        root.rebuild(&mut app, &mut state);
2651        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2652        root.paint(&mut Recorder::default(), ft(1000));
2653        controller.push_transparent(|| core_any::<NavState, _>(bg_page(400.0, 100.0)));
2654
2655        for i in 0..40u64 {
2656            root.rebuild(&mut app, &mut state);
2657            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2658            root.paint(&mut Recorder::default(), ft(1020 + i * 20));
2659        }
2660        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2661
2662        assert_eq!(
2663            controller.depth(),
2664            3,
2665            "the pushed page survives — the staged pop was refused, not aimed at it"
2666        );
2667        assert!(
2668            state.results.is_empty(),
2669            "and the sheet reported no dismissal it never made"
2670        );
2671
2672        // Recoverable, not bricked: back out of the pushed page, then dismiss
2673        // the sheet again. The fresh exit is staged against the stack it
2674        // actually pops, so this one lands.
2675        controller.request_back();
2676        for i in 0..40u64 {
2677            root.rebuild(&mut app, &mut state);
2678            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2679            root.paint(&mut Recorder::default(), ft(2000 + i * 20));
2680        }
2681        assert_eq!(controller.depth(), 2, "the pushed page backs out normally");
2682
2683        controller.request_back();
2684        for i in 0..40u64 {
2685            root.rebuild(&mut app, &mut state);
2686            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2687            root.paint(&mut Recorder::default(), ft(3000 + i * 20));
2688        }
2689        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2690        assert_eq!(
2691            state.results,
2692            vec![None],
2693            "the sheet is still dismissible after the refused pop"
2694        );
2695        assert_eq!(controller.depth(), 1);
2696    }
2697
2698    // --- The app-initiated dismiss (`ModalDismiss`) seam — the same handle
2699    //     type `crate::overlay::modal`/`crate::side_sheet` install, routed
2700    //     through this module's own staged-exit path. ---
2701
2702    /// The whole point of adopting the handle: a row's own action must not
2703    /// pop the navigator directly (which would take the page out from under
2704    /// the sheet mid-slide) — it stages the very same spring exit a scrim
2705    /// tap does, and can carry a result with it, the same way
2706    /// `crate::side_sheet`'s own "actions" test proves for its handle.
2707    #[test]
2708    fn a_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
2709        let controller: NavigatorController<NavState> = NavigatorController::new();
2710        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2711        let mut app = {
2712            let ctrl = controller.clone();
2713            move |_: &mut NavState| {
2714                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2715            }
2716        };
2717        let mut state = NavState::default();
2718        let area = Size::new(400.0, 600.0);
2719
2720        root.rebuild(&mut app, &mut state);
2721        let mut tcx = TextContext::new();
2722        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2723
2724        // Minted outside the page builder, exactly as an app does it.
2725        let dismiss = ModalDismiss::new();
2726        let installed = dismiss.clone();
2727        show_bottom_sheet(
2728            &controller,
2729            move || bottom_sheet(bg_page(400.0, 200.0)).dismiss_handle(installed.clone()),
2730            |state: &mut NavState, result: PopResult| {
2731                state.results.push(result.take::<i32>());
2732            },
2733        );
2734        root.rebuild(&mut app, &mut state);
2735        settle_navigator_frame(&mut root, area);
2736
2737        // A row's own "select" action, through the handle.
2738        dismiss.dismiss_with(PopResult::of(3));
2739        root.rebuild(&mut app, &mut state);
2740        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2741        root.paint(&mut Recorder::default(), ft(1000));
2742        assert_eq!(
2743            controller.depth(),
2744            2,
2745            "the request stages the exit — it does not pop on the spot"
2746        );
2747
2748        let mut frames = 0u64;
2749        while controller.depth() == 2 {
2750            frames += 1;
2751            assert!(
2752                frames < 60,
2753                "the staged exit settles and pops within a bounded number of frames"
2754            );
2755            root.rebuild(&mut app, &mut state);
2756            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2757            root.paint(&mut Recorder::default(), ft(1000 + frames * 20));
2758        }
2759        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2760
2761        assert_eq!(
2762            state.results,
2763            vec![Some(3)],
2764            "the request's own payload rides the staged pop"
2765        );
2766        assert_eq!(controller.depth(), 1);
2767    }
2768
2769    /// Two triggers landing before either is observed collapse into one
2770    /// staged exit (the generation latch), and a further trigger arriving
2771    /// while that exit is already running is a no-op (`request_dismiss`'s own
2772    /// `exiting`/`closed` guard) — the sheet pops exactly once either way.
2773    #[test]
2774    fn repeated_dismiss_requests_are_idempotent() {
2775        let controller: NavigatorController<NavState> = NavigatorController::new();
2776        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
2777        let mut app = {
2778            let ctrl = controller.clone();
2779            move |_: &mut NavState| {
2780                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2781            }
2782        };
2783        let mut state = NavState::default();
2784        let area = Size::new(400.0, 600.0);
2785
2786        root.rebuild(&mut app, &mut state);
2787        let mut tcx = TextContext::new();
2788        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2789
2790        let dismiss = ModalDismiss::new();
2791        let installed = dismiss.clone();
2792        show_bottom_sheet(
2793            &controller,
2794            move || bottom_sheet(bg_page(400.0, 200.0)).dismiss_handle(installed.clone()),
2795            |state: &mut NavState, result: PopResult| {
2796                state.results.push(result.take::<i32>());
2797            },
2798        );
2799        root.rebuild(&mut app, &mut state);
2800        settle_navigator_frame(&mut root, area);
2801
2802        // Two requests before either is observed — the second's generation
2803        // bump wins the latch, but only one exit stages.
2804        dismiss.dismiss();
2805        dismiss.dismiss();
2806        root.rebuild(&mut app, &mut state);
2807        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2808        root.paint(&mut Recorder::default(), ft(1000));
2809        assert_eq!(controller.depth(), 2, "still staging, not popped yet");
2810
2811        // A third request while the exit is already running: `stage_exit`'s
2812        // own guard refuses to restart the ramp or re-arm the pop.
2813        dismiss.dismiss();
2814        root.rebuild(&mut app, &mut state);
2815        root.layout_with_text(area, &mut tcx as &mut dyn Any);
2816        root.paint(&mut Recorder::default(), ft(1020));
2817
2818        let mut frames = 0u64;
2819        while controller.depth() == 2 {
2820            frames += 1;
2821            assert!(frames < 60, "the exit still settles within a bounded time");
2822            root.rebuild(&mut app, &mut state);
2823            root.layout_with_text(area, &mut tcx as &mut dyn Any);
2824            root.paint(&mut Recorder::default(), ft(1050 + frames * 20));
2825        }
2826        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2827
2828        assert_eq!(
2829            state.results,
2830            vec![None],
2831            "exactly one pop, however many requests were raised"
2832        );
2833        assert_eq!(controller.depth(), 1);
2834    }
2835
2836    /// The `Stack`-mount half of the handle contract, which must read exactly
2837    /// like `crate::overlay::modal`'s: with no staged close hook behind it, a
2838    /// request still dismisses — through the unstaged, state-bearing
2839    /// `on_dismiss` — and the `paint` backstop, which can reach neither hook,
2840    /// leaves the request for the event pass instead of swallowing it.
2841    #[test]
2842    fn a_stack_mounted_sheets_handle_reaches_the_unstaged_on_dismiss() {
2843        let dismiss = ModalDismiss::new();
2844        let view: BottomSheetView<Flag> = bottom_sheet(leaf_any_flag(300.0, 200.0))
2845            .on_dismiss(|s: &mut Flag| s.dismissed += 1)
2846            .dismiss_handle(dismiss.clone());
2847        let mut w = build_flag(&view);
2848        let area = Size::new(400.0, 600.0);
2849        settle(&mut w, area);
2850
2851        dismiss.dismiss();
2852        tick(&mut w, area, 20);
2853        let mut state = Flag::default();
2854        assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
2855
2856        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 5.0, 5.0));
2857        assert_eq!(
2858            state.dismissed, 1,
2859            "a `Stack` mount's handle is not inert — it dismisses unstaged"
2860        );
2861
2862        // Exactly once, however many further passes observe the same request.
2863        tick(&mut w, area, 20);
2864        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 6.0));
2865        assert_eq!(state.dismissed, 1);
2866    }
2867
2868    /// The cross-pass half of the same one-shot rule: a gesture dismissal and
2869    /// a request the `paint` backstop left pending are one dismissal between
2870    /// them, not one each. (`crate::overlay::modal`'s own within-one-pass
2871    /// twin: `escape_with_a_pending_unobserved_request_dismisses_exactly_once`.)
2872    #[test]
2873    fn a_scrim_tap_then_move_with_a_pending_request_dismisses_exactly_once() {
2874        let dismiss = ModalDismiss::new();
2875        let view: BottomSheetView<Flag> = bottom_sheet(leaf_any_flag(300.0, 200.0))
2876            .on_dismiss(|s: &mut Flag| s.dismissed += 1)
2877            .dismiss_handle(dismiss.clone());
2878        let mut w = build_flag(&view);
2879        let area = Size::new(400.0, 600.0);
2880        settle(&mut w, area);
2881
2882        // Pending, and unobservable from a paint on this mount.
2883        dismiss.dismiss();
2884        tick(&mut w, area, 20);
2885        let mut state = Flag::default();
2886        assert_eq!(state.dismissed, 0, "nothing dismisses from a paint");
2887
2888        // A scrim tap dismisses through the unstaged route — neither the `Down`
2889        // barrier arm nor the `scrim_captured` arm observes the handle, so the
2890        // request is still standing when the tap fires.
2891        assert!(!w.panel.contains(Point::new(5.0, 5.0)));
2892        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
2893        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
2894        assert_eq!(state.dismissed, 1, "the tap dismissed the sheet");
2895
2896        // The next pass that *does* observe it must find the route spent.
2897        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 6.0));
2898        assert_eq!(
2899            state.dismissed, 1,
2900            "the pending request rides the dismissal that already happened"
2901        );
2902    }
2903
2904    /// A navigator rooted on an opaque page with one `show_bottom_sheet`
2905    /// presentation, driven frame by frame — the shared shape of the
2906    /// payload-scoping tests below (`crate::overlay::modal`'s own `NavHarness`,
2907    /// in this module's idiom).
2908    struct SheetRig {
2909        controller: NavigatorController<NavState>,
2910        root: RenderRoot<NavState, NavigatorView<NavState>>,
2911        state: NavState,
2912        tcx: TextContext,
2913        /// The clock the frames advance; never rewound (see [`tick`]).
2914        ms: u64,
2915    }
2916
2917    impl SheetRig {
2918        /// A rig with the sheet presented, settled open, and the app's dismiss
2919        /// handle installed on it — minted outside the page builder, the way
2920        /// an app mints one.
2921        fn presented() -> (Self, ModalDismiss) {
2922            let mut rig = SheetRig {
2923                controller: NavigatorController::new(),
2924                root: RenderRoot::new(),
2925                state: NavState::default(),
2926                tcx: TextContext::new(),
2927                ms: 0,
2928            };
2929            rig.frame();
2930            let dismiss = ModalDismiss::new();
2931            let installed = dismiss.clone();
2932            show_bottom_sheet(
2933                &rig.controller,
2934                move || bottom_sheet(bg_page(400.0, 200.0)).dismiss_handle(installed.clone()),
2935                |state: &mut NavState, result: PopResult| {
2936                    state.results.push(result.take::<i32>());
2937                },
2938            );
2939            rig.drive(40);
2940            (rig, dismiss)
2941        }
2942
2943        fn app(&self) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> + use<> {
2944            let ctrl = self.controller.clone();
2945            move |_: &mut NavState| {
2946                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
2947            }
2948        }
2949
2950        /// One shell-shaped frame: rebuild → layout → paint, on an advancing
2951        /// clock.
2952        fn frame(&mut self) {
2953            let area = Size::new(400.0, 600.0);
2954            let mut app = self.app();
2955            self.root.rebuild(&mut app, &mut self.state);
2956            self.root
2957                .layout_with_text(area, &mut self.tcx as &mut dyn Any);
2958            self.ms += 20;
2959            self.root.paint(&mut Recorder::default(), ft(self.ms));
2960        }
2961
2962        fn drive(&mut self, frames: u64) {
2963            for _ in 0..frames {
2964                self.frame();
2965            }
2966        }
2967
2968        /// Flush the pusher's `on_result`, which the navigator delivers on the
2969        /// next event pass.
2970        fn flush(&mut self) {
2971            self.root
2972                .event(&mut self.state, &ev(PointerPhase::Move, 5.0, 5.0));
2973        }
2974    }
2975
2976    /// One handle shared by two panel actions (an "Apply" alongside a
2977    /// "Cancel") is the shape `crate::date_picker`'s own dialog takes. A
2978    /// request raised *during* an exit already in flight is the documented
2979    /// no-op — it must not retarget that exit's payload.
2980    #[test]
2981    fn a_request_refused_mid_exit_never_retargets_the_exit_it_found() {
2982        let (mut rig, dismiss) = SheetRig::presented();
2983
2984        dismiss.dismiss();
2985        rig.frame();
2986        dismiss.dismiss_with(PopResult::of(7));
2987        rig.drive(60);
2988        rig.flush();
2989
2990        assert_eq!(
2991            rig.state.results,
2992            vec![None],
2993            "the exit in flight keeps its own empty result"
2994        );
2995        assert_eq!(rig.controller.depth(), 1);
2996    }
2997
2998    /// A payload is scoped to the exit its own request staged: a staged pop
2999    /// **refused** at fire time delivered nothing and springs the sheet back
3000    /// open, so the payload dies with it rather than riding whichever
3001    /// unrelated dismissal lands next.
3002    #[test]
3003    fn a_refused_staged_pop_drops_the_payload_it_was_carrying() {
3004        let (mut rig, dismiss) = SheetRig::presented();
3005
3006        dismiss.dismiss_with(PopResult::of(7));
3007        rig.frame();
3008        rig.controller
3009            .push_transparent(|| core_any::<NavState, _>(bg_page(400.0, 100.0)));
3010        rig.drive(60);
3011        rig.flush();
3012        assert_eq!(rig.controller.depth(), 3, "the pushed page survives");
3013        assert!(rig.state.results.is_empty(), "nothing was delivered");
3014
3015        // Back out of the pushed page, then dismiss the sheet the ordinary way.
3016        rig.controller.request_back();
3017        rig.drive(60);
3018        assert_eq!(rig.controller.depth(), 2);
3019        rig.controller.request_back();
3020        rig.drive(60);
3021        rig.flush();
3022        assert_eq!(
3023            rig.state.results,
3024            vec![None],
3025            "a later, unrelated dismissal pops with its own empty result"
3026        );
3027        assert_eq!(rig.controller.depth(), 1);
3028    }
3029
3030    /// A payload-free `dismiss()` pops empty even when an earlier
3031    /// `dismiss_with(..)` was never observed: the newer request supersedes the
3032    /// stash on the handle itself.
3033    #[test]
3034    fn a_plain_dismiss_after_an_unobserved_dismiss_with_pops_empty() {
3035        let (mut rig, dismiss) = SheetRig::presented();
3036
3037        dismiss.dismiss_with(PopResult::of(7));
3038        dismiss.dismiss();
3039        rig.drive(60);
3040        rig.flush();
3041
3042        assert_eq!(
3043            rig.state.results,
3044            vec![None],
3045            "the plain dismissal pops with nothing, not with the stale payload"
3046        );
3047        assert_eq!(rig.controller.depth(), 1);
3048    }
3049
3050    // --- The staging flags: what cancels a staged exit, and what a refused pop
3051    //     must not latch. ---
3052
3053    /// A sheet wired the way [`show_bottom_sheet`] wires one — the state-free
3054    /// close hook plus the shared back-press dismiss cell — but mounted
3055    /// directly, so a test can drive its event and paint passes without a
3056    /// navigator. Returns the widget, the back-press generation cell, and a
3057    /// count of the hook's own firings.
3058    fn staged_flag_sheet() -> (BottomSheetWidget, Rc<Cell<u64>>, Rc<Cell<u32>>) {
3059        let signal = Rc::new(Cell::new(0u64));
3060        let pops = Rc::new(Cell::new(0u32));
3061        let counter = pops.clone();
3062        let mut view: BottomSheetView<Flag> =
3063            bottom_sheet(leaf_any_flag(300.0, 200.0)).on_dismiss(|s: &mut Flag| s.dismissed += 1);
3064        view.on_close = Some(Rc::new(StagedPop::unguarded(move || {
3065            counter.set(counter.get() + 1);
3066        })));
3067        view.dismiss_signal = Some(signal.clone());
3068        let mut w = build_flag(&view);
3069        settle(&mut w, Size::new(400.0, 600.0));
3070        (w, signal, pops)
3071    }
3072
3073    /// Pump `paint` frames from wherever `w`'s own clock currently sits until
3074    /// its spring settles — [`settle`]'s continuation, for a test that has
3075    /// already driven the widget past its entrance. A second `settle` would
3076    /// rewind the clock behind `w.last_frame_time` and corrupt the spring's own
3077    /// timeline (the same hazard [`tick`] exists to avoid).
3078    fn settle_from_here(w: &mut BottomSheetWidget, area: Size) {
3079        for _ in 0..60u64 {
3080            tick(w, area, 20);
3081            if !w.anim.is_animating() {
3082                return;
3083            }
3084        }
3085        panic!("sheet spring failed to settle within the test bound");
3086    }
3087
3088    /// A back press landing *while a handle drag is live* stages an exit under
3089    /// that drag — and the release then aims a fresh leg back open. That leg
3090    /// must cancel the staging: an exit left flagged over a sheet resting at
3091    /// `1.0` can never fire (the settle only fires at the closed end), while
3092    /// every later trigger hard-returns on the flag — scrim tap, `Escape` and
3093    /// back all dead, with the sheet still a modal barrier.
3094    #[test]
3095    fn a_release_back_open_cancels_an_exit_staged_under_the_drag() {
3096        for cancelled in [false, true] {
3097            let (mut w, signal, pops) = staged_flag_sheet();
3098            let area = Size::new(400.0, 600.0);
3099            let mut state = Flag::default();
3100            let handle_y = w.panel.y0 + 10.0;
3101
3102            // A live handle drag, short of the dismiss threshold (30px of a
3103            // 248px panel).
3104            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, handle_y));
3105            dispatch(
3106                &mut w,
3107                &mut state,
3108                &ev(PointerPhase::Move, 200.0, handle_y + 30.0),
3109            );
3110            assert!(w.progress > DRAG_DISMISS_FRACTION);
3111
3112            // An Android back press lands mid-drag: the next paint observes the
3113            // bumped cell and stages the exit with the finger still down
3114            // (`observe_dismiss_signal` has no drag guard, by design — the
3115            // release below is what decides).
3116            signal.set(signal.get() + 1);
3117            tick(&mut w, area, 16);
3118            assert!(w.exiting, "the back press stages an exit under the drag");
3119
3120            // Then the finger lifts above the dismiss threshold — or the OS
3121            // cancels the gesture outright, which an Android back *gesture*
3122            // does. Either way the panel springs back open.
3123            let phase = if cancelled {
3124                PointerPhase::Cancel
3125            } else {
3126                PointerPhase::Up
3127            };
3128            dispatch(&mut w, &mut state, &ev(phase, 200.0, handle_y + 30.0));
3129            assert!(
3130                !w.exiting,
3131                "a leg aimed back open cancels the staged exit (cancelled: {cancelled})"
3132            );
3133
3134            settle_from_here(&mut w, area);
3135            assert!(
3136                (w.progress - 1.0).abs() < 1e-6,
3137                "the sheet is back at rest, open: {}",
3138                w.progress
3139            );
3140            assert_eq!(pops.get(), 0, "nothing popped — the sheet reopened");
3141
3142            // The regression itself: the sheet is still dismissible.
3143            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
3144            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
3145            settle_from_here(&mut w, area);
3146            assert_eq!(
3147                pops.get(),
3148                1,
3149                "a scrim tap after the reopen still dismisses (cancelled: {cancelled})"
3150            );
3151        }
3152    }
3153
3154    /// A sheet that has finished its exit paints nothing — no scrim (alpha
3155    /// rides `progress`), no panel (off-screen) — but stays mounted until the
3156    /// enqueued pop drains. It must not go on swallowing presses there: an
3157    /// invisible barrier is an input trap with nothing on screen to explain it.
3158    #[test]
3159    fn a_sheet_that_has_finished_its_exit_swallows_no_press() {
3160        let (mut w, _signal, pops) = staged_flag_sheet();
3161        let area = Size::new(400.0, 600.0);
3162        let mut state = Flag::default();
3163        let over_panel = Point::new(200.0, w.panel.y0 + 100.0);
3164
3165        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
3166        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
3167        settle_from_here(&mut w, area);
3168        assert_eq!(pops.get(), 1, "the staged pop fired");
3169        assert!(w.closed, "and latched — the sheet is on its way out");
3170        assert!(w.progress <= SETTLED_EPSILON, "painting nothing");
3171
3172        let result = dispatch(
3173            &mut w,
3174            &mut state,
3175            &ev(PointerPhase::Down, over_panel.x, over_panel.y),
3176        );
3177        assert_eq!(
3178            result,
3179            EventResult::Ignored,
3180            "an invisible sheet absorbs nothing"
3181        );
3182        assert!(!w.scrim_captured, "and arms no scrim dismiss either");
3183    }
3184
3185    /// The mirror pin for the entrance: it belongs to the widget's spring
3186    /// alone. A page-level slide would offset the *whole* sheet page — scrim
3187    /// included — so every full-area fill sitting at the window origin is what
3188    /// proves `TransitionSpec::NONE` is still what `show_bottom_sheet` pushes,
3189    /// while the panel path underneath it still climbs.
3190    #[test]
3191    fn the_entrance_animates_only_the_panel_never_the_page() {
3192        let controller: NavigatorController<NavState> = NavigatorController::new();
3193        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
3194        let mut app = {
3195            let ctrl = controller.clone();
3196            move |_: &mut NavState| {
3197                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
3198            }
3199        };
3200        let mut state = NavState::default();
3201        let area = Size::new(400.0, 600.0);
3202
3203        root.rebuild(&mut app, &mut state);
3204        let mut tcx = TextContext::new();
3205        root.layout_with_text(area, &mut tcx as &mut dyn Any);
3206
3207        show_bottom_sheet(
3208            &controller,
3209            || bottom_sheet(bg_page(400.0, 200.0)),
3210            |state: &mut NavState, result: PopResult| {
3211                state.results.push(result.take::<i32>());
3212            },
3213        );
3214        root.rebuild(&mut app, &mut state);
3215
3216        let mut tops: Vec<f64> = Vec::new();
3217        for i in 0..40u64 {
3218            root.layout_with_text(area, &mut tcx as &mut dyn Any);
3219            let mut rec = Recorder::default();
3220            root.paint(&mut rec, ft(i * 20));
3221            for (origin, size, _) in &rec.rects {
3222                if *size == area {
3223                    assert_eq!(
3224                        *origin,
3225                        Point::ZERO,
3226                        "a full-area fill painted away from the window origin means the \
3227                         navigator is sliding the page too (frame {i})"
3228                    );
3229                }
3230            }
3231            if let Some(top) = rec.panel_top() {
3232                tops.push(top);
3233            }
3234        }
3235
3236        let rest_top = area.height - 248.0;
3237        assert!(
3238            tops.first().is_some_and(|t| (t - area.height).abs() < 1e-6),
3239            "the first frame is invisible — the panel starts fully below the fold: {tops:?}"
3240        );
3241        assert!(
3242            tops.iter()
3243                .any(|t| *t > rest_top + 1.0 && *t < area.height - 1.0),
3244            "the panel climbs through intermediate positions: {tops:?}"
3245        );
3246        assert!(
3247            tops.last().is_some_and(|t| (t - rest_top).abs() < 1e-6),
3248            "…and comes to rest with its top edge at {rest_top}: {tops:?}"
3249        );
3250    }
3251
3252    #[test]
3253    fn back_request_dismissable_false_vetoes_stack_unchanged() {
3254        let controller: NavigatorController<NavState> = NavigatorController::new();
3255        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
3256        let mut app = {
3257            let ctrl = controller.clone();
3258            move |_: &mut NavState| {
3259                navigator(&ctrl, || core_any::<NavState, _>(bg_page(400.0, 600.0)))
3260            }
3261        };
3262        let mut state = NavState::default();
3263        let area = Size::new(400.0, 600.0);
3264
3265        root.rebuild(&mut app, &mut state);
3266        let mut tcx = TextContext::new();
3267        root.layout_with_text(area, &mut tcx as &mut dyn Any);
3268
3269        show_bottom_sheet(
3270            &controller,
3271            || bottom_sheet(bg_page(400.0, 200.0)).dismissable(false),
3272            |state: &mut NavState, result: PopResult| {
3273                state.results.push(result.take::<i32>());
3274            },
3275        );
3276        root.rebuild(&mut app, &mut state);
3277        settle_navigator_frame(&mut root, area);
3278
3279        assert!(
3280            controller.back_interest(),
3281            "a Veto (non-dismissable) sheet still claims back interest"
3282        );
3283
3284        controller.request_back();
3285        root.rebuild(&mut app, &mut state);
3286        assert_eq!(controller.depth(), 2, "Veto leaves the stack unchanged");
3287
3288        root.paint(&mut Recorder::default(), ft(950));
3289        root.paint(&mut Recorder::default(), ft(1400));
3290        assert!(
3291            state.results.is_empty(),
3292            "a non-dismissable sheet never pops on a back request"
3293        );
3294        assert_eq!(controller.depth(), 2);
3295    }
3296
3297    // A minimal opaque page for the navigator integration test.
3298    struct BgPage {
3299        size: Size,
3300    }
3301    struct BgPageW {
3302        size: Size,
3303    }
3304    impl View<NavState> for BgPage {
3305        type Element = BgPageW;
3306        fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
3307            BgPageW { size: self.size }
3308        }
3309        fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
3310            ChangeFlags::NONE
3311        }
3312    }
3313    impl Widget for BgPageW {
3314        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3315            bc.constrain(self.size)
3316        }
3317        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
3318            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
3319        }
3320    }
3321    fn bg_page(w: f64, h: f64) -> BgPage {
3322        BgPage {
3323            size: Size::new(w, h),
3324        }
3325    }
3326
3327    #[test]
3328    fn semantics_is_a_modal_dialog_ish_node() {
3329        fn logic(_s: &mut ()) -> BottomSheetView<()> {
3330            bottom_sheet(leaf_any(300.0, 200.0))
3331        }
3332        let mut root: RenderRoot<(), BottomSheetView<()>> = RenderRoot::new();
3333        let mut state = ();
3334        root.rebuild(&mut logic, &mut state);
3335        let mut tcx = TextContext::new();
3336        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3337        let update = root.semantics();
3338        let (_, node) = update
3339            .nodes
3340            .iter()
3341            .find(|(_, n)| n.role() == Role::Dialog)
3342            .expect("a Role::Dialog node is contributed");
3343        assert!(node.is_modal(), "the sheet node sets the modal flag");
3344    }
3345}