Skip to main content

frust_widgets/nav/
options.rs

1//! The navigator's option and vocabulary types: the page/callback type
2//! aliases, [`PageVisibility`], [`PopResult`], [`BackPolicy`], [`PushOptions`],
3//! [`ReplaceOptions`], the queued [`NavOp`], and [`NavigatorId`].
4//!
5//! [`navigator`](super::navigator) re-exports every public name here, so the
6//! `nav::navigator::*` paths callers already use keep resolving.
7
8use std::any::Any;
9use std::cell::Cell;
10use std::rc::Rc;
11
12use frust_core::AnyView;
13
14use super::path::Location;
15use super::route_state::RouteStack;
16use super::transition::TransitionSpec;
17
18// Named only by the doc comments moved here with this module's items, so their
19// intra-doc links keep resolving to the same targets they did in `navigator`.
20#[allow(unused_imports)]
21use super::controller::NavigatorController;
22#[allow(unused_imports)]
23use super::navigator::NavigatorWidget;
24#[allow(unused_imports)]
25use super::view::NavigatorView;
26
27/// A page builder: a cheap closure that produces the page's view, re-run every
28/// rebuild so a retained page's content still reconciles against live app state
29/// (the pod, and thus the page's internal widget state, is preserved across the
30/// rebuild — only the view descriptor is rebuilt).
31pub type PageBuilder<State> = Rc<dyn Fn() -> AnyView<State>>;
32
33/// A pusher-registered result callback: invoked with `&mut State` when the page it
34/// was registered against is popped, carrying the [`PopResult`] the pop supplied.
35pub type ResultCallback<State> = Rc<dyn Fn(&mut State, PopResult)>;
36
37/// Where a retained page sits in the stack right now — the vocabulary
38/// [`PushOptions::on_visibility`]/[`NavigatorView::on_root_visibility`] report.
39///
40/// Derived from exactly the state layout and paint already cull against (see
41/// [`NavigatorWidget::visibility_of`]); there is deliberately no second notion of
42/// "visible" anywhere in the navigator.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
44pub enum PageVisibility {
45    /// Topmost: laid out, painted, and the ONLY page routed input.
46    #[default]
47    Current,
48    /// Painted (a transparent page — a dialog/sheet/palette — sits above it) but
49    /// routed no input.
50    Visible,
51    /// Fully covered by an opaque page: retained (its widget state survives), but
52    /// neither laid out nor painted.
53    Covered,
54}
55
56/// A page-visibility observer, registered per page via
57/// [`PushOptions::on_visibility`] (or [`NavigatorView::on_root_visibility`] for
58/// the root page). Fired by the navigator from a rebuild whenever the page's
59/// [`PageVisibility`] changes — never twice with the same value.
60pub type VisibilityCallback = Rc<dyn Fn(PageVisibility)>;
61
62/// A route-change observer, registered navigator-wide via
63/// [`NavigatorView::on_route_change`]. Fired from [`NavigatorWidget::publish_state`]
64/// only when the published [`RouteStack`] actually changed — see
65/// `route_state`'s module docs.
66pub type RouteChangeCallback = Rc<dyn Fn(&RouteStack)>;
67
68/// The value a [`pop`](NavigatorController::pop_with_result) hands back to the
69/// pusher's [`ResultCallback`], type-erased so a page can return any `'static`
70/// payload (mirroring Flutter's `Navigator.pop(result)` → `push(...).then(...)`).
71///
72/// Empty by default ([`PopResult::empty`]); recover a typed payload with
73/// [`PopResult::take`].
74pub struct PopResult(Option<Box<dyn Any>>);
75
76impl PopResult {
77    /// A result carrying no payload (a plain back-navigation).
78    pub fn empty() -> Self {
79        PopResult(None)
80    }
81
82    /// A result carrying `value`, recoverable by the pusher with
83    /// [`PopResult::take`].
84    pub fn of<T: Any>(value: T) -> Self {
85        PopResult(Some(Box::new(value)))
86    }
87
88    /// Whether this result carries no payload.
89    pub fn is_empty(&self) -> bool {
90        self.0.is_none()
91    }
92
93    /// Recover the payload as `T`, consuming the result. `None` if the result was
94    /// empty or carries a different concrete type.
95    pub fn take<T: Any>(self) -> Option<T> {
96        self.0
97            .and_then(|boxed| boxed.downcast::<T>().ok())
98            .map(|boxed| *boxed)
99    }
100}
101
102impl std::fmt::Debug for PopResult {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("PopResult")
105            .field("has_payload", &self.0.is_some())
106            .finish()
107    }
108}
109
110/// How a page participates in a back press routed through
111/// [`NavigatorController::request_back`] (Android hardware/gesture back, via
112/// the facade's back-press wiring). A page declares its policy when pushed via
113/// [`PushOptions::back`]; every existing push defaults to [`Pop`](Self::Pop).
114///
115/// This is *internal* routing vocabulary — the user-facing overlay builder is
116/// `dismissable(bool)`, which maps `true → DismissAnimated` and
117/// `false → Veto` when the overlay pushes its transparent page.
118///
119/// # The DismissAnimated observation seam
120///
121/// A [`DismissAnimated`](Self::DismissAnimated) page does *not* pop on the back
122/// press itself; instead the navigator increments the page's
123/// [`dismiss_signal`](PushOptions::dismiss_signal) generation counter, which the
124/// page's own widget subtree observes (comparing the shared `Rc<Cell<u64>>`
125/// against a last-seen value on its next paint/event) and turns into its own
126/// `begin_exit` staging — the overlay then pops *itself* on exit completion via
127/// its existing on-close path. This keeps `frust-widgets` reactive-free (a plain
128/// shared cell, mirroring [`NavigatorController::depth`]'s
129/// `Rc<Cell<usize>>`) — no `frust-reactive` dependency crosses into this crate.
130/// An overlay helper (`show_command_palette`/`show_dialog`/`show_bottom_sheet`)
131/// wires the seam by creating one cell, handing a clone to the overlay widget it
132/// builds *and* into [`PushOptions::dismiss_signal`].
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum BackPolicy {
135    /// The default: a back press pops this page (the normal
136    /// [`pop`](NavigatorController::pop) path, transitions preserved). A back
137    /// press at the root (this being the only page) is a safe no-op.
138    Pop,
139    /// A dismissable overlay: a back press fires the page's
140    /// [`dismiss_signal`](PushOptions::dismiss_signal) (see the seam above)
141    /// rather than popping — the stack is unchanged immediately, and the overlay
142    /// animates its own exit before popping itself.
143    DismissAnimated,
144    /// A non-dismissable overlay (a modal barrier): a back press is *consumed*
145    /// (the page claims it, so it never bubbles to the platform) but does
146    /// nothing — the stack is unchanged and no signal fires.
147    Veto,
148}
149
150/// Options for [`NavigatorController::push_with_options`], carrying a pushed
151/// page's opacity, back-press [`BackPolicy`], optional per-op transition
152/// override, optional result callback, and (for a
153/// [`DismissAnimated`](BackPolicy::DismissAnimated) overlay) the shared
154/// dismiss-signal cell the navigator bumps on a back request.
155///
156/// Construct with [`opaque`](Self::opaque)/[`transparent`](Self::transparent),
157/// then chain the builder setters. The existing `push*` methods are unchanged —
158/// they push with [`BackPolicy::Pop`] and no dismiss signal.
159pub struct PushOptions<State: 'static> {
160    pub(super) opaque: bool,
161    pub(super) back: BackPolicy,
162    pub(super) transition: Option<TransitionSpec>,
163    pub(super) on_result: Option<ResultCallback<State>>,
164    pub(super) dismiss_signal: Option<Rc<Cell<u64>>>,
165    pub(super) on_visibility: Option<VisibilityCallback>,
166    pub(super) route: Option<Location>,
167    pub(super) pop_swipe: Option<bool>,
168}
169
170impl<State: 'static> PushOptions<State> {
171    /// Options for an **opaque** page (the page below is culled while covered).
172    /// Defaults: [`BackPolicy::Pop`], navigator-default transition, no result
173    /// callback, no dismiss signal.
174    pub fn opaque() -> Self {
175        Self {
176            opaque: true,
177            back: BackPolicy::Pop,
178            transition: None,
179            on_result: None,
180            dismiss_signal: None,
181            on_visibility: None,
182            route: None,
183            pop_swipe: None,
184        }
185    }
186
187    /// Options for a **transparent** page (e.g. a dialog/sheet/palette overlay —
188    /// the page below stays visible). Same defaults as [`opaque`](Self::opaque)
189    /// otherwise.
190    pub fn transparent() -> Self {
191        Self {
192            opaque: false,
193            ..Self::opaque()
194        }
195    }
196
197    /// Set the page's back-press [`BackPolicy`] (default [`BackPolicy::Pop`]).
198    pub fn back(mut self, policy: BackPolicy) -> Self {
199        self.back = policy;
200        self
201    }
202
203    /// Override the navigator's default transition for this push only.
204    pub fn transition(mut self, spec: TransitionSpec) -> Self {
205        self.transition = Some(spec);
206        self
207    }
208
209    /// Register a result callback invoked with `&mut State` when this page is
210    /// later popped (carrying the pop's [`PopResult`]) — the same delivery the
211    /// [`push_for_result`](NavigatorController::push_for_result) path uses.
212    pub fn on_result(mut self, callback: impl Fn(&mut State, PopResult) + 'static) -> Self {
213        self.on_result = Some(Rc::new(callback));
214        self
215    }
216
217    /// Supply the shared generation cell the navigator increments when a
218    /// [`DismissAnimated`](BackPolicy::DismissAnimated) back press routes to this
219    /// page (see [`BackPolicy`]'s observation seam). Ignored for the other
220    /// policies.
221    pub fn dismiss_signal(mut self, signal: Rc<Cell<u64>>) -> Self {
222        self.dismiss_signal = Some(signal);
223        self
224    }
225
226    /// Observe this page's [`PageVisibility`]: fired once at push (with
227    /// [`Current`](PageVisibility::Current)) and on every subsequent change,
228    /// **never twice with the same value**. This is the seam a screen
229    /// pauses/resumes polling, a timer, or a camera session from.
230    ///
231    /// # Why it is a callback, not a published cell
232    ///
233    /// A covered page is neither painted nor (under
234    /// [`NavigatorView::cull_covered_builds`]) rebuilt, so it has **no pass in
235    /// which to poll** anything. The seam therefore has to push. It is shaped
236    /// exactly like [`on_result`](Self::on_result)/
237    /// [`dismiss_signal`](Self::dismiss_signal): a plain `Rc<dyn Fn>` slot, no
238    /// signal — `frust-widgets` is reactive-free.
239    ///
240    /// # No `&mut State`
241    ///
242    /// The callback receives **no** `&mut State`, unlike
243    /// [`on_result`](Self::on_result). It fires from a rebuild (a `BuildCtx`),
244    /// which carries no erased app state, and it fires *synchronously* there:
245    /// nothing is queued, so a covered page learns it is covered on the frame it
246    /// happens. Capture what you need (a signal, an `Rc<Cell<_>>`, a controller
247    /// handle) in the closure instead.
248    ///
249    /// (`on_result` does defer — it needs `&mut State` — but it is no longer
250    /// waiting on user input to be delivered: the rebuild that queues it also
251    /// dispatches the `InputEvent::Housekeeping` broadcast that flushes it, so
252    /// both seams now land on the same frame. See the [module docs](self).)
253    ///
254    /// # "No cleanup on cover" is the contract, not a bug
255    ///
256    /// A covering push does **not** fire the page's `on_cleanup` and must never
257    /// start to: the page stays mounted so its widget state survives the cover
258    /// (the retained-page-state guarantee the whole navigator rests on — see the
259    /// [module docs](self)' paint-culling section). This seam exists precisely so
260    /// a page can release its *own* resources on
261    /// [`Covered`](PageVisibility::Covered) and re-acquire them on
262    /// [`Current`](PageVisibility::Current), without the navigator disposing
263    /// anything.
264    ///
265    /// Calling back into the [`NavigatorController`] from here is safe: a
266    /// `push`/`pop` only *records* an op, drained at the next rebuild.
267    pub fn on_visibility(mut self, f: impl Fn(PageVisibility) + 'static) -> Self {
268        self.on_visibility = Some(Rc::new(f));
269        self
270    }
271
272    /// Stamp this page's route identity (`R-B1`): the [`Location`] it was
273    /// pushed with, published on the navigator's [`RouteStack`] and never
274    /// derived from depth. Omit for a page pushed as a bare builder (an
275    /// overlay/dialog) — it then publishes a `None` entry, which
276    /// [`RouteStack::current_route`] skips.
277    pub fn route(mut self, location: Location) -> Self {
278        self.route = Some(location);
279        self
280    }
281
282    /// Override this page's edge-swipe eligibility, ranked above every other
283    /// gesture-policy slot: page → navigator explicit
284    /// ([`NavigatorView::pop_swipe`]) → platform
285    /// ([`NavigatorView::platform_pop_swipe`]) → preset-derived default. Still
286    /// subject to [`BackPolicy`]: a [`DismissAnimated`](BackPolicy::DismissAnimated)
287    /// or [`Veto`](BackPolicy::Veto) page never arms the gesture regardless of
288    /// this override (see [`NavigatorWidget::swipe_armable`]).
289    pub fn pop_swipe(mut self, enabled: bool) -> Self {
290        self.pop_swipe = Some(enabled);
291        self
292    }
293}
294
295/// Options for [`NavigatorController::replace_with_options`] — opacity, an
296/// optional per-op transition override, and the route identity attached
297/// to the replacement page. A smaller sibling of [`PushOptions`]: a replaced
298/// page has no pusher-side use for a result callback, a `BackPolicy`, a
299/// dismiss signal, or a visibility observer, since it does not sit *under*
300/// anything it could be dismissed back to.
301pub struct ReplaceOptions {
302    pub(super) opaque: bool,
303    pub(super) transition: Option<TransitionSpec>,
304    pub(super) route: Option<Location>,
305}
306
307impl ReplaceOptions {
308    /// Options for an **opaque** replacement page (the shipped
309    /// [`NavigatorController::replace`] default).
310    pub fn opaque() -> Self {
311        Self {
312            opaque: true,
313            transition: None,
314            route: None,
315        }
316    }
317
318    /// Options for a **transparent** replacement page.
319    pub fn transparent() -> Self {
320        Self {
321            opaque: false,
322            ..Self::opaque()
323        }
324    }
325
326    /// Override the navigator's default transition for this replace only.
327    pub fn transition(mut self, spec: TransitionSpec) -> Self {
328        self.transition = Some(spec);
329        self
330    }
331
332    /// Stamp the replacement page's route identity — see
333    /// [`PushOptions::route`].
334    pub fn route(mut self, location: Location) -> Self {
335        self.route = Some(location);
336        self
337    }
338}
339
340/// One queued navigation op, recorded by the [`NavigatorController`] and drained
341/// (in order) by [`NavigatorView::rebuild`].
342pub(super) enum NavOp<State: 'static> {
343    /// Push a new page on top of the stack.
344    Push {
345        builder: PageBuilder<State>,
346        opaque: bool,
347        on_result: Option<ResultCallback<State>>,
348        /// Per-op transition override (`None` → the navigator's default).
349        transition: Option<TransitionSpec>,
350        /// How a back press treats this page (default [`BackPolicy::Pop`]).
351        back: BackPolicy,
352        /// The shared generation cell bumped on a
353        /// [`DismissAnimated`](BackPolicy::DismissAnimated) back press.
354        dismiss_signal: Option<Rc<Cell<u64>>>,
355        /// The page-visibility observer registered by
356        /// [`PushOptions::on_visibility`], if any.
357        on_visibility: Option<VisibilityCallback>,
358        /// The route identity from [`PushOptions::route`], `None` for a
359        /// bare-builder overlay/dialog push.
360        route: Option<Location>,
361        /// The per-route edge-swipe override from [`PushOptions::pop_swipe`],
362        /// `None` for every `push*` method other than
363        /// [`push_with_options`](NavigatorController::push_with_options).
364        pop_swipe: Option<bool>,
365    },
366    /// Pop the top page (never the last/root page), delivering `result` to the
367    /// popped page's pusher-registered callback. A pop *reverses* the popped
368    /// page's own stored transition (no override slot).
369    Pop { result: PopResult },
370    /// Route a back press through the top page's [`BackPolicy`]:
371    /// [`Pop`](BackPolicy::Pop) pops, [`DismissAnimated`](BackPolicy::DismissAnimated)
372    /// fires the page's dismiss signal, [`Veto`](BackPolicy::Veto) consumes it.
373    RequestBack,
374    /// Replace the top page in place.
375    Replace {
376        builder: PageBuilder<State>,
377        opaque: bool,
378        /// Per-op transition override (`None` → the navigator's default).
379        transition: Option<TransitionSpec>,
380        /// The route identity from [`ReplaceOptions::route`], `None` for
381        /// a plain [`NavigatorController::replace`]/`replace_with`.
382        route: Option<Location>,
383    },
384}
385
386/// An opaque identity for the navigator a [`NavigatorController`] drives: every
387/// clone of one controller reports the same value, and two independently
388/// constructed controllers never do.
389///
390/// The seam a *multi-navigator* registry keys on — the facade's back-press
391/// arbitration (rule **R44-back**) holds one entry per registered controller and
392/// needs to tell "this controller again" from "a second controller", which it
393/// cannot do through the op queue or the published cells.
394///
395/// **Uniqueness holds among *live* controllers only.** The value is derived from
396/// the address of the shared op queue, so a holder that wants the identity to
397/// stay meaningful must keep a controller clone alive alongside it (as the
398/// facade's registry does) — otherwise a freed allocation could be reused and
399/// two ids collide.
400#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
401pub struct NavigatorId(pub(super) usize);