Skip to content

Instantly share code, notes, and snippets.

@omaskery
Created July 30, 2024 20:33
Show Gist options
  • Save omaskery/512862251f2f5fbf29e93213bedf1a96 to your computer and use it in GitHub Desktop.
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.
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