Skip to main content

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