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}