frust_widgets/nav/navigator.rs
1//! The navigator core: a retained page stack with imperative
2//! push/pop/replace, per-page result callbacks, opaque-page paint culling, and
3//! test-pinned capture/focus/IME page-switch semantics.
4//!
5//! # Shape
6//!
7//! [`navigator`] is the app-facing view fn: `navigator(controller,
8//! initial_page_builder)` produces a [`NavigatorView`] whose retained
9//! [`NavigatorWidget`] owns a `Vec` of page entries. The
10//! **[`NavigatorController`]** is the app-state handle the app keeps in its
11//! `Component::State` (a cloneable `Rc<RefCell<…>>`): it *records requested ops*
12//! (`push`/`pop`/`replace`), which the widget *applies at rebuild*, never
13//! self-mutating mid-event (`docs/CODE_STANDARDS.md`, "Controlled components
14//! never self-mutate").
15//!
16//! # Op application is view-driven (at rebuild), which enqueue guarantees runs
17//!
18//! Structural ops are drained and applied in [`NavigatorView::rebuild`] (a
19//! `BuildCtx` pass), *not* inside `NavigatorWidget::event`: building a new page
20//! pod ([`crate::authoring::build_child`]) and tearing a popped one down
21//! ([`crate::authoring::teardown_child`]) both need a `BuildCtx`. A rebuild runs
22//! every frame only on desktop; the mobile shells gate a frame behind a run/skip
23//! decision (`frust-shell-common`'s `FrameGate`) that a bare queued op does not
24//! by itself satisfy, so a *programmatic* push/pop (from a background task, with
25//! no triggering event) is otherwise invisible to it. [`NavigatorController::enqueue`]
26//! closes that gap unconditionally: every recorded op also raises
27//! [`frust_core::mark_pending_result_flush`], which the mobile frame gate peeks
28//! (`FrameInputs::deferred_callbacks_pending`) as a run-forcing input independent
29//! of anything else dirty — the same flag [`finalize_transition`](NavigatorWidget::finalize_transition)
30//! already raises for a pop-result callback (below), reused here for a queued op
31//! rather than a callback needing `&mut State`. Left ungated, a programmatic push
32//! measured on device as a page mounted but painted nothing for 15+ seconds,
33//! until whatever input arrived next forced a frame. On every stack mutation the
34//! widget then applies the page-switch contract the structural-rebuild machinery
35//! does not cover for a hand-managed stack, in that order: (a) cancel an
36//! in-flight capture on the outgoing page ([`crate::authoring::cancel_pod`]'s
37//! synthetic `Cancel`), (b) clear its focus flag, (c) publish a *cleared* IME
38//! surface on the next paint so the platform keyboard hides deterministically
39//! rather than waiting for the lazy event-pass convergence `RenderRoot`
40//! otherwise relies on. On a push the outgoing page is the one being
41//! **covered**.
42//!
43//! A [`pop`](NavigatorController::pop_with_result) result destined for a
44//! pusher-registered `on_result` callback needs `&mut State` — which a rebuild
45//! (`BuildCtx`) does not carry — so the callback is queued at rebuild and flushed
46//! at the start of the next [`NavigatorWidget::event`] pass, where the erased
47//! app state is in scope. The mark already raised at `enqueue` time is what makes
48//! the *same* rebuild dispatch a non-input [`InputEvent::Housekeeping`] broadcast
49//! and flush the callback before the frame ends, so a result lands on the frame
50//! that produced it, not just on some later frame the gate happens to run — the
51//! [`apply_pop`](NavigatorWidget::apply_pop) call site that queues the callback
52//! marks it again regardless, a defensive second raise (idempotent, so free) in
53//! case a future caller ever reaches it outside the op queue. Waiting on the
54//! next touch instead measured on device as a sheet opening seconds after its
55//! menu row — or never, when that touch went to chrome outside the navigator. An
56//! eager `NavOp::Pop` delivers on the pop's own frame; an interactive edge-swipe
57//! pop delivers on its settle frame, since that is where it queues (via
58//! `finalize_transition`, not `enqueue` — an interactive pop is driven from
59//! `NavigatorWidget::event` directly, never through the op queue, so it still
60//! needs its own explicit mark). See [`NavigatorController::push_for_result`].
61//!
62//! # Paint culling (Flutter opaque-route parity)
63//!
64//! Only the topmost **settled opaque** page (and any transparent pages stacked
65//! above it) is laid out and painted; pages fully covered by an opaque page keep
66//! their retained widgets (so their state survives) but are neither laid out nor
67//! painted while covered. Layout runs unconditionally every frame, so a page
68//! revealed by a pop is re-laid-out and correct on the very next frame.
69//!
70//! # Root overlay host
71//!
72//! [`overlay_host`] is the same widget wearing a different hat: a navigator whose
73//! root page is the *whole app* and whose pushed pages are app-level modals, so an
74//! overlay dims and blocks chrome an inner navigator's overlay cannot reach. A
75//! constructor, not a second widget — everything below (input routing, R23,
76//! `BackPolicy`, dismiss signals, `on_result`) applies to it unchanged.
77//!
78//! # Accessibility reach (R23)
79//!
80//! The accessibility tree follows **input routing**, not painting:
81//! [`NavigatorWidget::semantics`](Widget::semantics) forwards exactly the pages
82//! [`NavigatorWidget::input_routed_pages`] says an event could reach — today the
83//! top page alone — and omits every other page outright: a deliberate, documented
84//! exception to the forward-to-every-child container rule in
85//! `docs/CODE_STANDARDS.md`, whose derivation the `semantics` doc comment carries.
86//!
87//! # Observation seams (reactive-free, by construction)
88//!
89//! Nothing outside a page's own subtree can reach into the navigator, so every
90//! observation is *published* or *pushed* — never polled through the widget, and
91//! never through a signal (`frust-widgets` carries no reactive dependency; signal
92//! mirroring is the facade's job, as `back_glue`/`router_glue` do it). The seams
93//! themselves are catalogued in `docs/WIDGETS_ARCHITECTURE.md`; what is fixed here
94//! is *where* each lives and why:
95//!
96//! * **Transition** — a published [`TransitionState`] snapshot, on the
97//! **controller** ([`NavigatorController::transition`]) because the chrome
98//! matching page motion is a *sibling* of the navigator, not a descendant. Its
99//! own doc carries the read-timing contract.
100//! * **Page visibility** — a **callback** ([`PushOptions::on_visibility`],
101//! [`NavigatorView::on_root_visibility`]) rather than a published cell, because
102//! a covered page has no pass in which to poll one. Single derivation:
103//! [`NavigatorWidget::visibility_of`], from the same `base_visible_index`
104//! layout and paint cull against.
105//!
106//! Neither seam changes disposal: a covering push still does **not** run a
107//! page's `on_cleanup`, so its widget state survives the cover.
108//!
109//! # Back reach follows input routing too (R23)
110//!
111//! Back arbitration obeys the same reach as input and semantics: a navigator
112//! whose **hosting page** is not one of its host navigator's
113//! [`input_routed_pages`](NavigatorWidget::input_routed_pages) reports **no**
114//! [`back_interest`](NavigatorController::back_interest), whatever its own stack
115//! looks like. Otherwise a nested navigator sitting on a covered page — a
116//! section stack with a detail page pushed over it — would claim the press and
117//! pop a stack nobody can see, leaving the visible page put.
118//!
119//! The mechanism is a third published seam, and the only one that flows
120//! *downward*: each [`PageEntry::reach`] is an `Rc<Cell<bool>>` its navigator
121//! sets to `own_reachability && page is input-routed`, installed as an ambient
122//! scope ([`with_page_reach`]) around that page's builder and subtree reconcile.
123//! A navigator built anywhere under it captures the cell
124//! ([`ambient_page_reach`]) as its own [`NavigatorWidget::host_reach`] and ANDs
125//! it into what it publishes — so the invariant composes to any depth with no
126//! tree walk, and holds even for a page frozen by
127//! [`cull_covered_builds`](NavigatorView::cull_covered_builds) (the cell is shared
128//! and live, not a per-wire snapshot).
129
130use std::cell::{Cell, RefCell};
131use std::collections::HashMap;
132use std::rc::Rc;
133use std::sync::atomic::{AtomicU64, Ordering};
134
135use frust_core::{
136 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, DiscardScene, EditingState, EventCtx,
137 EventResult, FrameTime, HeroDirective, HeroFrames, ImeState, InputEvent, LayoutCtx, PaintCtx,
138 PaintScene, PointerPhase, SemanticsCtx, SpringDesc, TOUCH_SLOP, View, Widget,
139};
140use frust_theme::Theme;
141use kurbo::{Affine, Point, Rect, Size, Vec2};
142
143use super::ambient::{
144 ambient_page_reach, ambient_swipe_claim, host_reachable, with_page_reach, with_swipe_claim,
145};
146use super::edge_swipe::{EDGE_SWIPE_ZONE_DP, EdgeSwipe};
147use super::options::NavOp;
148use super::path::Location;
149use super::route_state::RouteStack;
150use super::transition::{
151 Layer, PageTransition, TransitionDriver, TransitionSpec, TransitionState, lerp_rect,
152 make_driver, resolve_layers, resolve_spec, settle_driver,
153};
154
155pub use super::controller::NavigatorController;
156pub use super::options::{
157 BackPolicy, NavigatorId, PageBuilder, PageVisibility, PopResult, PushOptions, ReplaceOptions,
158 ResultCallback, RouteChangeCallback, VisibilityCallback,
159};
160pub use super::view::{NavigatorView, navigator, overlay_host};
161
162/// Process-wide, monotonically increasing source for
163/// [`PageEntry::snapshot_key`] and `motion::switcher`'s per-instance
164/// `snapshot_base` — every [`PaintScene::push_snapshot`] key handed to a
165/// renderer, across every navigator and switcher in the process, is drawn
166/// from this one counter. A renderer caches a snapshot's rasterized body by
167/// key alone, so two unrelated pods that happened to share a key would alias
168/// each other's cached content; per-navigator or per-switcher counters could
169/// collide with each other where this single process-wide one cannot.
170/// `Relaxed` ordering is sufficient: the counter's only job is producing
171/// distinct values, never synchronizing access to anything else.
172pub(crate) static NEXT_SNAPSHOT_KEY: AtomicU64 = AtomicU64::new(0);
173
174/// One retained page in the [`NavigatorWidget`]'s stack: its builder (re-run each
175/// rebuild), the last view it produced (for reconciliation), the retained child
176/// pod, its opacity, and the pusher's result callback (fired when this page pops).
177pub(super) struct PageEntry<State: 'static> {
178 builder: PageBuilder<State>,
179 view: AnyView<State>,
180 pod: ChildPod,
181 opaque: bool,
182 on_result: Option<ResultCallback<State>>,
183 /// The transition this page was pushed/replaced with — *reversed* when the
184 /// page is later popped (a pop animates the popped page's own transition
185 /// backwards, Flutter-parity: a route carries its transition).
186 pub(super) transition: TransitionSpec,
187 /// How a back press routed through
188 /// [`request_back`](NavigatorController::request_back) treats this page.
189 /// Pushed pages set it via [`PushOptions::back`]; the root and
190 /// replaced pages default to [`BackPolicy::Pop`].
191 pub(super) back: BackPolicy,
192 /// The shared generation cell a
193 /// [`DismissAnimated`](BackPolicy::DismissAnimated) back press increments so
194 /// the page's own widget subtree observes it (see [`BackPolicy`]'s seam).
195 /// `None` for any page that did not supply one.
196 dismiss_signal: Option<Rc<Cell<u64>>>,
197 /// This page's [`PageVisibility`] as of the last
198 /// [`publish_visibility`](NavigatorWidget::publish_visibility) pass.
199 /// `None` until the *first* publish, so a freshly pushed page's opening
200 /// [`Current`](PageVisibility::Current) always fires (there is no
201 /// "unknown" enum variant to model that with).
202 visibility: Option<PageVisibility>,
203 /// The page-visibility observer from [`PushOptions::on_visibility`] (or, for
204 /// the root page, [`NavigatorView::on_root_visibility`]). `None` for a page
205 /// that registered none.
206 on_visibility: Option<VisibilityCallback>,
207 /// Whether the *previous* per-page reconcile pass saw this page as
208 /// [`Covered`](PageVisibility::Covered). Only read when
209 /// [`NavigatorView::cull_covered_builds`] is on, and it is what implements
210 /// that switch's "the frame a page becomes covered still rebuilds it" rule:
211 /// a page is skipped only once it has *already* been reconciled while
212 /// covered.
213 reconciled_covered: bool,
214 /// This page's route identity, stamped at push time from
215 /// [`PushOptions::route`]/[`NavigatorView::root_route`]/
216 /// [`ReplaceOptions::route`]. `None` for a bare-builder overlay/dialog
217 /// push — see `route_state`'s module docs.
218 route: Option<Location>,
219 /// This page's edge-swipe override from [`PushOptions::pop_swipe`] —
220 /// the highest-ranked slot in [`NavigatorWidget::swipe_armable`]'s
221 /// resolution. `None` for the root page and for any page pushed/replaced
222 /// without one, deferring to the navigator's own resolved default
223 /// (`pop_swipe_enabled`).
224 pub(super) pop_swipe: Option<bool>,
225 /// Whether an input event can reach **this page**, all the way up: this
226 /// navigator is itself reachable AND this page is in
227 /// [`input_routed_pages`](NavigatorWidget::input_routed_pages). Republished
228 /// by [`publish_reach`](NavigatorWidget::publish_reach) on every stack
229 /// mutation and rebuild.
230 ///
231 /// Installed as the ambient [`PAGE_REACH`] scope while this page's builder
232 /// and subtree reconcile run, so a *nested* navigator on this page can read
233 /// it — the seam that makes back arbitration follow input routing (R23) even
234 /// though a nested navigator has no idea what the navigator hosting it is
235 /// doing. Shared with every such descendant, so it stays a live read rather
236 /// than a snapshot: a page frozen by
237 /// [`cull_covered_builds`](NavigatorView::cull_covered_builds) still reports
238 /// truthfully.
239 pub(super) reach: Rc<Cell<bool>>,
240 /// This page's [`PaintScene::push_snapshot`] cache key, drawn once from
241 /// [`NEXT_SNAPSHOT_KEY`] when the page is constructed and stable for its
242 /// whole lifetime — including a pop/replace stash, since the same
243 /// `PageEntry` moves into [`ActiveTransition::stashed`] rather than being
244 /// rebuilt. A key that changed frame to frame (or page to page) would
245 /// give a caching renderer nothing to hit.
246 snapshot_key: u64,
247}
248
249/// The single in-flight page transition a [`NavigatorWidget`] owns (Flutter
250/// parity: created per push/pop, disposed on settle). Pairs the progress
251/// [`TransitionDriver`] with the retained *leaving* page, when the op removed it
252/// from the stack (pop/replace); a push's leaving page stays in the stack below
253/// the new top, so `stashed` is `None` there.
254pub(super) struct ActiveTransition<State: 'static> {
255 /// Drives `0.0..=1.0`; advanced from `PaintCtx::frame_time` during paint.
256 pub(super) driver: TransitionDriver,
257 /// The visual preset (slide/fade/parallax geometry).
258 pub(super) preset: PageTransition,
259 /// Direction: `true` reverses the horizontal motion + paint order (a pop).
260 pub(super) is_pop: bool,
261 /// The removed page retained until settle (pop/replace). `None` for a push,
262 /// whose leaving page is still in the stack at `len - 2`.
263 pub(super) stashed: Option<PageEntry<State>>,
264 /// The spring a manual [`settle`](NavigatorWidget::settle_transition) uses
265 /// when the timing mode is duration-based (a duration has no spring).
266 pub(super) settle_spring: SpringDesc,
267 /// Set by paint when the driver reaches rest; the next rebuild finalizes the
268 /// transition (tears down `stashed`, resumes culling).
269 pub(super) settled: bool,
270 /// This transition is being driven by an interactive edge-swipe: its
271 /// progress is `Held` by the drag, then settled on release. An
272 /// interactive pop stashed the top page *without* queuing its result
273 /// callback (a swipe may still cancel), so finalize does the completion
274 /// bookkeeping the [`NavOp::Pop`] path did eagerly.
275 pub(super) interactive: bool,
276 /// Set when an interactive pop was *cancelled* (settled toward `0.0`): finalize
277 /// pushes the stashed page back onto the stack instead of tearing it down (the
278 /// page was never really popped). See [`NavigatorWidget::finalize_transition`].
279 pub(super) restore_on_finalize: bool,
280 /// Shared-element ("hero") state. Page-local rects of the tagged
281 /// heroes discovered on the **leaving** page during the previous transition
282 /// paint, keyed by tag. `layout`/`paint` capture these each frame; the next
283 /// frame reads them to place the morph overlay. Empty until the first paint
284 /// discovers any (so the morph starts a frame into the flight — the rects
285 /// are static page layout, so the delay is invisible).
286 pub(super) hero_leaving: HashMap<String, Rect>,
287 /// Page-local hero rects discovered on the **entering** page — the morph
288 /// target endpoint. See [`hero_leaving`](ActiveTransition::hero_leaving).
289 pub(super) hero_entering: HashMap<String, Rect>,
290 /// The unresolved [`TransitionSpec`] awaiting theme resolution on the first
291 /// paint — the LAZY driver seam mirroring [`motion::switcher`](crate::motion).
292 /// A programmatic transition is staged in a `BuildCtx`
293 /// ([`start_transition`](NavigatorWidget::start_transition)), which carries
294 /// no theme, so [`Timing::ThemeDefault`](super::transition::Timing) and
295 /// `reduce_motion` cannot resolve there. `Some` until the first
296 /// [`paint_transition`](NavigatorWidget::paint_transition) resolves it
297 /// against the active [`MotionScheme`](frust_theme::MotionScheme) — via
298 /// [`resolve_spec`] — and rebuilds `driver`/`preset`/`settle_spring` before
299 /// any frame is staged; `None` thereafter (and always `None` for the
300 /// interactive edge-swipe path, whose progress is drag-held, not
301 /// theme-timed). With no theme threaded the `make_driver` fallback built at
302 /// `start_transition` (M3 defaults) stands — the unthemed behavior
303 /// `docs/CODE_STANDARDS.md` mandates.
304 pub(super) pending_spec: Option<TransitionSpec>,
305}
306
307impl<State: 'static> crate::authoring::VisitPods for PageEntry<State> {
308 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
309 visitor(&self.pod);
310 }
311}
312
313impl<State: 'static> crate::authoring::VisitPods for ActiveTransition<State> {
314 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
315 // Only the stashed (removed-but-still-animating) page: a transition's
316 // other participants are still in `pages`.
317 crate::authoring::VisitPods::visit_pods(&self.stashed, visitor);
318 }
319}
320
321/// The retained widget for a [`NavigatorView`]: owns the page stack and applies
322/// the [`NavigatorController`]'s queued ops at rebuild. See the [module docs](self).
323pub struct NavigatorWidget<State: 'static> {
324 pub(super) pages: Vec<PageEntry<State>>,
325 /// Pop-result callbacks awaiting `&mut State` — flushed at the start of the
326 /// next [`event`](NavigatorWidget::event) pass, which the queuing rebuild
327 /// guarantees itself by raising
328 /// [`frust_core::mark_pending_result_flush`] (see the [module docs](self)).
329 pending_results: Vec<(ResultCallback<State>, PopResult)>,
330 /// Set on every stack mutation; the next paint publishes a cleared IME surface
331 /// and clears this, so the platform keyboard hides deterministically.
332 pub(super) needs_ime_clear: bool,
333 /// The navigator's default transition (per-op overrides win). Refreshed from
334 /// the view on rebuild so an app can change it live.
335 default_transition: TransitionSpec,
336 /// The single in-flight transition, if any. `None` between
337 /// transitions — the common case, where paint/layout cull normally.
338 pub(super) transition: Option<ActiveTransition<State>>,
339 /// Whether the interactive edge-swipe back gesture is enabled.
340 /// Resolved from the view each rebuild — default-on for the iOS-push preset,
341 /// or explicitly via [`NavigatorView::pop_swipe`].
342 pub(super) pop_swipe_enabled: bool,
343 /// The in-progress edge-swipe gesture state.
344 pub(super) edge: EdgeSwipe,
345 /// The most recent frame time seen during [`paint`](NavigatorWidget::paint),
346 /// reused as the event-pass timestamp for velocity tracking — the event pass
347 /// carries no clock of its own (time is provided only at paint). The
348 /// same seam [`ScrollWidget`](crate::ScrollWidget) uses.
349 last_frame_time: FrameTime,
350 /// The shared depth slot published to the [`NavigatorController`] every
351 /// `build`/`rebuild`. A clone of the controller's
352 /// `Rc<Cell<usize>>`, updated by [`publish_state`](Self::publish_state)
353 /// after every stack mutation so `NavigatorController::can_pop` reads the
354 /// authoritative page count.
355 depth: Rc<Cell<usize>>,
356 /// The shared back-interest slot published to the [`NavigatorController`]
357 /// alongside `depth`. A clone of the controller's
358 /// `Rc<Cell<bool>>`, recomputed by [`publish_state`](Self::publish_state)
359 /// from the current depth + top-page [`BackPolicy`] after every stack
360 /// mutation so `NavigatorController::back_interest` is authoritative.
361 back_interest: Rc<Cell<bool>>,
362 /// The shared [`TransitionState`] slot published to the
363 /// [`NavigatorController`]. A clone of the controller's
364 /// `Rc<Cell<TransitionState>>`, written at every transition edge
365 /// (start/interactive-start/hold/settle/finalize) *and* on every paint frame
366 /// that advances the driver — see
367 /// [`NavigatorController::transition`]'s timing contract.
368 transition_state: Rc<Cell<TransitionState>>,
369 /// The shared liveness slot [`mount`](NavigatorController::mount)
370 /// increments and [`unmount_cell`] decrements, cloned from whichever
371 /// controller's cells this widget is currently bound to (`build`, or the
372 /// last controller-swap `rebuild`). Stored on the *widget* — not read back
373 /// off `self.controller` — so `teardown`'s decrement always pairs with
374 /// whichever cell the widget last incremented, even if a later rebuild
375 /// swapped `NavigatorView::controller` again in between: pairing is
376 /// structural (same field written and read), never a lookup by identity
377 /// that could drift. See [`NavigatorView::rebuild`]'s controller-swap
378 /// handling for how this field gets re-bound.
379 mounted: Rc<Cell<usize>>,
380 /// Whether a [`Covered`](PageVisibility::Covered) page skips its per-frame
381 /// reconcile. Refreshed from the view each rebuild (live-configurable, like
382 /// `pop_swipe_enabled`); default `false`.
383 cull_covered_builds: bool,
384 /// The [`PageEntry::reach`] cell of the page **hosting this navigator**, or
385 /// `None` for a navigator at the top level (a root navigator / a root
386 /// [`overlay_host`], whose reach is unconditional).
387 ///
388 /// Captured from the ambient [`PAGE_REACH`] scope at `build` and re-captured
389 /// at every `rebuild`, so it always names whichever page this navigator is
390 /// currently reconciled under. Read through
391 /// [`reachable`](Self::reachable) — the one input this navigator has into
392 /// "can a back press legitimately reach me?".
393 host_reach: Option<Rc<Cell<bool>>>,
394 /// The shared route-state slot published to the [`NavigatorController`] —
395 /// a clone of the controller's `Rc<RefCell<RouteStack>>`, refreshed
396 /// by [`publish_route_stack`](Self::publish_route_stack) — called from
397 /// [`publish_state`](Self::publish_state) — after every committed stack
398 /// mutation.
399 route_stack: Rc<RefCell<RouteStack>>,
400 /// The navigator-wide route-change observer from
401 /// [`NavigatorView::on_route_change`], refreshed every `build`/`rebuild`
402 /// (unlike per-page `on_visibility`, since this observes the whole
403 /// navigator).
404 route_change: Option<RouteChangeCallback>,
405}
406
407/// Whether the navigator's **own stack** wants a back press ahead-of-time: it is
408/// poppable (`depth > 1`) **or** the top page's [`BackPolicy`] is not
409/// [`Pop`](BackPolicy::Pop). Pure so it is unit-testable directly, including the
410/// depth-1-with-overlay case a raw `can_pop` cannot express.
411///
412/// This is only half the answer [`NavigatorController::back_interest`] gives:
413/// the R23 reach gate (is the page hosting this navigator input-routed at all?)
414/// is ANDed on at *read* time, deliberately not baked in here — see
415/// [`NavigatorController::host_reach`].
416fn compute_back_interest(depth: usize, top_policy: BackPolicy) -> bool {
417 depth > 1 || top_policy != BackPolicy::Pop
418}
419
420/// Decrement a [`NavigatorController`]'s `mounted` cell, saturating so an
421/// already-zero cell can never wrap. The one place a mounted count is ever
422/// decremented — [`NavigatorView::teardown`] and [`NavigatorView::rebuild`]'s
423/// controller-swap handling both call this against the cell
424/// [`NavigatorWidget`] itself owns (never by looking `NavigatorController`
425/// back up), which is what makes the mount/unmount pairing structural rather
426/// than an identity lookup that could drift after a swap.
427fn unmount_cell(mounted: &Cell<usize>) {
428 mounted.set(mounted.get().saturating_sub(1));
429}
430
431impl<State: 'static> NavigatorWidget<State> {
432 /// Publish the current page-stack depth **and** back-interest to the shared
433 /// controller slots, refresh the published [`TransitionState`]'s depths,
434 /// fire any page-visibility changes, and republish the route-state
435 /// snapshot via [`publish_route_stack`](Self::publish_route_stack).
436 /// Called after every **committed** stack mutation — at the end of
437 /// `apply_ops`, after a transition finalize, and at the end of
438 /// `build`/`rebuild` — so `NavigatorController::depth`/`can_pop`/
439 /// `back_interest`/`transition`/`route_stack` read authoritative values.
440 /// Deliberately **not** called from
441 /// [`begin_interactive_pop`](Self::begin_interactive_pop): an in-flight
442 /// interactive edge-swipe pop is uncommitted (see `route_state`'s module
443 /// docs' staleness contract).
444 fn publish_state(&mut self) {
445 self.depth.set(self.pages.len());
446 let top_policy = self.pages.last().map(|p| p.back).unwrap_or(BackPolicy::Pop);
447 // Reach first: every page's cell must be current before a nested
448 // navigator on one of them reconciles below (R23).
449 self.publish_reach();
450 self.back_interest
451 .set(compute_back_interest(self.pages.len(), top_policy));
452 // Keep the published transition's depths coherent even for a stack
453 // mutation that started no transition at all (an instant push/pop): the
454 // stack is always the transition's *destination*, and with nothing in
455 // flight it is both endpoints.
456 let mut t = self.transition_state.get();
457 t.to_depth = self.pages.len();
458 if !t.active {
459 t.from_depth = t.to_depth;
460 }
461 self.transition_state.set(t);
462 self.publish_visibility();
463 self.publish_route_stack();
464 }
465
466 /// Recompute the route-state snapshot from the current page stack and
467 /// publish it iff it actually changed — an unchanged stack costs only the
468 /// O(depth) comparison below, no allocation (see `route_state`'s module
469 /// docs' derivation note). Called from
470 /// [`publish_state`](Self::publish_state), so it runs at build, at the
471 /// end of `apply_ops`, after a transition finalize, and at the end of
472 /// `rebuild` — **never** from [`begin_interactive_pop`](Self::begin_interactive_pop)
473 /// itself, which is the deliberate uncommitted-swipe gap `route_state`'s
474 /// staleness contract documents.
475 ///
476 /// **The interactive guard.** `rebuild`'s trailing `publish_state` call is
477 /// unconditional (it also refreshes `depth`/`back_interest` every pass),
478 /// so it still runs on every settle-spring frame between release and
479 /// finalize — not just at steal. `self.pages` has already lost the
480 /// stashed page for that whole window (popped at steal, restored or torn
481 /// down only at [`finalize_transition`](Self::finalize_transition), which
482 /// clears `self.transition` first thing), so this checks the transition
483 /// itself rather than trying to keep every OTHER call site from ever
484 /// running during the drag: while `self.transition` is `Some` and
485 /// `interactive`, the stack is still provisional and this returns without
486 /// touching the published snapshot at all — not even the unchanged-check
487 /// below runs. The settle-frame publish (`finalize_transition` already
488 /// cleared `self.transition`) is what finally sees the real diff, in one
489 /// step, whichever way the drag resolved.
490 fn publish_route_stack(&mut self) {
491 if self.transition.as_ref().is_some_and(|t| t.interactive) {
492 return;
493 }
494 let unchanged = {
495 let published = self.route_stack.borrow();
496 let entries = published.entries();
497 entries.len() == self.pages.len()
498 && entries
499 .iter()
500 .zip(self.pages.iter())
501 .all(|(prev, page)| *prev == page.route)
502 };
503 if unchanged {
504 return;
505 }
506 let entries: Vec<Option<Location>> = self.pages.iter().map(|p| p.route.clone()).collect();
507 self.route_stack.borrow_mut().set(entries);
508 if let Some(observer) = self.route_change.clone() {
509 // Clone the `Rc` out before firing, like `publish_visibility`: the
510 // callback is app code and may reach back into the controller.
511 observer(&self.route_stack.borrow());
512 }
513 }
514
515 /// Whether **this navigator** is reachable by input at all — `true` unless
516 /// the page hosting it is not the page its own host navigator routes input
517 /// to (see [`host_reach`](Self::host_reach)).
518 ///
519 /// Always `true` for a top-level navigator, which is why every
520 /// single-navigator app is unaffected by the R23 back gate.
521 fn reachable(&self) -> bool {
522 host_reachable(self.host_reach.as_ref())
523 }
524
525 /// Republish every page's [`PageEntry::reach`] cell: a page is reachable iff
526 /// this navigator is reachable AND the page is in
527 /// [`input_routed_pages`](Self::input_routed_pages).
528 ///
529 /// **This is the whole propagation mechanism.** The AND folds this
530 /// navigator's own reachability into what it publishes to its pages, so the
531 /// invariant composes to any nesting depth without anyone walking the tree:
532 /// a navigator three levels down reads one cell and gets the answer for the
533 /// entire chain above it. Called from
534 /// [`publish_state`](Self::publish_state), i.e. after every stack mutation
535 /// and at the end of every `build`/`rebuild`, and always *before* the
536 /// per-page reconcile loop that re-runs page builders — so a nested
537 /// navigator reconciling this pass reads the value for the stack it is
538 /// actually being reconciled into.
539 ///
540 /// Reach is derived from `input_routed_pages`, not from
541 /// [`PageVisibility`](crate::PageVisibility): a page under a *transparent*
542 /// overlay is still `Visible` but is routed no input, and R23 tracks input
543 /// routing exactly (the same divergence [`semantics`](Widget::semantics)
544 /// documents).
545 fn publish_reach(&mut self) {
546 let own = self.reachable();
547 let routed = self.input_routed_pages();
548 for index in 0..self.pages.len() {
549 self.pages[index].reach.set(own && routed.contains(&index));
550 }
551 }
552
553 /// Fire every page's [`PushOptions::on_visibility`] observer whose
554 /// [`PageVisibility`] changed since the last pass (and no others — a value is
555 /// never reported twice in a row).
556 ///
557 /// Called only from [`publish_state`](Self::publish_state), so it runs at
558 /// build, at the end of `apply_ops`, after a transition finalize, and at the
559 /// end of `rebuild` — in every case *before* the per-page reconcile loop
560 /// gets to decide anything, which is what lets a revealed page rebuild in the
561 /// same pass that revealed it.
562 ///
563 /// Re-entrancy is safe by construction: a callback that calls
564 /// `controller.push()`/`pop()` only records a [`NavOp`], drained at the next
565 /// rebuild — it cannot re-enter the widget.
566 fn publish_visibility(&mut self) {
567 for i in 0..self.pages.len() {
568 let next = self.visibility_of(i);
569 if self.pages[i].visibility == Some(next) {
570 continue;
571 }
572 self.pages[i].visibility = Some(next);
573 // Clone the `Rc` out before firing: the callback is app code and may
574 // reach back into the controller.
575 if let Some(observer) = self.pages[i].on_visibility.clone() {
576 observer(next);
577 }
578 }
579 }
580
581 /// Where the page at `index` sits in the stack right now.
582 ///
583 /// **The single derivation of "visible" in the navigator**, computed from the
584 /// same [`base_visible_index`](Self::base_visible_index) that `layout` and
585 /// `paint` already cull against — the visibility seam, the covered-build cull
586 /// and the semantics rule all read this one function rather than
587 /// recomputing it.
588 ///
589 /// [`Current`](PageVisibility::Current) iff `index` is the top of the stack;
590 /// [`Covered`](PageVisibility::Covered) iff it is below the topmost opaque
591 /// page; [`Visible`](PageVisibility::Visible) otherwise (a page under a
592 /// transparent overlay).
593 ///
594 /// # During a transition
595 ///
596 /// Layout/paint culling is *suspended* mid-transition, but visibility is
597 /// computed against the settled stack regardless: a pop/replace stashes the
598 /// leaving page out of `self.pages` and a push already has the new page on
599 /// top, so `self.pages` **is** the destination stack from the transition's
600 /// first frame. A page therefore learns it is about to be covered when the
601 /// push is applied, not 340ms later — which is the point, since the
602 /// observation exists to let it release resources.
603 fn visibility_of(&self, index: usize) -> PageVisibility {
604 if index + 1 >= self.pages.len() {
605 PageVisibility::Current
606 } else if index < self.base_visible_index() {
607 PageVisibility::Covered
608 } else {
609 PageVisibility::Visible
610 }
611 }
612
613 /// The index of the topmost **opaque** page — the bottom of the visible
614 /// (laid-out + painted) range. Pages below it are culled. With no opaque page
615 /// at all (an all-transparent stack), everything is visible.
616 fn base_visible_index(&self) -> usize {
617 for i in (0..self.pages.len()).rev() {
618 if self.pages[i].opaque {
619 return i;
620 }
621 }
622 0
623 }
624
625 /// Publish a fresh [`TransitionState`] for a transition that is *starting*,
626 /// bumping the generation. The stack has already been mutated to the
627 /// destination when this runs, so `to_depth` is simply the current page
628 /// count; `from_depth` is the pre-op depth the caller knows.
629 ///
630 /// Shared by the programmatic
631 /// [`start_transition`](Self::start_transition) and the interactive
632 /// edge-swipe [`begin_interactive_pop`](Self::begin_interactive_pop) — the
633 /// first two of the publication points listed on
634 /// [`NavigatorController::transition`].
635 pub(super) fn publish_transition_start(
636 &self,
637 from_depth: usize,
638 progress: f64,
639 is_pop: bool,
640 interactive: bool,
641 ) {
642 let to_depth = self.pages.len();
643 let generation = self.transition_state.get().generation.wrapping_add(1);
644 self.transition_state.set(TransitionState {
645 active: true,
646 progress,
647 is_pop,
648 interactive,
649 from_depth,
650 to_depth,
651 generation,
652 });
653 }
654
655 /// Update the published progress of the in-flight transition, leaving every
656 /// other field alone. `interactive` is set alongside it (a drag holds the
657 /// progress; a release hands it back to a spring).
658 pub(super) fn publish_transition_progress(&self, progress: f64, interactive: Option<bool>) {
659 let mut t = self.transition_state.get();
660 t.progress = progress;
661 if let Some(interactive) = interactive {
662 t.interactive = interactive;
663 }
664 self.transition_state.set(t);
665 }
666
667 /// Cancel any in-flight capture and clear the focus flag on the *current* top
668 /// page — the page being covered/replaced/popped by a stack mutation.
669 ///
670 /// Capture unwinds via [`crate::authoring::cancel_pod`]'s synthetic `Cancel` (the outgoing
671 /// widget's state machine must not fire on a later `Up`); focus is a reflected
672 /// pod flag, so clearing it is enough (no widget-internal blur to drive) — the
673 /// same asymmetry the container reconcilers document.
674 pub(super) fn cancel_top(&mut self) {
675 if let Some(top) = self.pages.last_mut() {
676 if top.pod.is_active() {
677 crate::authoring::cancel_pod(&mut top.pod);
678 top.pod.set_active(false);
679 }
680 if top.pod.is_focused() {
681 top.pod.set_focused(false);
682 }
683 }
684 }
685
686 /// Whether the page currently on top holds the recorded focus path
687 /// ([`ChildPod::is_focused`](frust_core::ChildPod::is_focused)) — the
688 /// outgoing/covered page's own half of the gate every
689 /// [`needs_ime_clear`](Self::needs_ime_clear) producer in this widget shares.
690 ///
691 /// # Why every producer is gated
692 ///
693 /// Raising `needs_ime_clear` makes the next [`paint`](Widget::paint) publish
694 /// [`cleared_ime_state`], and an **inactive** publish is not a value update:
695 /// `RenderRoot` reads it as a full focus/IME **session release** at the root
696 /// (see `docs/CORE_ARCHITECTURE.md`'s Focus/IME Lifecycle). The published
697 /// surface bubbles last-write-wins, so a navigator that mutates its stack
698 /// while the live session belongs to an *unrelated* subtree — a search field
699 /// sitting above the navigator, painted earlier in the same frame — would
700 /// otherwise kill that field's session every push/pop, deterministically and
701 /// with no self-heal (the next paint re-seeds `has_focus == false` for the
702 /// blurred field, so its own republish never fires; only a user tap recovers).
703 ///
704 /// So a producer clears only when the outgoing/covered subtree is the one
705 /// that actually owns the session: `ctx.has_focus()` ANDed with the outgoing
706 /// pod's own `is_focused()` — the same composition `frust-widgets`'
707 /// `mark_orphan_if_live` applies to the orphan mark, ANDing the rebuild-pass
708 /// chain down to *this navigator* onto the page's own link. Neither half
709 /// alone is evidence: a page-pod flag can be stale under an already-blurred
710 /// ancestor, and a live chain running past an unfocused navigator says
711 /// nothing about it.
712 ///
713 /// # Read it before [`cancel_top`](Self::cancel_top)
714 ///
715 /// `cancel_top` clears this very flag, so every caller reads it *first*;
716 /// reading after would report `false` unconditionally and suppress a clear
717 /// that was genuinely owed.
718 pub(super) fn top_pod_focused(&self) -> bool {
719 self.pages.last().is_some_and(|p| p.pod.is_focused())
720 }
721
722 /// The effective transition for an op, resolving a `None` per-op override to
723 /// the navigator's default.
724 fn effective_spec(&self, over: Option<TransitionSpec>) -> TransitionSpec {
725 over.unwrap_or(self.default_transition)
726 }
727
728 /// Begin a new transition, finalizing any in-flight one first (a new op
729 /// supersedes a running transition — snap it to its end and tear down its
730 /// retained page). `stashed` is the removed leaving page (pop/replace) or
731 /// `None` for a push (leaving stays in the stack).
732 ///
733 /// # Interactive supersede
734 ///
735 /// If the superseded transition is an *unreleased* interactive edge-swipe
736 /// pop, the [`finalize_transition`](Self::finalize_transition) call here
737 /// **completes** it (tears down the stashed page and queues its result
738 /// callback) rather than cancelling it — a programmatic op wins over an
739 /// in-flight drag, and the drag's page does not spring back.
740 fn start_transition(
741 &mut self,
742 spec: TransitionSpec,
743 is_pop: bool,
744 stashed: Option<PageEntry<State>>,
745 ctx: &mut BuildCtx<'_>,
746 ) {
747 self.finalize_transition(ctx);
748 // A stashed (leaving) page is out of `self.pages`, so `publish_reach`
749 // will never see it again — mark it unreachable HERE or a nested
750 // navigator riding it out would keep claiming back presses for the whole
751 // flight (input is fully suppressed mid-transition anyway). A cancelled
752 // interactive pop pushes the page back onto the stack, and the finalize
753 // that does so is followed by an explicit `publish_state` that restores
754 // this.
755 if let Some(leaving) = stashed.as_ref() {
756 leaving.reach.set(false);
757 }
758 // The stack is already at its destination here (the op mutated it before
759 // staging the animation), so the *pre-op* depth is derived from the op
760 // shape: a pop removed a page (now in `stashed`), a replace swapped one
761 // in place, a push added one.
762 let to_depth = self.pages.len();
763 let from_depth = if is_pop {
764 to_depth + 1
765 } else if stashed.is_some() {
766 to_depth
767 } else {
768 to_depth.saturating_sub(1)
769 };
770 // Build a fallback driver eagerly (the unthemed M3 default — current
771 // behavior), and stash the *unresolved* spec so the first paint can
772 // re-resolve `ThemeDefault` timing + `reduce_motion` against the live
773 // theme (a `BuildCtx` carries none). See `pending_spec`.
774 let (driver, settle_spring) = make_driver(spec.timing);
775 self.transition = Some(ActiveTransition {
776 driver,
777 preset: spec.preset,
778 is_pop,
779 stashed,
780 settle_spring,
781 settled: false,
782 interactive: false,
783 restore_on_finalize: false,
784 hero_leaving: HashMap::new(),
785 hero_entering: HashMap::new(),
786 pending_spec: Some(spec),
787 });
788 // Publication point: a programmatic transition starts at progress 0, not
789 // interactive, with a fresh generation. This runs in a `BuildCtx` pass,
790 // so a build-time observer sees `active` flip on this very frame.
791 self.publish_transition_start(from_depth, 0.0, is_pop, false);
792 }
793
794 /// Dispose the active transition: tear down its retained (leaving) page, if
795 /// any, and drop it. Called on settle (from rebuild) and when a new op
796 /// supersedes a running transition.
797 ///
798 /// # Interactive-pop finalization
799 ///
800 /// A *cancelled* interactive edge-swipe pop
801 /// ([`restore_on_finalize`](ActiveTransition)) never really removed its page —
802 /// the stashed page is pushed back onto the stack (origin reset, so it lands
803 /// at exact resting geometry) instead of torn down. A *completing* interactive
804 /// pop, conversely, is where its result callback is queued (the swipe path
805 /// defers this since the pop may still cancel — unlike the eager
806 /// [`NavOp::Pop`] path). Also clears the edge-swipe drive flags: the
807 /// transition ending means no interactive drive continues.
808 fn finalize_transition(&mut self, ctx: &mut BuildCtx<'_>) {
809 if let Some(mut t) = self.transition.take() {
810 self.edge.active = false;
811 self.edge.armed = false;
812 self.edge.inner_claimed = false;
813 if let Some(mut stashed) = t.stashed.take() {
814 if t.restore_on_finalize {
815 // Cancelled interactive pop: the page was never popped — restore
816 // it at exact resting geometry.
817 stashed.pod.set_origin(Point::ZERO);
818 self.pages.push(stashed);
819 } else {
820 // A completing interactive pop is the point where its pusher's
821 // result callback fires (the non-interactive pop queued it up
822 // front; the swipe defers until it commits).
823 if t.interactive
824 && let Some(callback) = stashed.on_result.take()
825 {
826 self.pending_results.push((callback, PopResult::empty()));
827 // Ask this frame's rebuild for a housekeeping pass so the
828 // callback runs on the settle frame instead of waiting for
829 // whatever input happens to arrive next.
830 frust_core::mark_pending_result_flush();
831 }
832 crate::authoring::teardown_child(&stashed.view, &mut stashed.pod, ctx);
833 }
834 }
835 // Publication point: the transition is over. Publish an at-rest
836 // snapshot for the (possibly just-restored) stack, keeping the
837 // generation so an observer can still tell which transition ended.
838 // Like `start_transition` this runs in a `BuildCtx` pass, so the
839 // `active` falling edge is visible to a build-time observer on the
840 // frame it happens.
841 let generation = self.transition_state.get().generation;
842 self.transition_state
843 .set(TransitionState::settled(self.pages.len(), generation));
844 }
845 }
846
847 /// Interactive-edge-swipe seam: pin the active transition's progress to `p`
848 /// (an edge-swipe drag holds it here between frames). No-op if no
849 /// transition is active.
850 ///
851 /// The gesture that drives this lives in `event`; the navigator supplies the
852 /// held-progress driver state a swipe manipulates.
853 pub fn set_transition_progress(&mut self, p: f64) {
854 let Some(t) = self.transition.as_mut() else {
855 return;
856 };
857 t.driver = TransitionDriver::Held { value: p };
858 t.settled = false;
859 // Publication point: a drag is holding the progress.
860 self.publish_transition_progress(p, Some(true));
861 }
862
863 /// Interactive-edge-swipe seam: release the active transition into a spring
864 /// settle toward `1.0` (non-negative `velocity`) or `0.0` (negative). No-op
865 /// if no transition.
866 ///
867 /// Note: a settle toward `0.0` runs the *visual* reversal, but restoring the
868 /// stack (un-popping the retained page on a cancelled pop) is the
869 /// finalize path's responsibility — this seam only drives the progress
870 /// driver.
871 pub fn settle_transition(&mut self, velocity: f64) {
872 let Some(t) = self.transition.as_mut() else {
873 return;
874 };
875 let from = t.driver.value();
876 let target = if velocity >= 0.0 { 1.0 } else { 0.0 };
877 t.driver = settle_driver(t.settle_spring, from, velocity, target);
878 t.settled = false;
879 // Publication point: the drag released — progress is unchanged this
880 // instant, but a spring (not a finger) drives it from here.
881 self.publish_transition_progress(from, Some(false));
882 }
883
884 /// The event-pass timestamp (ms) for velocity tracking — the last frame time
885 /// seen at paint, since the event pass carries no clock (see
886 /// [`last_frame_time`](NavigatorWidget::last_frame_time)).
887 fn event_time_ms(&self) -> f64 {
888 self.last_frame_time.as_secs_f64() * 1000.0
889 }
890
891 /// The set of pages an input event can reach, as an index range into
892 /// `self.pages` (ascending = bottom-to-top).
893 ///
894 /// **The single derivation of "reachable"**, read by both
895 /// [`route_top`](Self::route_top) and
896 /// [`semantics`](Widget::semantics) so input reach and the accessibility
897 /// tree can never drift apart (rule R23 — *navigator semantics forwarding
898 /// follows input routing, exactly*). Today the set is exactly
899 /// `{ pages.last() }`; if non-modal overlays ever start passing input
900 /// through, both sides widen together by construction.
901 ///
902 /// This is deliberately **narrower** than the painted range
903 /// [`base_visible_index`](Self::base_visible_index) yields: a page under a
904 /// transparent overlay is [`PageVisibility::Visible`] — painted, but routed
905 /// no input — and is therefore *not* in this set.
906 fn input_routed_pages(&self) -> std::ops::Range<usize> {
907 self.pages.len().saturating_sub(1)..self.pages.len()
908 }
909
910 /// Route an event to the top page via the shared single-child router.
911 ///
912 /// Walks [`input_routed_pages`](Self::input_routed_pages) top-first,
913 /// stopping at the first page that consumes the event — one page today.
914 fn route_top(&mut self, ctx: &mut EventCtx<'_>, event: &InputEvent) -> EventResult {
915 for i in self.input_routed_pages().rev() {
916 if crate::authoring::route_event_single(&mut self.pages[i].pod, ctx, event)
917 == EventResult::Handled
918 {
919 return EventResult::Handled;
920 }
921 }
922 EventResult::Ignored
923 }
924
925 /// The event body with an explicit timestamp so velocity math is deterministic
926 /// in tests ([`Widget::event`] supplies the real paint-derived clock).
927 ///
928 /// Ordering: an active interactive swipe is driven first (it bypasses the
929 /// mid-transition input block); otherwise a running transition suppresses all
930 /// page routing; otherwise pointer events run the edge-swipe arm/steal
931 /// machinery before falling through to normal top-page routing.
932 fn event_at(&mut self, ctx: &mut EventCtx<'_>, event: &InputEvent, t_ms: f64) -> EventResult {
933 // An in-progress interactive swipe owns the pointer stream.
934 if self.edge.active {
935 return self.drive_edge_swipe(ctx, event, t_ms);
936 }
937 // Input-blocking contract: while a non-interactive transition is
938 // in flight, suppress ALL routing to pages.
939 if self.transition.is_some() {
940 return EventResult::Ignored;
941 }
942 let InputEvent::Pointer(p) = event else {
943 // Focus-routed / scroll events route straight to the top page.
944 return self.route_top(ctx, event);
945 };
946 match p.phase {
947 PointerPhase::Down => {
948 // Arm an edge-swipe on a left-edge Down over a poppable stack
949 // whose top page currently honours the gesture. The navigator
950 // does NOT capture here (ScrollView precedent): the page
951 // still sees the Down and may capture; a later steal sends the page
952 // a synthetic Cancel. No buffering/re-dispatch — children see Down
953 // first.
954 // Only a primary press arms the swipe: a secondary press is a
955 // context gesture, never the start of an interactive pop.
956 self.edge.armed = crate::authoring::presses(p)
957 && self.swipe_armable()
958 && self.pages.len() > 1
959 && p.position.x <= EDGE_SWIPE_ZONE_DP;
960 if self.edge.armed {
961 self.edge.down_start = p.position;
962 self.edge.tracker.clear();
963 self.edge.tracker.record(t_ms, p.position.x);
964 }
965 // R-B3-inner. A left-edge Down does not capture (above), so it
966 // is forwarded through `route_top` unconditionally — a nested
967 // navigator on the routed page's own `event_at` runs
968 // underneath and may ALSO arm (both legitimately arm; nothing
969 // is stolen yet). Record whatever armed below this navigator
970 // into a fresh claim cell, read it back once routing returns
971 // (`edge.inner_claimed`, consulted at the Move steal site),
972 // and propagate the combined result into whatever cell is now
973 // ambient — this navigator's own host, if any — so a third
974 // nesting level defers too.
975 let claim = Rc::new(Cell::new(false));
976 let routed = with_swipe_claim(&claim, || self.route_top(ctx, event));
977 self.edge.inner_claimed = claim.get();
978 if let Some(host) = ambient_swipe_claim() {
979 host.set(self.edge.inner_claimed || self.edge.armed);
980 }
981 routed
982 }
983 PointerPhase::Move => {
984 if !self.edge.armed {
985 return self.route_top(ctx, event);
986 }
987 self.edge.tracker.record(t_ms, p.position.x);
988 let dx = p.position.x - self.edge.down_start.x;
989 let dy = p.position.y - self.edge.down_start.y;
990 if dx > TOUCH_SLOP && dx.abs() > dy.abs() {
991 // Decisive rightward horizontal drag → STEAL from the page —
992 // after re-validating everything the `Down` arm captured
993 // against, since a rebuild may have run between then and now:
994 //
995 // R-B3-inner: a nested navigator on the same `Down` armed too
996 // (`edge.inner_claimed`) — it is upstream of nobody in the
997 // routing order, so defer to it instead of stealing here.
998 if self.edge.inner_claimed {
999 self.edge.armed = false;
1000 return self.route_top(ctx, event);
1001 }
1002 // BackPolicy/pop_swipe re-check (§4.1): a push applied between
1003 // `Down` and now may have put a page on top this arm no longer
1004 // honours (a DismissAnimated/Veto top, or a page-level
1005 // `pop_swipe(false)` override).
1006 //
1007 // Depth re-check: a programmatic pop/replace applied at a
1008 // rebuild between this arm's `Down` and now may have emptied
1009 // the poppable stack. `begin_interactive_pop`'s only depth
1010 // check is a debug-only `debug_assert!` (compiled out in
1011 // release), so without this guard a stale arm could steal
1012 // wrongly or pop the root page in a release build — draining
1013 // the stack to zero pages. Either way, drop the stale arm and
1014 // fall through to normal routing rather than stealing.
1015 if self.pages.len() <= 1 || !self.swipe_armable() {
1016 self.edge.armed = false;
1017 return self.route_top(ctx, event);
1018 }
1019 let width = ctx.size().width.max(1.0);
1020 let progress = (dx / width).clamp(0.0, 1.0);
1021 self.begin_interactive_pop(progress);
1022 self.edge.armed = false;
1023 self.edge.active = true;
1024 ctx.capture_pointer();
1025 ctx.request_redraw();
1026 EventResult::Handled
1027 } else if dy.abs() > TOUCH_SLOP || dx < -TOUCH_SLOP {
1028 // Vertical dominance or a leftward drag: not an edge pop. Disarm
1029 // and let the page own the gesture (e.g. a ScrollView child that
1030 // starts in the edge zone but drags vertically still scrolls).
1031 self.edge.armed = false;
1032 self.edge.inner_claimed = false;
1033 self.route_top(ctx, event)
1034 } else {
1035 // Still within slop: keep observing, forward to the page.
1036 self.route_top(ctx, event)
1037 }
1038 }
1039 PointerPhase::Up | PointerPhase::Cancel => {
1040 // An armed-but-never-stolen gesture just releases its arm; the page
1041 // owned the Down/Move/Up stream throughout. Clears the R-B3-inner
1042 // claim too, so a later independent swipe is not deferred against a
1043 // stale record from this one.
1044 self.edge.armed = false;
1045 self.edge.inner_claimed = false;
1046 self.route_top(ctx, event)
1047 }
1048 }
1049 }
1050
1051 /// Pop the top page (the shared body of [`NavOp::Pop`] and a
1052 /// [`BackPolicy::Pop`] back request), delivering `result` to the popped
1053 /// page's pusher-registered callback. A pop of the last/root page is a safe
1054 /// no-op (the navigator always keeps one page; the result payload is
1055 /// dropped). Transitions are preserved: an animated page animates out and is
1056 /// torn down on settle. Returns the accumulated dirtiness.
1057 fn apply_pop(&mut self, result: PopResult, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
1058 let mut flags = ChangeFlags::NONE;
1059 if self.pages.len() > 1 {
1060 // The outgoing page's own focus link, read before `cancel_top` drops
1061 // it (see `top_pod_focused`).
1062 let outgoing_focused = self.top_pod_focused();
1063 self.cancel_top();
1064 // Disarm any pending edge-swipe: this pop shrinks the stack, so an arm
1065 // captured before it must not later steal an interactive pop against
1066 // the now-shallower stack (mirrors the `cancel_top` contract).
1067 self.edge.armed = false;
1068 self.edge.inner_claimed = false;
1069 let mut popped = self.pages.pop().expect("len checked > 1");
1070 let spec = popped.transition;
1071 if let Some(callback) = popped.on_result.take() {
1072 self.pending_results.push((callback, result));
1073 // Ask this frame's rebuild for a housekeeping pass so the callback
1074 // runs on the very frame the pop applied, with no input needed.
1075 // `apply_pop` is only ever reached through the op queue, whose
1076 // `enqueue` already raised this mark before the rebuild started —
1077 // idempotent, so a defensive re-raise here is free insurance
1078 // against a future caller reaching this method any other way.
1079 frust_core::mark_pending_result_flush();
1080 }
1081 // Full gate (`top_pod_focused`): the popped page's own link ANDed
1082 // with the rebuild-pass chain down to this navigator. A pop inside a
1083 // navigator that never held focus leaves an unrelated subtree's live
1084 // session alone.
1085 if outgoing_focused && ctx.has_focus() {
1086 self.needs_ime_clear = true;
1087 }
1088 if spec.is_animated() {
1089 // Keep the popped page alive & painted, animating out; torn down
1090 // on settle (a pop reverses its transition).
1091 self.start_transition(spec, true, Some(popped), ctx);
1092 } else {
1093 crate::authoring::teardown_child(&popped.view, &mut popped.pod, ctx);
1094 }
1095 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1096 }
1097 flags
1098 }
1099
1100 /// Route a back press through the top page's [`BackPolicy`]:
1101 ///
1102 /// - [`Pop`](BackPolicy::Pop) → a normal [`apply_pop`](Self::apply_pop) (the
1103 /// existing path, transitions preserved; a no-op at the root);
1104 /// - [`DismissAnimated`](BackPolicy::DismissAnimated) → increment the top
1105 /// page's dismiss-signal generation (the page's own subtree observes it
1106 /// and begins its exit, then pops itself); the stack is unchanged now, so
1107 /// only `PAINT` is flagged (a repaint is needed for the page to observe the
1108 /// bump);
1109 /// - [`Veto`](BackPolicy::Veto) → the press is *consumed* (the page claimed
1110 /// it via `back_interest`, so it never reached the platform) but nothing
1111 /// happens — no signal, no stack change, no dirtiness.
1112 fn apply_request_back(&mut self, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
1113 let policy = self.pages.last().map(|p| p.back).unwrap_or(BackPolicy::Pop);
1114 match policy {
1115 BackPolicy::Pop => self.apply_pop(PopResult::empty(), ctx),
1116 BackPolicy::DismissAnimated => {
1117 if let Some(top) = self.pages.last()
1118 && let Some(signal) = &top.dismiss_signal
1119 {
1120 // Bump exactly once per request — the page compares the shared
1121 // generation against its last-seen value and stages its exit.
1122 signal.set(signal.get().wrapping_add(1));
1123 }
1124 ChangeFlags::PAINT
1125 }
1126 // Consumed, but no visible change and no stack mutation.
1127 BackPolicy::Veto => ChangeFlags::NONE,
1128 }
1129 }
1130
1131 /// Drain and apply the controller's queued ops (structural changes only),
1132 /// building/tearing down pods through `ctx`. Returns the accumulated dirtiness.
1133 fn apply_ops(&mut self, ops: Vec<NavOp<State>>, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
1134 let mut flags = ChangeFlags::NONE;
1135 for op in ops {
1136 match op {
1137 NavOp::Push {
1138 builder,
1139 opaque,
1140 on_result,
1141 transition,
1142 back,
1143 dismiss_signal,
1144 on_visibility,
1145 route,
1146 pop_swipe,
1147 } => {
1148 let spec = self.effective_spec(transition);
1149 // A push severs nothing, but it does *cover* the current top
1150 // — and a covered page's session must go down with the
1151 // keyboard, which is why the existing
1152 // `push_clears_focused_field_ime_surface` behavior is
1153 // deliberate. The COVERED page is therefore the outgoing pod
1154 // here; read its link before `cancel_top` drops it (see
1155 // `top_pod_focused`).
1156 let covered_focused = self.top_pod_focused();
1157 self.cancel_top();
1158 // Disarm any pending edge-swipe: a structural stack mutation
1159 // invalidates an arm captured against the pre-mutation stack
1160 // (mirrors the capture/focus-clearing `cancel_top` contract).
1161 self.edge.armed = false;
1162 self.edge.inner_claimed = false;
1163 // The incoming page becomes the top, so it is reachable iff
1164 // this navigator is; its cell is live from before its own
1165 // builder runs, so a nested navigator built in there reads a
1166 // correct value on its very first `build`.
1167 let reach = Rc::new(Cell::new(self.reachable()));
1168 let (view, pod) = with_page_reach(&reach, || {
1169 let view = builder();
1170 let pod = crate::authoring::build_child(&view, ctx);
1171 (view, pod)
1172 });
1173 self.pages.push(PageEntry {
1174 builder,
1175 view,
1176 pod,
1177 opaque,
1178 on_result,
1179 transition: spec,
1180 back,
1181 dismiss_signal,
1182 // `None`, not `Current`: the end-of-`apply_ops`
1183 // `publish_state` is what fires this page's opening
1184 // `Current` observation.
1185 visibility: None,
1186 on_visibility,
1187 reconciled_covered: false,
1188 route,
1189 pop_swipe,
1190 reach,
1191 snapshot_key: NEXT_SNAPSHOT_KEY.fetch_add(1, Ordering::Relaxed),
1192 });
1193 // Full gate (`top_pod_focused`): the covered page's own link
1194 // ANDed with the rebuild-pass chain down to this navigator.
1195 // `ctx.has_focus()` is unchanged by the `build_child` above —
1196 // that descent restores the chain on the way out.
1197 if covered_focused && ctx.has_focus() {
1198 self.needs_ime_clear = true;
1199 }
1200 // A push's leaving page (now at `len - 2`) stays in the stack;
1201 // the transition keeps it painted (culling deferred to settle).
1202 if spec.is_animated() && self.pages.len() >= 2 {
1203 self.start_transition(spec, false, None, ctx);
1204 }
1205 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1206 }
1207 NavOp::Pop { result } => {
1208 flags |= self.apply_pop(result, ctx);
1209 }
1210 NavOp::RequestBack => {
1211 flags |= self.apply_request_back(ctx);
1212 }
1213 NavOp::Replace {
1214 builder,
1215 opaque,
1216 transition,
1217 route,
1218 } => {
1219 let spec = self.effective_spec(transition);
1220 // The replaced top is the outgoing pod; read its link before
1221 // `cancel_top` drops it (see `top_pod_focused`).
1222 let outgoing_focused = self.top_pod_focused();
1223 self.cancel_top();
1224 // Disarm any pending edge-swipe: replacing the top page
1225 // invalidates an arm captured against the outgoing page
1226 // (mirrors the `cancel_top` capture/focus-clearing contract).
1227 self.edge.armed = false;
1228 self.edge.inner_claimed = false;
1229 // As the push arm: the replacement page is the new top.
1230 let reach = Rc::new(Cell::new(self.reachable()));
1231 let (view, pod) = with_page_reach(&reach, || {
1232 let view = builder();
1233 let pod = crate::authoring::build_child(&view, ctx);
1234 (view, pod)
1235 });
1236 if let Some(top) = self.pages.last_mut() {
1237 let entry = PageEntry {
1238 builder,
1239 view,
1240 pod,
1241 opaque,
1242 on_result: None,
1243 transition: spec,
1244 // A replaced page pops on back like the root — an
1245 // overlay uses `push_with_options`, never replace.
1246 back: BackPolicy::Pop,
1247 dismiss_signal: None,
1248 visibility: None,
1249 // A replace has no `PushOptions`, so the incoming
1250 // page carries no observer (the outgoing one's dies
1251 // with it — it is being torn down, not covered).
1252 on_visibility: None,
1253 reconciled_covered: false,
1254 route,
1255 // A replace carries no `ReplaceOptions::pop_swipe`
1256 // (unspecced) — the replacement page defers to the
1257 // navigator's own resolved default, same as a
1258 // page pushed with no override.
1259 pop_swipe: None,
1260 reach: Rc::clone(&reach),
1261 snapshot_key: NEXT_SNAPSHOT_KEY.fetch_add(1, Ordering::Relaxed),
1262 };
1263 if spec.is_animated() {
1264 // Stash the old top and animate the new one in over it
1265 // (push-like direction); torn down on settle.
1266 let old = std::mem::replace(top, entry);
1267 self.start_transition(spec, false, Some(old), ctx);
1268 } else {
1269 crate::authoring::teardown_child(&top.view, &mut top.pod, ctx);
1270 *top = entry;
1271 }
1272 } else {
1273 // Defensive: an empty stack should not occur (build seeds
1274 // the root page), but replace-into-empty pushes.
1275 self.pages.push(PageEntry {
1276 builder,
1277 view,
1278 pod,
1279 opaque,
1280 on_result: None,
1281 transition: spec,
1282 back: BackPolicy::Pop,
1283 dismiss_signal: None,
1284 visibility: None,
1285 on_visibility: None,
1286 reconciled_covered: false,
1287 route,
1288 pop_swipe: None,
1289 reach,
1290 snapshot_key: NEXT_SNAPSHOT_KEY.fetch_add(1, Ordering::Relaxed),
1291 });
1292 }
1293 // Full gate (`top_pod_focused`): the replaced page's own link
1294 // ANDed with the rebuild-pass chain down to this navigator.
1295 if outgoing_focused && ctx.has_focus() {
1296 self.needs_ime_clear = true;
1297 }
1298 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1299 }
1300 }
1301 }
1302 // Publish the (possibly changed) stack depth + back-interest so
1303 // `NavigatorController::can_pop`/`back_interest` reflect this batch of
1304 // ops.
1305 self.publish_state();
1306 flags
1307 }
1308
1309 /// Lay out the two pages a transition involves (entering = top of stack,
1310 /// leaving = the retained page or the page below), returning the union size.
1311 /// Origins are reset to `ZERO`; paint applies the animated offset per frame.
1312 fn layout_transition(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1313 let mut size = Size::ZERO;
1314 if let Some(entry) = self.pages.last_mut() {
1315 let child = entry.pod.layout_child(ctx, bc);
1316 entry.pod.set_origin(Point::ZERO);
1317 size = Size::new(size.width.max(child.width), size.height.max(child.height));
1318 }
1319 // Leaving page: retained (pop/replace) or the page below (push).
1320 let has_stashed = self
1321 .transition
1322 .as_ref()
1323 .map(|t| t.stashed.is_some())
1324 .unwrap_or(false);
1325 if has_stashed {
1326 if let Some(t) = self.transition.as_mut()
1327 && let Some(stashed) = t.stashed.as_mut()
1328 {
1329 let child = stashed.pod.layout_child(ctx, bc);
1330 stashed.pod.set_origin(Point::ZERO);
1331 size = Size::new(size.width.max(child.width), size.height.max(child.height));
1332 }
1333 } else {
1334 let n = self.pages.len();
1335 if n >= 2 {
1336 let child = self.pages[n - 2].pod.layout_child(ctx, bc);
1337 self.pages[n - 2].pod.set_origin(Point::ZERO);
1338 size = Size::new(size.width.max(child.width), size.height.max(child.height));
1339 }
1340 }
1341 bc.constrain(size)
1342 }
1343
1344 /// Paint a transition frame: advance the driver, resolve per-page geometry,
1345 /// and paint both pages (clipped to the navigator area) in the order the
1346 /// direction dictates. Requests the next frame while running; flags `settled`
1347 /// (finalized on the next rebuild) once the driver reaches rest.
1348 fn paint_transition(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1349 // LAZY driver resolution (mirrors `motion::switcher`'s deferred driver):
1350 // the first paint of a programmatic transition resolves `ThemeDefault`
1351 // timing + `reduce_motion` against the active `MotionScheme` — which the
1352 // `BuildCtx` that staged the transition could not reach — and rebuilds
1353 // the driver, preset, and settle spring before this frame advances. With
1354 // no theme threaded the `make_driver` fallback built at
1355 // `start_transition` (M3 defaults) stands (docs/CODE_STANDARDS.md).
1356 if let Some(spec) = self.transition.as_ref().and_then(|t| t.pending_spec) {
1357 if let Some(theme) = Theme::from_paint_ctx(ctx) {
1358 let resolved = resolve_spec(spec, Some(&theme.motion));
1359 let (driver, settle_spring) = make_driver(resolved.timing);
1360 if let Some(t) = self.transition.as_mut() {
1361 t.driver = driver;
1362 t.preset = resolved.preset;
1363 t.settle_spring = settle_spring;
1364 }
1365 }
1366 if let Some(t) = self.transition.as_mut() {
1367 t.pending_spec = None;
1368 }
1369 }
1370 let (adv, is_pop, preset, has_stashed) = {
1371 let t = self.transition.as_mut().expect("transition present");
1372 let adv = t.driver.advance(ctx.frame_time());
1373 (adv, t.is_pop, t.preset, t.stashed.is_some())
1374 };
1375 // Publication point, immediately after the driver advanced: this is the
1376 // ONLY place progress moves for a programmatic transition, and it is what
1377 // makes a read from a widget painted AFTER the navigator frame-exact.
1378 // See `NavigatorController::transition`'s timing contract.
1379 self.publish_transition_progress(adv.value, None);
1380 let area = ctx.size();
1381 let nav_origin = ctx.origin();
1382 let (enter_layer, leave_layer) = resolve_layers(preset, adv.value, is_pop, area);
1383
1384 // Shared-element ("hero") directives for this frame, derived from the
1385 // rects the *previous* frame captured (page layout is static during a
1386 // transition, so last frame's rects are the current resting geometry).
1387 // The morph rect is clamped-progress interpolated so a spatial-spring
1388 // overshoot past 1.0 never flips a hero dimension.
1389 let (enter_dirs, leave_dirs) =
1390 self.hero_directives(adv.value.clamp(0.0, 1.0), is_pop, nav_origin);
1391
1392 // Clip every offset page to the navigator's own area — content that slides
1393 // off-screen must not bleed past the navigator (relevant when nested).
1394 scene.push_clip(nav_origin, area);
1395 // Paint order: a push paints leaving (below) then entering (on top); a pop
1396 // paints entering (revealed, below) then leaving (popped, on top). Each
1397 // page paints with a hero reporter installed so its tagged descendants
1398 // report their rects and the matched endpoint paints (or suppresses) the
1399 // morph. The overlay is painted by the hero on the page drawn LAST, so it
1400 // always lands above both page layers.
1401 let (new_entering, new_leaving);
1402 if is_pop {
1403 new_entering =
1404 self.paint_entering_heroes(ctx, scene, enter_layer, area, nav_origin, enter_dirs);
1405 new_leaving = self.paint_leaving_heroes(
1406 ctx,
1407 scene,
1408 leave_layer,
1409 area,
1410 nav_origin,
1411 has_stashed,
1412 leave_dirs,
1413 );
1414 } else {
1415 new_leaving = self.paint_leaving_heroes(
1416 ctx,
1417 scene,
1418 leave_layer,
1419 area,
1420 nav_origin,
1421 has_stashed,
1422 leave_dirs,
1423 );
1424 new_entering =
1425 self.paint_entering_heroes(ctx, scene, enter_layer, area, nav_origin, enter_dirs);
1426 }
1427 scene.pop_clip();
1428
1429 // Store this frame's captured rects for next frame's directives.
1430 if let Some(t) = self.transition.as_mut() {
1431 t.hero_entering = new_entering;
1432 t.hero_leaving = new_leaving;
1433 }
1434
1435 if adv.animating {
1436 ctx.request_frame();
1437 }
1438 if adv.done {
1439 // Settle: mark for finalize and request one more frame so the next
1440 // rebuild tears down the retained page and resumes culling.
1441 if let Some(t) = self.transition.as_mut() {
1442 t.settled = true;
1443 }
1444 ctx.request_frame();
1445 }
1446 }
1447
1448 /// Compute this frame's per-page hero [`HeroDirective`]s from the rects the
1449 /// previous paint captured. A tag present on **both** pages is a matched
1450 /// shared element: the hero on the page painted last (on top — entering for
1451 /// a push, leaving for a pop) paints the morph at the interpolated absolute
1452 /// rect; its counterpart is suppressed. Returns `(entering, leaving)`
1453 /// directive maps.
1454 fn hero_directives(
1455 &self,
1456 p: f64,
1457 is_pop: bool,
1458 nav_origin: Point,
1459 ) -> (
1460 HashMap<String, HeroDirective>,
1461 HashMap<String, HeroDirective>,
1462 ) {
1463 let mut enter_dirs = HashMap::new();
1464 let mut leave_dirs = HashMap::new();
1465 let Some(t) = self.transition.as_ref() else {
1466 return (enter_dirs, leave_dirs);
1467 };
1468 for (tag, leaving_rect) in &t.hero_leaving {
1469 let Some(entering_rect) = t.hero_entering.get(tag) else {
1470 continue;
1471 };
1472 let interp = lerp_rect(*leaving_rect, *entering_rect, p);
1473 let dest =
1474 Rect::from_origin_size(nav_origin + interp.origin().to_vec2(), interp.size());
1475 let (painter, suppressed) = if is_pop {
1476 (&mut leave_dirs, &mut enter_dirs)
1477 } else {
1478 (&mut enter_dirs, &mut leave_dirs)
1479 };
1480 painter.insert(tag.clone(), HeroDirective::Morph { dest });
1481 suppressed.insert(tag.clone(), HeroDirective::Suppress);
1482 }
1483 (enter_dirs, leave_dirs)
1484 }
1485
1486 /// Paint the entering page (always the stack top) with `layer`, a hero
1487 /// reporter installed, returning its captured page-local hero rects.
1488 ///
1489 /// Also decides this page's snapshot-bracket eligibility for
1490 /// [`paint_page_layer`]: a *programmatic* transition (never an
1491 /// interactive edge-swipe, whose progress is drag-held and whose stack
1492 /// is provisional) whose `directives` this frame are empty — a hero
1493 /// morph paints inside the page and must move every frame, so a page
1494 /// carrying one is never snapshotted. `alpha > 0.0` is checked inside
1495 /// [`paint_page_layer`] itself (its `DiscardScene` branch stays first).
1496 fn paint_entering_heroes(
1497 &mut self,
1498 ctx: &mut PaintCtx,
1499 scene: &mut dyn PaintScene,
1500 layer: Layer,
1501 area: Size,
1502 nav_origin: Point,
1503 directives: HashMap<String, HeroDirective>,
1504 ) -> HashMap<String, Rect> {
1505 let snapshot_eligible = self.snapshot_eligible(&directives);
1506 if let Some(entry) = self.pages.last_mut() {
1507 let snapshot = snapshot_eligible.then_some(entry.snapshot_key);
1508 let reference = nav_origin + Vec2::new(layer.dx, layer.dy);
1509 paint_page_heroes(
1510 &mut entry.pod,
1511 ctx,
1512 scene,
1513 layer,
1514 area,
1515 reference,
1516 directives,
1517 snapshot,
1518 )
1519 } else {
1520 HashMap::new()
1521 }
1522 }
1523
1524 /// Paint the leaving page — the retained page (pop/replace) or the page below
1525 /// the new top (push) — with `layer`, a hero reporter installed, returning
1526 /// its captured page-local hero rects.
1527 ///
1528 /// See [`paint_entering_heroes`](Self::paint_entering_heroes) for the
1529 /// snapshot-bracket eligibility rule this applies identically, including
1530 /// to the stashed pod a pop/replace retains.
1531 #[allow(clippy::too_many_arguments)]
1532 fn paint_leaving_heroes(
1533 &mut self,
1534 ctx: &mut PaintCtx,
1535 scene: &mut dyn PaintScene,
1536 layer: Layer,
1537 area: Size,
1538 nav_origin: Point,
1539 has_stashed: bool,
1540 directives: HashMap<String, HeroDirective>,
1541 ) -> HashMap<String, Rect> {
1542 let reference = nav_origin + Vec2::new(layer.dx, layer.dy);
1543 let snapshot_eligible = self.snapshot_eligible(&directives);
1544 if has_stashed {
1545 if let Some(t) = self.transition.as_mut()
1546 && let Some(stashed) = t.stashed.as_mut()
1547 {
1548 let snapshot = snapshot_eligible.then_some(stashed.snapshot_key);
1549 return paint_page_heroes(
1550 &mut stashed.pod,
1551 ctx,
1552 scene,
1553 layer,
1554 area,
1555 reference,
1556 directives,
1557 snapshot,
1558 );
1559 }
1560 HashMap::new()
1561 } else {
1562 let n = self.pages.len();
1563 if n >= 2 {
1564 let snapshot = snapshot_eligible.then_some(self.pages[n - 2].snapshot_key);
1565 paint_page_heroes(
1566 &mut self.pages[n - 2].pod,
1567 ctx,
1568 scene,
1569 layer,
1570 area,
1571 reference,
1572 directives,
1573 snapshot,
1574 )
1575 } else {
1576 HashMap::new()
1577 }
1578 }
1579 }
1580
1581 /// The shared snapshot-bracket eligibility test both
1582 /// [`paint_entering_heroes`](Self::paint_entering_heroes) and
1583 /// [`paint_leaving_heroes`](Self::paint_leaving_heroes) apply to their
1584 /// own page this frame: the in-flight transition is programmatic (not an
1585 /// interactive edge-swipe) and this frame's hero `directives` for the
1586 /// page are empty.
1587 fn snapshot_eligible(&self, directives: &HashMap<String, HeroDirective>) -> bool {
1588 let programmatic = self.transition.as_ref().is_some_and(|t| !t.interactive);
1589 programmatic && directives.is_empty()
1590 }
1591}
1592
1593/// Paint one transition page with a hero reporter installed over its subtree,
1594/// returning the page-local rects its tagged descendants captured. `reference`
1595/// is the page's absolute top-left this frame (nav origin + the layer's
1596/// animated offset), subtracted from each reported rect so captures are stable
1597/// across the slide. `snapshot` is this page's resolved snapshot-bracket key
1598/// this frame, if eligible — see
1599/// [`paint_entering_heroes`](NavigatorWidget::paint_entering_heroes).
1600#[allow(clippy::too_many_arguments)]
1601fn paint_page_heroes(
1602 pod: &mut ChildPod,
1603 ctx: &mut PaintCtx,
1604 scene: &mut dyn PaintScene,
1605 layer: Layer,
1606 area: Size,
1607 reference: Point,
1608 directives: HashMap<String, HeroDirective>,
1609 snapshot: Option<u64>,
1610) -> HashMap<String, Rect> {
1611 let registry = RefCell::new(HeroFrames::new(reference, directives));
1612 ctx.with_hero_registry(®istry, |page_ctx| {
1613 paint_page_layer(pod, page_ctx, scene, layer, area, snapshot);
1614 });
1615 registry.into_inner().into_captured()
1616}
1617
1618/// Build the affine that scales uniformly by `scale` about the absolute point
1619/// `pivot` — the standard translate/scale/translate-back "scale about a point"
1620/// construction (mirrors `motion::switcher`'s `scale_about`).
1621fn scale_about(pivot: Point, scale: f64) -> Affine {
1622 Affine::translate((pivot.x, pivot.y))
1623 * Affine::scale(scale)
1624 * Affine::translate((-pivot.x, -pivot.y))
1625}
1626
1627/// Paint one transition page: offset its pod origin by the layer's `dx`/`dy` (so
1628/// paint and hit-testing move together), then bracket its paint with either a
1629/// snapshot bracket (see below) or, when ineligible, a `push_transform` scale
1630/// (about the page's paint-area centre) and a `push_layer` opacity when either
1631/// differs from the identity — strict LIFO (transform outer, opacity inner).
1632/// The scale realises M3 fade-through's `0.92 → 1.0` incoming scale-up
1633/// ([`Layer::scale`](super::transition::Layer::scale)); every other preset
1634/// leaves `scale == 1.0`, so the transform is skipped. Mirrors
1635/// `motion::switcher`'s `paint_staged_child`.
1636///
1637/// For the split-crossfade presets (M3SharedAxisX, M3FadeThrough, Glyph),
1638/// `resolve_layers` leaves at most one page visible at any instant — the
1639/// other is `alpha == 0`, and at the exact split instant both are. Rather
1640/// than rasterize an alpha-0 page under a zero-opacity layer — real GPU work
1641/// multiplied away to nothing — it paints into a [`DiscardScene`] sink
1642/// instead. The paint pass still has to run for its side effects (hero
1643/// rects reported through `PaintCtx::with_hero_registry`, animating
1644/// descendants advancing), so this is a redirect of the *scene*, not a skip of
1645/// the pass; no `push_layer`/`push_transform` bracket is needed since nothing
1646/// the sink records is ever composited. This DiscardScene check runs first,
1647/// ahead of the snapshot bracket below, unconditionally.
1648///
1649/// When `snapshot` is `Some(key)` (see
1650/// [`NavigatorWidget::paint_entering_heroes`] for the eligibility rule), the
1651/// alpha-surviving page paints through
1652/// [`PaintScene::push_snapshot`]/[`pop_snapshot`] instead, unconditionally —
1653/// no `has_scale`/`has_alpha` identity-skip, since the bracket itself is what
1654/// tells a caching renderer this body is worth caching, whatever this
1655/// particular frame's alpha/scale happen to be. **The bracket rect origin must
1656/// follow the pod's absolute paint origin** — `ctx.origin() + (layer.dx,
1657/// layer.dy)` — so the painted body lands inside the texture (preventing crops
1658/// of the shifted body). The scale pivot still centers on the rect, which now
1659/// follows the slid page.
1660fn paint_page_layer(
1661 pod: &mut ChildPod,
1662 ctx: &mut PaintCtx,
1663 scene: &mut dyn PaintScene,
1664 layer: Layer,
1665 area: Size,
1666 snapshot: Option<u64>,
1667) {
1668 pod.set_origin(Point::new(layer.dx, layer.dy));
1669 let alpha = layer.alpha.clamp(0.0, 1.0);
1670
1671 if alpha <= 0.0 {
1672 let mut sink = DiscardScene;
1673 pod.paint_child(ctx, &mut sink);
1674 return;
1675 }
1676
1677 if let Some(key) = snapshot {
1678 let bracket_origin = ctx.origin() + Vec2::new(layer.dx, layer.dy);
1679 scene.push_snapshot(key, bracket_origin, area, alpha, layer.scale);
1680 pod.paint_child(ctx, scene);
1681 scene.pop_snapshot();
1682 return;
1683 }
1684
1685 let has_scale = (layer.scale - 1.0).abs() > f64::EPSILON;
1686 let has_alpha = alpha < 1.0;
1687
1688 if has_scale {
1689 let origin = ctx.origin();
1690 let pivot = Point::new(origin.x + area.width / 2.0, origin.y + area.height / 2.0);
1691 scene.push_transform(scale_about(pivot, layer.scale));
1692 }
1693 if has_alpha {
1694 scene.push_layer(ctx.origin(), area, alpha);
1695 }
1696 pod.paint_child(ctx, scene);
1697 if has_alpha {
1698 scene.pop_layer();
1699 }
1700 if has_scale {
1701 scene.pop_transform();
1702 }
1703}
1704
1705impl<State: 'static> View<State> for NavigatorView<State> {
1706 type Element = NavigatorWidget<State>;
1707
1708 fn build(&self, ctx: &mut BuildCtx<'_>) -> NavigatorWidget<State> {
1709 // Publish liveness FIRST: the root page builder below can wire a nested
1710 // navigator (an app's inner navigator inside a root `overlay_host`'s
1711 // page), and the facade's back arbitration must already see this
1712 // navigator as mounted when that happens — the widget itself does not
1713 // exist until the end of this function.
1714 self.controller.mount();
1715 // Capture the hosting page's reach cell BEFORE installing our own root
1716 // page's — `ambient_page_reach` here names the page *this navigator*
1717 // lives on (`None` at the top level), which is what gates whether this
1718 // navigator may claim a back press at all (R23).
1719 let host_reach = ambient_page_reach();
1720 // Bind it on the controller too: that is where the R23 gate is applied,
1721 // live, by `NavigatorController::back_interest`.
1722 self.controller.bind_host_reach(host_reach.clone());
1723 // The root page is the only page, hence the routed one: its reach is
1724 // this navigator's own.
1725 let root_reach = Rc::new(Cell::new(host_reachable(host_reach.as_ref())));
1726 let (view, pod) = with_page_reach(&root_reach, || {
1727 let view = (self.initial)();
1728 let pod = crate::authoring::build_child(&view, ctx);
1729 (view, pod)
1730 });
1731 let mut widget = NavigatorWidget {
1732 pages: vec![PageEntry {
1733 builder: self.initial.clone(),
1734 view,
1735 pod,
1736 opaque: true,
1737 on_result: None,
1738 transition: self.default_transition,
1739 // The root page always pops on back (never an overlay policy).
1740 back: BackPolicy::Pop,
1741 dismiss_signal: None,
1742 visibility: None,
1743 on_visibility: self.root_visibility.clone(),
1744 reconciled_covered: false,
1745 route: self.root_route.clone(),
1746 // The root has no `PushOptions` to carry an override (mirrors
1747 // `root_visibility`/`root_route`'s shape) — it defers to the
1748 // navigator's own resolved default.
1749 pop_swipe: None,
1750 reach: root_reach,
1751 snapshot_key: NEXT_SNAPSHOT_KEY.fetch_add(1, Ordering::Relaxed),
1752 }],
1753 pending_results: Vec::new(),
1754 needs_ime_clear: false,
1755 default_transition: self.default_transition,
1756 transition: None,
1757 pop_swipe_enabled: self.resolve_pop_swipe(),
1758 edge: EdgeSwipe::new(),
1759 last_frame_time: FrameTime::ZERO,
1760 depth: Rc::clone(&self.controller.depth),
1761 back_interest: Rc::clone(&self.controller.back_interest),
1762 transition_state: Rc::clone(&self.controller.transition),
1763 mounted: Rc::clone(&self.controller.mounted),
1764 cull_covered_builds: self.cull_covered_builds,
1765 host_reach,
1766 route_stack: Rc::clone(&self.controller.route_stack),
1767 route_change: self.route_change.clone(),
1768 };
1769 // Apply any ops the app queued before the first frame.
1770 let ops = self.controller.drain();
1771 if !ops.is_empty() {
1772 widget.apply_ops(ops, ctx);
1773 }
1774 // Publish the initial (post-any-queued-ops) depth + back-interest so
1775 // `can_pop`/`back_interest` are authoritative from the first frame, even
1776 // if no ops ran.
1777 widget.publish_state();
1778 widget
1779 }
1780
1781 fn rebuild(
1782 &self,
1783 _prev: &Self,
1784 element: &mut NavigatorWidget<State>,
1785 ctx: &mut BuildCtx<'_>,
1786 ) -> ChangeFlags {
1787 let mut flags = ChangeFlags::NONE;
1788 // 0. Controller identity. `AnyView::rebuild` (crates/frust-core/src/view.rs)
1789 // matches only the concrete view type (`NavigatorView<State>`), never
1790 // controller identity — so a slot that gets rebuilt against a
1791 // *different* `NavigatorController` reaches this `rebuild`, not
1792 // `build`, unlike every other structural change. Left unhandled, the
1793 // widget would keep draining ops from `self.controller` (the new one,
1794 // correct) while publishing depth/back_interest/transition/mounted
1795 // into the cells captured at `build` (the old one) — an ops/state
1796 // split, and the old controller's mounted count would never return to
1797 // 0 (permanently "mounted", defeating the mounted-veto prune in
1798 // `frust::back_glue`).
1799 //
1800 // Re-bind rather than rebuild the element: swapping which controller
1801 // drives a navigator is app-level misuse (idiomatic usage keeps one
1802 // controller per `Component::State` for the view's whole life), but
1803 // tearing down and rebuilding the retained page stack on top of that
1804 // misuse would additionally blow away every page's widget state
1805 // (`push_pop_preserves_page_widget_state`'s guarantee) for a
1806 // consequence out of proportion to the mistake. Re-binding keeps the
1807 // stack — and the app's data — intact; only the five published cells
1808 // (`route_stack` joined the original four) move to point at the
1809 // new controller.
1810 //
1811 // Ordering: unmount the OLD controller through `element.mounted`
1812 // (the cell still bound from the last build/rebind) BEFORE rebinding
1813 // that field to the new controller's cell — otherwise the decrement
1814 // would land on the wrong cell and the leak would just move rather
1815 // than close. Mount the NEW controller only after every cell points
1816 // at it, so a re-entrant read mid-rebind never sees a half-swapped
1817 // widget.
1818 //
1819 // An in-flight transition (`element.transition`, the widget's own
1820 // retained animation state — never controller-owned) is left
1821 // running untouched; only where its progress gets *published*
1822 // moves. The new controller's `transition` cell starts at
1823 // `TransitionState::default()` (inactive), so a chrome observer
1824 // reading it through the very next frame after a mid-transition swap
1825 // sees a one-frame-stale "at rest" snapshot — self-healing at the
1826 // next paint (which republishes progress every frame a transition is
1827 // active) or at `finalize_transition`, whichever comes first. Bounded
1828 // and self-correcting, not a permanent split — the cost of swapping
1829 // controllers mid-transition, which is already deep into misuse
1830 // territory.
1831 if self.controller.id() != _prev.controller.id() {
1832 unmount_cell(&element.mounted);
1833 element.depth = Rc::clone(&self.controller.depth);
1834 element.back_interest = Rc::clone(&self.controller.back_interest);
1835 element.transition_state = Rc::clone(&self.controller.transition);
1836 element.mounted = Rc::clone(&self.controller.mounted);
1837 element.route_stack = Rc::clone(&self.controller.route_stack);
1838 self.controller.mount();
1839 }
1840 // Keep the widget's default transition in sync with the view so an app can
1841 // change it live (per-op overrides always win over it).
1842 element.default_transition = self.default_transition;
1843 // Refresh the edge-swipe enable flag from the view too (live-configurable).
1844 element.pop_swipe_enabled = self.resolve_pop_swipe();
1845 // Refresh the route-change observer too — navigator-wide (unlike
1846 // per-page `on_visibility`), so live-configurable exactly like the two
1847 // fields above.
1848 element.route_change = self.route_change.clone();
1849 // Same for the covered-build cull switch (default `false`).
1850 element.cull_covered_builds = self.cull_covered_builds;
1851 // Re-capture the hosting page's reach cell: this rebuild runs inside
1852 // whichever page's `with_page_reach` scope currently owns this
1853 // navigator, which is authoritative even if the subtree moved between
1854 // pages (or between a page and the top level) since `build`. Before the
1855 // ops drain, because `apply_ops` stamps `self.reachable()` onto any page
1856 // it builds.
1857 element.host_reach = ambient_page_reach();
1858 // Re-bind on the controller as well (`self.controller` is the CURRENT
1859 // one, so this is correct across a controller swap too) — the live R23
1860 // gate reads it there.
1861 self.controller.bind_host_reach(element.host_reach.clone());
1862 // Republish page reach IMMEDIATELY, not just from the end-of-rebuild
1863 // `publish_state`: this navigator's own reachability may have changed
1864 // this frame (an ancestor covered the page hosting it), and the pages'
1865 // cells must already say so before the reconcile loop below rebuilds a
1866 // navigator nested inside one of them — that nested navigator reads the
1867 // cell during its own rebuild, which happens strictly before this
1868 // rebuild's closing publish. Without this, reach propagated only one
1869 // nesting level per frame.
1870 element.publish_reach();
1871 // 1. Structural ops (view-driven): push/pop/replace the retained stack.
1872 let ops = self.controller.drain();
1873 if !ops.is_empty() {
1874 flags |= element.apply_ops(ops, ctx);
1875 }
1876 // 1b. Finalize a transition that settled during the previous paint: tear
1877 // down its retained (leaving) page and resume normal culling.
1878 if element
1879 .transition
1880 .as_ref()
1881 .map(|t| t.settled)
1882 .unwrap_or(false)
1883 {
1884 element.finalize_transition(ctx);
1885 // Finalizing MUTATES the page stack — a cancelled interactive pop
1886 // pushes its stashed page back on — so publish explicitly here
1887 // rather than leaning on the end-of-rebuild publish below to cover
1888 // it by accident. This is also what fires the restored page's
1889 // `on_visibility` before the reconcile loop reads it.
1890 element.publish_state();
1891 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1892 }
1893 // 2. Reconcile every retained page by re-running its builder against live
1894 // state — the pod, and thus the page's own widget state, is preserved;
1895 // only the view descriptor is rebuilt.
1896 //
1897 // Covered pages are included by default. Under
1898 // `cull_covered_builds(true)` a page is skipped once it has ALREADY
1899 // been reconciled while covered, so the frame it becomes covered still
1900 // gets one final reconcile (see that builder's doc). Steps 1 and 1b
1901 // above both ran before this loop, so a page revealed this frame is no
1902 // longer `Covered` and rebuilds in the same pass that revealed it.
1903 let cull = element.cull_covered_builds;
1904 for index in 0..element.pages.len() {
1905 let covered = element.pages[index].visibility == Some(PageVisibility::Covered);
1906 let skip = cull && covered && element.pages[index].reconciled_covered;
1907 element.pages[index].reconciled_covered = covered;
1908 if skip {
1909 continue;
1910 }
1911 // Install this page's reach cell for the whole builder + subtree
1912 // reconcile: a nested navigator anywhere under it (the app's page
1913 // builder is where `frust::navigator` auto-wires, and the nested
1914 // `NavigatorView::rebuild` runs inside `rebuild_child`) reads it and
1915 // gates its own back interest on it — R23, structurally.
1916 let reach = Rc::clone(&element.pages[index].reach);
1917 flags |= with_page_reach(&reach, || {
1918 let entry = &mut element.pages[index];
1919 let next_view = (entry.builder)();
1920 let child_flags =
1921 crate::authoring::rebuild_child(&entry.view, &next_view, &mut entry.pod, ctx);
1922 entry.view = next_view;
1923 child_flags
1924 });
1925 }
1926 // Republish depth + back-interest at rebuild time — the rebuild-time
1927 // refresh contract the back handler relies on. A settled-transition
1928 // finalize (step 1b) above can change the stack, so publish once more
1929 // here after `apply_ops` already did.
1930 element.publish_state();
1931 flags
1932 }
1933
1934 fn teardown(&self, element: &mut NavigatorWidget<State>, ctx: &mut BuildCtx<'_>) {
1935 // Tear down a transition's retained (leaving) page first, then the stack.
1936 element.finalize_transition(ctx);
1937 for entry in &mut element.pages {
1938 crate::authoring::teardown_child(&entry.view, &mut entry.pod, ctx);
1939 }
1940 // This navigator has left the tree: drop the liveness `mount()` count
1941 // this widget holds. Decrement through `element.mounted` — the cell
1942 // `build`/the last controller-swap `rebuild` bound — rather than calling
1943 // `self.controller.unmount()`. `self.controller` is merely whichever
1944 // controller *this* view instance happens to carry; after a swap it is
1945 // already the NEW controller, which `rebuild`'s swap handling already
1946 // mounted for a widget still alive. Unmounting through `self.controller`
1947 // here would double-unmount the new one (permanently `mounted() ==
1948 // false` even while the app still holds it elsewhere) and leave the OLD
1949 // one's earlier `rebuild`-time unmount as the only correction it ever
1950 // got — the mount/unmount pair is only provably balanced when both ends
1951 // read the same cell, which `element.mounted` guarantees regardless of
1952 // how many times the controller was swapped underneath this widget.
1953 unmount_cell(&element.mounted);
1954 }
1955}
1956
1957impl<State: 'static> Widget for NavigatorWidget<State> {
1958 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1959 // While a transition runs, both involved pages must be laid out (culling
1960 // is deferred to settle — Flutter opaque-route parity).
1961 if self.transition.is_some() {
1962 return self.layout_transition(ctx, bc);
1963 }
1964 // Lay out only the visible range (topmost opaque page + any transparent
1965 // pages above it); covered pages keep their retained widgets but are not
1966 // laid out while covered (re-laid-out on the next frame once revealed).
1967 // Constraints pass through unchanged — a full-screen page returns
1968 // `bc.max()`; the navigator sizes to the largest visible page.
1969 let start = self.base_visible_index();
1970 let mut size = Size::ZERO;
1971 for entry in &mut self.pages[start..] {
1972 let child = entry.pod.layout_child(ctx, bc);
1973 entry.pod.set_origin(Point::ZERO);
1974 size = Size::new(size.width.max(child.width), size.height.max(child.height));
1975 }
1976 bc.constrain(size)
1977 }
1978
1979 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1980 // Record the shared frame clock so the between-frames event pass (which
1981 // carries no clock) has a timestamp for edge-swipe velocity tracking.
1982 self.last_frame_time = ctx.frame_time();
1983 if self.transition.is_some() {
1984 // Animated page switch: paint both involved pages with per-frame
1985 // offsets/opacity and drive the transition off the frame clock.
1986 self.paint_transition(ctx, scene);
1987 } else {
1988 // Paint the visible range bottom-to-top: only the topmost opaque page
1989 // (and any transparent pages above it) — fully-covered pages are culled.
1990 let start = self.base_visible_index();
1991 for entry in &mut self.pages[start..] {
1992 entry.pod.paint_child(ctx, scene);
1993 }
1994 }
1995 // Deterministic IME hide after a stack mutation: publish a cleared surface
1996 // so the platform keyboard drops immediately rather than waiting for the
1997 // lazy event-pass convergence. `RenderRoot::paint` only accepts this while
1998 // focus is (still) active — exactly the stale-focus window a switch opens.
1999 if self.needs_ime_clear {
2000 ctx.publish_ime_state(cleared_ime_state());
2001 self.needs_ime_clear = false;
2002 }
2003 }
2004
2005 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2006 // Flush pop-result callbacks queued during the rebuild — this is the
2007 // first point after a pop where the erased app state is in scope. Runs
2008 // for every event kind, including the `Housekeeping` broadcast the
2009 // rebuild dispatches for exactly this purpose, so a result never waits on
2010 // user input that may never arrive.
2011 if !self.pending_results.is_empty() {
2012 let pending = std::mem::take(&mut self.pending_results);
2013 let state = ctx.state_mut::<State>();
2014 for (callback, result) in pending {
2015 callback(state, result);
2016 }
2017 }
2018 if event.is_broadcast() {
2019 // A broadcast is not user input, so none of `event_at`'s machinery
2020 // applies: it must not drive an in-flight edge swipe, must not be
2021 // swallowed by the mid-transition input block, and must not be
2022 // narrowed to `input_routed_pages` — a nested navigator on a *covered*
2023 // page can have queued a result too, and it is entitled to the same
2024 // same-frame flush. Forward to every page, consume nothing.
2025 //
2026 // This is deliberately wider than R23's input/semantics reach and does
2027 // not weaken it: R23 governs what a *user* can activate, and a
2028 // housekeeping pass activates nothing — every widget below either has
2029 // deferred work of its own to run or ignores it outright.
2030 for entry in &mut self.pages {
2031 crate::authoring::route_event_single(&mut entry.pod, ctx, event);
2032 }
2033 return EventResult::Ignored;
2034 }
2035 // The rest of the event body (edge-swipe arm/steal/drive + the
2036 // mid-transition input block + top-page routing) runs against the
2037 // paint-derived event-pass clock. See [`event_at`](Self::event_at).
2038 //
2039 // Input-blocking contract (STRICT): while a non-interactive transition is
2040 // in flight, `event_at` suppresses ALL routing to pages — a mid-transition
2041 // `Down` reaches no page and records no `active`/focus path (the involved
2042 // pages' captures were already synthetically cancelled at transition start
2043 // via `cancel_top`). An interactive edge-swipe is the deliberate
2044 // exception: it drives a held transition and keeps receiving its own
2045 // pointer stream.
2046 let t_ms = self.event_time_ms();
2047 self.event_at(ctx, event, t_ms)
2048 }
2049
2050 /// **R23 — semantics forwarding follows input routing, exactly.**
2051 ///
2052 /// A transparent container (like [`Stack`](crate::Stack)): the navigator
2053 /// contributes **no node of its own** and forwards
2054 /// [`ChildPod::semantics_child`] for exactly the pages
2055 /// [`input_routed_pages`](Self::input_routed_pages) says an input event
2056 /// could reach — today `{ pages.last() }`. Every other page is **omitted**:
2057 /// no node, no recursion. Offering a screen-reader user a control they
2058 /// physically cannot activate is worse than not offering it, so the two
2059 /// reaches are derived from one function rather than kept in sync by hand.
2060 ///
2061 /// # This omits more than paint culling does
2062 ///
2063 /// A page under a *transparent* overlay is [`PageVisibility::Visible`] —
2064 /// still painted — yet it is omitted here, because R23 tracks **input
2065 /// routing**, not painting, and [`route_top`](Self::route_top) routes only
2066 /// to the top page. The divergence is deliberate: it is what makes a modal
2067 /// modal to assistive technology for free (the page beneath a dialog is
2068 /// already inert to a finger). The topmost transparent page — the dialog
2069 /// itself — *is* forwarded, since it is `pages.last()`.
2070 ///
2071 /// # Why omission and not an accesskit flag
2072 ///
2073 /// Not `hidden`: it would need a synthesized per-page wrapper node to carry
2074 /// the flag (churning node ids on every navigation for nodes that exist
2075 /// only to say "ignore me"), it would publish the **stale bounds** of a
2076 /// covered page that `layout` skipped, and whether every platform adapter
2077 /// honours the flag is unverified — omission needs no such trust. Not
2078 /// `clips_children`, which asserts `overflow: hidden` and would be simply
2079 /// false here. Not `modal`, which is the right flag but belongs on the
2080 /// dialog widget: the navigator does not know a page is a dialog and cannot
2081 /// infer it from [`BackPolicy`] (most shipped modals push through
2082 /// `push_transparent_for_result` and so take the default
2083 /// [`BackPolicy::Pop`]).
2084 ///
2085 /// # During a transition
2086 ///
2087 /// No special case: `pages.last()` is the *destination* page for a push,
2088 /// pop and replace alike, and its mid-flight bounds are the animated pod
2089 /// origins `paint` is using — consistent with the screen and
2090 /// self-correcting within one transition. Input is *fully* suppressed
2091 /// mid-transition ([`event_at`](Self::event_at)), so a screen reader
2092 /// activating a node during those ≤340ms hits exactly the same suppression
2093 /// a finger would.
2094 fn semantics(&self, ctx: &mut SemanticsCtx) {
2095 // The routed set is always the top of the settled stack — i.e. exactly
2096 // the pages `visibility_of` calls `Current`. Pinned here so a future
2097 // change to either derivation trips in debug rather than silently
2098 // widening the accessibility tree past the input reach.
2099 debug_assert!(
2100 self.input_routed_pages()
2101 .all(|i| self.visibility_of(i) == PageVisibility::Current),
2102 "R23: the input-routed page set must be exactly the Current page(s)"
2103 );
2104 for i in self.input_routed_pages() {
2105 self.pages[i].pod.semantics_child(ctx);
2106 }
2107 }
2108
2109 // Every RETAINED page, not just the ones input or semantics reach:
2110 // an inspector's job is to show what the tree holds, including a
2111 // covered page and the stashed page of a running transition.
2112 crate::authoring::visit_children!(pages, transition);
2113}
2114
2115/// The cleared/inactive IME surface the navigator publishes after a page switch so
2116/// the platform keyboard hides deterministically (see [`NavigatorWidget::paint`]).
2117///
2118/// `active: false` is what makes this a **session release**, not a surface
2119/// refresh: `RenderRoot::paint`'s take path reads the inactive flag as "the
2120/// focus session is over" and clears `focus_active` *and* the stored surface
2121/// (storing `None`, never this value), so a popped page's focus cannot outlive
2122/// the widget that held it. The rest of the fields are the empty/no-selection
2123/// form and are never read by any shell for an inactive surface — both mobile
2124/// bridges serialise `None` to the byte-identical inactive JSON — but they stay
2125/// spelled out so the value is a valid, self-describing `ImeState` on its own.
2126fn cleared_ime_state() -> ImeState {
2127 ImeState {
2128 active: false,
2129 editing: EditingState {
2130 text: String::new(),
2131 selection_base: -1,
2132 selection_extent: -1,
2133 composing_base: -1,
2134 composing_extent: -1,
2135 },
2136 caret: None,
2137 content_type: Default::default(),
2138 suppress_soft_keyboard: false,
2139 }
2140}
2141
2142#[cfg(test)]
2143#[path = "navigator_tests/mod.rs"]
2144mod tests;