Skip to main content

lumen_scene/
routing.rs

1//! Navigating between the pages of one app.
2//!
3//! A page is a key, navigation is a write to a reserved signal, and the
4//! `<if>` reconciler does the mounting: the assembled tree is one gate per
5//! page, and the resolver decides which gate is open. Nothing here knows
6//! where the pages came from, so the same resolver serves an app loaded from
7//! `.lmn` files, one loaded from a compiled artifact, and one running in a
8//! browser.
9//!
10//! A requested path resolves by longest existing key ([`lumen_core::nav`]):
11//! `/settings` reaches `settings`, and `/user/7` with no `user/7` page
12//! reaches `user` with `/7` left on the `route.segment` signal for the page
13//! to read.
14//!
15//! [`RouteHistory`] is the back/forward stack an app keeps in memory. A
16//! desktop window steps through it; a browser hands back and forward to its
17//! own history instead, and a `popstate` comes back here as a navigation.
18
19use bevy_ecs::prelude::*;
20use lumen_core::nav::{self, NavOp};
21use lumen_core::property_store::PropertyStore;
22
23/// Navigation target attached to a spawned `<a href="...">` element. A click
24/// on the entity navigates the active page.
25#[derive(Component, Clone, Debug)]
26pub struct Anchor(pub String);
27
28/// Host policy: the host follows a link click on its own, so a click on an
29/// [`Anchor`] raises no navigation here.
30///
31/// A host inserts this when its links are real links it lets through, such
32/// as a browser loading every page as a document of its own. The click
33/// still reaches the app, and navigating from it as well would do the
34/// host's work twice. A script's own navigation is unaffected: this covers
35/// the anchor click and nothing else.
36#[derive(Clone, Copy, Debug, Default, Resource)]
37pub struct HostFollowsLinks;
38
39/// Runtime page registry - the resolver's view of the loaded pages.
40#[derive(Clone, Debug, Resource)]
41pub struct PageRegistry {
42    /// Home page key.
43    pub entry: String,
44    /// Page keys, longest-first.
45    pub keys: Vec<String>,
46}
47
48/// One entry on the in-memory history stack.
49#[derive(Clone, Debug)]
50pub struct Location {
51    /// Resolved page key.
52    pub path: String,
53    /// Leftover segment after the matched page prefix.
54    pub segment: String,
55}
56
57impl Location {
58    /// The location of a page opened at its own address, with nothing left
59    /// over: what a desktop app opens on, and what a build renders.
60    pub fn page(key: impl Into<String>) -> Self {
61        Self {
62            path: key.into(),
63            segment: String::new(),
64        }
65    }
66}
67
68/// In-memory back/forward history. A desktop window steps through this; in a
69/// browser the browser's own history is what answers back and forward, and
70/// this records where the app has been without being stepped.
71#[derive(Clone, Debug, Resource)]
72pub struct RouteHistory {
73    /// Visited locations, oldest first.
74    pub stack: Vec<Location>,
75    /// Index of the currently-active location within [`Self::stack`].
76    pub cursor: usize,
77}
78
79impl RouteHistory {
80    fn active(&self) -> Option<&Location> {
81        self.stack.get(self.cursor)
82    }
83}
84
85/// Install navigation for a known page set: the registry, the in-memory
86/// history, the reserved-signal seeds, and the two navigation systems.
87///
88/// This is what both an app loaded from source and one loaded from a compiled
89/// artifact end up calling; they differ only in where the page set came from,
90/// a directory listing in one case and [`lumen_ir::artifact::CompiledPages`]
91/// in the other.
92///
93/// `entry` and `keys` are the site: its home page and every page it holds,
94/// which is what a later navigation resolves against. `at` is where this app
95/// opens, which is a question about the address it was asked for: a window
96/// opens on the entry with nothing left over, and a document served for
97/// `/user/42` opens on `user` with `/42` in hand.
98pub fn install_routing(
99    app: &mut lumen_core::app::App,
100    entry: String,
101    keys: Vec<String>,
102    at: Location,
103) {
104    use lumen_core::tick::TickStage;
105
106    // Seed the reserved signals so the opening page's `<if>` gate mounts on
107    // the first reconcile pass.
108    {
109        let mut store = app.world.resource_mut::<PropertyStore>();
110        store.set_global_str(nav::PATH_SIGNAL, at.path.as_str());
111        store.set_global_str(nav::SEGMENT_SIGNAL, at.segment.as_str());
112    }
113    nav::set_current(&at.path);
114
115    app.world.insert_resource(PageRegistry { entry, keys });
116    app.world.insert_resource(RouteHistory {
117        stack: vec![at],
118        cursor: 0,
119    });
120
121    // Resolver runs before the `<if>` reconciler so a navigation this tick
122    // swaps the mounted page this tick.
123    app.add_systems(
124        TickStage::Systems,
125        apply_navigation.before(crate::spawn::reconcile_if_blocks),
126    );
127    app.add_systems(TickStage::Systems, navigate_on_anchor_click);
128}
129
130/// The single navigation resolver. Reads the reserved request signal (written
131/// by every surface via [`lumen_core::nav::request`]), resolves the target by
132/// longest existing-file prefix, updates the reserved `route.path` /
133/// `route.segment` cells, and maintains the in-memory history stack.
134pub fn apply_navigation(
135    mut store: ResMut<PropertyStore>,
136    registry: Option<Res<PageRegistry>>,
137    mut history: ResMut<RouteHistory>,
138    mut last: Local<Option<String>>,
139) {
140    let Some(registry) = registry else {
141        return;
142    };
143    let Some(request) = store.get_global_str(nav::REQUEST_SIGNAL) else {
144        return;
145    };
146    let request = request.to_string();
147    if last.as_deref() == Some(request.as_str()) {
148        return; // already processed this exact request
149    }
150    *last = Some(request.clone());
151
152    let Some((_seq, op)) = nav::parse_request(&request) else {
153        return;
154    };
155
156    let target: Option<Location> = match op {
157        NavOp::Navigate(path) => {
158            let (key, segment) = nav::resolve_path(&path, &registry.keys, &registry.entry);
159            // Truncate any forward history, then push.
160            let keep = history.cursor + 1;
161            history.stack.truncate(keep);
162            history.stack.push(Location {
163                path: key.clone(),
164                segment: segment.clone(),
165            });
166            history.cursor = history.stack.len() - 1;
167            Some(Location { path: key, segment })
168        }
169        NavOp::Back => {
170            if history.cursor > 0 {
171                history.cursor -= 1;
172            }
173            history.active().cloned()
174        }
175        NavOp::Forward => {
176            if history.cursor + 1 < history.stack.len() {
177                history.cursor += 1;
178            }
179            history.active().cloned()
180        }
181    };
182
183    if let Some(loc) = target {
184        store.set_global_str(nav::PATH_SIGNAL, loc.path.as_str());
185        store.set_global_str(nav::SEGMENT_SIGNAL, loc.segment.as_str());
186        nav::set_current(&loc.path);
187    }
188}
189
190/// Declarative navigation: a click on a spawned `<a href>` navigates the
191/// active page. The anchor is a real element; on the web target it is a real
192/// DOM `<a href>`, and under `[web] navigation = "soft"` the browser's own
193/// anchor navigation is prevented so that this system's swap is what the
194/// click ends at. A host holding [`HostFollowsLinks`] follows the link
195/// itself, and the click raises nothing here.
196pub fn navigate_on_anchor_click(
197    mut clicks: bevy_ecs::message::MessageReader<lumen_core::input::ClickEvent>,
198    anchors: Query<&Anchor>,
199    host_follows: Option<Res<HostFollowsLinks>>,
200) {
201    if host_follows.is_some() {
202        clicks.clear();
203        return;
204    }
205    for click in clicks.read() {
206        if let Ok(anchor) = anchors.get(click.entity) {
207            // Honor `event.prevent_default()` from a phase-4 click handler:
208            // link navigation is the click default action, so a prevented
209            // click does not navigate.
210            let handle = lumen_core::node::NodeHandle::new(click.entity).pack();
211            if lumen_script::event::is_click_default_prevented(handle) {
212                continue;
213            }
214            nav::navigate(anchor.0.clone());
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use lumen_core::app::App;
223    use lumen_core::input::{ClickEvent, PointerButton};
224
225    /// An app on the `index` page of a two-page site, with one link to
226    /// `settings`.
227    fn linked_app() -> (App, Entity) {
228        let mut app = App::new();
229        app.extract_fns.clear();
230        app.world.init_resource::<PropertyStore>();
231        install_routing(
232            &mut app,
233            "index".to_string(),
234            vec!["settings".to_string(), "index".to_string()],
235            Location::page("index"),
236        );
237        let link = app.world.spawn(Anchor("settings".to_string())).id();
238        (app, link)
239    }
240
241    /// A primary click on `entity`, read by the next tick.
242    fn click(app: &mut App, entity: Entity) {
243        app.world.write_message(ClickEvent {
244            entity,
245            position: glam::Vec2::ZERO,
246            button: PointerButton::Primary,
247            local: None,
248        });
249    }
250
251    /// The page the router has open.
252    fn route_path(app: &App) -> Option<String> {
253        app.world
254            .resource::<PropertyStore>()
255            .get_global_str(nav::PATH_SIGNAL)
256            .map(|path| path.to_string())
257    }
258
259    #[test]
260    fn a_host_that_follows_links_gets_no_navigation_from_a_click() {
261        let (mut app, link) = linked_app();
262        app.world.insert_resource(HostFollowsLinks);
263        let before = app
264            .world
265            .resource::<PropertyStore>()
266            .get_global_str(nav::REQUEST_SIGNAL)
267            .map(|request| request.to_string());
268        click(&mut app, link);
269        app.tick();
270        app.tick();
271        assert_eq!(
272            app.world
273                .resource::<PropertyStore>()
274                .get_global_str(nav::REQUEST_SIGNAL)
275                .map(|request| request.to_string()),
276            before,
277            "the host is following the link, so the app raises no navigation"
278        );
279        assert_eq!(route_path(&app).as_deref(), Some("index"));
280
281        // The same click in a host that leaves links to the app navigates,
282        // which is what makes the silence above the policy's doing.
283        app.world.remove_resource::<HostFollowsLinks>();
284        click(&mut app, link);
285        app.tick();
286        app.tick();
287        assert_eq!(route_path(&app).as_deref(), Some("settings"));
288    }
289}