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);