use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use frust_core::{AnyView, any};
use super::navigator::{NavigatorController, NavigatorId, PushOptions, ReplaceOptions};
use super::path::{Location, PathPattern, RouteParams, encode_segment};
use super::route::{NavRequest, RouteNavigator};
pub type RouteBuilder<State> = Rc<dyn Fn(&RouteParams) -> AnyView<State>>;
pub type Redirect = Rc<dyn Fn(&Location) -> Option<String>>;
pub type ErrorBuilder<State> = Rc<dyn Fn(&Location) -> AnyView<State>>;
pub const DEFAULT_REDIRECT_LIMIT: usize = 5;
pub struct Route<State: 'static> {
path: String,
name: Option<String>,
builder: RouteBuilder<State>,
redirect: Option<Redirect>,
children: Vec<Route<State>>,
shell: Option<NavigatorController<State>>,
}
impl<State: 'static> Route<State> {
pub fn new(
path: impl Into<String>,
builder: impl Fn(&RouteParams) -> AnyView<State> + 'static,
) -> Self {
Route {
path: path.into(),
name: None,
builder: Rc::new(builder),
redirect: None,
children: Vec::new(),
shell: None,
}
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
self.redirect = Some(Rc::new(redirect));
self
}
pub fn child(mut self, child: Route<State>) -> Self {
self.children.push(child);
self
}
pub fn children(mut self, children: Vec<Route<State>>) -> Self {
self.children = children;
self
}
}
pub fn shell_route<State: 'static>(
inner: &NavigatorController<State>,
builder: impl Fn(&RouteParams) -> AnyView<State> + 'static,
children: Vec<Route<State>>,
) -> Route<State> {
Route {
path: String::new(),
name: None,
builder: Rc::new(builder),
redirect: None,
children,
shell: Some(inner.clone()),
}
}
pub struct ResolvedPage<State: 'static> {
builder: RouteBuilder<State>,
params: RouteParams,
location: Location,
shell: Option<NavigatorController<State>>,
}
impl<State: 'static> ResolvedPage<State> {
pub fn build(&self) -> AnyView<State> {
(self.builder)(&self.params)
}
pub fn params(&self) -> &RouteParams {
&self.params
}
pub fn location(&self) -> &Location {
&self.location
}
fn into_page_builder(self) -> impl Fn() -> AnyView<State> + 'static {
let ResolvedPage {
builder, params, ..
} = self;
move || (builder)(¶ms)
}
}
pub enum Resolution<State: 'static> {
Matched {
location: Location,
params: RouteParams,
pages: Vec<ResolvedPage<State>>,
},
Error { location: Location },
}
impl<State: 'static> Resolution<State> {
pub fn is_matched(&self) -> bool {
matches!(self, Resolution::Matched { .. })
}
pub fn params(&self) -> RouteParams {
match self {
Resolution::Matched { params, .. } => params.clone(),
Resolution::Error { .. } => RouteParams::new(),
}
}
pub fn page_count(&self) -> usize {
match self {
Resolution::Matched { pages, .. } => pages.len(),
Resolution::Error { .. } => 0,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Verb {
Go,
Push,
Replace,
}
struct Segment<State: 'static> {
controller: NavigatorController<State>,
pages: Vec<ResolvedPage<State>>,
}
pub struct Router<State: 'static> {
routes: Vec<Route<State>>,
controller: NavigatorController<State>,
redirect: Option<Redirect>,
error_builder: ErrorBuilder<State>,
redirect_limit: usize,
route_nav: RouteNavigator,
pending_shells: RefCell<HashMap<NavigatorId, u64>>,
}
impl<State: 'static> Router<State> {
pub fn new(routes: Vec<Route<State>>) -> Self {
Self::with_controller(&NavigatorController::new(), routes)
}
pub fn with_controller(
controller: &NavigatorController<State>,
routes: Vec<Route<State>>,
) -> Self {
Router {
routes,
controller: controller.clone(),
redirect: None,
error_builder: Rc::new(default_error_page),
redirect_limit: DEFAULT_REDIRECT_LIMIT,
route_nav: RouteNavigator::new(),
pending_shells: RefCell::new(HashMap::new()),
}
}
pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
self.redirect = Some(Rc::new(redirect));
self
}
pub fn error_builder(
mut self,
error_builder: impl Fn(&Location) -> AnyView<State> + 'static,
) -> Self {
self.error_builder = Rc::new(error_builder);
self
}
pub fn redirect_limit(mut self, limit: usize) -> Self {
self.redirect_limit = limit;
self
}
pub fn controller(&self) -> &NavigatorController<State> {
&self.controller
}
pub fn resolve(&self, raw: &str) -> Resolution<State> {
let mut loc = Location::parse(raw);
let mut redirects = 0usize;
loop {
if let Some(next) = self.fire_redirect(self.redirect.as_ref(), &loc) {
redirects += 1;
if redirects > self.redirect_limit {
return Resolution::Error { location: loc };
}
loc = next;
continue;
}
let Some((chain, params)) = self.match_chain(&loc) else {
return Resolution::Error { location: loc };
};
let mut redirected = None;
for route in &chain {
if let Some(next) = self.fire_redirect(route.redirect.as_ref(), &loc) {
redirected = Some(next);
break;
}
}
if let Some(next) = redirected {
redirects += 1;
if redirects > self.redirect_limit {
return Resolution::Error { location: loc };
}
loc = next;
continue;
}
let mut merged = loc.query.clone();
merged.extend(params);
let pages = chain
.iter()
.map(|route| ResolvedPage {
builder: route.builder.clone(),
params: merged.clone(),
location: loc.clone(),
shell: route.shell.clone(),
})
.collect();
return Resolution::Matched {
location: loc,
params: merged,
pages,
};
}
}
fn fire_redirect(&self, redirect: Option<&Redirect>, loc: &Location) -> Option<Location> {
let redirect = redirect?;
let next = redirect(loc)?;
let next_loc = Location::parse(&next);
if next_loc.location_string() == loc.location_string() {
None
} else {
Some(next_loc)
}
}
fn match_chain<'a>(&'a self, loc: &Location) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
match_routes(&self.routes, &loc.segments, RouteParams::new(), Vec::new())
}
pub fn route_navigator(&self) -> RouteNavigator {
self.route_nav.clone()
}
pub fn pump(&self) {
for request in self.route_nav.drain() {
match request {
NavRequest::Go(location) => self.go(&location),
NavRequest::Push(location) => self.push(&location),
NavRequest::Replace(location) => self.replace(&location),
NavRequest::Pop => self.pop(),
NavRequest::GoNamed { name, params } => self.go_named(&name, ¶ms),
NavRequest::PushNamed { name, params } => self.push_named(&name, ¶ms),
}
}
}
pub fn go(&self, location: &str) {
self.apply(self.resolved(location), Verb::Go);
}
pub fn push(&self, location: &str) {
self.apply(self.resolved(location), Verb::Push);
}
pub fn replace(&self, location: &str) {
self.apply(self.resolved(location), Verb::Replace);
}
pub fn pop(&self) {
self.controller.pop();
}
fn apply(&self, resolution: Resolution<State>, verb: Verb) {
match resolution {
Resolution::Matched { pages, .. } => {
let mut segments = self.split_chain(pages);
if segments.len() == 1 {
let segment = segments.pop().expect("a chain has at least one segment");
Self::apply_flat(&segment.controller, segment.pages, verb);
} else {
self.apply_shell_chain(segments, verb);
}
}
Resolution::Error { location } => match verb {
Verb::Push => self.controller.push(self.error_page_builder(location)),
Verb::Go | Verb::Replace => {
self.controller.replace(self.error_page_builder(location))
}
},
}
}
fn split_chain(&self, pages: Vec<ResolvedPage<State>>) -> Vec<Segment<State>> {
let mut segments = vec![Segment {
controller: self.controller.clone(),
pages: Vec::new(),
}];
for page in pages {
let inner = page.shell.clone();
segments
.last_mut()
.expect("segments is seeded with the outer segment")
.pages
.push(page);
if let Some(inner) = inner {
segments.push(Segment {
controller: inner,
pages: Vec::new(),
});
}
}
segments
}
fn apply_shell_chain(&self, segments: Vec<Segment<State>>, verb: Verb) {
let shells: Vec<NavigatorId> = segments
.iter()
.skip(1)
.map(|segment| segment.controller.id())
.collect();
let last = segments.len() - 1;
let mut enclosing_kept = true;
for (index, segment) in segments.into_iter().enumerate() {
let effective = if enclosing_kept { verb } else { Verb::Go };
if index == last {
Self::apply_flat(&segment.controller, segment.pages, effective);
continue;
}
let shell = shells[index];
if enclosing_kept && self.shell_is_placed(&segment.controller, index, shell) {
continue;
}
enclosing_kept = false;
self.pending_shells
.borrow_mut()
.insert(shell, segment.controller.route_generation());
Self::place_segment(&segment.controller, segment.pages, effective);
}
}
fn shell_is_placed(
&self,
target: &NavigatorController<State>,
boundary: usize,
shell: NavigatorId,
) -> bool {
let stack = target.route_stack();
let committed = stack
.entries()
.iter()
.flatten()
.any(|location| self.shell_at(location, boundary) == Some(shell));
if committed {
return true;
}
self.pending_shells.borrow().get(&shell) == Some(&stack.generation())
}
fn shell_at(&self, location: &Location, boundary: usize) -> Option<NavigatorId> {
let (chain, _) = self.match_chain(location)?;
chain
.iter()
.filter_map(|route| route.shell.as_ref())
.nth(boundary)
.map(|controller| controller.id())
}
fn apply_flat(
controller: &NavigatorController<State>,
pages: Vec<ResolvedPage<State>>,
verb: Verb,
) {
match verb {
Verb::Go => {
let mut pages = pages.into_iter();
if let Some(first) = pages.next() {
let route = first.location().clone();
controller.replace_with_options(
first.into_page_builder(),
ReplaceOptions::opaque().route(route),
);
}
for page in pages {
let route = page.location().clone();
controller.push_with_options(
page.into_page_builder(),
PushOptions::opaque().route(route),
);
}
}
Verb::Push => {
if let Some(leaf) = pages.into_iter().next_back() {
let route = leaf.location().clone();
controller.push_with_options(
leaf.into_page_builder(),
PushOptions::opaque().route(route),
);
}
}
Verb::Replace => {
if let Some(leaf) = pages.into_iter().next_back() {
let route = leaf.location().clone();
controller.replace_with_options(
leaf.into_page_builder(),
ReplaceOptions::opaque().route(route),
);
}
}
}
}
fn place_segment(
controller: &NavigatorController<State>,
pages: Vec<ResolvedPage<State>>,
verb: Verb,
) {
match verb {
Verb::Push => {
for page in pages {
let route = page.location().clone();
controller.push_with_options(
page.into_page_builder(),
PushOptions::opaque().route(route),
);
}
}
Verb::Go | Verb::Replace => Self::apply_flat(controller, pages, Verb::Go),
}
}
pub fn handle_location(&self, location: &str) {
self.go(location);
}
pub fn go_named(&self, name: &str, params: &RouteParams) {
match self.path_for_name(name, params) {
Some(path) => self.go(&path),
None => {
let location = named_error_location(name);
self.route_nav.set_location(location.clone());
self.controller.replace(self.error_page_builder(location));
}
}
}
pub fn push_named(&self, name: &str, params: &RouteParams) {
match self.path_for_name(name, params) {
Some(path) => self.push(&path),
None => {
let location = named_error_location(name);
self.route_nav.set_location(location.clone());
self.controller.push(self.error_page_builder(location));
}
}
}
pub fn path_for_name(&self, name: &str, params: &RouteParams) -> Option<String> {
let pattern = find_named_pattern(&self.routes, name, "")?;
Some(substitute_pattern(&pattern, params))
}
fn resolved(&self, raw: &str) -> Resolution<State> {
let resolution = self.resolve(raw);
let location = match &resolution {
Resolution::Matched { location, .. } | Resolution::Error { location } => location,
};
self.route_nav.set_location(location.clone());
resolution
}
fn error_page_builder(&self, location: Location) -> impl Fn() -> AnyView<State> + 'static {
let error_builder = self.error_builder.clone();
move || (error_builder)(&location)
}
}
fn match_routes<'a, State: 'static>(
routes: &'a [Route<State>],
segments: &[String],
acc_params: RouteParams,
chain: Vec<&'a Route<State>>,
) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
for route in routes {
let pattern = PathPattern::parse(&route.path);
let Some((consumed, params)) = pattern.match_prefix(segments) else {
continue;
};
let mut next_params = acc_params.clone();
next_params.extend(params);
let mut next_chain = chain.clone();
next_chain.push(route);
let remaining = &segments[consumed..];
if remaining.is_empty() {
return Some((next_chain, next_params));
}
if let Some(found) = match_routes(&route.children, remaining, next_params, next_chain) {
return Some(found);
}
}
None
}
fn find_named_pattern<State: 'static>(
routes: &[Route<State>],
name: &str,
prefix: &str,
) -> Option<String> {
for route in routes {
let full = join_paths(prefix, &route.path);
if route.name.as_deref() == Some(name) {
return Some(full);
}
if let Some(found) = find_named_pattern(&route.children, name, &full) {
return Some(found);
}
}
None
}
fn join_paths(prefix: &str, path: &str) -> String {
let a = prefix.trim_matches('/');
let b = path.trim_matches('/');
match (a.is_empty(), b.is_empty()) {
(true, true) => String::new(),
(true, false) => b.to_string(),
(false, true) => a.to_string(),
(false, false) => format!("{a}/{b}"),
}
}
fn substitute_pattern(pattern: &str, params: &RouteParams) -> String {
let mut used: HashSet<&str> = HashSet::new();
let mut path = String::new();
for seg in pattern.split('/').filter(|s| !s.is_empty()) {
path.push('/');
if let Some(name) = seg.strip_prefix(':') {
match params.get(name) {
Some(value) => {
path.push_str(&encode_segment(value));
used.insert(name);
}
None => path.push_str(seg),
}
} else {
path.push_str(seg);
}
}
if path.is_empty() {
path.push('/');
}
let extras: Vec<String> = params
.iter()
.filter(|(k, _)| !used.contains(k.as_str()))
.map(|(k, v)| format!("{}={}", encode_segment(k), encode_segment(v)))
.collect();
if extras.is_empty() {
path
} else {
format!("{}?{}", path, extras.join("&"))
}
}
fn named_error_location(name: &str) -> Location {
Location::parse(&format!("/{name}"))
}
fn default_error_page<State: 'static>(location: &Location) -> AnyView<State> {
any(crate::text(format!("Page not found: {}", location.path)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::RecordingScene;
use frust_core::{
BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, FrameTime, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, RenderRoot,
View, Widget,
};
use kurbo::{Point, Size};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::super::navigator::{NavigatorView, navigator};
use super::super::route_state::NavChange;
fn params(pairs: &[(&str, &str)]) -> RouteParams {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
struct SizedLeaf {
size: Size,
}
struct SizedLeafWidget {
size: Size,
}
impl<S: 'static> View<S> for SizedLeaf {
type Element = SizedLeafWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
SizedLeafWidget { size: self.size }
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut SizedLeafWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for SizedLeafWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
}
}
fn sized<S: 'static>(w: f64, h: f64) -> AnyView<S> {
any(SizedLeaf {
size: Size::new(w, h),
})
}
struct CounterView {
observed: Rc<Cell<u32>>,
}
struct CounterWidget {
count: u32,
observed: Rc<Cell<u32>>,
}
impl View<()> for CounterView {
type Element = CounterWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> CounterWidget {
CounterWidget {
count: 0,
observed: self.observed.clone(),
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut CounterWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.observed = self.observed.clone();
ChangeFlags::NONE
}
}
impl Widget for CounterWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
self.observed.set(self.count);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Down
{
self.count += 1;
ctx.request_redraw();
return EventResult::Handled;
}
EventResult::Ignored
}
}
fn counter(observed: &Rc<Cell<u32>>) -> AnyView<()> {
any(CounterView {
observed: observed.clone(),
})
}
fn down(x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
#[test]
fn matches_static_route() {
let router: Router<()> = Router::new(vec![Route::new("/settings", |_| sized(10.0, 10.0))]);
let res = router.resolve("/settings");
assert!(res.is_matched());
assert_eq!(res.page_count(), 1);
}
#[test]
fn extracts_param() {
let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
let res = router.resolve("/users/42");
assert!(res.is_matched());
assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
}
fn builder_params(resolution: &Resolution<()>) -> RouteParams {
let Resolution::Matched { pages, .. } = resolution else {
panic!("expected match");
};
let leaf = pages.last().expect("a matched chain has a leaf");
let _view = leaf.build();
leaf.params().clone()
}
#[test]
fn query_reaches_the_page_builder() {
let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
let recorder = seen.clone();
let router: Router<()> = Router::new(vec![Route::new("/terminal", move |p| {
*recorder.borrow_mut() = p.clone();
sized(10.0, 10.0)
})]);
let resolution = router.resolve("/terminal?session=abc");
assert_eq!(
builder_params(&resolution)
.get("session")
.map(String::as_str),
Some("abc")
);
assert_eq!(
seen.borrow().get("session").map(String::as_str),
Some("abc"),
"the builder itself receives the query parameter"
);
}
#[test]
fn path_capture_beats_a_colliding_query_key() {
let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
let resolution = router.resolve("/users/42?id=99");
assert_eq!(
builder_params(&resolution).get("id").map(String::as_str),
Some("42")
);
assert_eq!(
resolution.params().get("id").map(String::as_str),
Some("42")
);
}
#[test]
fn named_extra_params_round_trip_through_resolve() {
let router: Router<()> = Router::new(vec![
Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
]);
let path = router
.path_for_name("user", ¶ms(&[("id", "42"), ("tab", "posts")]))
.expect("named route");
let received = builder_params(&router.resolve(&path));
assert_eq!(received.get("id").map(String::as_str), Some("42"));
assert_eq!(received.get("tab").map(String::as_str), Some("posts"));
}
#[test]
fn resolved_page_exposes_its_location() {
let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
let Resolution::Matched { pages, .. } = router.resolve("/users/42?tab=posts") else {
panic!("expected match");
};
let leaf = pages.last().expect("leaf");
assert_eq!(leaf.location().path, "/users/42");
assert_eq!(
leaf.location().query.get("tab").map(String::as_str),
Some("posts"),
"the raw location keeps path and query separate"
);
}
#[test]
fn parses_query_into_params_via_location() {
let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
let Resolution::Matched { location, .. } = router.resolve("/users/42?tab=posts") else {
panic!("expected match");
};
assert_eq!(location.query.get("tab").map(String::as_str), Some("posts"));
}
#[test]
fn nested_route_composes_and_chains() {
let router: Router<()> = Router::new(vec![
Route::new("/users", |_| sized(10.0, 10.0))
.child(Route::new(":id", |_| sized(20.0, 20.0))),
]);
let res = router.resolve("/users/42");
assert!(res.is_matched());
assert_eq!(res.page_count(), 2);
assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
}
#[test]
fn trailing_slash_still_matches() {
let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
assert!(router.resolve("/users/42/").is_matched());
}
#[test]
fn no_match_is_error() {
let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
let res = router.resolve("/nope");
assert!(!res.is_matched());
assert!(matches!(res, Resolution::Error { .. }));
}
#[test]
fn per_route_redirect_follows_chain() {
let router: Router<()> = Router::new(vec![
Route::new("/old", |_| sized(10.0, 10.0)).redirect(|_| Some("/new".to_string())),
Route::new("/new", |_| sized(20.0, 20.0)),
]);
let Resolution::Matched { location, .. } = router.resolve("/old") else {
panic!("expected match after redirect");
};
assert_eq!(location.path, "/new");
}
#[test]
fn top_level_redirect_applies() {
let router: Router<()> = Router::new(vec![
Route::new("/login", |_| sized(10.0, 10.0)),
Route::new("/home", |_| sized(20.0, 20.0)),
])
.redirect(|loc| (loc.path == "/home").then(|| "/login".to_string()));
let Resolution::Matched { location, .. } = router.resolve("/home") else {
panic!("expected redirect to /login");
};
assert_eq!(location.path, "/login");
}
#[test]
fn redirect_loop_falls_back_to_error_at_limit() {
let router: Router<()> = Router::new(vec![
Route::new("/a", |_| sized(10.0, 10.0)).redirect(|_| Some("/b".to_string())),
Route::new("/b", |_| sized(20.0, 20.0)).redirect(|_| Some("/a".to_string())),
]);
let res = router.resolve("/a");
assert!(
matches!(res, Resolution::Error { .. }),
"loop must error out"
);
}
#[test]
fn redirect_to_same_location_is_not_a_loop() {
let router: Router<()> = Router::new(vec![
Route::new("/x", |_| sized(10.0, 10.0)).redirect(|_| Some("/x".to_string())),
]);
assert!(router.resolve("/x").is_matched());
}
#[test]
fn named_resolves_params_into_path() {
let router: Router<()> = Router::new(vec![
Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
]);
assert_eq!(
router.path_for_name("user", ¶ms(&[("id", "42")])),
Some("/users/42".to_string())
);
}
#[test]
fn named_composes_nested_path() {
let router: Router<()> = Router::new(vec![
Route::new("/users", |_| sized(10.0, 10.0))
.child(Route::new(":id", |_| sized(20.0, 20.0)).name("user")),
]);
assert_eq!(
router.path_for_name("user", ¶ms(&[("id", "7")])),
Some("/users/7".to_string())
);
}
#[test]
fn named_extra_params_become_query() {
let router: Router<()> = Router::new(vec![
Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
]);
assert_eq!(
router.path_for_name("user", ¶ms(&[("id", "42"), ("tab", "posts")])),
Some("/users/42?tab=posts".to_string())
);
}
#[test]
fn unknown_name_is_none() {
let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
assert!(
router
.path_for_name("missing", &RouteParams::new())
.is_none()
);
}
fn drive_paint(root: &mut RenderRoot<(), NavigatorView<()>>) -> Vec<(Point, Size)> {
root.layout(Size::new(100.0, 100.0));
let mut scene = RecordingScene::default();
root.paint(&mut scene, FrameTime::ZERO);
scene.rects
}
#[test]
fn go_replaces_top_dropping_state() {
let controller: NavigatorController<()> = NavigatorController::new();
let observed = Rc::new(Cell::new(0u32));
let router = {
let obs = observed.clone();
Router::with_controller(
&controller,
vec![Route::new("/home", move |_| counter(&obs))],
)
};
let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
let obs = observed.clone();
move |_: &mut ()| {
let obs = obs.clone();
navigator(&ctrl, move || counter(&obs))
}
};
let mut state = ();
root.rebuild(&mut app, &mut state);
root.layout(Size::new(100.0, 100.0));
root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
root.event(&mut state, &down(5.0, 5.0));
root.rebuild(&mut app, &mut state);
root.layout(Size::new(100.0, 100.0));
root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
assert_eq!(observed.get(), 1);
router.go("/home");
root.rebuild(&mut app, &mut state);
root.layout(Size::new(100.0, 100.0));
root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
assert_eq!(observed.get(), 0, "go dropped the previous page's state");
}
#[test]
fn push_stacks_and_retains_below() {
let controller: NavigatorController<()> = NavigatorController::new();
let router: Router<()> = Router::with_controller(
&controller,
vec![
Route::new("/home", |_| sized(10.0, 10.0)),
Route::new("/detail", |_| sized(20.0, 20.0)),
],
);
let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
};
let mut state = ();
root.rebuild(&mut app, &mut state);
assert_eq!(
drive_paint(&mut root),
vec![(Point::ZERO, Size::new(10.0, 10.0))]
);
router.push("/detail");
root.rebuild(&mut app, &mut state);
assert_eq!(
drive_paint(&mut root),
vec![(Point::ZERO, Size::new(20.0, 20.0))]
);
router.pop();
root.rebuild(&mut app, &mut state);
assert_eq!(
drive_paint(&mut root),
vec![(Point::ZERO, Size::new(10.0, 10.0))]
);
}
#[test]
fn go_to_unmatched_shows_error_page() {
let controller: NavigatorController<()> = NavigatorController::new();
let router: Router<()> = Router::with_controller(
&controller,
vec![Route::new("/home", |_| sized(10.0, 10.0))],
)
.error_builder(|_| sized(99.0, 99.0));
let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
};
let mut state = ();
root.rebuild(&mut app, &mut state);
router.go("/does-not-exist");
root.rebuild(&mut app, &mut state);
assert_eq!(
drive_paint(&mut root),
vec![(Point::ZERO, Size::new(99.0, 99.0))],
"the error page replaced the top"
);
}
#[test]
fn go_push_replace_all_stamp_the_resolved_location_onto_the_route_stack() {
let controller: NavigatorController<()> = NavigatorController::new();
let router: Router<()> = Router::with_controller(
&controller,
vec![
Route::new("/home", |_| sized(10.0, 10.0)),
Route::new("/detail", |_| sized(20.0, 20.0)),
Route::new("/other", |_| sized(30.0, 30.0)),
],
);
let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
};
let mut state = ();
root.rebuild(&mut app, &mut state);
router.go("/home");
root.rebuild(&mut app, &mut state);
assert_eq!(
controller.route_stack().entries(),
&[Some(Location::parse("/home"))],
"Router::go stamps the resolved location via ReplaceOptions::route"
);
router.push("/detail");
root.rebuild(&mut app, &mut state);
assert_eq!(
controller.route_stack().entries(),
&[
Some(Location::parse("/home")),
Some(Location::parse("/detail"))
],
"Router::push stamps the resolved location via PushOptions::route"
);
assert_eq!(controller.route_stack().change(), NavChange::Push);
router.replace("/other");
root.rebuild(&mut app, &mut state);
assert_eq!(
controller.route_stack().entries(),
&[
Some(Location::parse("/home")),
Some(Location::parse("/other"))
],
"Router::replace stamps the resolved location via ReplaceOptions::route"
);
assert_eq!(controller.route_stack().change(), NavChange::Replace);
}
type Build = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
struct PumpHarness {
router: Router<()>,
root: RenderRoot<(), NavigatorView<()>>,
app: Build,
state: (),
}
impl PumpHarness {
fn new(routes: Vec<Route<()>>) -> Self {
let controller: NavigatorController<()> = NavigatorController::new();
let router =
Router::with_controller(&controller, routes).error_builder(|_| sized(99.0, 99.0));
let app: Build =
Box::new(move |_: &mut ()| navigator(&controller, || sized(10.0, 10.0)));
let mut harness = PumpHarness {
router,
root: RenderRoot::new(),
app,
state: (),
};
harness.rebuild();
harness
}
fn rebuild(&mut self) {
self.root.rebuild(&mut self.app, &mut self.state);
}
fn frame(&mut self) -> Vec<(Point, Size)> {
self.router.pump();
self.rebuild();
drive_paint(&mut self.root)
}
}
fn pump_routes() -> Vec<Route<()>> {
vec![
Route::new("/home", |_| sized(10.0, 10.0)),
Route::new("/detail", |_| sized(20.0, 20.0)),
Route::new("/other", |_| sized(30.0, 30.0)),
]
}
#[test]
fn pump_applies_a_queued_request_on_the_next_frame() {
let mut h = PumpHarness::new(pump_routes());
let nav = h.router.route_navigator();
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
nav.push("/detail");
assert_eq!(
h.frame(),
vec![(Point::ZERO, Size::new(20.0, 20.0))],
"a request queued between frames lands on the very next one"
);
nav.pop();
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
}
#[test]
fn pump_applies_every_request_variant_in_order() {
let mut h = PumpHarness::new(pump_routes());
let nav = h.router.route_navigator();
nav.push("/detail");
nav.replace("/other");
assert_eq!(
h.frame(),
vec![(Point::ZERO, Size::new(30.0, 30.0))],
"push then replace leaves /other on top, not /detail"
);
nav.pop();
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
nav.go("/detail");
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
}
#[test]
fn pump_applies_named_requests_with_their_params() {
let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
let recorder = seen.clone();
let mut h = PumpHarness::new(vec![
Route::new("/home", |_| sized(10.0, 10.0)),
Route::new("/users/:id", move |p| {
*recorder.borrow_mut() = p.clone();
sized(20.0, 20.0)
})
.name("user"),
]);
let nav = h.router.route_navigator();
nav.push_named("user", params(&[("id", "42"), ("tab", "posts")]));
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
assert_eq!(seen.borrow().get("id").map(String::as_str), Some("42"));
assert_eq!(seen.borrow().get("tab").map(String::as_str), Some("posts"));
}
#[test]
fn pump_is_a_no_op_when_the_queue_is_empty() {
let mut h = PumpHarness::new(pump_routes());
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
for _ in 0..3 {
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
}
}
#[test]
fn a_request_from_another_thread_pumps_normally() {
let mut h = PumpHarness::new(pump_routes());
let nav = h.router.route_navigator();
let woke = Arc::new(AtomicUsize::new(0));
let counter = woke.clone();
nav.set_waker(Arc::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
std::thread::spawn(move || nav.push("/detail"))
.join()
.expect("off-thread request must not panic");
assert_eq!(
woke.load(Ordering::SeqCst),
1,
"the waker asked for a frame"
);
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
}
#[test]
fn route_navigator_publishes_the_resolved_location() {
let mut h = PumpHarness::new(pump_routes());
let nav = h.router.route_navigator();
assert!(nav.location().is_none(), "nothing resolved yet");
nav.push("/detail?tab=posts");
let _ = h.frame();
let loc = nav.location().expect("published after the pump");
assert_eq!(loc.path, "/detail");
assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
nav.go("/nope");
let _ = h.frame();
assert_eq!(nav.location().expect("published").path, "/nope");
}
#[test]
fn route_navigator_clones_share_one_queue() {
let mut h = PumpHarness::new(pump_routes());
let first = h.router.route_navigator();
let second = h.router.route_navigator();
first.push("/detail");
second.replace("/other");
assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(30.0, 30.0))]);
}
struct BuildCounter {
builds: Rc<Cell<u32>>,
}
struct BuildCounterWidget;
impl<S: 'static> View<S> for BuildCounter {
type Element = BuildCounterWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> BuildCounterWidget {
self.builds.set(self.builds.get() + 1);
BuildCounterWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut BuildCounterWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for BuildCounterWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::ZERO)
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
}
struct ShellHarness {
router: Router<()>,
outer: NavigatorController<()>,
inner: NavigatorController<()>,
root: RenderRoot<(), NavigatorView<()>>,
app: Build,
shell_builds: Rc<Cell<u32>>,
}
impl ShellHarness {
fn new() -> Self {
let outer: NavigatorController<()> = NavigatorController::new();
let inner: NavigatorController<()> = NavigatorController::new();
let shell_builds = Rc::new(Cell::new(0u32));
let shell_page = {
let inner = inner.clone();
let builds = shell_builds.clone();
move |_: &RouteParams| {
let inner = inner.clone();
let builds = builds.clone();
any(crate::Column(vec![
any(BuildCounter { builds }),
any(navigator(&inner, || sized(11.0, 11.0))),
]))
}
};
let routes = vec![
Route::new("/", |_| sized(10.0, 10.0)),
shell_route(
&inner,
shell_page,
vec![
Route::new("/sessions", |_| sized(20.0, 20.0)),
Route::new("/terminal", |_| sized(30.0, 30.0)),
],
),
];
let router =
Router::with_controller(&outer, routes).error_builder(|_| sized(99.0, 99.0));
let app: Build = {
let c = outer.clone();
Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
};
let mut harness = ShellHarness {
router,
outer,
inner,
root: RenderRoot::new(),
app,
shell_builds,
};
harness.rebuild();
harness
}
fn rebuild(&mut self) {
self.root.rebuild(&mut self.app, &mut ());
}
fn stacks(&self) -> (Vec<String>, Vec<String>) {
(paths(&self.outer), paths(&self.inner))
}
}
fn paths(controller: &NavigatorController<()>) -> Vec<String> {
controller
.route_stack()
.entries()
.iter()
.map(|entry| match entry {
Some(location) => location.path.clone(),
None => "-".to_string(),
})
.collect()
}
#[test]
fn a_shell_chain_splits_the_outer_page_from_the_inner_leaf() {
let mut h = ShellHarness::new();
h.router.go("/terminal");
h.rebuild();
let (outer, inner) = h.stacks();
assert_eq!(
outer,
vec!["/terminal".to_string()],
"the outer controller got the SHELL page (stamped with the location \
that placed it), never the leaf"
);
assert_eq!(
inner,
vec!["/terminal".to_string()],
"the leaf landed on the shell's inner controller"
);
assert_eq!(h.inner.depth(), 1, "the inner leaf replaced its root");
assert!(h.inner.is_mounted(), "the shell page mounted its navigator");
assert_eq!(h.shell_builds.get(), 1, "the shell page was built once");
}
#[test]
fn a_sibling_navigation_inside_the_shell_issues_zero_outer_ops() {
let mut h = ShellHarness::new();
h.router.go("/sessions");
h.rebuild();
let outer_generation = h.outer.route_generation();
assert_eq!(h.shell_builds.get(), 1);
h.router.go("/terminal");
h.rebuild();
assert_eq!(
h.outer.route_generation(),
outer_generation,
"no outer op ran at all — an unchanged stack publishes nothing"
);
assert_eq!(
h.shell_builds.get(),
1,
"the shell page's widget was retained, not rebuilt"
);
assert_eq!(
h.stacks().1,
vec!["/terminal".to_string()],
"only the inner navigator moved"
);
assert_eq!(
h.stacks().0,
vec!["/sessions".to_string()],
"the outer entry keeps naming the location that PLACED the shell — \
live in-shell state is read from the inner navigator"
);
}
#[test]
fn a_push_inside_a_placed_shell_stacks_on_the_inner_navigator() {
let mut h = ShellHarness::new();
h.router.go("/sessions");
h.rebuild();
h.router.push("/terminal");
h.rebuild();
assert_eq!(h.outer.depth(), 1, "the outer stack never moved");
assert_eq!(
h.stacks().1,
vec!["/sessions".to_string(), "/terminal".to_string()],
"the leaf stacked inside the shell"
);
assert_eq!(h.shell_builds.get(), 1);
}
#[test]
fn a_push_that_must_place_the_shell_retains_the_page_below_and_resets_the_inner() {
let mut h = ShellHarness::new();
h.router.go("/");
h.rebuild();
assert_eq!(h.stacks().0, vec!["/".to_string()]);
h.router.push("/sessions");
h.rebuild();
assert_eq!(
h.stacks().0,
vec!["/".to_string(), "/sessions".to_string()],
"the shell page stacked over the root page, which is retained"
);
assert_eq!(
h.inner.depth(),
1,
"the fresh inner navigator is at its root"
);
assert_eq!(h.stacks().1, vec!["/sessions".to_string()]);
}
#[test]
fn a_page_pushed_over_the_shell_still_counts_as_placed() {
let mut h = ShellHarness::new();
h.router.go("/sessions");
h.rebuild();
h.outer.push(|| sized(77.0, 77.0));
h.rebuild();
assert_eq!(h.outer.depth(), 2);
h.router.go("/terminal");
h.rebuild();
assert_eq!(h.outer.depth(), 2, "no second shell page was placed");
assert_eq!(h.shell_builds.get(), 1);
assert_eq!(h.stacks().1, vec!["/terminal".to_string()]);
}
#[test]
fn two_shell_navigations_in_one_frame_place_the_shell_page_once() {
let mut h = ShellHarness::new();
h.router.go("/");
h.rebuild();
h.router.push("/sessions");
h.router.push("/terminal");
h.rebuild();
assert_eq!(
h.stacks().0,
vec!["/".to_string(), "/sessions".to_string()],
"exactly one shell page, placed by the first navigation"
);
assert_eq!(
h.stacks().1,
vec!["/sessions".to_string(), "/terminal".to_string()],
"both in-shell ops landed on the one inner navigator"
);
assert_eq!(h.shell_builds.get(), 1);
}
#[test]
fn the_root_route_beats_a_pathless_shell_for_the_empty_location() {
let mut h = ShellHarness::new();
h.router.go("/");
h.rebuild();
assert_eq!(h.stacks().0, vec!["/".to_string()]);
assert!(
!h.inner.is_mounted(),
"the shell was never placed, so its navigator never mounted"
);
}
#[test]
fn a_redirect_out_of_the_shell_splits_the_post_redirect_chain() {
let outer: NavigatorController<()> = NavigatorController::new();
let inner: NavigatorController<()> = NavigatorController::new();
let router = {
let inner_for_shell = inner.clone();
Router::with_controller(
&outer,
vec![
Route::new("/", |_| sized(10.0, 10.0)),
shell_route(
&inner,
move |_| {
let inner = inner_for_shell.clone();
any(navigator(&inner, || sized(11.0, 11.0)))
},
vec![
Route::new("/sessions", |_| sized(20.0, 20.0))
.redirect(|_| Some("/".to_string())),
],
),
],
)
};
let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
let mut app: Build = {
let c = outer.clone();
Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
};
root.rebuild(&mut app, &mut ());
router.go("/sessions");
root.rebuild(&mut app, &mut ());
assert_eq!(paths(&outer), vec!["/".to_string()], "the redirect won");
assert!(!inner.is_mounted(), "no shell page, no inner navigator");
}
#[test]
fn a_non_shell_chain_still_flattens_onto_one_controller() {
let controller: NavigatorController<()> = NavigatorController::new();
let router: Router<()> = Router::with_controller(
&controller,
vec![
Route::new("/home", |_| sized(10.0, 10.0)),
Route::new("/users", |_| sized(20.0, 20.0))
.child(Route::new(":id", |_| sized(30.0, 30.0))),
],
);
let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
let mut app: Build = {
let c = controller.clone();
Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
};
root.rebuild(&mut app, &mut ());
router.go("/users/42");
root.rebuild(&mut app, &mut ());
assert_eq!(
paths(&controller),
vec!["/users/42".to_string(), "/users/42".to_string()],
"go replaced the top with the chain root and pushed the rest — both \
pages on the one controller"
);
router.push("/users/7");
root.rebuild(&mut app, &mut ());
assert_eq!(
controller.depth(),
3,
"push stacked the leaf alone, unchanged"
);
router.replace("/home");
root.rebuild(&mut app, &mut ());
assert_eq!(
paths(&controller).last().map(String::as_str),
Some("/home"),
"replace swapped the top alone, unchanged"
);
assert_eq!(controller.depth(), 3);
}
}