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}