tpt_appfront_core/
router.rs1use crate::signal::Signal;
16use crate::ui_tree::UITree;
17use std::collections::HashMap;
18use std::rc::Rc;
19
20pub type RouteHandler<Msg> = Rc<dyn Fn(&HashMap<String, String>) -> UITree<Msg>>;
23
24#[derive(Debug, Clone)]
30pub struct Route {
31 raw: String,
32 segments: Vec<Segment>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36enum Segment {
37 Literal(String),
39 Param(String),
41}
42
43impl Route {
44 pub fn parse(pattern: &str) -> Result<Route, String> {
47 let trimmed = pattern.trim_matches('/');
48 let segments = if trimmed.is_empty() {
49 Vec::new()
50 } else {
51 trimmed
52 .split('/')
53 .map(|seg| {
54 if seg.is_empty() {
55 return Err(format!("empty segment in route `{pattern}`"));
56 }
57 if let Some(name) = seg.strip_prefix(':') {
58 if name.is_empty() {
59 return Err(format!("unnamed `:` param in route `{pattern}`"));
60 }
61 Ok(Segment::Param(name.to_string()))
62 } else {
63 Ok(Segment::Literal(seg.to_string()))
64 }
65 })
66 .collect::<Result<Vec<_>, String>>()?
67 };
68 Ok(Route {
69 raw: pattern.to_string(),
70 segments,
71 })
72 }
73
74 pub fn pattern(&self) -> &str {
76 &self.raw
77 }
78
79 pub fn match_path(&self, path: &str) -> Option<HashMap<String, String>> {
82 let trimmed = path.trim_matches('/');
83 let incoming: Vec<&str> = if trimmed.is_empty() {
84 Vec::new()
85 } else {
86 trimmed.split('/').collect()
87 };
88
89 if self.segments.is_empty() {
91 return if incoming.is_empty() {
92 Some(HashMap::new())
93 } else {
94 None
95 };
96 }
97
98 if incoming.len() != self.segments.len() {
99 return None;
100 }
101 let mut params = HashMap::new();
102 for (seg, raw) in self.segments.iter().zip(incoming.iter()) {
103 match seg {
104 Segment::Literal(lit) => {
105 if lit != raw {
106 return None;
107 }
108 }
109 Segment::Param(name) => {
110 params.insert(name.clone(), (*raw).to_string());
111 }
112 }
113 }
114 Some(params)
115 }
116}
117
118pub struct RouteTable<Msg> {
124 routes: Vec<(Route, RouteHandler<Msg>)>,
125 not_found: Option<Rc<dyn Fn() -> UITree<Msg>>>,
127}
128
129impl<Msg> Default for RouteTable<Msg> {
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135impl<Msg> RouteTable<Msg> {
136 pub fn new() -> Self {
138 RouteTable {
139 routes: Vec::new(),
140 not_found: None,
141 }
142 }
143
144 pub fn route<F>(mut self, pattern: &str, handler: F) -> Result<Self, String>
147 where
148 F: Fn(&HashMap<String, String>) -> UITree<Msg> + 'static,
149 {
150 let route = Route::parse(pattern)?;
151 self.routes.push((route, Rc::new(handler)));
152 Ok(self)
153 }
154
155 pub fn fallback<F>(mut self, handler: F) -> Self
157 where
158 F: Fn() -> UITree<Msg> + 'static,
159 {
160 self.not_found = Some(Rc::new(handler));
161 self
162 }
163
164 pub fn resolve(&self, path: &str) -> UITree<Msg> {
166 for (route, handler) in &self.routes {
167 if let Some(params) = route.match_path(path) {
168 return handler(¶ms);
169 }
170 }
171 if let Some(fb) = &self.not_found {
172 return fb();
173 }
174 UITree::container(|_| {})
176 }
177}
178
179pub struct Router<Msg> {
186 inner: Rc<RouterInner<Msg>>,
187}
188
189struct RouterInner<Msg> {
190 table: RouteTable<Msg>,
191 location: Signal<String>,
192}
193
194impl<Msg> Clone for Router<Msg> {
195 fn clone(&self) -> Self {
196 Router {
197 inner: self.inner.clone(),
198 }
199 }
200}
201
202impl<Msg> Router<Msg> {
203 pub fn new(table: RouteTable<Msg>, initial_path: &str) -> Self {
205 Router {
206 inner: Rc::new(RouterInner {
207 table,
208 location: Signal::new(initial_path.to_string()),
209 }),
210 }
211 }
212
213 pub fn current_path(&self) -> String {
215 self.inner.location.get()
216 }
217
218 pub fn navigate(&self, path: &str) {
221 self.inner.location.set(path.to_string());
222 }
223
224 pub fn signal(&self) -> Signal<String> {
226 self.inner.location.clone()
227 }
228
229 pub fn current_view(&self) -> UITree<Msg> {
231 self.inner.table.resolve(&self.inner.location.get())
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::ui_tree::{ContainerBuilder, NodeKind};
239
240 #[test]
241 fn route_match_literal_and_root() {
242 let r = Route::parse("/").unwrap();
243 assert!(r.match_path("/").is_some());
244 assert!(r.match_path("/x").is_none());
245
246 let r = Route::parse("/about").unwrap();
247 assert!(r.match_path("/about").is_some());
248 assert!(r.match_path("/").is_none());
249 assert!(r.match_path("/about/extra").is_none());
250 }
251
252 #[test]
253 fn route_match_param_capture() {
254 let r = Route::parse("/users/:id").unwrap();
255 let m = r.match_path("/users/42").unwrap();
256 assert_eq!(m.get("id"), Some(&"42".to_string()));
257 assert!(r.match_path("/users").is_none());
258 assert!(r.match_path("/posts/42").is_none());
259 }
260
261 #[test]
262 fn route_table_resolves_and_falls_back() {
263 let table = RouteTable::<()>::new()
264 .route("/", |_| UITree::container(|_| {}))
265 .unwrap()
266 .route("/users/:id", |p| {
267 let _ = p;
268 UITree::container(|b: &mut ContainerBuilder<()>| {
269 b.text(format!("user {}", p.get("id").unwrap()));
270 })
271 })
272 .unwrap()
273 .fallback(|| UITree::container(|_| {}));
274
275 assert!(matches!(table.resolve("/"), UITree { kind: NodeKind::Container { .. }, .. }));
276 let view = table.resolve("/users/7");
277 assert!(matches!(view, UITree { kind: NodeKind::Container { .. }, .. }));
278 assert!(matches!(table.resolve("/nope"), UITree { kind: NodeKind::Container { .. }, .. }));
280 }
281
282 #[test]
283 fn router_navigation_updates_view_via_signal() {
284 let table = RouteTable::<()>::new()
285 .route("/", |_| UITree::container(|_| {}))
286 .unwrap()
287 .route("/b", |_| UITree::container(|_| {}))
288 .unwrap();
289 let router = Router::new(table, "/");
290 assert_eq!(router.current_path(), "/");
291
292 let sig = router.signal();
293 let before = sig.get();
294 router.navigate("/b");
295 assert_eq!(sig.get(), "/b");
296 assert_ne!(before, sig.get());
297 }
298
299 #[test]
300 fn route_parse_rejects_bad_patterns() {
301 assert!(Route::parse("/:").is_err());
303 assert!(Route::parse("/users/").is_ok());
305 }
306}