frust_widgets/nav/controller.rs
1//! [`NavigatorController`]: the cloneable app-state handle that *records*
2//! navigation ops for a [`NavigatorWidget`]
3//! to drain at its next rebuild, plus the published depth / back-interest /
4//! transition / route-stack read seams the facade and app code observe it
5//! through.
6
7use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9
10use frust_core::{AnyView, View};
11
12use super::ambient::host_reachable;
13use super::options::{
14 BackPolicy, NavOp, NavigatorId, PageBuilder, PopResult, PushOptions, ReplaceOptions,
15 ResultCallback,
16};
17use super::route_state::RouteStack;
18use super::transition::{TransitionSpec, TransitionState};
19
20// Named only by the doc comments moved here with this module's items, so their
21// intra-doc links keep resolving to the same targets they did in `navigator`.
22#[allow(unused_imports)]
23use super::navigator::NavigatorWidget;
24#[allow(unused_imports)]
25use super::transition::PageTransition;
26#[allow(unused_imports)]
27use super::view::navigator;
28#[allow(unused_imports)]
29use frust_core::Widget;
30
31/// The app-state handle to a [`navigator`]: a cloneable op queue an app keeps in
32/// its `Component::State` and drives with [`push`](Self::push)/[`pop`](Self::pop)/
33/// [`replace`](Self::replace). Every clone shares one queue (`Rc`), so the handle
34/// the view carries and the handle event handlers call are the same.
35///
36/// Ops are *recorded*, not applied — the [`NavigatorWidget`] drains and applies
37/// them at its next rebuild (see the [module docs](self)).
38pub struct NavigatorController<State: 'static> {
39 pub(super) ops: Rc<RefCell<Vec<NavOp<State>>>>,
40 /// The current page-stack **depth**, published by the attached
41 /// [`NavigatorWidget`] on every `build`/`rebuild`/`apply_ops`. The widget
42 /// owns the authoritative stack; this shared cell is
43 /// the read seam [`depth`](Self::depth)/[`can_pop`](Self::can_pop) expose so
44 /// the facade's back handler (and app code) can ask "would a pop do
45 /// anything?" without reaching into the widget. `0` until a widget attaches.
46 ///
47 /// `frust-widgets` stays reactive-free: this is a plain `Rc<Cell<_>>`,
48 /// not a signal — a shell/facade polls it at rebuild time (see the timing
49 /// note in `frust-reactive::back`).
50 pub(super) depth: Rc<Cell<usize>>,
51 /// Whether a back press should be *claimed* by the navigator ahead-of-time
52 /// (predictive-back parity) — published by the attached
53 /// [`NavigatorWidget`] alongside [`depth`](Self::depth). `true` iff the stack
54 /// is poppable (`depth > 1`) **or** the top page's [`BackPolicy`] is not
55 /// [`Pop`](BackPolicy::Pop) (a dismissable/veto overlay claims back even at
56 /// the root) — **and** the page hosting this navigator is itself
57 /// input-reachable (the R23 gate; see
58 /// [`NavigatorWidget::reachable`], always satisfied for a top-level
59 /// navigator). This is the signal the facade's back handler computes
60 /// `handles_back` from — it differs from [`can_pop`](Self::can_pop) exactly
61 /// in the depth-1-with-overlay case, where a raw pop would do nothing but the
62 /// overlay still owns the press. Same plain `Rc<Cell<_>>` (reactive-free)
63 /// polled-at-rebuild contract as `depth`. The cell holds the stack's own
64 /// answer only: the reach gate and the liveness gate
65 /// ([`is_mounted`](Self::is_mounted)) are ANDed on at read time by
66 /// [`back_interest()`](Self::back_interest), never baked in.
67 pub(super) back_interest: Rc<Cell<bool>>,
68 /// The published snapshot of the navigator's single in-flight page
69 /// transition, written by the attached [`NavigatorWidget`] at every
70 /// transition edge *and* on every paint frame that advances the driver. The
71 /// read seam is [`transition`](Self::transition), whose doc carries the
72 /// timing contract.
73 ///
74 /// Same reactive-free idiom as [`depth`](Self::depth)/
75 /// [`back_interest`](Self::back_interest) — a plain `Rc<Cell<_>>` of `Copy`
76 /// data, never a signal. Unlike those two it is published from *paint* as
77 /// well as build, which is what makes a frame-exact read possible.
78 pub(super) transition: Rc<Cell<TransitionState>>,
79 /// How many live [`NavigatorWidget`]s currently render this controller's
80 /// stack — incremented by [`NavigatorView::build`] and (if a live widget's
81 /// controller is swapped) by [`NavigatorView::rebuild`]; decremented by
82 /// `teardown` and by that same swap handling for the controller being
83 /// swapped *away from*. Read through [`is_mounted`](Self::is_mounted).
84 ///
85 /// A **liveness** seam, not a published-state one: the three cells above
86 /// answer "what does the stack look like?", this one answers "is this
87 /// navigator in the retained tree at all?". The facade's back arbitration
88 /// needs the latter to tell a navigator that merely did not wire this pass
89 /// from one whose screen was torn down (see `frust::back_glue`'s R44-back
90 /// prune). A count, not a bool, so a reconcile that builds the replacement
91 /// widget before tearing down the old one never reads as unmounted.
92 ///
93 /// **The widget never decrements this cell by looking `self.controller` up
94 /// again** — [`NavigatorWidget`] holds its own clone (`mounted`, alongside
95 /// `depth`/`back_interest`/`transition`), rebound only at `build` and at a
96 /// controller-swap `rebuild`, and every decrement goes through that field.
97 /// A `NavigatorView`'s `self.controller` is whatever the app currently
98 /// hands it — after a swap that is already the *new* controller — so
99 /// `teardown` reading it instead would double-unmount the new one and never
100 /// correct the old one, exactly the structural gap this field closes (see
101 /// [`NavigatorView::rebuild`]'s controller-swap comment).
102 ///
103 /// Same reactive-free `Rc<Cell<_>>` idiom as the rest of the controller.
104 pub(super) mounted: Rc<Cell<usize>>,
105 /// The [`PageEntry::reach`] cell of the page **hosting** this navigator,
106 /// bound by the attached [`NavigatorWidget`] at `build`/`rebuild`; `None`
107 /// for a top-level navigator (and until a widget attaches).
108 ///
109 /// The R23 back-reach gate [`back_interest`](Self::back_interest) reads
110 /// *live*, rather than a value baked into the published cell: the page
111 /// hosting a nested navigator can stop being input-routed on a pass in which
112 /// that navigator does not rebuild at all (a page frozen by
113 /// [`cull_covered_builds`](NavigatorView::cull_covered_builds), or one
114 /// stashed out of the stack by a pop transition), and a baked-in value would
115 /// go stale exactly there — the worst case for this gate.
116 ///
117 /// A `RefCell` slot around a plain `Rc<Cell<bool>>`, like `ops` — a
118 /// *binding* that moves when the widget re-captures its host, wrapping the
119 /// same reactive-free cell idiom as the published state.
120 pub(super) host_reach: Rc<RefCell<Option<Rc<Cell<bool>>>>>,
121 /// The published route-state snapshot — a fourth published slot
122 /// beside `depth`/`back_interest`/`transition`, written by
123 /// [`NavigatorWidget::publish_state`] after every committed stack
124 /// mutation (see `route_state`'s module docs for the staleness contract
125 /// and why an in-flight interactive edge swipe does not publish here).
126 ///
127 /// `RefCell`, not `Cell`: the payload (`RouteStack`) is not `Copy` —
128 /// precedented on this same type by [`ops`](Self::ops) and
129 /// [`host_reach`](Self::host_reach).
130 pub(super) route_stack: Rc<RefCell<RouteStack>>,
131}
132
133impl<State: 'static> Clone for NavigatorController<State> {
134 fn clone(&self) -> Self {
135 Self {
136 ops: Rc::clone(&self.ops),
137 depth: Rc::clone(&self.depth),
138 back_interest: Rc::clone(&self.back_interest),
139 transition: Rc::clone(&self.transition),
140 mounted: Rc::clone(&self.mounted),
141 host_reach: Rc::clone(&self.host_reach),
142 route_stack: Rc::clone(&self.route_stack),
143 }
144 }
145}
146
147impl<State: 'static> Default for NavigatorController<State> {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl<State: 'static> NavigatorController<State> {
154 /// A fresh controller with an empty op queue.
155 pub fn new() -> Self {
156 Self {
157 ops: Rc::new(RefCell::new(Vec::new())),
158 depth: Rc::new(Cell::new(0)),
159 back_interest: Rc::new(Cell::new(false)),
160 transition: Rc::new(Cell::new(TransitionState::default())),
161 mounted: Rc::new(Cell::new(0)),
162 host_reach: Rc::new(RefCell::new(None)),
163 route_stack: Rc::new(RefCell::new(RouteStack::default())),
164 }
165 }
166
167 /// Bind (or re-bind) the hosting page's reach cell — called by the attached
168 /// [`NavigatorWidget`] from `build` and every `rebuild` with whatever
169 /// [`ambient_page_reach`] says at that point, so the binding follows the
170 /// navigator if its subtree ever moves between pages.
171 ///
172 /// Deliberately not public: reach is derived by the navigator hosting this
173 /// one, never declared by an app.
174 pub(super) fn bind_host_reach(&self, reach: Option<Rc<Cell<bool>>>) {
175 *self.host_reach.borrow_mut() = reach;
176 }
177
178 /// Whether the page hosting this navigator is input-routed right now — the
179 /// **R23 back-reach gate**, read live (see
180 /// [`host_reach`](Self::host_reach)). `true` for a top-level navigator and
181 /// before a widget attaches.
182 fn host_reachable(&self) -> bool {
183 host_reachable(self.host_reach.borrow().as_ref())
184 }
185
186 /// Whether a live [`NavigatorWidget`] currently renders this controller's
187 /// stack — i.e. whether this navigator is in the retained tree *right now*.
188 ///
189 /// `false` before the first `build` and again after the widget's `teardown`
190 /// (a screen with its own nested navigator, popped). Unlike
191 /// [`depth`](Self::depth)/[`back_interest`](Self::back_interest) this is not
192 /// an advisory snapshot of the stack: it is exact at every point after the
193 /// widget's `build` began, because `build`/`teardown` write it directly.
194 ///
195 /// The facade's back arbitration is the intended consumer: a registered
196 /// controller that is still mounted is still part of the tree, so it must
197 /// keep its place in the arbitration list even on a pass in which it did not
198 /// re-wire; one that is no longer mounted can be released.
199 pub fn is_mounted(&self) -> bool {
200 self.mounted.get() > 0
201 }
202
203 /// Record that a [`NavigatorWidget`] attached to this controller. Called at
204 /// the very top of [`NavigatorView::build`], *before* the root page builder
205 /// runs: that builder may itself wire a nested navigator, and the facade's
206 /// back arbitration must already see this navigator as mounted by then.
207 pub(super) fn mount(&self) {
208 self.mounted.set(self.mounted.get() + 1);
209 }
210
211 // No `unmount` method here deliberately: unmounting always goes through
212 // the widget-owned `mounted` cell (`unmount_cell`, below), never back
213 // through a `NavigatorController` reference — see the `mounted` field
214 // doc's structural-pairing note and `NavigatorView::rebuild`'s
215 // controller-swap handling.
216
217 /// This controller's [`NavigatorId`] — stable across clones, distinct per
218 /// independently constructed controller. See [`NavigatorId`] for the
219 /// liveness caveat.
220 pub fn id(&self) -> NavigatorId {
221 NavigatorId(Rc::as_ptr(&self.ops) as *const u8 as usize)
222 }
223
224 /// The current page-stack depth of the navigator this controller drives, as
225 /// last published by that navigator's `build`/`rebuild`, or `0` if no
226 /// navigator is attached yet.
227 ///
228 /// **Advisory**: this reflects the depth at the last rebuild, so a query
229 /// racing a same-frame stack change sees the previous value (the
230 /// rebuild-time refresh contract — see [`can_pop`](Self::can_pop) and
231 /// `frust-reactive::back`'s timing note).
232 pub fn depth(&self) -> usize {
233 self.depth.get()
234 }
235
236 /// Whether a [`pop`](Self::pop) would actually remove a page — `true` iff
237 /// the navigator has more than one page ([`depth`](Self::depth)` > 1`).
238 ///
239 /// **Advisory**, for exactly the Android back contract: the
240 /// facade's back handler reads this to decide whether a back press pops or
241 /// bubbles to the platform, and publishes it as
242 /// `frust-reactive::set_handles_back`. The authoritative guard stays the
243 /// widget's own `len > 1` check in [`apply_ops`](NavigatorWidget) — a pop at
244 /// the root remains a safe no-op even if this raced stale, so a
245 /// mis-predicted root-level back never removes the last page.
246 pub fn can_pop(&self) -> bool {
247 self.depth.get() > 1
248 }
249
250 /// Whether the navigator claims the next back press ahead-of-time
251 /// (predictive-back parity) — `true` iff the stack is poppable
252 /// **or** the top page declares a non-[`Pop`](BackPolicy::Pop) policy (a
253 /// dismissable/veto overlay). The facade's back handler reads this
254 /// (in preference to [`can_pop`](Self::can_pop)) to compute the shell's
255 /// `handles_back`, so a dismissable overlay at the root still consumes back
256 /// rather than exiting the app.
257 ///
258 /// # Nested navigators: reach follows input routing (R23)
259 ///
260 /// A navigator hosted on a page its own host navigator routes **no input**
261 /// to (a covered page, or the page under a transparent overlay) reports
262 /// `false` here regardless of its own stack: back arbitration reaches
263 /// exactly as far as input does, so a press can never pop an off-screen
264 /// stack while the visible page stays put. Unconditional `true` for the
265 /// reach term at the top level, so a single-navigator app is unaffected.
266 ///
267 /// # Only a mounted navigator claims
268 ///
269 /// A navigator whose view is not in the retained tree — never built yet,
270 /// or already torn down (a popped page hosting it, a switched-away section)
271 /// — cannot apply the op a claimed press would enqueue, so it claims
272 /// nothing: this is `false` whenever [`is_mounted`](Self::is_mounted) is.
273 /// The gate is exact at teardown (no rebuild or re-wire needed), so the
274 /// first press after a navigator leaves the tree already goes elsewhere.
275 /// A handle created ahead of its view (e.g. a back handler made in
276 /// `Component::init`) simply claims nothing until that view mounts.
277 ///
278 /// **Advisory**, published at the last rebuild like [`depth`](Self::depth) —
279 /// a query racing a same-frame stack change sees the previous value; the
280 /// navigator's own [`request_back`](Self::request_back) routing stays
281 /// authoritative regardless (see `frust-reactive::back`'s timing note).
282 pub fn back_interest(&self) -> bool {
283 // The published cell answers for this navigator's own stack; the reach
284 // gate is ANDed HERE (live) rather than baked into the cell, because the
285 // hosting page can stop being input-routed on a pass in which this
286 // navigator never rebuilds — see `host_reach`. The liveness gate is
287 // live for the same reason: teardown writes `mounted` directly but
288 // never republishes the cell, so a torn-down navigator's last published
289 // answer would otherwise keep claiming presses.
290 self.back_interest.get() && self.host_reachable() && self.is_mounted()
291 }
292
293 /// The navigator's current [`TransitionState`] — the observation seam chrome
294 /// *outside* the navigator subtree (an app bar, a tab bar, a progress
295 /// indicator) drives its own motion from. [`TransitionState::default`] (an
296 /// at-rest depth-0 snapshot) until a navigator attaches.
297 ///
298 /// Published on the **controller**, not the widget, deliberately: chrome that
299 /// wants to match page motion is a *sibling* of the navigator, not a
300 /// descendant, so it can never reach the widget — but it can hold a
301 /// controller clone, exactly as it already does to `push`.
302 ///
303 /// # The timing contract
304 ///
305 /// The progress driver advances in exactly one place — `paint`, off
306 /// `PaintCtx::frame_time` (the No-`Instant::now()` rule in
307 /// `docs/REVIEW_FOCUS.md` forbids any other clock in `frust-widgets`).
308 /// Therefore:
309 ///
310 /// - **A read during your own `Widget::paint`, from a widget painted *after*
311 /// the navigator, is exact for the current frame.** In a root
312 /// `Stack(vec![navigator_subtree, chrome])`, `StackWidget::paint` walks its
313 /// children in order, so `chrome` paints second and reads the value the
314 /// navigator wrote microseconds earlier **in the same frame**. This is the
315 /// frame-perfect path; it is the one to use for choreography.
316 /// - **A read during `Component::build` (or any `View::build`/`rebuild`) is
317 /// always exactly one frame stale** for `progress`, because build precedes
318 /// paint. Fine for "is a transition running?"; wrong for choreography.
319 /// - **The [`active`](TransitionState::active) edges are the exception.**
320 /// Both `start_transition` and `finalize_transition` publish from a
321 /// `BuildCtx` pass, so a build-time reader that builds *after* the navigator
322 /// sees `active` flip on the very frame it happens. Only intermediate
323 /// `progress` lags.
324 ///
325 /// The navigator already requests a frame for every frame a transition runs,
326 /// so a paint-time observer needs no wake of its own.
327 pub fn transition(&self) -> TransitionState {
328 self.transition.get()
329 }
330
331 /// The navigator's currently published route-state snapshot — a
332 /// clone, authoritative as of the last publish (see `route_state`'s
333 /// module docs' staleness contract, in particular the interactive-swipe
334 /// window).
335 pub fn route_stack(&self) -> RouteStack {
336 self.route_stack.borrow().clone()
337 }
338
339 /// The route stack's current generation — an O(1) change gate equivalent
340 /// to `route_stack().generation()` but without cloning the whole
341 /// snapshot.
342 pub fn route_generation(&self) -> u64 {
343 self.route_stack.borrow().generation()
344 }
345
346 /// Push an **opaque** page built by `builder` on top of the stack, using the
347 /// navigator's default transition (instant unless the navigator sets one).
348 ///
349 /// Every `push*`/`replace*` builder may return any [`View`]; it is erased
350 /// here.
351 ///
352 /// ```no_run
353 /// use frust_widgets::{NavigatorController, text};
354 /// let nav: NavigatorController<()> = NavigatorController::new();
355 /// nav.push(|| text("details"));
356 /// ```
357 pub fn push<V: View<State>>(&self, builder: impl Fn() -> V + 'static) {
358 self.push_impl(erase_page(builder), true, None, None);
359 }
360
361 /// Push an **opaque** page with an explicit [`TransitionSpec`], overriding the
362 /// navigator's default for this push only. The spec is stored on the pushed
363 /// page and *reversed* when it is later popped.
364 pub fn push_with<V: View<State>>(
365 &self,
366 builder: impl Fn() -> V + 'static,
367 transition: TransitionSpec,
368 ) {
369 self.push_impl(erase_page(builder), true, None, Some(transition));
370 }
371
372 /// Push a **transparent** page (e.g. a dialog/overlay) — the page below it
373 /// stays visible and painted (see [module docs](self)'s paint culling).
374 pub fn push_transparent<V: View<State>>(&self, builder: impl Fn() -> V + 'static) {
375 self.push_impl(erase_page(builder), false, None, None);
376 }
377
378 /// Push an opaque page and register `on_result`, invoked with `&mut State`
379 /// when *this* page is later popped (carrying the pop's [`PopResult`]).
380 ///
381 /// The callback is delivered at the start of the [`NavigatorWidget::event`]
382 /// pass after the pop's rebuild — the first point after the pop where the
383 /// erased app state is in scope (a rebuild carries only a `BuildCtx`).
384 pub fn push_for_result<V: View<State>>(
385 &self,
386 builder: impl Fn() -> V + 'static,
387 on_result: impl Fn(&mut State, PopResult) + 'static,
388 ) {
389 self.push_impl(erase_page(builder), true, Some(Rc::new(on_result)), None);
390 }
391
392 /// Push a **transparent** page (e.g. a dialog/bottom sheet) with an explicit
393 /// [`TransitionSpec`] (e.g. [`PageTransition::M3FadeThrough`] for a dialog,
394 /// [`PageTransition::SlideUp`] for a bottom sheet — a design system's own
395 /// dialog/sheet helpers are the shipped callers),
396 /// and register `on_result`, invoked with `&mut State` when *this* page is
397 /// later popped (carrying the pop's [`PopResult`]) — the modal-with-a-result
398 /// combination [`push_transparent`](Self::push_transparent) and
399 /// [`push_for_result`](Self::push_for_result) each cover only half of.
400 ///
401 /// ```ignore
402 /// // A confirm dialog that reports whether the user confirmed:
403 /// controller.push_transparent_for_result(
404 /// || dialog_view(),
405 /// TransitionSpec::duration(PageTransition::M3FadeThrough),
406 /// |state: &mut State, result: PopResult| {
407 /// state.confirmed = result.take::<bool>().unwrap_or(false);
408 /// },
409 /// );
410 /// ```
411 ///
412 /// The callback is delivered the same way [`push_for_result`](Self::push_for_result)'s
413 /// is: at the start of the [`NavigatorWidget::event`] pass after the pop's
414 /// rebuild.
415 pub fn push_transparent_for_result<V: View<State>>(
416 &self,
417 builder: impl Fn() -> V + 'static,
418 transition: TransitionSpec,
419 on_result: impl Fn(&mut State, PopResult) + 'static,
420 ) {
421 self.push_impl(
422 erase_page(builder),
423 false,
424 Some(Rc::new(on_result)),
425 Some(transition),
426 );
427 }
428
429 /// Push a page with an explicit [`PushOptions`] — the full-control variant
430 /// carrying a back-press [`BackPolicy`] (and, for a
431 /// [`DismissAnimated`](BackPolicy::DismissAnimated) overlay, its dismiss
432 /// signal) alongside opacity/transition/result. The dismissable-overlay
433 /// helpers push through this; every other `push*` method
434 /// pushes with [`BackPolicy::Pop`].
435 pub fn push_with_options<V: View<State>>(
436 &self,
437 builder: impl Fn() -> V + 'static,
438 options: PushOptions<State>,
439 ) {
440 self.enqueue(NavOp::Push {
441 builder: erase_page(builder),
442 opaque: options.opaque,
443 on_result: options.on_result,
444 transition: options.transition,
445 back: options.back,
446 dismiss_signal: options.dismiss_signal,
447 on_visibility: options.on_visibility,
448 route: options.route,
449 pop_swipe: options.pop_swipe,
450 });
451 }
452
453 /// Shared push-op construction every `push*` method above funnels through —
454 /// the five public variants differ only in which of `opaque`/`on_result`/
455 /// `transition` they fix vs. expose. All push with [`BackPolicy::Pop`] and
456 /// no dismiss signal, and no visibility observer; a page wanting any of those
457 /// uses [`push_with_options`](Self::push_with_options).
458 fn push_impl(
459 &self,
460 builder: PageBuilder<State>,
461 opaque: bool,
462 on_result: Option<ResultCallback<State>>,
463 transition: Option<TransitionSpec>,
464 ) {
465 self.enqueue(NavOp::Push {
466 builder,
467 opaque,
468 on_result,
469 transition,
470 back: BackPolicy::Pop,
471 dismiss_signal: None,
472 on_visibility: None,
473 route: None,
474 pop_swipe: None,
475 });
476 }
477
478 /// Pop the top page with no result payload (a plain back-navigation). A pop
479 /// of the last/root page is ignored (a navigator always keeps one page).
480 pub fn pop(&self) {
481 self.enqueue(NavOp::Pop {
482 result: PopResult::empty(),
483 });
484 }
485
486 /// Pop the top page, handing `result` to its pusher-registered
487 /// [`push_for_result`](Self::push_for_result) callback.
488 pub fn pop_with_result(&self, result: PopResult) {
489 self.enqueue(NavOp::Pop { result });
490 }
491
492 /// Route a back press through the top page's [`BackPolicy`] (the
493 /// entry the facade's back handler drives instead of a bare
494 /// [`pop`](Self::pop)):
495 ///
496 /// - [`Pop`](BackPolicy::Pop) → a normal pop (transitions preserved; a safe
497 /// no-op at the root);
498 /// - [`DismissAnimated`](BackPolicy::DismissAnimated) → fires the top page's
499 /// dismiss signal (stack unchanged; the overlay animates its own exit and
500 /// pops itself), see [`BackPolicy`]'s observation seam;
501 /// - [`Veto`](BackPolicy::Veto) → the press is consumed but nothing happens.
502 ///
503 /// Recorded like every other op and applied at the next rebuild (never
504 /// self-mutating mid-event). Whether this call *would* claim the press is
505 /// [`back_interest`](Self::back_interest).
506 pub fn request_back(&self) {
507 self.enqueue(NavOp::RequestBack);
508 }
509
510 /// Replace the top page in place with an opaque page built by `builder`,
511 /// using the navigator's default transition.
512 pub fn replace<V: View<State>>(&self, builder: impl Fn() -> V + 'static) {
513 self.enqueue(NavOp::Replace {
514 builder: erase_page(builder),
515 opaque: true,
516 transition: None,
517 route: None,
518 });
519 }
520
521 /// Replace the top page with an explicit [`TransitionSpec`], overriding the
522 /// navigator's default for this replace only.
523 pub fn replace_with<V: View<State>>(
524 &self,
525 builder: impl Fn() -> V + 'static,
526 transition: TransitionSpec,
527 ) {
528 self.enqueue(NavOp::Replace {
529 builder: erase_page(builder),
530 opaque: true,
531 transition: Some(transition),
532 route: None,
533 });
534 }
535
536 /// Replace the top page with an explicit [`ReplaceOptions`] — the
537 /// full-control variant carrying a route identity alongside opacity
538 /// and transition. [`Router::go`](super::router::Router::go)/
539 /// [`Router::replace`](super::router::Router::replace) push through this.
540 pub fn replace_with_options<V: View<State>>(
541 &self,
542 builder: impl Fn() -> V + 'static,
543 options: ReplaceOptions,
544 ) {
545 self.enqueue(NavOp::Replace {
546 builder: erase_page(builder),
547 opaque: options.opaque,
548 transition: options.transition,
549 route: options.route,
550 });
551 }
552
553 /// Record `op`, then raise [`frust_core::mark_pending_result_flush`] so the
554 /// next tick's mobile frame gate `Run`s and reaches the rebuild that drains
555 /// it — the same guarantee [`finalize_transition`](NavigatorWidget::finalize_transition)
556 /// already gives a pop-result callback, reused here for a different reason:
557 /// this flag is not just "a callback needs `&mut State`", it is the shell's
558 /// one thread-affine "something is owed, run the next frame regardless of
559 /// what else is dirty" side channel, and a queued op recorded from outside
560 /// any input/signal path (a `spawn_local` continuation, e.g.) needs exactly
561 /// that with no callback involved at all. Without it a page could mount and
562 /// sit unpainted until whatever input happened to arrive next forced a
563 /// frame — see the [module docs](self).
564 ///
565 /// Unconditional, on every op, deliberately, even though the shared flag's
566 /// other reader (`RenderRoot::rebuild`'s pending-flush convergence loop)
567 /// cannot tell "just force a run" apart from "a callback is genuinely
568 /// owed": the frame that applies a queued op also pays one extra, empty
569 /// build + view-diff pass it did not strictly need. That pass is
570 /// bounded (never more than one here, since nothing re-raises the flag for
571 /// a plain structural op) and lands only on the frame a nav op was actually
572 /// queued, not on every frame — a cost this crate's authoring toolkit
573 /// already treats as affordable (rebuilds are cheap by construction) and a
574 /// small, known price next to the bug it replaces: a page mounted with zero
575 /// frames painted for 15+ seconds.
576 fn enqueue(&self, op: NavOp<State>) {
577 self.ops.borrow_mut().push(op);
578 frust_core::mark_pending_result_flush();
579 }
580
581 /// Take the queued ops (leaving the queue empty). Called by
582 /// [`NavigatorView::rebuild`]/`build`.
583 pub(super) fn drain(&self) -> Vec<NavOp<State>> {
584 std::mem::take(&mut *self.ops.borrow_mut())
585 }
586}
587
588/// Erase a page builder at the API boundary: the public `push*`/`replace*`
589/// family (and the navigator/router entry points) accept a closure returning
590/// any [`View`] and store it as the erased [`PageBuilder`] the op queue holds.
591pub(super) fn erase_page<State: 'static, V: View<State>>(
592 builder: impl Fn() -> V + 'static,
593) -> PageBuilder<State> {
594 Rc::new(move || AnyView::new(builder()))
595}