1use crate::tree::NodeId;
13use crate::platform::{PlatformBridge, NativeHandle};
14use std::collections::HashMap;
15
16#[derive(Debug, Clone)]
18pub struct RouteDefinition {
19 pub name: String,
20 pub path: Option<String>, pub presentation: Presentation,
22 pub options: RouteOptions,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Presentation {
28 Push,
30 Modal,
32 Replace,
34 Tab,
36}
37
38#[derive(Debug, Clone, Default)]
39pub struct RouteOptions {
40 pub title: Option<String>,
41 pub header_shown: bool,
42 pub gesture_enabled: bool,
43 pub animation: TransitionAnimation,
44}
45
46#[derive(Debug, Clone, Copy, Default)]
47pub enum TransitionAnimation {
48 #[default]
49 Platform, SlideRight,
51 SlideUp,
52 Fade,
53 None,
54}
55
56#[derive(Debug, Clone)]
58pub struct Screen {
59 pub id: ScreenId,
60 pub route_name: String,
61 pub params: HashMap<String, String>,
62 pub presentation: Presentation,
63 pub root_node: Option<NodeId>, pub native_handle: Option<NativeHandle>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct ScreenId(pub u64);
69
70#[derive(Debug, Clone)]
72pub enum NavigationAction {
73 Push { route: String, params: HashMap<String, String> },
74 Pop,
75 PopToRoot,
76 Replace { route: String, params: HashMap<String, String> },
77 PresentModal { route: String, params: HashMap<String, String> },
78 DismissModal,
79 SwitchTab { index: usize },
80 DeepLink { url: String },
81 GoBack, }
83
84#[derive(Debug, Clone)]
86pub enum NavigationEvent {
87 ScreenMounted { screen_id: ScreenId, route_name: String, params: HashMap<String, String> },
89 ScreenUnmounting { screen_id: ScreenId },
91 ActiveScreenChanged { screen_id: ScreenId },
93 StateChanged { stack_depth: usize, active_route: String },
95}
96
97pub struct Navigator {
99 routes: HashMap<String, RouteDefinition>,
101
102 stack: Vec<Screen>,
104
105 modals: Vec<Screen>,
107
108 tabs: Vec<Screen>,
110 active_tab: usize,
111
112 next_screen_id: u64,
114
115 pending_events: Vec<NavigationEvent>,
117}
118
119impl Navigator {
120 pub fn new() -> Self {
121 Self {
122 routes: HashMap::new(),
123 stack: Vec::new(),
124 modals: Vec::new(),
125 tabs: Vec::new(),
126 active_tab: 0,
127 next_screen_id: 1,
128 pending_events: Vec::new(),
129 }
130 }
131
132 pub fn register_route(&mut self, route: RouteDefinition) {
134 self.routes.insert(route.name.clone(), route);
135 }
136
137 pub fn dispatch(&mut self, action: NavigationAction) -> Vec<NavigationEvent> {
139 self.pending_events.clear();
140
141 match action {
142 NavigationAction::Push { route, params } => {
143 self.push_screen(&route, params, Presentation::Push);
144 }
145 NavigationAction::Pop => {
146 self.pop_screen();
147 }
148 NavigationAction::PopToRoot => {
149 while self.stack.len() > 1 {
150 self.pop_screen();
151 }
152 }
153 NavigationAction::Replace { route, params } => {
154 if !self.stack.is_empty() {
156 let screen_id = self.stack.last().unwrap().id;
157 self.pending_events.push(NavigationEvent::ScreenUnmounting { screen_id });
158 self.stack.pop();
159 }
160 self.push_screen(&route, params, Presentation::Replace);
161 }
162 NavigationAction::PresentModal { route, params } => {
163 self.push_screen(&route, params, Presentation::Modal);
164 }
165 NavigationAction::DismissModal => {
166 if let Some(modal) = self.modals.pop() {
167 self.pending_events.push(NavigationEvent::ScreenUnmounting {
168 screen_id: modal.id,
169 });
170 self.emit_active_changed();
171 }
172 }
173 NavigationAction::SwitchTab { index } => {
174 if index < self.tabs.len() {
175 self.active_tab = index;
176 self.pending_events.push(NavigationEvent::ActiveScreenChanged {
177 screen_id: self.tabs[index].id,
178 });
179 }
180 }
181 NavigationAction::DeepLink { url } => {
182 self.resolve_deep_link(&url);
183 }
184 NavigationAction::GoBack => {
185 if !self.modals.is_empty() {
187 self.dispatch(NavigationAction::DismissModal);
188 } else if self.stack.len() > 1 {
189 self.pop_screen();
190 }
191 }
194 }
195
196 std::mem::take(&mut self.pending_events)
197 }
198
199 fn push_screen(
200 &mut self,
201 route_name: &str,
202 params: HashMap<String, String>,
203 presentation: Presentation,
204 ) {
205 let screen_id = ScreenId(self.next_screen_id);
206 self.next_screen_id += 1;
207
208 let screen = Screen {
209 id: screen_id,
210 route_name: route_name.to_string(),
211 params: params.clone(),
212 presentation,
213 root_node: None,
214 native_handle: None,
215 };
216
217 match presentation {
218 Presentation::Modal => self.modals.push(screen),
219 Presentation::Tab => self.tabs.push(screen),
220 _ => self.stack.push(screen),
221 }
222
223 self.pending_events.push(NavigationEvent::ScreenMounted {
224 screen_id,
225 route_name: route_name.to_string(),
226 params,
227 });
228
229 self.emit_active_changed();
230 }
231
232 fn pop_screen(&mut self) {
233 if self.stack.len() <= 1 {
234 return; }
236
237 if let Some(screen) = self.stack.pop() {
238 self.pending_events.push(NavigationEvent::ScreenUnmounting {
239 screen_id: screen.id,
240 });
241 self.emit_active_changed();
242 }
243 }
244
245 fn emit_active_changed(&mut self) {
246 let active = self.active_screen();
247 if let Some(screen) = active {
248 self.pending_events.push(NavigationEvent::StateChanged {
249 stack_depth: self.stack.len() + self.modals.len(),
250 active_route: screen.route_name.clone(),
251 });
252 }
253 }
254
255 fn resolve_deep_link(&mut self, url: &str) {
257 for (name, route) in &self.routes {
259 if let Some(pattern) = &route.path {
260 if let Some(params) = match_path(pattern, url) {
261 let presentation = route.presentation;
262 let route_name = name.clone();
263 match presentation {
264 Presentation::Modal => {
265 self.push_screen(&route_name, params, Presentation::Modal);
266 }
267 _ => {
268 self.push_screen(&route_name, params, Presentation::Push);
269 }
270 }
271 return;
272 }
273 }
274 }
275 tracing::warn!(url = url, "No route matched deep link");
276 }
277
278 pub fn active_screen(&self) -> Option<&Screen> {
280 self.modals.last()
281 .or_else(|| self.stack.last())
282 }
283
284 pub fn stack_snapshot(&self) -> Vec<&Screen> {
286 self.stack.iter().chain(self.modals.iter()).collect()
287 }
288
289 pub fn can_go_back(&self) -> bool {
291 !self.modals.is_empty() || self.stack.len() > 1
292 }
293}
294
295fn match_path(pattern: &str, url: &str) -> Option<HashMap<String, String>> {
298 let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
299 let url_parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
300
301 let url_parts: Vec<&str> = url_parts.iter()
303 .map(|p| p.split('?').next().unwrap_or(p))
304 .collect();
305
306 if pattern_parts.len() != url_parts.len() {
307 return None;
308 }
309
310 let mut params = HashMap::new();
311
312 for (pattern_part, url_part) in pattern_parts.iter().zip(url_parts.iter()) {
313 if pattern_part.starts_with(':') {
314 let param_name = &pattern_part[1..];
315 params.insert(param_name.to_string(), url_part.to_string());
316 } else if pattern_part != url_part {
317 return None;
318 }
319 }
320
321 Some(params)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn setup_navigator() -> Navigator {
329 let mut nav = Navigator::new();
330 nav.register_route(RouteDefinition {
331 name: "Home".to_string(),
332 path: Some("/".to_string()),
333 presentation: Presentation::Push,
334 options: RouteOptions::default(),
335 });
336 nav.register_route(RouteDefinition {
337 name: "Profile".to_string(),
338 path: Some("/profile/:id".to_string()),
339 presentation: Presentation::Push,
340 options: RouteOptions { gesture_enabled: true, ..Default::default() },
341 });
342 nav.register_route(RouteDefinition {
343 name: "Settings".to_string(),
344 path: Some("/settings".to_string()),
345 presentation: Presentation::Modal,
346 options: RouteOptions::default(),
347 });
348
349 nav.dispatch(NavigationAction::Push {
351 route: "Home".to_string(),
352 params: HashMap::new(),
353 });
354
355 nav
356 }
357
358 #[test]
359 fn test_push_and_pop() {
360 let mut nav = setup_navigator();
361 assert_eq!(nav.stack.len(), 1);
362
363 nav.dispatch(NavigationAction::Push {
364 route: "Profile".to_string(),
365 params: [("id".to_string(), "42".to_string())].into(),
366 });
367 assert_eq!(nav.stack.len(), 2);
368 assert_eq!(nav.active_screen().unwrap().route_name, "Profile");
369
370 nav.dispatch(NavigationAction::Pop);
371 assert_eq!(nav.stack.len(), 1);
372 assert_eq!(nav.active_screen().unwrap().route_name, "Home");
373 }
374
375 #[test]
376 fn test_modal() {
377 let mut nav = setup_navigator();
378
379 nav.dispatch(NavigationAction::PresentModal {
380 route: "Settings".to_string(),
381 params: HashMap::new(),
382 });
383 assert_eq!(nav.modals.len(), 1);
384 assert_eq!(nav.active_screen().unwrap().route_name, "Settings");
385
386 nav.dispatch(NavigationAction::GoBack);
388 assert_eq!(nav.modals.len(), 0);
389 assert_eq!(nav.active_screen().unwrap().route_name, "Home");
390 }
391
392 #[test]
393 fn test_deep_link() {
394 let mut nav = setup_navigator();
395
396 nav.dispatch(NavigationAction::DeepLink {
397 url: "/profile/99".to_string(),
398 });
399 assert_eq!(nav.stack.len(), 2);
400 assert_eq!(nav.active_screen().unwrap().params.get("id").unwrap(), "99");
401 }
402
403 #[test]
404 fn test_cannot_pop_root() {
405 let mut nav = setup_navigator();
406 nav.dispatch(NavigationAction::Pop);
407 assert_eq!(nav.stack.len(), 1); }
409
410 #[test]
411 fn test_path_matching() {
412 let params = match_path("/profile/:id", "/profile/123").unwrap();
413 assert_eq!(params.get("id").unwrap(), "123");
414
415 assert!(match_path("/profile/:id", "/settings").is_none());
416 assert!(match_path("/a/:b/:c", "/a/1/2").is_some());
417 }
418}