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::View;
10
11use super::controller::{NavigatorController, erase_page};
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::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)).
180///
181/// `initial` may return any [`View`]; it is erased here.
182///
183/// ```no_run
184/// # use frust_widgets::{NavigatorController, navigator, text};
185/// # let controller: NavigatorController<()> = NavigatorController::new();
186/// let nav = navigator(&controller, || text("home"));
187/// # let _ = nav;
188/// ```
189pub fn navigator<State: 'static, V: View<State>>(
190    controller: &NavigatorController<State>,
191    initial: impl Fn() -> V + 'static,
192) -> NavigatorView<State> {
193    NavigatorView {
194        controller: controller.clone(),
195        initial: erase_page(initial),
196        default_transition: TransitionSpec::NONE,
197        pop_swipe: None,
198        platform_pop_swipe: None,
199        root_visibility: None,
200        cull_covered_builds: false,
201        root_route: None,
202        route_change: None,
203    }
204}
205
206/// The **root overlay host**: a navigator whose root page is the whole app —
207/// chrome, tab shell, inner navigator and all — and whose pushed pages are the
208/// app's modals. Because the host sits *above* every piece of chrome, an overlay
209/// pushed here dims and blocks chrome that an overlay on an inner navigator
210/// cannot reach.
211///
212/// It is a [`navigator`] with two defaults changed and nothing else:
213///
214/// * **[`pop_swipe(false)`](NavigatorView::pop_swipe)** — an edge swipe must
215///   never dismiss an overlay.
216/// * **[`TransitionSpec::NONE`]** — each overlay widget stages its *own*
217///   enter/exit (the contract the dialog/sheet catalogs already rely on), so the
218///   host must not animate the page swap underneath them.
219///
220/// Everything else is the ordinary navigator, deliberately: dismiss-signal
221/// routing, [`PushOptions`]/[`BackPolicy`], per-overlay
222/// [`on_result`](PushOptions::on_result), the keyboard drop on a page switch,
223/// and the capture-cancel + focus-clear are inherited rather than re-invented.
224///
225/// ```no_run
226/// # use frust_widgets::{NavigatorController, overlay_host, text};
227/// # let controller: NavigatorController<()> = NavigatorController::new();
228/// # let app_root = || text("the whole app: chrome, tabs, inner navigator");
229/// // Wrap the app's existing root view; nothing inside it changes.
230/// let root = overlay_host(&controller, move || app_root());
231/// # let _ = root;
232/// ```
233///
234/// # The host owns no scrim
235///
236/// The per-page-paints-its-own-scrim convention is preserved verbatim: the host
237/// is purely structural and paints nothing of its own. An overlay page already
238/// fills `ctx.origin()..ctx.size()` with its scrim, and at the root that rect
239/// *is* the window — so the catalogs' dialogs and sheets need no change to dim
240/// the whole app.
241///
242/// # Chrome inertness is not new code
243///
244/// [`NavigatorWidget::event`](Widget::event) routes to
245/// [`input_routed_pages`](NavigatorWidget::input_routed_pages) — the top page
246/// only — so with an overlay up the entire app root, chrome included, receives
247/// nothing. The accessibility tree says the same thing through the same
248/// function under rule R23 (see [`semantics`](Widget::semantics)), so there is
249/// no second reachability path to keep in sync.
250///
251/// # Back arbitration
252///
253/// With a root host *and* an inner navigator there are two back registrants. The
254/// facade's back glue (`frust::back_glue`) routes a press to the first
255/// registrant claiming [`back_interest`](NavigatorController::back_interest),
256/// ranked host-first and then innermost-first among plain navigators — so a
257/// press with a root overlay open reaches the host, and a press with none open
258/// falls through to the inner navigator (a host at depth 1 with the default
259/// [`BackPolicy::Pop`] claims nothing). An open overlay additionally silences
260/// the inner navigator outright: the host's root page — the whole app — is no
261/// longer input-routed, so under the R23 back-reach gate (see the module docs)
262/// nothing inside it claims the press either.
263pub fn overlay_host<State: 'static, V: View<State>>(
264    controller: &NavigatorController<State>,
265    app: impl Fn() -> V + 'static,
266) -> NavigatorView<State> {
267    navigator(controller, app)
268        // Both are stated explicitly rather than left to the `navigator`
269        // defaults: they are the host's *contract*, not a coincidence of what
270        // `navigator` happens to default to.
271        .pop_swipe(false)
272        .transition(TransitionSpec::NONE)
273}