use bevy::prelude::*;
use std::collections::HashMap;
pub use crate::load;
pub use inventory;
#[macro_export]
macro_rules! load {
($component:expr) => {
$crate::routing::RouteTarget::load($component)
};
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Routes {
routes: Vec<Route>,
redirects: Vec<RouteRedirect>,
fallback_component: Option<String>,
}
impl Routes {
pub fn new() -> Self {
Self::default()
}
pub fn route(mut self, path: impl Into<String>, component: impl Into<RouteTarget>) -> Self {
let target = component.into();
self.routes.push(Route {
path: normalize_route_path(path.into()),
component: target.component,
keep_alive: target.keep_alive,
});
self
}
pub fn merge(self, routes: impl IntoRoutes) -> Self {
merge_routes(self, routes.into_routes())
}
pub fn redirect(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
self.redirects.push(RouteRedirect {
from: normalize_route_path(from.into()),
to: normalize_route_path(to.into()),
});
self
}
pub fn fallback(mut self, component: impl Into<String>) -> Self {
self.fallback_component = Some(component.into());
self
}
pub fn resolve_component(&self, path: &str) -> Option<&str> {
let resolved_path = self.resolve_redirect(path);
self.routes
.iter()
.find(|route| route.path == resolved_path)
.map(|route| route.component.as_str())
.or(self.fallback_component.as_deref())
}
pub fn routes(&self) -> &[Route] {
&self.routes
}
pub fn redirects(&self) -> &[RouteRedirect] {
&self.redirects
}
pub fn fallback_component(&self) -> Option<&str> {
self.fallback_component.as_deref()
}
fn resolve_redirect(&self, path: &str) -> String {
let mut current = normalize_route_path(path);
for _ in 0..16 {
let Some(redirect) = self
.redirects
.iter()
.find(|redirect| redirect.from == current)
else {
return current;
};
if redirect.to == current {
return current;
}
current = redirect.to.clone();
}
warn!("Route redirect loop detected for path `{}`", path);
current
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Route {
pub path: String,
pub component: String,
pub keep_alive: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteTarget {
component: String,
keep_alive: bool,
}
impl RouteTarget {
pub fn new(component: impl Into<String>) -> Self {
Self {
component: component.into(),
keep_alive: false,
}
}
pub fn load(component: impl Into<String>) -> Self {
Self {
component: component.into(),
keep_alive: true,
}
}
}
impl From<&str> for RouteTarget {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for RouteTarget {
fn from(value: String) -> Self {
Self::new(value)
}
}
pub trait IntoRoutes {
fn into_routes(self) -> Routes;
}
impl IntoRoutes for Routes {
fn into_routes(self) -> Routes {
self
}
}
impl<F> IntoRoutes for F
where
F: FnOnce() -> Routes,
{
fn into_routes(self) -> Routes {
self()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteRedirect {
pub from: String,
pub to: String,
}
#[derive(Resource, Clone, Debug)]
pub struct Router {
routes: Routes,
current_path: String,
revision: u64,
}
impl Default for Router {
fn default() -> Self {
Self {
routes: Routes::default(),
current_path: "/".to_string(),
revision: 0,
}
}
}
impl Router {
pub fn configure(&mut self, routes: Routes) {
if self.routes == routes {
return;
}
self.routes = routes;
self.bump_revision();
}
pub fn navigate(&mut self, path: impl Into<String>) {
let next = normalize_route_path(path.into());
if self.current_path == next {
return;
}
self.current_path = next;
self.bump_revision();
}
pub fn current_path(&self) -> &str {
&self.current_path
}
pub fn active_component(&self) -> Option<&str> {
self.routes.resolve_component(&self.current_path)
}
pub fn routes(&self) -> &Routes {
&self.routes
}
pub fn revision(&self) -> u64 {
self.revision
}
fn bump_revision(&mut self) {
self.revision = self.revision.saturating_add(1);
}
}
pub struct RoutesRegistration {
pub name: &'static str,
pub build: fn() -> Routes,
}
inventory::collect!(RoutesRegistration);
pub struct ExtendedRoutingPlugin;
impl Plugin for ExtendedRoutingPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Router>();
app.add_systems(Startup, register_beu_routes);
}
}
pub fn register_beu_routes(world: &mut World) {
let mut merged = Routes::new();
let mut has_registrations = false;
for registration in inventory::iter::<RoutesRegistration> {
has_registrations = true;
let routes = (registration.build)();
merged = merge_routes(merged, routes);
debug!("Registered UI routes from `{}`", registration.name);
}
if has_registrations {
world.resource_mut::<Router>().configure(merged);
}
}
fn merge_routes(mut left: Routes, right: Routes) -> Routes {
let mut by_path = left
.routes
.iter()
.map(|route| (route.path.clone(), route.clone()))
.collect::<HashMap<_, _>>();
for route in right.routes {
by_path.insert(route.path.clone(), route);
}
left.routes = by_path.into_values().collect();
left.routes.sort_by(|a, b| a.path.cmp(&b.path));
left.redirects.extend(right.redirects);
if right.fallback_component.is_some() {
left.fallback_component = right.fallback_component;
}
left
}
fn normalize_route_path(path: impl AsRef<str>) -> String {
let path = path.as_ref().trim();
if path.is_empty() {
return "/".to_string();
}
let mut normalized = path.replace('\\', "/");
if !normalized.starts_with('/') {
normalized.insert(0, '/');
}
while normalized.len() > 1 && normalized.ends_with('/') {
normalized.pop();
}
normalized
}