Skip to main content

frust_widgets/nav/
view.rs

1//! [`NavigatorView`] — the declarative navigator descriptor — and its two
2//! constructors, [`navigator`] and [`overlay_host`].
3//!
4//! The `View` impl itself (build/rebuild/teardown, where the op queue is
5//! drained) stays with the widget core in [`navigator`](super::navigator).
6
7use std::rc::Rc;
8
9use frust_core::AnyView;
10
11use super::controller::NavigatorController;
12use super::options::{PageBuilder, PageVisibility, RouteChangeCallback, VisibilityCallback};
13use super::path::Location;
14use super::route_state::RouteStack;
15use super::transition::{PageTransition, TransitionSpec};
16
17// Named only by the doc comments moved here with this module's items, so their
18// intra-doc links keep resolving to the same targets they did in `navigator`.
19#[allow(unused_imports)]
20use super::navigator::NavigatorWidget;
21#[allow(unused_imports)]
22use super::options::{BackPolicy, PushOptions};
23#[allow(unused_imports)]
24use frust_core::{View, Widget};
25
26/// A declarative navigator. See the [module docs](self).
27pub struct NavigatorView<State: 'static> {
28    pub(super) controller: NavigatorController<State>,
29    pub(super) initial: PageBuilder<State>,
30    /// The transition applied to a push/replace that supplies no per-op override.
31    /// Defaults to [`TransitionSpec::NONE`] (instant switches).
32    pub(super) default_transition: TransitionSpec,
33    /// Explicit override for the interactive edge-swipe back gesture — the
34    /// highest-ranked slot in [`resolve_pop_swipe`](Self::resolve_pop_swipe)'s
35    /// resolution (page → navigator explicit → platform → preset). `None`
36    /// defers to [`platform_pop_swipe`](Self::platform_pop_swipe), and past
37    /// that to the default transition preset — on for
38    /// [`PageTransition::IosPush`], off otherwise.
39    pub(super) pop_swipe: Option<bool>,
40    /// **Facade-only** platform-derived default, ranked below an explicit
41    /// [`pop_swipe`](Self::pop_swipe) override and above the preset-derived
42    /// fallback. `frust-widgets` carries no `cfg(target_os)` of its own — this
43    /// is the plain setter the facade calls with `cfg!(target_os = "ios")`
44    /// (`crates/frust/src/lib.rs`'s `navigator`/`overlay_host` wrappers).
45    /// `None` until set.
46    pub(super) platform_pop_swipe: Option<bool>,
47    /// The ROOT page's [`PageVisibility`] observer (the root has no
48    /// [`PushOptions`] to carry one). Installed on the root page entry at
49    /// `build`, like the root's [`BackPolicy`] — not live-refreshed.
50    pub(super) root_visibility: Option<VisibilityCallback>,
51    /// Whether a [`Covered`](PageVisibility::Covered) page stops re-running its
52    /// builder. Default `false` — the shipped behaviour.
53    pub(super) cull_covered_builds: bool,
54    /// The ROOT page's route identity — the root has no [`PushOptions`]
55    /// to carry [`PushOptions::route`], so this mirrors
56    /// [`root_visibility`](Self::root_visibility)'s shape. Installed on the
57    /// root page entry at `build`, like the root's [`BackPolicy`].
58    pub(super) root_route: Option<Location>,
59    /// Navigator-wide route-change observer — see
60    /// [`on_route_change`](Self::on_route_change). Unlike `root_visibility`
61    /// this is refreshed on every rebuild (live-configurable, like
62    /// `default_transition`), since it observes the whole navigator rather
63    /// than one page.
64    pub(super) route_change: Option<RouteChangeCallback>,
65}
66
67impl<State: 'static> NavigatorView<State> {
68    /// Set the default page transition applied to every push/replace that does
69    /// not carry its own [`push_with`](NavigatorController::push_with)/
70    /// [`replace_with`](NavigatorController::replace_with) override.
71    pub fn transition(mut self, spec: TransitionSpec) -> Self {
72        self.default_transition = spec;
73        self
74    }
75
76    /// Explicitly enable or disable the interactive edge-swipe back gesture,
77    /// outranking both [`platform_pop_swipe`](Self::platform_pop_swipe) and the
78    /// preset-derived default (on for [`PageTransition::IosPush`], off
79    /// otherwise). The gesture pops the top page with a left-edge drag: drag
80    /// progress reverses the popped page's transition, and release completes
81    /// or cancels the pop by progress/velocity. A page may still narrow this
82    /// further with [`PushOptions::pop_swipe`] (the highest-ranked slot) or
83    /// refuse it outright via a non-[`Pop`](BackPolicy::Pop) `BackPolicy` (see
84    /// [`NavigatorWidget::swipe_armable`]).
85    pub fn pop_swipe(mut self, enabled: bool) -> Self {
86        self.pop_swipe = Some(enabled);
87        self
88    }
89
90    /// Set the platform-derived edge-swipe default — ranked below an explicit
91    /// [`pop_swipe`](Self::pop_swipe) override and above the preset-derived
92    /// fallback. `frust-widgets` carries no `cfg(target_os)` of its own; this
93    /// is the plain setter the facade calls (`crates/frust/src/lib.rs`) with
94    /// `cfg!(target_os = "ios")`, so an app using `frust::navigator` gets an
95    /// iOS-on / Android-and-desktop-off default with zero app-side wiring.
96    pub fn platform_pop_swipe(mut self, enabled: bool) -> Self {
97        self.platform_pop_swipe = Some(enabled);
98        self
99    }
100
101    /// Observe the **root** page's [`PageVisibility`] — the same seam
102    /// [`PushOptions::on_visibility`] gives a pushed page, for the one page that
103    /// has no `PushOptions`. Fired once with
104    /// [`Current`](PageVisibility::Current) on the navigator's first build, then
105    /// on every change (e.g. [`Covered`](PageVisibility::Covered) when an opaque
106    /// page is pushed over it).
107    ///
108    /// Read [`PushOptions::on_visibility`]'s doc for the full contract: no
109    /// `&mut State`, no duplicate values, and — importantly — **no `on_cleanup`
110    /// on cover**; the root page stays mounted with its widget state intact.
111    ///
112    /// The callback is captured at the navigator's first `build` (like the root
113    /// page's builder itself) and is not refreshed on later rebuilds.
114    pub fn on_root_visibility(mut self, f: impl Fn(PageVisibility) + 'static) -> Self {
115        self.root_visibility = Some(Rc::new(f));
116        self
117    }
118
119    /// Skip re-running the builder (and the child reconcile) for pages that are
120    /// [`Covered`](PageVisibility::Covered).
121    ///
122    /// **Default `false`** — the shipped behaviour, where every retained page
123    /// reconciles every frame whether covered or not. Making covered builds stop
124    /// is a real behaviour change (an app may rely on a covered page's builder
125    /// running against live state), so it is strictly opt-in.
126    ///
127    /// Two ordering rules hold when enabled, and neither needs a wake mechanism:
128    ///
129    /// - **A revealed page rebuilds in the same pass that revealed it.** Ops are
130    ///   applied — and a settled transition finalized — *before* the per-page
131    ///   reconcile loop in [`NavigatorView::rebuild`], so by the time the loop
132    ///   asks [`visibility_of`](NavigatorWidget::visibility_of) the revealed page
133    ///   is no longer `Covered`. There is no cross-frame gap to bridge.
134    /// - **The frame on which a page *becomes* `Covered` still rebuilds it.** The
135    ///   cull decision reads the page's visibility as of the *previous* reconcile,
136    ///   so a page gets exactly one final reconcile after its
137    ///   `on_visibility(Covered)` fires — a page staging teardown UI on cover can
138    ///   still render it.
139    pub fn cull_covered_builds(mut self, enabled: bool) -> Self {
140        self.cull_covered_builds = enabled;
141        self
142    }
143
144    /// Stamp the ROOT page's route identity — see
145    /// [`PushOptions::route`], the pushed-page equivalent.
146    pub fn root_route(mut self, location: Location) -> Self {
147        self.root_route = Some(location);
148        self
149    }
150
151    /// Observe this navigator's route-state, navigator-wide: fired from
152    /// [`NavigatorWidget::publish_state`] only when the published
153    /// [`RouteStack`] actually changes (an unchanged stack across N rebuilds
154    /// fires it zero times) — never with `&mut State` (it fires from a
155    /// rebuild), exactly like [`PushOptions::on_visibility`]'s contract. The
156    /// facade's reactive route-observer bridges this into signals (see
157    /// `docs/WIDGETS_ARCHITECTURE.md`'s reactive-free note): capture a plain
158    /// `Rc<Cell<_>>` here, don't reach for app state.
159    pub fn on_route_change(mut self, f: impl Fn(&RouteStack) + 'static) -> Self {
160        self.route_change = Some(Rc::new(f));
161        self
162    }
163
164    /// Resolve the navigator-wide edge-swipe default: the explicit override if
165    /// set, else [`platform_pop_swipe`](Self::platform_pop_swipe) if set, else
166    /// on for the iOS-push preset (the transition the swipe is designed around)
167    /// and off for every other default. A page's own
168    /// [`PushOptions::pop_swipe`] outranks this at the arm site — see
169    /// [`NavigatorWidget::swipe_armable`], which is what `event_at` actually
170    /// consults.
171    pub(super) fn resolve_pop_swipe(&self) -> bool {
172        self.pop_swipe
173            .or(self.platform_pop_swipe)
174            .unwrap_or(self.default_transition.preset == PageTransition::IosPush)
175    }
176}
177
178/// Build a [`NavigatorView`] driven by `controller`, whose initial (root) page is
179/// produced by `initial`. The app-facing entry point (see [module docs](self)).
180pub fn navigator<State: 'static>(
181    controller: &NavigatorController<State>,
182    initial: impl Fn() -> AnyView<State> + 'static,
183) -> NavigatorView<State> {
184    NavigatorView {
185        controller: controller.clone(),
186        initial: Rc::new(initial),
187        default_transition: TransitionSpec::NONE,
188        pop_swipe: None,
189        platform_pop_swipe: None,
190        root_visibility: None,
191        cull_covered_builds: false,
192        root_route: None,
193        route_change: None,
194    }
195}
196
197/// The **root overlay host**: a navigator whose root page is the whole app —
198/// chrome, tab shell, inner navigator and all — and whose pushed pages are the
199/// app's modals. Because the host sits *above* every piece of chrome, an overlay
200/// pushed here dims and blocks chrome that an overlay on an inner navigator
201/// cannot reach.
202///
203/// It is a [`navigator`] with two defaults changed and nothing else:
204///
205/// * **[`pop_swipe(false)`](NavigatorView::pop_swipe)** — an edge swipe must
206///   never dismiss an overlay.
207/// * **[`TransitionSpec::NONE`]** — each overlay widget stages its *own*
208///   enter/exit (the contract the dialog/sheet catalogs already rely on), so the
209///   host must not animate the page swap underneath them.
210///
211/// Everything else is the ordinary navigator, deliberately: dismiss-signal
212/// routing, [`PushOptions`]/[`BackPolicy`], per-overlay
213/// [`on_result`](PushOptions::on_result), the keyboard drop on a page switch,
214/// and the capture-cancel + focus-clear are inherited rather than re-invented.
215///
216/// ```no_run
217/// # use frust_widgets::{NavigatorController, overlay_host, text};
218/// # use frust_core::any;
219/// # let controller: NavigatorController<()> = NavigatorController::new();
220/// # let app_root = || any(text("the whole app: chrome, tabs, inner navigator"));
221/// // Wrap the app's existing root view; nothing inside it changes.
222/// let root = overlay_host(&controller, move || app_root());
223/// # let _ = root;
224/// ```
225///
226/// # The host owns no scrim
227///
228/// The per-page-paints-its-own-scrim convention is preserved verbatim: the host
229/// is purely structural and paints nothing of its own. An overlay page already
230/// fills `ctx.origin()..ctx.size()` with its scrim, and at the root that rect
231/// *is* the window — so the catalogs' dialogs and sheets need no change to dim
232/// the whole app.
233///
234/// # Chrome inertness is not new code
235///
236/// [`NavigatorWidget::event`](Widget::event) routes to
237/// [`input_routed_pages`](NavigatorWidget::input_routed_pages) — the top page
238/// only — so with an overlay up the entire app root, chrome included, receives
239/// nothing. The accessibility tree says the same thing through the same
240/// function under rule R23 (see [`semantics`](Widget::semantics)), so there is
241/// no second reachability path to keep in sync.
242///
243/// # Back arbitration
244///
245/// With a root host *and* an inner navigator there are two back registrants. The
246/// facade's back glue (`frust::back_glue`) routes a press to the first
247/// registrant claiming [`back_interest`](NavigatorController::back_interest),
248/// ranked host-first and then innermost-first among plain navigators — so a
249/// press with a root overlay open reaches the host, and a press with none open
250/// falls through to the inner navigator (a host at depth 1 with the default
251/// [`BackPolicy::Pop`] claims nothing). An open overlay additionally silences
252/// the inner navigator outright: the host's root page — the whole app — is no
253/// longer input-routed, so under the R23 back-reach gate (see the module docs)
254/// nothing inside it claims the press either.
255pub fn overlay_host<State: 'static>(
256    controller: &NavigatorController<State>,
257    app: impl Fn() -> AnyView<State> + 'static,
258) -> NavigatorView<State> {
259    navigator(controller, app)
260        // Both are stated explicitly rather than left to the `navigator`
261        // defaults: they are the host's *contract*, not a coincidence of what
262        // `navigator` happens to default to.
263        .pop_swipe(false)
264        .transition(TransitionSpec::NONE)
265}