Skip to main content

frust/
route_state.rs

1//! The facade's reactive route-state observable: the signal face over
2//! `frust_widgets`' signal-free [`RouteStack`]/[`NavChange`], riding
3//! `provide_context` exactly like [`RouteNavigator`] does — the difference is
4//! that `RouteNavigator` carries *intent* (queued, undrained requests) while
5//! [`RouteObserver`] carries *fact* (the last-published stack), the same split
6//! `route_state`'s (`frust_widgets::nav::route_state`) own module docs draw.
7//!
8//! This is the one place the facade sees both `frust_widgets`'
9//! [`NavigatorView::on_route_change`] and `frust-reactive`'s `RwSignal`
10//! together — `frust-widgets` stays reactive-free (see
11//! `docs/WIDGETS_ARCHITECTURE.md`'s Reactive-Free Design section), so the
12//! bridge lives here, wired from [`RouterDeepLinks`](crate::RouterDeepLinks)
13//! alongside its existing deep-link waker.
14
15use frust_reactive::RwSignal;
16use frust_widgets::{Location, NavChange, NavigatorView, RouteStack};
17use reactive_graph::traits::{Get, Set};
18
19/// Reactive face of one navigator's route stack: `Copy + Send + Sync`
20/// (five `RwSignal`s and nothing else), so it rides `provide_context` — the
21/// navigator's own [`NavigatorController`](frust_widgets::NavigatorController)/
22/// [`Router`](frust_widgets::Router) never can (see
23/// `frust_widgets::nav::route`'s module docs for why).
24#[derive(Clone, Copy)]
25pub struct RouteObserver {
26    current: RwSignal<Option<Location>>,
27    stack: RwSignal<Vec<Option<Location>>>,
28    depth: RwSignal<usize>,
29    change: RwSignal<NavChange>,
30    generation: RwSignal<u64>,
31}
32
33/// The seam's whole point, pinned at compile time: if `RouteObserver` ever
34/// stops being `Send + Sync + 'static` it can no longer ride
35/// `provide_context`, and this becomes a compile error rather than a review
36/// finding — mirrors `RouteNavigator`'s own assertion
37/// (`frust_widgets::nav::route`).
38const _: fn() = || {
39    fn assert<T: Send + Sync + 'static>() {}
40    assert::<RouteObserver>();
41};
42
43impl RouteObserver {
44    /// A fresh observer at rest (`NavChange::Initial`, depth 0, generation 0,
45    /// no current route) — construct once, typically in `Component::init`,
46    /// and keep the result in `State`.
47    pub fn new() -> Self {
48        Self {
49            current: RwSignal::new(None),
50            stack: RwSignal::new(Vec::new()),
51            depth: RwSignal::new(0),
52            change: RwSignal::new(NavChange::default()),
53            generation: RwSignal::new(0),
54        }
55    }
56
57    /// Attach: installs [`NavigatorView::on_route_change`], writing these
58    /// signals from inside the navigator's own rebuild whenever the published
59    /// [`RouteStack`] actually changes. Chainable and cheap to call again
60    /// (`RouteObserver` is `Copy`), so a second navigator elsewhere in the app
61    /// gets its own observer the same way:
62    /// `state.routes.observe(navigator(&controller, initial))`.
63    ///
64    /// # Wake correctness
65    ///
66    /// The write happens inside the navigator's rebuild, *after* `apply_ops`:
67    /// a navigation requested in frame N is published in N, read by the app's
68    /// `build` in N+1, and the write itself schedules N+1 — the same
69    /// tracked-write-wakes-the-shell bridge every other signal in the
70    /// framework rides (`docs/CODE_STANDARDS.md`'s tracked-scope rule), not a
71    /// manual `ReactiveRuntime::wake()` call. One frame behind is possible;
72    /// *silently* behind is not (a page transition runs ~300ms; 16ms of bar
73    /// lag is below the perceptual floor).
74    pub fn observe<State: 'static>(self, view: NavigatorView<State>) -> NavigatorView<State> {
75        view.on_route_change(move |stack: &RouteStack| {
76            self.current.set(stack.current_route().cloned());
77            self.stack.set(stack.entries().to_vec());
78            self.depth.set(stack.depth());
79            self.change.set(stack.change());
80            self.generation.set(stack.generation());
81        })
82    }
83
84    /// The topmost page that **has** a route — skips a route-less
85    /// (overlay/dialog) page on top, the read chrome (an app bar title, e.g.)
86    /// wants. Tracked read; see [`RouteStack::current_route`]'s doc for the
87    /// full contract.
88    pub fn current(&self) -> Option<Location> {
89        self.current.get()
90    }
91
92    /// [`current`](Self::current)'s path, or an empty string before the first
93    /// publish (or if no page in the stack carries a route at all).
94    pub fn path(&self) -> String {
95        self.current().map(|loc| loc.path).unwrap_or_default()
96    }
97
98    /// A query parameter off [`current`](Self::current)'s location. `None` if
99    /// there is no current routed page, or the key is absent.
100    pub fn param(&self, key: &str) -> Option<String> {
101        self.current().and_then(|loc| loc.query.get(key).cloned())
102    }
103
104    /// The full stack, bottom→top; `None` at a route-less (overlay/dialog)
105    /// page — the tracked-read mirror of [`RouteStack::entries`].
106    pub fn stack(&self) -> Vec<Option<Location>> {
107        self.stack.get()
108    }
109
110    /// The stack depth as of the last publish.
111    pub fn depth(&self) -> usize {
112        self.depth.get()
113    }
114
115    /// The diff label for the most recent publish — see [`NavChange`].
116    pub fn change(&self) -> NavChange {
117        self.change.get()
118    }
119
120    /// Whether the most recent change was a [`NavChange::Pop`] — the
121    /// direction chrome resolves a title-transition from, e.g.
122    /// `.title_direction(if routes.is_back() { Back } else { Forward })`.
123    pub fn is_back(&self) -> bool {
124        self.change() == NavChange::Pop
125    }
126}
127
128impl Default for RouteObserver {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use frust_core::{BuildCtx, ChangeFlags, FrameTime, PaintScene, RenderRoot, View};
138    use frust_reactive::ReactiveRuntime;
139    use frust_widgets::{NavigatorController, NavigatorView, PushOptions, navigator};
140    use kurbo::Size;
141    use std::sync::Arc;
142
143    struct SizedLeaf {
144        size: Size,
145    }
146    struct SizedLeafWidget {
147        size: Size,
148    }
149    impl View<()> for SizedLeaf {
150        type Element = SizedLeafWidget;
151        fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
152            SizedLeafWidget { size: self.size }
153        }
154        fn rebuild(
155            &self,
156            _prev: &Self,
157            element: &mut SizedLeafWidget,
158            _ctx: &mut BuildCtx<'_>,
159        ) -> ChangeFlags {
160            element.size = self.size;
161            ChangeFlags::NONE
162        }
163    }
164    impl frust_core::Widget for SizedLeafWidget {
165        fn layout(
166            &mut self,
167            _ctx: &mut frust_core::LayoutCtx,
168            bc: &frust_core::BoxConstraints,
169        ) -> Size {
170            bc.constrain(self.size)
171        }
172        fn paint(&mut self, _ctx: &mut frust_core::PaintCtx, _scene: &mut dyn PaintScene) {}
173    }
174    fn sized(w: f64, h: f64) -> impl View<()> {
175        SizedLeaf {
176            size: Size::new(w, h),
177        }
178    }
179
180    // A minimal `PaintScene` that just needs to exist for `paint` to run —
181    // mirrors `router_glue.rs`'s own test fixture (this crate has no
182    // `test-support` feature dependency on `frust-widgets`).
183    #[derive(Default)]
184    struct RecordingScene;
185    impl PaintScene for RecordingScene {
186        fn fill_rect(&mut self, _origin: kurbo::Point, _size: Size, _color: peniko::Color) {}
187        fn draw_text(&mut self, _origin: kurbo::Point, _text: &str) {}
188    }
189
190    /// `RouteObserver` compile-asserted `Send + Sync` (the `const _` above),
191    /// round-trips `provide_context`, and a navigation applied in rebuild N is
192    /// readable in build N+1.
193    #[test]
194    fn route_observer_rides_context_and_updates_across_a_navigation() {
195        use frust_reactive::{provide_context, use_context};
196
197        let rt = ReactiveRuntime::init(Arc::new(|| {}));
198        let controller: NavigatorController<()> = NavigatorController::new();
199        let observer = rt.with_owner(RouteObserver::new);
200
201        let recovered = rt.with_owner(|| {
202            provide_context(observer);
203            use_context::<RouteObserver>()
204        });
205        let recovered = recovered.expect("a RouteObserver must survive provide_context");
206
207        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
208        let mut app = {
209            let ctrl = controller.clone();
210            move |_: &mut ()| recovered.observe(navigator(&ctrl, || sized(10.0, 10.0)))
211        };
212        let mut state = ();
213
214        // Build N: no navigation yet — at rest.
215        rt.with_owner(|| root.rebuild(&mut app, &mut state));
216        assert_eq!(recovered.depth(), 1);
217        assert_eq!(recovered.change(), NavChange::Initial);
218
219        // A navigation applied in rebuild N ...
220        controller.push_with_options(
221            || sized(20.0, 20.0),
222            PushOptions::opaque().route(Location::parse("/detail")),
223        );
224        rt.with_owner(|| root.rebuild(&mut app, &mut state));
225        root.layout(Size::new(100.0, 100.0));
226        let mut scene = RecordingScene;
227        root.paint(&mut scene, FrameTime::ZERO);
228
229        // ... is readable via the SAME `RouteObserver` handle in build N+1
230        // (rebuild already ran build N+1's logic above; the write happened
231        // inside it, which is the point — no extra frame was needed for the
232        // signal write itself to land).
233        assert_eq!(recovered.depth(), 2);
234        assert_eq!(recovered.change(), NavChange::Push);
235        assert_eq!(recovered.path(), "/detail");
236        assert!(!recovered.is_back());
237    }
238}