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}