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, ®istry.keys, ®istry.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}