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