Created
July 30, 2024 20:33
-
-
Save omaskery/512862251f2f5fbf29e93213bedf1a96 to your computer and use it in GitHub Desktop.
A failed experiment in creating a suped-up `EntityPath` alternative whose aim was to improve ergonomics of wiring up UI behaviour in Bevy, supposing some future BSN-like proposal improves the ergonomics of instantiating a widget hierarchy.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use std::cmp::Ordering; | |
use std::fmt::Debug; | |
use std::str::FromStr; | |
use bevy::ecs::component::ComponentId; | |
use bevy::ecs::system::{EntityCommands, IntoObserverSystem}; | |
use bevy::prelude::*; | |
#[derive(Debug, Clone, Eq, PartialEq)] | |
pub struct EntityPath { | |
parts: Vec<EntityPathPart>, | |
} | |
#[derive(Clone, Debug, Eq, PartialEq)] | |
pub enum Error { | |
NoMatchesFound, | |
UnknownComponentName(String), | |
} | |
impl EntityPath { | |
pub fn optimize(&mut self, world: &World) -> Result<(), Error> { | |
for part in self.parts.iter_mut() { | |
part.predicate.optimize(world)?; | |
} | |
Ok(()) | |
} | |
pub fn resolve(&self, world: &World, start: Entity) -> Result<Entity, Error> { | |
struct Cursor<'a> { | |
world: &'a World, | |
current: Entity, | |
} | |
impl<'a> Cursor<'a> { | |
fn iterate_relation(&self, start: Entity, relationship: Relationship) -> Vec<Entity> { | |
match relationship { | |
Relationship::Children => self.world.get::<Children>(start) | |
.map(|children| children.iter().cloned().collect()), | |
Relationship::Parent => self.world.get::<Parent>(start) | |
.map(|parent| vec![parent.get()]), | |
}.unwrap_or_else(|| vec![]) | |
} | |
fn advance(&mut self, part: &EntityPathPart) -> Result<(), Error> { | |
#[derive(PartialEq, Eq)] | |
struct Node { | |
entity: Entity, | |
depth: usize, | |
} | |
impl PartialOrd for Node { | |
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { | |
self.depth.partial_cmp(&other.depth) | |
} | |
} | |
impl Ord for Node { | |
fn cmp(&self, other: &Self) -> Ordering { | |
self.depth.cmp(&other.depth) | |
} | |
} | |
let mut visited = std::collections::HashSet::new(); | |
let mut frontier = std::collections::BinaryHeap::new(); | |
frontier.push(Node { | |
entity: self.current, | |
depth: 0, | |
}); | |
while let Some(current) = frontier.pop() { | |
visited.insert(current.entity); | |
if part.predicate.satisfied_by(self.world, current.entity)? { | |
self.current = current.entity; | |
return Ok(()); | |
} | |
// If we can't recurse down, stop here | |
if part.steps == TraversalSteps::One { | |
continue; | |
} | |
// Traverse to all previously unvisited neighbours | |
let next_depth = current.depth + 1; | |
let next = self.iterate_relation(current.entity, part.relationship) | |
.into_iter() | |
.filter(|e| !visited.contains(e)) | |
.map(|e| Node { | |
entity: e, | |
depth: next_depth, | |
}); | |
frontier.extend(next); | |
} | |
Err(Error::NoMatchesFound) | |
} | |
} | |
let mut cursor = Cursor { | |
world, | |
current: start, | |
}; | |
for part in self.parts.iter() { | |
cursor.advance(part)?; | |
} | |
Ok(cursor.current) | |
} | |
} | |
#[derive(Debug, Clone, Eq, PartialEq)] | |
struct EntityPathPart { | |
steps: TraversalSteps, | |
predicate: Predicate, | |
relationship: Relationship, | |
} | |
#[derive(Debug, Copy, Clone, Eq, PartialEq)] | |
enum TraversalSteps { | |
One, | |
Many, | |
} | |
#[derive(Debug, Clone, Eq, PartialEq)] | |
enum Predicate { | |
Any, | |
Name(String), | |
ComponentType(ComponentId), | |
ComponentTypeName(String), | |
And(Box<Predicate>, Box<Predicate>), | |
} | |
impl Predicate { | |
fn optimize(&mut self, world: &World) -> Result<(), Error> { | |
match self { | |
Predicate::Name(_) | Predicate::ComponentType(_) | Predicate::Any => {} | |
Predicate::And(a, b) => { | |
a.optimize(world)?; | |
b.optimize(world)?; | |
} | |
Predicate::ComponentTypeName(name) => { | |
if let Some(id) = resolve_component_name_to_id(world, name) { | |
*self = Predicate::ComponentType(id); | |
} else { | |
return Err(Error::UnknownComponentName(name.clone())); | |
} | |
} | |
} | |
Ok(()) | |
} | |
fn satisfied_by(&self, world: &World, e: Entity) -> Result<bool, Error> { | |
match self { | |
Predicate::Name(name) => { | |
Ok(world.get::<Name>(e).map(|n| n.as_str() == name).unwrap_or(false)) | |
} | |
Predicate::ComponentType(id) => { | |
Ok(world.get_by_id(e, *id).is_some()) | |
} | |
Predicate::ComponentTypeName(name) => { | |
resolve_component_name_to_id(world, name) | |
.map(|id| world.get_by_id(e, id).is_some()) | |
.ok_or_else(|| Error::UnknownComponentName(name.clone())) | |
} | |
Predicate::Any => Ok(true), | |
Predicate::And(a, b) => | |
Ok(a.satisfied_by(world, e)? && b.satisfied_by(world, e)?), | |
} | |
} | |
} | |
#[derive(Debug, Copy, Clone, Eq, PartialEq)] | |
enum Relationship { | |
Children, | |
Parent, | |
} | |
impl TryFrom<&str> for EntityPath { | |
type Error = <EntityPath as FromStr>::Err; | |
fn try_from(value: &str) -> Result<Self, Self::Error> { | |
value.parse() | |
} | |
} | |
impl FromStr for EntityPath { | |
type Err = (); | |
fn from_str(s: &str) -> Result<Self, Self::Err> { | |
let mut result = EntityPath { | |
parts: Vec::new(), | |
}; | |
let tokens = tokenize_entity_path(s); | |
let mut traversal_steps = TraversalSteps::Many; | |
let mut new_part = true; | |
let mut add_predicate = |new_part: &mut bool, predicate, steps| { | |
if *new_part { | |
result.parts.push(EntityPathPart { | |
predicate, | |
steps, | |
relationship: Relationship::Children, | |
}); | |
} else if let Some(last) = result.parts.last_mut() { | |
let previous = std::mem::replace(&mut last.predicate, Predicate::Any); | |
last.predicate = Predicate::And(Box::new(previous), Box::new(predicate)); | |
} | |
*new_part = false; | |
}; | |
for token in tokens { | |
match token { | |
EntityPathToken::ComponentType(c) => { | |
add_predicate(&mut new_part, Predicate::ComponentTypeName(c), traversal_steps); | |
} | |
EntityPathToken::EntityName(n) => { | |
add_predicate(&mut new_part, Predicate::Name(n), traversal_steps); | |
} | |
EntityPathToken::Any => { | |
add_predicate(&mut new_part, Predicate::Any, TraversalSteps::One); | |
} | |
EntityPathToken::ImmediateOnly => { | |
traversal_steps = TraversalSteps::One; | |
new_part = true; | |
} | |
EntityPathToken::Gap => { | |
traversal_steps = TraversalSteps::Many; | |
new_part = true; | |
} | |
} | |
} | |
Ok(result) | |
} | |
} | |
#[derive(Debug, Clone, PartialEq, Eq)] | |
enum EntityPathToken { | |
EntityName(String), | |
ComponentType(String), | |
ImmediateOnly, | |
Gap, | |
Any, | |
} | |
const ANY_TOKEN_CHAR: char = '*'; | |
const IMMEDIATE_TOKEN_CHAR: char = '>'; | |
const ENTITY_NAME_TOKEN_PREFIX: char = '.'; | |
fn tokenize_entity_path(s: &str) -> Vec<EntityPathToken> { | |
let mut result = Vec::new(); | |
struct Tokenizer<'a> { | |
source: &'a str, | |
offset: usize, | |
} | |
impl<'a> Tokenizer<'a> { | |
fn advance(&mut self, n: usize) { | |
self.offset = self.source.len().min(self.offset + n); | |
} | |
fn peek(&self) -> Option<char> { | |
self.source.chars().nth(self.offset) | |
} | |
fn skip_while(&mut self, mut f: impl FnMut(char) -> bool) -> bool { | |
let mut skipped = false; | |
while let Some(c) = self.peek() { | |
if !f(c) { | |
break; | |
} | |
self.offset += 1; | |
skipped = true; | |
} | |
skipped | |
} | |
fn skip_whitespace(&mut self) -> bool { | |
self.skip_while(|c| c.is_whitespace()) | |
} | |
fn consume_while(&mut self, mut f: impl FnMut(char) -> bool) -> String { | |
let mut consumed = String::new(); | |
self.skip_while(|c| { | |
let consume = f(c); | |
if consume { | |
consumed.push(c); | |
} | |
consume | |
}); | |
consumed | |
} | |
} | |
fn should_start_new_predicate(c: char) -> bool { | |
!c.is_alphanumeric() | |
} | |
let mut tokenizer = Tokenizer { | |
source: s, | |
offset: 0, | |
}; | |
loop { | |
let skipped = tokenizer.skip_whitespace(); | |
let Some(next) = tokenizer.peek() else { break; }; | |
if next == IMMEDIATE_TOKEN_CHAR { | |
tokenizer.advance(1); | |
result.push(EntityPathToken::ImmediateOnly); | |
tokenizer.skip_whitespace(); | |
continue; | |
} | |
if next == ANY_TOKEN_CHAR { | |
tokenizer.advance(1); | |
result.push(EntityPathToken::Any); | |
tokenizer.skip_whitespace(); | |
continue; | |
} | |
if skipped && result.len() > 0 { | |
result.push(EntityPathToken::Gap); | |
continue; | |
} | |
let mut first = true; | |
let predicate = tokenizer.consume_while(|c| { | |
if first { | |
first = false; | |
return true; | |
} | |
!should_start_new_predicate(c) | |
}); | |
let token = match predicate { | |
p if predicate.starts_with(ENTITY_NAME_TOKEN_PREFIX) => | |
EntityPathToken::EntityName(p[1..].into()), | |
p => EntityPathToken::ComponentType(p), | |
}; | |
result.push(token); | |
} | |
result | |
} | |
fn resolve_component_name_to_id(world: &World, name: &str) -> Option<ComponentId> { | |
world.components().iter() | |
.filter_map(|info| { | |
let n = info.name(); | |
let start = n.rfind(':') | |
.map(|i| i + 1) | |
.unwrap_or(0); | |
if n[start..] == *name { | |
Some(info.id()) | |
} else { | |
None | |
} | |
}) | |
.next() | |
} | |
pub trait EntityPathEntityCommandsExt { | |
fn observe_path<E: Event, B: Bundle, M, R: Debug>(&mut self, path: impl TryInto<EntityPath, Error=R>, system: impl IntoObserverSystem<E, B, M>); | |
} | |
impl<'a> EntityPathEntityCommandsExt for EntityCommands<'a> { | |
fn observe_path<E: Event, B: Bundle, M, R: Debug>(&mut self, path: impl TryInto<EntityPath, Error=R>, system: impl IntoObserverSystem<E, B, M>) { | |
let path = match path.try_into() { | |
Ok(p) => p, | |
Err(e) => panic!("invalid EntityPath: {:?}", e), | |
}; | |
self.add(move |entity: Entity, world: &mut World| { | |
let Ok(observe_target) = path.resolve(world, entity) else { return; }; | |
world.entity_mut(observe_target) | |
.observe(system); | |
}); | |
} | |
} | |
#[cfg(test)] | |
mod test { | |
use std::cell::RefCell; | |
use std::collections::HashMap; | |
use super::*; | |
#[test] | |
fn tokenizer_test() { | |
assert_eq!(tokenize_entity_path(""), vec![]); | |
assert_eq!(tokenize_entity_path(" "), vec![]); | |
assert_eq!(tokenize_entity_path(" "), vec![]); | |
assert_eq!(tokenize_entity_path(">"), vec![EntityPathToken::ImmediateOnly]); | |
assert_eq!(tokenize_entity_path("> "), vec![EntityPathToken::ImmediateOnly]); | |
assert_eq!(tokenize_entity_path(" >"), vec![EntityPathToken::ImmediateOnly]); | |
assert_eq!(tokenize_entity_path(" > "), vec![EntityPathToken::ImmediateOnly]); | |
assert_eq!(tokenize_entity_path("blah"), vec![EntityPathToken::ComponentType("blah".into())]); | |
assert_eq!(tokenize_entity_path(" blah"), vec![EntityPathToken::ComponentType("blah".into())]); | |
assert_eq!(tokenize_entity_path(" blah "), vec![EntityPathToken::ComponentType("blah".into())]); | |
assert_eq!(tokenize_entity_path("blah "), vec![EntityPathToken::ComponentType("blah".into())]); | |
assert_eq!(tokenize_entity_path(".foo"), vec![EntityPathToken::EntityName("foo".into())]); | |
assert_eq!(tokenize_entity_path(" .foo"), vec![EntityPathToken::EntityName("foo".into())]); | |
assert_eq!(tokenize_entity_path(" .foo "), vec![EntityPathToken::EntityName("foo".into())]); | |
assert_eq!(tokenize_entity_path(".foo "), vec![EntityPathToken::EntityName("foo".into())]); | |
assert_eq!( | |
tokenize_entity_path(".PlayerProfile TabPage.Achievements > Text"), | |
vec![ | |
EntityPathToken::EntityName("PlayerProfile".into()), | |
EntityPathToken::Gap, | |
EntityPathToken::ComponentType("TabPage".into()), | |
EntityPathToken::EntityName("Achievements".into()), | |
EntityPathToken::ImmediateOnly, | |
EntityPathToken::ComponentType("Text".into()), | |
], | |
); | |
} | |
#[test] | |
fn parse_test() { | |
assert_eq!("".parse(), Ok(EntityPath { | |
parts: vec![], | |
})); | |
assert_eq!("UiImage".parse(), Ok(EntityPath { | |
parts: vec![EntityPathPart { | |
predicate: Predicate::ComponentTypeName("UiImage".into()), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}], | |
})); | |
assert_eq!(".PlayerProfile".parse(), Ok(EntityPath { | |
parts: vec![EntityPathPart { | |
predicate: Predicate::Name("PlayerProfile".into()), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}], | |
})); | |
assert_eq!(".PlayerProfile TabPage".parse(), Ok(EntityPath { | |
parts: vec![ | |
EntityPathPart { | |
predicate: Predicate::Name("PlayerProfile".into()), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}, | |
EntityPathPart { | |
predicate: Predicate::ComponentTypeName("TabPage".into()), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}, | |
], | |
})); | |
assert_eq!(".PlayerProfile TabPage.Achievements".parse(), Ok(EntityPath { | |
parts: vec![ | |
EntityPathPart { | |
predicate: Predicate::Name("PlayerProfile".into()), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}, | |
EntityPathPart { | |
predicate: Predicate::And( | |
Box::new(Predicate::ComponentTypeName("TabPage".into())), | |
Box::new(Predicate::Name("Achievements".into())), | |
), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}, | |
], | |
})); | |
assert_eq!(".PlayerProfile TabPage.Achievements > UiImage".parse(), Ok(EntityPath { | |
parts: vec![ | |
EntityPathPart { | |
predicate: Predicate::Name("PlayerProfile".into()), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}, | |
EntityPathPart { | |
predicate: Predicate::And( | |
Box::new(Predicate::ComponentTypeName("TabPage".into())), | |
Box::new(Predicate::Name("Achievements".into())), | |
), | |
steps: TraversalSteps::Many, | |
relationship: Relationship::Children, | |
}, | |
EntityPathPart { | |
predicate: Predicate::ComponentTypeName("UiImage".into()), | |
steps: TraversalSteps::One, | |
relationship: Relationship::Children, | |
}, | |
], | |
})); | |
} | |
#[test] | |
fn test_component_name_resolution() { | |
#[derive(Component)] | |
struct TestComponent; | |
#[derive(Component)] | |
struct AnotherTestComponent; | |
let mut world = World::new(); | |
let test_component_id = world.init_component::<TestComponent>(); | |
let another_test_component_id = world.init_component::<AnotherTestComponent>(); | |
assert_eq!( | |
resolve_component_name_to_id(&world, "TestComponent"), | |
Some(test_component_id), | |
); | |
assert_eq!( | |
resolve_component_name_to_id(&world, "AnotherTestComponent"), | |
Some(another_test_component_id), | |
); | |
assert_eq!( | |
resolve_component_name_to_id(&world, "InvalidComponentNameGoesHere"), | |
None, | |
); | |
} | |
#[test] | |
fn test_traversal() { | |
use bevy::prelude::Name; | |
let mut world = World::new(); | |
let entities: RefCell<HashMap<String, Entity>> = RefCell::new(HashMap::new()); | |
let record_entity = |e: Entity, name: &str| { | |
entities.borrow_mut().insert(name.into(), e); | |
}; | |
let get_entity = |name: &str| -> Entity { | |
*entities.borrow().get(name).expect("missing entity!") | |
}; | |
macro_rules! spawn { | |
($parent:ident, $name:literal) => { | |
{ | |
let builder = $parent.spawn(Name::from($name)); | |
let id = builder.id(); | |
record_entity(id, $name); | |
builder | |
} | |
} | |
} | |
#[derive(Component)] | |
struct TabPage; | |
#[derive(Component)] | |
struct UiImage; | |
spawn!(world, "Root") | |
.with_children(|children| { | |
spawn!(children, "FrontendUI") | |
.with_children(|children| { | |
spawn!(children, "ProfileUI") | |
.with_children(|children| { | |
spawn!(children, "ProfilePicture") | |
.insert(UiImage); | |
spawn!(children, "ProfileTabControl") | |
.with_children(|children| { | |
spawn!(children, "ProfileTab") | |
.insert(TabPage); | |
spawn!(children, "AchievementsTab") | |
.insert(TabPage) | |
.with_children(|children| { | |
spawn!(children, "HeadingText"); | |
spawn!(children, "AchievementsImage") | |
.insert(UiImage); | |
}); | |
spawn!(children, "StatisticsTab") | |
.insert(TabPage); | |
spawn!(children, "MatchHistory") | |
.insert(TabPage); | |
spawn!(children, "Clan") | |
.insert(TabPage); | |
}); | |
}); | |
}); | |
}); | |
{ | |
let path = EntityPath::from_str(".FrontendUI").unwrap(); | |
let root = get_entity("Root"); | |
let frontend_ui = get_entity("FrontendUI"); | |
let result = path.resolve(&world, root); | |
assert_eq!(result, Ok(frontend_ui)); | |
} | |
{ | |
let path = EntityPath::from_str("TabPage.AchievementsTab").unwrap(); | |
let root = get_entity("Root"); | |
let achievements_tab = get_entity("AchievementsTab"); | |
let result = path.resolve(&world, root); | |
assert_eq!(result, Ok(achievements_tab)); | |
} | |
{ | |
let path = EntityPath::from_str("UiImage").unwrap(); | |
let root = get_entity("Root"); | |
let profile_picture = get_entity("ProfilePicture"); | |
let result = path.resolve(&world, root); | |
assert_eq!(result, Ok(profile_picture)); | |
} | |
{ | |
let path = EntityPath::from_str("TabPage.AchievementsTab UiImage").unwrap(); | |
let root = get_entity("Root"); | |
let achievements_image = get_entity("AchievementsImage"); | |
let result = path.resolve(&world, root); | |
assert_eq!(result, Ok(achievements_image)); | |
} | |
} | |
#[test] | |
fn test_command_extensions() { | |
let mut world = World::new(); | |
#[derive(Component)] | |
struct Node; | |
#[derive(Component)] | |
struct Counter(i32); | |
#[derive(Component)] | |
struct Text(String); | |
#[derive(Component)] | |
struct Button; | |
#[derive(Event)] | |
struct Clicked; | |
#[derive(Event)] | |
struct Mutated; | |
let mut commands = world.commands(); | |
let mut counter_cmds = commands.spawn((Node, Counter(0))); | |
let mut decrement_id = Entity::from_raw(0); | |
let mut text_id = Entity::from_raw(0); | |
let mut increment_id = Entity::from_raw(0); | |
counter_cmds.with_children(|children| { | |
decrement_id = children.spawn((Node, Name::from("Decrement"), Button)).id(); | |
text_id = children.spawn((Node, Name::from("Text"), Text("".into()))).id(); | |
increment_id = children.spawn((Node, Name::from("Increment"), Button)).id(); | |
}); | |
let counter_id = counter_cmds.id(); | |
counter_cmds.observe_path(".Decrement", move |_trigger: Trigger<Clicked>, mut counter_query: Query<&mut Counter>| { | |
let mut state = counter_query.get_mut(counter_id).unwrap(); | |
state.0 -= 1; | |
}); | |
counter_cmds.observe_path(".Increment", move |_trigger: Trigger<Clicked>, mut counter_query: Query<&mut Counter>| { | |
let mut state = counter_query.get_mut(counter_id).unwrap(); | |
state.0 += 1; | |
}); | |
counter_cmds.observe(move |trigger: Trigger<Mutated>, counter_query: Query<&Counter>, mut text_query: Query<&mut Text>| { | |
let state = counter_query.get(trigger.entity()).unwrap(); | |
let mut text = text_query.get_mut(text_id).unwrap(); | |
text.0 = format!("Count: {}", state.0); | |
}); | |
world.flush(); | |
world.trigger_targets(Mutated, counter_id); | |
assert_eq!(world.get::<Text>(text_id).unwrap().0, "Count: 0"); | |
world.trigger_targets(Clicked, increment_id); | |
world.trigger_targets(Mutated, counter_id); | |
assert_eq!(world.get::<Counter>(counter_id).unwrap().0, 1); | |
assert_eq!(world.get::<Text>(text_id).unwrap().0, "Count: 1"); | |
world.trigger_targets(Clicked, increment_id); | |
world.trigger_targets(Mutated, counter_id); | |
assert_eq!(world.get::<Counter>(counter_id).unwrap().0, 2); | |
assert_eq!(world.get::<Text>(text_id).unwrap().0, "Count: 2"); | |
world.trigger_targets(Clicked, decrement_id); | |
world.trigger_targets(Mutated, counter_id); | |
assert_eq!(world.get::<Counter>(counter_id).unwrap().0, 1); | |
assert_eq!(world.get::<Text>(text_id).unwrap().0, "Count: 1"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment