envision/component/router/mod.rs
1//! A component for multi-screen navigation with history.
2//!
3//! `Router` provides type-safe navigation between screens with back navigation
4//! support. Unlike most components, Router is state-only and doesn't implement
5//! a view - the parent application renders based on the current screen.
6//!
7//! # Example
8//!
9//! ```rust
10//! use envision::component::{Router, RouterState, RouterMessage, Component};
11//!
12//! #[derive(Clone, Debug, PartialEq, Eq)]
13//! enum Screen {
14//! Home,
15//! Settings,
16//! Profile,
17//! }
18//!
19//! // Create router starting at Home screen
20//! let mut state = RouterState::new(Screen::Home);
21//!
22//! // Navigate to Settings
23//! Router::update(&mut state, RouterMessage::Navigate(Screen::Settings));
24//! assert_eq!(state.current(), &Screen::Settings);
25//! assert!(state.can_go_back());
26//!
27//! // Go back to Home
28//! Router::update(&mut state, RouterMessage::Back);
29//! assert_eq!(state.current(), &Screen::Home);
30//! ```
31//!
32//! # Usage Pattern
33//!
34//! ```rust
35//! use envision::component::RouterState;
36//! use ratatui::Frame;
37//!
38//! #[derive(Clone, Debug, PartialEq, Eq)]
39//! enum Screen { Home, Settings, Profile }
40//!
41//! struct AppState {
42//! router: RouterState<Screen>,
43//! }
44//!
45//! // In your app's view function:
46//! fn view(state: &AppState, frame: &mut Frame) {
47//! match state.router.current() {
48//! Screen::Home => { /* render home */ }
49//! Screen::Settings => { /* render settings */ }
50//! Screen::Profile => { /* render profile */ }
51//! }
52//! }
53//! ```
54
55use ratatui::prelude::*;
56
57use super::Component;
58use crate::theme::Theme;
59
60/// Navigation mode for screen transitions.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub enum NavigationMode {
63 /// Push the new screen onto the history stack.
64 #[default]
65 Push,
66 /// Replace the current screen without adding to history.
67 Replace,
68}
69
70/// Messages for the Router component.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum RouterMessage<S: Clone + PartialEq> {
73 /// Navigate to a new screen (pushes to history).
74 Navigate(S),
75 /// Navigate with a specific mode.
76 NavigateWith(S, NavigationMode),
77 /// Replace the current screen without adding to history.
78 Replace(S),
79 /// Go back to the previous screen.
80 Back,
81 /// Clear all navigation history.
82 ClearHistory,
83 /// Reset to a specific screen, clearing all history.
84 Reset(S),
85}
86
87/// Output messages from the Router component.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub enum RouterOutput<S: Clone + PartialEq> {
90 /// Screen changed (from, to).
91 ScreenChanged {
92 /// The previous screen.
93 from: S,
94 /// The new current screen.
95 to: S,
96 },
97 /// Successfully navigated back.
98 NavigatedBack {
99 /// The screen we navigated to.
100 to: S,
101 },
102 /// Tried to go back but there's no history.
103 NoPreviousScreen,
104 /// Router was reset to a new screen.
105 Reset(S),
106 /// History was cleared.
107 HistoryCleared,
108}
109
110/// State for the Router component.
111///
112/// The type parameter `S` is your screen enum. It must implement `Clone` and `PartialEq`.
113///
114/// # Example
115///
116/// ```rust
117/// use envision::component::RouterState;
118///
119/// #[derive(Clone, Debug, PartialEq, Eq)]
120/// enum Screen {
121/// Home,
122/// Settings,
123/// }
124///
125/// let state = RouterState::new(Screen::Home);
126/// assert_eq!(state.current(), &Screen::Home);
127/// assert!(!state.can_go_back());
128/// ```
129#[derive(Clone, Debug)]
130#[cfg_attr(
131 feature = "serialization",
132 derive(serde::Serialize, serde::Deserialize)
133)]
134pub struct RouterState<S: Clone + PartialEq> {
135 /// The current screen.
136 current: S,
137 /// Navigation history (most recent last).
138 history: Vec<S>,
139 /// Maximum history size (0 = unlimited).
140 max_history: usize,
141}
142
143impl<S: Default + Clone + PartialEq> Default for RouterState<S> {
144 fn default() -> Self {
145 Self::new(S::default())
146 }
147}
148
149impl<S: Clone + PartialEq> PartialEq for RouterState<S> {
150 fn eq(&self, other: &Self) -> bool {
151 self.current == other.current
152 && self.history == other.history
153 && self.max_history == other.max_history
154 }
155}
156
157impl<S: Clone + PartialEq> RouterState<S> {
158 /// Creates a new router state starting at the given screen.
159 pub fn new(initial: S) -> Self {
160 Self {
161 current: initial,
162 history: Vec::new(),
163 max_history: 0,
164 }
165 }
166
167 /// Sets the maximum history size.
168 ///
169 /// When the history exceeds this limit, the oldest entries are removed.
170 /// Set to 0 for unlimited history (default).
171 pub fn with_max_history(mut self, max: usize) -> Self {
172 self.max_history = max;
173 self
174 }
175
176 /// Returns the current screen.
177 pub fn current(&self) -> &S {
178 &self.current
179 }
180
181 /// Returns true if we can navigate back.
182 pub fn can_go_back(&self) -> bool {
183 !self.history.is_empty()
184 }
185
186 /// Returns the number of screens in history.
187 pub fn history_len(&self) -> usize {
188 self.history.len()
189 }
190
191 /// Returns the history stack (oldest first).
192 pub fn history(&self) -> &[S] {
193 &self.history
194 }
195
196 /// Returns the maximum history size (0 = unlimited).
197 pub fn max_history(&self) -> usize {
198 self.max_history
199 }
200
201 /// Sets the maximum history size.
202 pub fn set_max_history(&mut self, max: usize) {
203 self.max_history = max;
204 self.enforce_history_limit();
205 }
206
207 /// Returns the previous screen if available.
208 pub fn previous(&self) -> Option<&S> {
209 self.history.last()
210 }
211
212 /// Checks if the current screen is the given screen.
213 pub fn is_at(&self, screen: &S) -> bool {
214 &self.current == screen
215 }
216
217 /// Clears all navigation history.
218 pub fn clear_history(&mut self) {
219 self.history.clear();
220 }
221
222 /// Enforces the max history limit.
223 fn enforce_history_limit(&mut self) {
224 if self.max_history > 0 && self.history.len() > self.max_history {
225 let excess = self.history.len() - self.max_history;
226 self.history.drain(0..excess);
227 }
228 }
229}
230
231/// A component for multi-screen navigation with history.
232///
233/// Router manages screen navigation with a history stack for back navigation.
234/// It's designed to be used with an enum representing your application's screens.
235///
236/// # Note
237///
238/// Router doesn't implement `view()` - it's a state-only component. Your
239/// application should render based on `state.current()`.
240///
241/// # Example
242///
243/// ```rust
244/// use envision::component::{Router, RouterState, RouterMessage, RouterOutput, Component};
245///
246/// #[derive(Clone, Debug, PartialEq, Eq)]
247/// enum Screen {
248/// Home,
249/// Settings,
250/// About,
251/// }
252///
253/// let mut state = RouterState::new(Screen::Home);
254///
255/// // Navigate to Settings
256/// let output = Router::update(&mut state, RouterMessage::Navigate(Screen::Settings));
257/// assert!(matches!(output, Some(RouterOutput::ScreenChanged { .. })));
258/// assert_eq!(state.current(), &Screen::Settings);
259///
260/// // Navigate to About
261/// Router::update(&mut state, RouterMessage::Navigate(Screen::About));
262/// assert_eq!(state.history_len(), 2);
263///
264/// // Go back twice
265/// Router::update(&mut state, RouterMessage::Back);
266/// assert_eq!(state.current(), &Screen::Settings);
267/// Router::update(&mut state, RouterMessage::Back);
268/// assert_eq!(state.current(), &Screen::Home);
269/// ```
270pub struct Router<S: Clone + PartialEq>(std::marker::PhantomData<S>);
271
272impl<S: Clone + PartialEq> Router<S> {
273 /// Updates the router state.
274 ///
275 /// This inherent method is available for all screen types that implement
276 /// `Clone + PartialEq`. Screen types that also implement `Default` can
277 /// use the [`Component`] trait methods instead.
278 pub fn update(state: &mut RouterState<S>, msg: RouterMessage<S>) -> Option<RouterOutput<S>> {
279 match msg {
280 RouterMessage::Navigate(screen) => {
281 if state.current == screen {
282 return None; // Already at this screen
283 }
284
285 let from = state.current.clone();
286 state.history.push(state.current.clone());
287 state.current = screen.clone();
288 state.enforce_history_limit();
289
290 Some(RouterOutput::ScreenChanged { from, to: screen })
291 }
292
293 RouterMessage::NavigateWith(screen, mode) => match mode {
294 NavigationMode::Push => Self::update(state, RouterMessage::Navigate(screen)),
295 NavigationMode::Replace => Self::update(state, RouterMessage::Replace(screen)),
296 },
297
298 RouterMessage::Replace(screen) => {
299 if state.current == screen {
300 return None;
301 }
302
303 let from = state.current.clone();
304 state.current = screen.clone();
305
306 Some(RouterOutput::ScreenChanged { from, to: screen })
307 }
308
309 RouterMessage::Back => {
310 if let Some(previous) = state.history.pop() {
311 state.current = previous.clone();
312 Some(RouterOutput::NavigatedBack { to: previous })
313 } else {
314 Some(RouterOutput::NoPreviousScreen)
315 }
316 }
317
318 RouterMessage::ClearHistory => {
319 if state.history.is_empty() {
320 None
321 } else {
322 state.history.clear();
323 Some(RouterOutput::HistoryCleared)
324 }
325 }
326
327 RouterMessage::Reset(screen) => {
328 state.history.clear();
329 state.current = screen.clone();
330 Some(RouterOutput::Reset(screen))
331 }
332 }
333 }
334
335 /// Renders the router view.
336 ///
337 /// Router is state-only, so this is a no-op. The parent application
338 /// should render based on `state.current()`.
339 pub fn view(_state: &RouterState<S>, _frame: &mut Frame, _area: Rect, _theme: &Theme) {
340 // Router is state-only - no view implementation.
341 // The parent application should render based on state.current()
342 }
343}
344
345impl<S: Clone + PartialEq + Default> Component for Router<S> {
346 type State = RouterState<S>;
347 type Message = RouterMessage<S>;
348 type Output = RouterOutput<S>;
349
350 fn init() -> Self::State {
351 RouterState::new(S::default())
352 }
353
354 fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
355 Router::update(state, msg)
356 }
357
358 fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
359 Router::view(state, frame, area, theme)
360 }
361}
362
363#[cfg(test)]
364mod tests;