use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use frust_reactive::{ReactiveRuntime, deep_links};
use frust_widgets::{RouteNavigator, Router};
use reactive_graph::traits::{Get, GetUntracked};
use crate::route_state::RouteObserver;
fn normalize_deep_link(raw: &str) -> String {
let Some((scheme, rest)) = raw.split_once("://") else {
return raw.to_string();
};
if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
return match rest.find('/') {
Some(idx) => rest[idx..].to_string(),
None => "/".to_string(),
};
}
let (host, tail) = match rest.split_once('/') {
Some((h, t)) => (h, Some(t)),
None => (rest, None),
};
match (host.is_empty(), tail) {
(true, Some(t)) if !t.is_empty() => format!("/{t}"),
(true, _) => "/".to_string(),
(false, Some(t)) if !t.is_empty() => format!("/{host}/{t}"),
(false, _) => format!("/{host}"),
}
}
pub struct RouterDeepLinks<State: 'static> {
router: Router<State>,
consumed: Rc<Cell<Option<u64>>>,
routes: RouteObserver,
}
impl<State: 'static> RouterDeepLinks<State> {
pub fn new(router: Router<State>, initial_location: &str) -> Self {
router.route_navigator().set_waker(Arc::new(|| {
if let Some(runtime) = ReactiveRuntime::get() {
runtime.wake();
}
}));
let links = deep_links();
let start = links
.initial
.clone()
.unwrap_or_else(|| initial_location.to_string());
router.handle_location(&normalize_deep_link(&start));
let consumed = Rc::new(Cell::new(links.initial.as_deref().and_then(
|initial_url| {
links
.latest
.get_untracked()
.filter(|link| link.url == initial_url)
.map(|link| link.sequence)
},
)));
Self {
router,
consumed,
routes: RouteObserver::new(),
}
}
pub fn track(&self) {
self.router.pump();
let Some(link) = deep_links().latest.get() else {
return;
};
let already_consumed = self.consumed.get() == Some(link.sequence);
if already_consumed {
return;
}
self.consumed.set(Some(link.sequence));
self.router.handle_location(&normalize_deep_link(&link.url));
}
pub fn router(&self) -> &Router<State> {
&self.router
}
pub fn route_navigator(&self) -> RouteNavigator {
self.router.route_navigator()
}
pub fn routes(&self) -> RouteObserver {
self.routes
}
}
pub fn router_with_deep_links<State: 'static>(
router: Router<State>,
initial_location: &str,
) -> RouterDeepLinks<State> {
RouterDeepLinks::new(router, initial_location)
}
#[cfg(test)]
mod tests {
use super::*;
use frust_core::{AnyView, FrameTime, PaintScene, RenderRoot, View, any};
use frust_reactive::{ReactiveRuntime, push_deep_link};
use frust_widgets::{NavigatorView, Route, navigator};
use kurbo::{Point, Size};
use std::sync::Arc;
struct SizedLeaf {
size: Size,
}
struct SizedLeafWidget {
size: Size,
}
impl View<()> for SizedLeaf {
type Element = SizedLeafWidget;
fn build(&self, _ctx: &mut frust_core::BuildCtx<'_>) -> SizedLeafWidget {
SizedLeafWidget { size: self.size }
}
fn rebuild(
&self,
_prev: &Self,
element: &mut SizedLeafWidget,
_ctx: &mut frust_core::BuildCtx<'_>,
) -> frust_core::ChangeFlags {
element.size = self.size;
frust_core::ChangeFlags::LAYOUT
}
}
impl frust_core::Widget for SizedLeafWidget {
fn layout(
&mut self,
_ctx: &mut frust_core::LayoutCtx,
bc: &frust_core::BoxConstraints,
) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut frust_core::PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
}
}
fn sized(w: f64, h: f64) -> AnyView<()> {
any(SizedLeaf {
size: Size::new(w, h),
})
}
#[derive(Default)]
struct RecordingScene {
rects: Vec<(Point, Size)>,
}
impl PaintScene for RecordingScene {
fn fill_rect(&mut self, origin: Point, size: Size, _color: peniko::Color) {
self.rects.push((origin, size));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
const HOME: Size = Size::new(10.0, 10.0);
const PROFILE: Size = Size::new(20.0, 20.0);
const SETTINGS: Size = Size::new(30.0, 30.0);
const ERROR: Size = Size::new(99.0, 99.0);
fn routes() -> Vec<Route<()>> {
vec![
Route::new("/", |_params| sized(HOME.width, HOME.height)),
Route::new("/profile/:id", |_params| {
sized(PROFILE.width, PROFILE.height)
}),
Route::new("/settings", |_params| {
sized(SETTINGS.width, SETTINGS.height)
}),
]
}
fn router_with_error_leaf() -> Router<()> {
Router::new(routes()).error_builder(|_loc| sized(ERROR.width, ERROR.height))
}
type AppLogic = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
struct Harness {
links: RouterDeepLinks<()>,
root: RenderRoot<(), NavigatorView<()>>,
app: AppLogic,
state: (),
}
impl Harness {
fn new(router: Router<()>, initial_location: &str) -> Self {
let links = RouterDeepLinks::new(router, initial_location);
let controller = links.router().controller().clone();
let app: AppLogic = Box::new(move |_: &mut ()| {
navigator(&controller, || sized(HOME.width, HOME.height))
});
let mut harness = Harness {
links,
root: RenderRoot::new(),
app,
state: (),
};
harness.rebuild();
harness
}
fn rebuild(&mut self) {
self.root.rebuild(&mut self.app, &mut self.state);
}
fn track_and_paint(&mut self) -> Size {
self.links.track();
self.rebuild();
self.paint()
}
fn paint(&mut self) -> Size {
self.root.layout(Size::new(100.0, 100.0));
let mut scene = RecordingScene::default();
self.root.paint(&mut scene, FrameTime::ZERO);
scene
.rects
.last()
.map(|(_, size)| *size)
.unwrap_or(Size::ZERO)
}
}
#[test]
fn router_deep_link_glue() {
let _rt = ReactiveRuntime::init(Arc::new(|| {}));
let mut cold_no_link = Harness::new(router_with_error_leaf(), "/");
assert_eq!(cold_no_link.paint(), HOME);
push_deep_link("/profile/42");
let mut warm = Harness::new(router_with_error_leaf(), "/");
assert_eq!(warm.paint(), PROFILE);
warm.links.router().go("/");
warm.rebuild();
assert_eq!(warm.paint(), HOME);
assert_eq!(
warm.track_and_paint(),
HOME,
"track() must not re-navigate to the cold-start link on the first call"
);
push_deep_link("/profile/42");
assert_eq!(
warm.track_and_paint(),
PROFILE,
"an identical URL delivered again must still navigate"
);
warm.links.router().go("/");
warm.rebuild();
assert_eq!(warm.paint(), HOME);
push_deep_link("/settings");
assert_eq!(warm.track_and_paint(), SETTINGS);
warm.links.router().go("/");
warm.rebuild();
assert_eq!(warm.paint(), HOME);
assert_eq!(
warm.track_and_paint(),
HOME,
"track() must not re-navigate to an already-consumed link"
);
push_deep_link("/does/not/exist");
assert_eq!(warm.track_and_paint(), ERROR);
warm.links.route_navigator().go("/settings");
assert_eq!(
warm.track_and_paint(),
SETTINGS,
"track() must pump the RouteNavigator queue"
);
warm.links.router().go("/");
warm.rebuild();
assert_eq!(warm.track_and_paint(), HOME);
}
#[test]
fn route_navigator_round_trips_through_context() {
use frust_reactive::{provide_context, use_context};
let rt = ReactiveRuntime::init(Arc::new(|| {}));
let router = router_with_error_leaf();
let nav = router.route_navigator();
let recovered = rt.with_owner(|| {
provide_context(nav.clone());
use_context::<RouteNavigator>()
});
let recovered = recovered.expect("a RouteNavigator must survive provide_context");
recovered.push("/settings");
router.pump();
assert_eq!(
nav.location().map(|loc| loc.path),
Some("/settings".to_string()),
"the context-recovered handle drove the original router"
);
}
#[test]
fn normalize_bare_path_passes_through() {
assert_eq!(normalize_deep_link("/item/7"), "/item/7");
assert_eq!(normalize_deep_link("/"), "/");
assert_eq!(normalize_deep_link("/search?q=asdf"), "/search?q=asdf");
}
#[test]
fn normalize_custom_scheme_host_and_path() {
assert_eq!(normalize_deep_link("fktest://item/7"), "/item/7");
assert_eq!(
normalize_deep_link("fktest://item/7/nested"),
"/item/7/nested"
);
}
#[test]
fn normalize_custom_scheme_host_only() {
assert_eq!(normalize_deep_link("fktest://settings"), "/settings");
}
#[test]
fn normalize_custom_scheme_hostless() {
assert_eq!(normalize_deep_link("fktest:///settings"), "/settings");
}
#[test]
fn normalize_https_universal_link_drops_host() {
assert_eq!(normalize_deep_link("https://host/item/7"), "/item/7");
assert_eq!(normalize_deep_link("http://host/item/7"), "/item/7");
assert_eq!(normalize_deep_link("HTTPS://host/item/7"), "/item/7");
}
#[test]
fn normalize_trailing_and_empty_edge_cases() {
assert_eq!(normalize_deep_link("https://host/"), "/");
assert_eq!(normalize_deep_link("https://host"), "/");
assert_eq!(normalize_deep_link("fktest://item/"), "/item");
assert_eq!(normalize_deep_link("fktest://"), "/");
assert_eq!(normalize_deep_link(""), "");
}
}