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::{
138        AnyView, BuildCtx, ChangeFlags, FrameTime, PaintScene, RenderRoot, View, any,
139    };
140    use frust_reactive::ReactiveRuntime;
141    use frust_widgets::{NavigatorController, NavigatorView, PushOptions, navigator};
142    use kurbo::Size;
143    use std::sync::Arc;
144
145    struct SizedLeaf {
146        size: Size,
147    }
148    struct SizedLeafWidget {
149        size: Size,
150    }
151    impl View<()> for SizedLeaf {
152        type Element = SizedLeafWidget;
153        fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
154            SizedLeafWidget { size: self.size }
155        }
156        fn rebuild(
157            &self,
158            _prev: &Self,
159            element: &mut SizedLeafWidget,
160            _ctx: &mut BuildCtx<'_>,
161        ) -> ChangeFlags {
162            element.size = self.size;
163            ChangeFlags::NONE
164        }
165    }
166    impl frust_core::Widget for SizedLeafWidget {
167        fn layout(
168            &mut self,
169            _ctx: &mut frust_core::LayoutCtx,
170            bc: &frust_core::BoxConstraints,
171        ) -> Size {
172            bc.constrain(self.size)
173        }
174        fn paint(&mut self, _ctx: &mut frust_core::PaintCtx, _scene: &mut dyn PaintScene) {}
175    }
176    fn sized(w: f64, h: f64) -> AnyView<()> {
177        any(SizedLeaf {
178            size: Size::new(w, h),
179        })
180    }
181
182    // A minimal `PaintScene` that just needs to exist for `paint` to run —
183    // mirrors `router_glue.rs`'s own test fixture (this crate has no
184    // `test-support` feature dependency on `frust-widgets`).
185    #[derive(Default)]
186    struct RecordingScene;
187    impl PaintScene for RecordingScene {
188        fn fill_rect(&mut self, _origin: kurbo::Point, _size: Size, _color: peniko::Color) {}
189        fn draw_text(&mut self, _origin: kurbo::Point, _text: &str) {}
190    }
191
192    /// `RouteObserver` compile-asserted `Send + Sync` (the `const _` above),
193    /// round-trips `provide_context`, and a navigation applied in rebuild N is
194    /// readable in build N+1.
195    #[test]
196    fn route_observer_rides_context_and_updates_across_a_navigation() {
197        use frust_reactive::{provide_context, use_context};
198
199        let rt = ReactiveRuntime::init(Arc::new(|| {}));
200        let controller: NavigatorController<()> = NavigatorController::new();
201        let observer = rt.with_owner(RouteObserver::new);
202
203        let recovered = rt.with_owner(|| {
204            provide_context(observer);
205            use_context::<RouteObserver>()
206        });
207        let recovered = recovered.expect("a RouteObserver must survive provide_context");
208
209        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
210        let mut app = {
211            let ctrl = controller.clone();
212            move |_: &mut ()| recovered.observe(navigator(&ctrl, || sized(10.0, 10.0)))
213        };
214        let mut state = ();
215
216        // Build N: no navigation yet — at rest.
217        rt.with_owner(|| root.rebuild(&mut app, &mut state));
218        assert_eq!(recovered.depth(), 1);
219        assert_eq!(recovered.change(), NavChange::Initial);
220
221        // A navigation applied in rebuild N ...
222        controller.push_with_options(
223            || sized(20.0, 20.0),
224            PushOptions::opaque().route(Location::parse("/detail")),
225        );
226        rt.with_owner(|| root.rebuild(&mut app, &mut state));
227        root.layout(Size::new(100.0, 100.0));
228        let mut scene = RecordingScene;
229        root.paint(&mut scene, FrameTime::ZERO);
230
231        // ... is readable via the SAME `RouteObserver` handle in build N+1
232        // (rebuild already ran build N+1's logic above; the write happened
233        // inside it, which is the point — no extra frame was needed for the
234        // signal write itself to land).
235        assert_eq!(recovered.depth(), 2);
236        assert_eq!(recovered.change(), NavChange::Push);
237        assert_eq!(recovered.path(), "/detail");
238        assert!(!recovered.is_back());
239    }
240}