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