Skip to main content

appscale_core/
navigation.rs

1//! Navigation System — stack, tab, modal, and deep linking.
2//!
3//! Navigation is state + stack + transitions.
4//! The Rust side manages the navigation state machine.
5//! The platform bridge handles native transition animations.
6//!
7//! Design: Navigation state lives in Rust (not React state) because:
8//! 1. Platform bridges need it for native transitions (UINavigationController, etc.)
9//! 2. Deep links must resolve before React renders
10//! 3. Back button/gesture handling is synchronous and platform-specific
11
12use crate::tree::NodeId;
13use crate::platform::{PlatformBridge, NativeHandle};
14use std::collections::HashMap;
15
16/// A route definition (registered at app startup).
17#[derive(Debug, Clone)]
18pub struct RouteDefinition {
19    pub name: String,
20    pub path: Option<String>,           // URL path for deep linking, e.g. "/profile/:id"
21    pub presentation: Presentation,
22    pub options: RouteOptions,
23}
24
25/// How a screen is presented.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Presentation {
28    /// Push onto the stack (slide from right on iOS, slide up on Android)
29    Push,
30    /// Present as a modal (slide up on iOS, dialog on Android)
31    Modal,
32    /// Replace current screen (no animation, or custom)
33    Replace,
34    /// Tab screen (managed by tab bar)
35    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,       // Use native platform animation
50    SlideRight,
51    SlideUp,
52    Fade,
53    None,
54}
55
56/// An active screen in the navigation stack.
57#[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>,       // Root shadow tree node for this screen
64    pub native_handle: Option<NativeHandle>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct ScreenId(pub u64);
69
70/// Navigation action (sent from React or deep link resolver).
71#[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,     // Platform back button (Android, Windows, web browser)
82}
83
84/// Navigation event (sent to React for rendering decisions).
85#[derive(Debug, Clone)]
86pub enum NavigationEvent {
87    /// A new screen should be rendered.
88    ScreenMounted { screen_id: ScreenId, route_name: String, params: HashMap<String, String> },
89    /// A screen is being removed (animate out, then destroy).
90    ScreenUnmounting { screen_id: ScreenId },
91    /// The active screen changed (for tab bar highlighting, etc.).
92    ActiveScreenChanged { screen_id: ScreenId },
93    /// Navigation state changed (for DevTools).
94    StateChanged { stack_depth: usize, active_route: String },
95}
96
97/// The navigator manages all navigation state.
98pub struct Navigator {
99    /// Route definitions (registered at startup).
100    routes: HashMap<String, RouteDefinition>,
101
102    /// The main stack.
103    stack: Vec<Screen>,
104
105    /// Modal stack (layered on top of main stack).
106    modals: Vec<Screen>,
107
108    /// Tab screens (if using tab navigation).
109    tabs: Vec<Screen>,
110    active_tab: usize,
111
112    /// Screen ID counter.
113    next_screen_id: u64,
114
115    /// Pending events to deliver to React.
116    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    /// Register a route definition.
133    pub fn register_route(&mut self, route: RouteDefinition) {
134        self.routes.insert(route.name.clone(), route);
135    }
136
137    /// Process a navigation action. Returns events for React to handle.
138    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                // Pop current, push new (no animation)
155                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                // Priority: dismiss modal → pop stack → do nothing
186                if !self.modals.is_empty() {
187                    self.dispatch(NavigationAction::DismissModal);
188                } else if self.stack.len() > 1 {
189                    self.pop_screen();
190                }
191                // If stack has only 1 screen, GoBack is a no-op
192                // (platform bridge handles app minimize/exit)
193            }
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; // Never pop the root screen
235        }
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    /// Resolve a deep link URL to a navigation action.
256    fn resolve_deep_link(&mut self, url: &str) {
257        // Simple path matching: "/profile/123" matches "/profile/:id"
258        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    /// Get the currently active (visible) screen.
279    pub fn active_screen(&self) -> Option<&Screen> {
280        self.modals.last()
281            .or_else(|| self.stack.last())
282    }
283
284    /// Get the current stack for DevTools.
285    pub fn stack_snapshot(&self) -> Vec<&Screen> {
286        self.stack.iter().chain(self.modals.iter()).collect()
287    }
288
289    /// Check if back navigation is possible.
290    pub fn can_go_back(&self) -> bool {
291        !self.modals.is_empty() || self.stack.len() > 1
292    }
293}
294
295/// Simple path pattern matching.
296/// Pattern: "/profile/:id" matches URL: "/profile/123" → {"id": "123"}
297fn 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    // Strip query string from URL
302    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        // Push initial screen
350        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        // GoBack should dismiss modal first
387        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); // Root is preserved
408    }
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}