lumen_core/node.rs
1//! Node handles and the per-tick DOM index for the dynamic query API.
2//!
3//! The read side of the scripting DOM surface (`query`, `get_by_id`,
4//! traversal) addresses live elements through a [`NodeHandle`]: an
5//! `Entity` plus its generation, so a stale handle resolves to nothing
6//! instead of aliasing a recycled entity. [`DomIndex`] is an immutable
7//! per-tick snapshot of the selector-reachable tree that the runtime
8//! rebuilds each frame and publishes into a process-shared cache; script
9//! hosts and the C-ABI read that snapshot without touching the live world.
10//!
11//! Selector matching itself lives in `lumen-ir` (the cascade matcher) and
12//! is driven from `lumen-script`, which can depend on both this crate and
13//! `lumen-ir`. This module holds only the handle types, the pure snapshot
14//! data, and the traversal that needs no selector engine.
15
16use bevy_ecs::entity::{Entities, Entity};
17use bevy_ecs::resource::Resource;
18use std::collections::HashMap;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::{Arc, Mutex, OnceLock, RwLock};
21
22/// Opaque handle to a live element: an [`Entity`] plus the generation it
23/// carried when the handle was minted. Resolving a handle validates the
24/// generation, so a handle to a despawned entity returns `None` rather
25/// than addressing whatever entity later reused that index.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct NodeHandle {
28 /// The referenced entity (index + generation).
29 pub entity: Entity,
30 /// The entity's generation bits at mint time, kept for the packed
31 /// wire form and for cross-checking after an unpack.
32 pub generation: u32,
33}
34
35impl NodeHandle {
36 /// Mint a handle for `entity`, capturing its current generation.
37 pub fn new(entity: Entity) -> Self {
38 Self {
39 entity,
40 generation: entity.generation().to_bits(),
41 }
42 }
43
44 /// Pack into the `u64` wire form used by the C-ABI `LumenNode` and by
45 /// hosts whose value type can hold 64 bits. The layout is
46 /// `Entity::to_bits` (index in the low half, generation in the high
47 /// half); treat it as opaque and round-trip only through [`Self::unpack`].
48 pub fn pack(self) -> u64 {
49 self.entity.to_bits()
50 }
51
52 /// Reconstruct from the packed wire form. Returns `None` for bits that
53 /// never came from [`Self::pack`] (invalid index), never panicking.
54 pub fn unpack(bits: u64) -> Option<Self> {
55 Entity::try_from_bits(bits).map(Self::new)
56 }
57
58 /// Resolve against the live entity allocator: `Some(entity)` when the
59 /// exact `(index, generation)` is still alive, `None` when it has been
60 /// despawned (stale handle). Never panics.
61 pub fn validate(&self, entities: &Entities) -> Option<Entity> {
62 if entities.contains(self.entity) {
63 Some(self.entity)
64 } else {
65 None
66 }
67 }
68}
69
70/// Side-table mapping small `i32` ids to node handles, for script hosts
71/// whose value type cannot hold a 64-bit handle (candela's `Value` is
72/// internally `i32`). `intern` is idempotent per entity, so a handle
73/// keeps the same id across a tick; the sentinel id `0` means "no node".
74///
75/// Registered as a [`Resource`] for embedders that want a world-scoped
76/// table; the runtime also drives a process-global instance (see
77/// [`intern_node`] / [`resolve_node`]) so a `Send + Sync` host with no
78/// `&World` at call time can still mint ids.
79#[derive(Resource, Default, Debug)]
80pub struct NodeHandles {
81 /// `i32` id to packed handle bits. The bits are a real
82 /// [`NodeHandle::pack`] value for a live element, or a reserved spawn
83 /// token (see [`reserve_node_token`]) for a not-yet-materialized node.
84 forward: HashMap<i32, u64>,
85 reverse: HashMap<u64, i32>,
86 next: i32,
87}
88
89impl NodeHandles {
90 /// Intern `(entity, generation)`, returning a stable `i32` id (>= 1).
91 /// Re-interning the same entity returns the same id.
92 pub fn intern(&mut self, entity: Entity, generation: u32) -> i32 {
93 let _ = generation;
94 self.intern_raw(entity.to_bits())
95 }
96
97 /// Intern any packed handle (a real element's [`NodeHandle::pack`] or a
98 /// reserved spawn token), returning a stable `i32` id. Idempotent per
99 /// packed value.
100 pub fn intern_raw(&mut self, packed: u64) -> i32 {
101 if let Some(&id) = self.reverse.get(&packed) {
102 return id;
103 }
104 self.next = self.next.wrapping_add(1);
105 if self.next <= 0 {
106 self.next = 1;
107 }
108 let id = self.next;
109 self.forward.insert(id, packed);
110 self.reverse.insert(packed, id);
111 id
112 }
113
114 /// Resolve an interned id back to a live-element handle. Returns `None`
115 /// for `0`, an unknown id, or a reserved spawn token (which has no
116 /// entity until command-drain materializes it).
117 pub fn resolve(&self, id: i32) -> Option<NodeHandle> {
118 let raw = self.resolve_raw(id)?;
119 if is_reserved_token(raw) {
120 return None;
121 }
122 NodeHandle::unpack(raw)
123 }
124
125 /// Resolve an interned id to its packed bits (real handle or reserved
126 /// spawn token). `0` / unknown yields `None`.
127 pub fn resolve_raw(&self, id: i32) -> Option<u64> {
128 if id == 0 {
129 return None;
130 }
131 self.forward.get(&id).copied()
132 }
133}
134
135// ---------------------------------------------------------------------------
136// Reserved spawn tokens
137// ---------------------------------------------------------------------------
138//
139// `spawn(tag)` must return a handle synchronously so a fluent chain
140// (`spawn("div").set_class("row").append_to(parent)`) addresses one node
141// across a whole tick, while the real ECS entity only materializes at the
142// next command-drain. Script hosts hold no `&World` at call time, so they
143// cannot reserve a live `Entity`. Instead a spawn mints a process-global
144// reserved token: a `u64` with the top bit set so it never aliases a real
145// `Entity::to_bits` value. Each structural command carries this token; the
146// runtime's command applier maps token -> freshly spawned entity in FIFO
147// order, so the queued mutations land on the right node.
148
149/// Top bit marking a `u64` as a reserved spawn token rather than a packed
150/// [`NodeHandle`]. A real `Entity::to_bits` sets this bit only after a
151/// single index is recycled ~2^31 times, which does not happen in a UI
152/// session; handles are opaque and round-trip only through the provided
153/// helpers, per the wire-format contract.
154pub const RESERVED_TOKEN_FLAG: u64 = 1 << 63;
155
156static SPAWN_TOKEN_COUNTER: AtomicU64 = AtomicU64::new(1);
157
158/// Mint a fresh reserved spawn token. Unique within the process for the
159/// life of the run; consumed by the runtime's command applier, which maps
160/// it onto the entity it spawns.
161pub fn reserve_node_token() -> u64 {
162 let n = SPAWN_TOKEN_COUNTER.fetch_add(1, Ordering::Relaxed) & !RESERVED_TOKEN_FLAG;
163 RESERVED_TOKEN_FLAG | n
164}
165
166/// Whether `handle` is a reserved spawn token (top bit set) rather than a
167/// packed live-element handle.
168pub fn is_reserved_token(handle: u64) -> bool {
169 handle & RESERVED_TOKEN_FLAG != 0
170}
171
172/// Intern any packed handle (real or reserved token) in the process-global
173/// side-table, returning its `i32` id. Used by the candela host, whose
174/// values cannot carry a 64-bit handle.
175pub fn intern_node_raw(packed: u64) -> i32 {
176 node_handles()
177 .lock()
178 .map(|mut h| h.intern_raw(packed))
179 .unwrap_or(0)
180}
181
182/// Resolve an `i32` id to its packed bits (real handle or reserved token)
183/// against the process-global side-table.
184pub fn resolve_node_raw(id: i32) -> Option<u64> {
185 node_handles().lock().ok().and_then(|h| h.resolve_raw(id))
186}
187
188static NODE_HANDLES: OnceLock<Mutex<NodeHandles>> = OnceLock::new();
189
190fn node_handles() -> &'static Mutex<NodeHandles> {
191 NODE_HANDLES.get_or_init(|| Mutex::new(NodeHandles::default()))
192}
193
194/// Intern a handle in the process-global side-table, returning its `i32`
195/// id. Used by the candela host, whose closures have no `&World`.
196pub fn intern_node(entity: Entity, generation: u32) -> i32 {
197 node_handles()
198 .lock()
199 .map(|mut h| h.intern(entity, generation))
200 .unwrap_or(0)
201}
202
203/// Resolve an `i32` id against the process-global side-table.
204pub fn resolve_node(id: i32) -> Option<NodeHandle> {
205 node_handles().lock().ok().and_then(|h| h.resolve(id))
206}
207
208/// One selector-reachable element in a [`DomIndex`] snapshot. Positional
209/// fields (`child_index`, `sibling_count`, `doc_order`) are computed by
210/// [`DomIndex::build`] from the hierarchy; callers constructing records
211/// leave them at zero.
212#[derive(Debug, Clone)]
213pub struct DomRecord {
214 /// The element entity.
215 pub entity: Entity,
216 /// Its generation bits, mirrored into minted handles.
217 pub generation: u32,
218 /// Markup tag (`button`, `label`, ...). Empty for a tagless container.
219 pub tag: String,
220 /// Stable `id="..."`, if any.
221 pub id: Option<String>,
222 /// Class list from `class="..."`.
223 pub classes: Vec<String>,
224 /// Parent element, if this record has one inside the snapshot.
225 pub parent: Option<Entity>,
226 /// Child elements in document order.
227 pub children: Vec<Entity>,
228 /// 1-based position among siblings (computed).
229 pub child_index: i32,
230 /// Total sibling count including self (computed).
231 pub sibling_count: i32,
232 /// Depth-first pre-order rank across the whole snapshot (computed).
233 pub doc_order: u32,
234}
235
236/// Immutable per-tick snapshot of the selector-reachable element tree.
237/// Traversal and `get_by_id` read this directly; selector queries run in
238/// `lumen-script` over the same records.
239#[derive(Debug, Default)]
240pub struct DomIndex {
241 records: Vec<DomRecord>,
242 by_entity: HashMap<u64, usize>,
243 by_id: HashMap<String, Entity>,
244 roots: Vec<Entity>,
245}
246
247impl DomIndex {
248 /// Build a snapshot from unordered records. Each record must carry its
249 /// `parent` and ordered `children`; this computes sibling positions,
250 /// depth-first document order, the entity and id lookup maps, and the
251 /// root list. The last `id` wins on a duplicate (matching cascade
252 /// "first match by document order" is applied by `get_by_id` reading
253 /// the first-in-doc-order entry).
254 pub fn build(mut records: Vec<DomRecord>) -> Self {
255 let mut by_entity: HashMap<u64, usize> = HashMap::with_capacity(records.len());
256 for (i, r) in records.iter().enumerate() {
257 by_entity.insert(r.entity.to_bits(), i);
258 }
259
260 // Sibling positions. A record whose parent is absent from the
261 // snapshot is treated as a root; roots are ordered by entity bits
262 // for determinism.
263 let mut roots: Vec<Entity> = Vec::new();
264 for r in &records {
265 let in_index = r
266 .parent
267 .is_some_and(|p| by_entity.contains_key(&p.to_bits()));
268 if !in_index {
269 roots.push(r.entity);
270 }
271 }
272 roots.sort_by_key(|e| e.to_bits());
273
274 // child_index / sibling_count from each parent's children list;
275 // roots take their position among the sorted root list. Indexed so
276 // the write to `records[i]` can also read sibling records.
277 #[allow(clippy::needless_range_loop)]
278 for i in 0..records.len() {
279 let (idx, count) = match records[i].parent {
280 Some(p) if by_entity.contains_key(&p.to_bits()) => {
281 let pi = by_entity[&p.to_bits()];
282 let siblings = &records[pi].children;
283 let pos = siblings
284 .iter()
285 .position(|c| *c == records[i].entity)
286 .map(|z| z as i32 + 1)
287 .unwrap_or(1);
288 (pos, siblings.len().max(1) as i32)
289 }
290 _ => {
291 let pos = roots
292 .iter()
293 .position(|c| *c == records[i].entity)
294 .map(|z| z as i32 + 1)
295 .unwrap_or(1);
296 (pos, roots.len().max(1) as i32)
297 }
298 };
299 records[i].child_index = idx;
300 records[i].sibling_count = count;
301 }
302
303 // Depth-first document order from the roots.
304 let mut order: u32 = 0;
305 let mut stack: Vec<Entity> = roots.iter().rev().copied().collect();
306 let mut doc: HashMap<u64, u32> = HashMap::with_capacity(records.len());
307 while let Some(e) = stack.pop() {
308 let bits = e.to_bits();
309 if doc.contains_key(&bits) {
310 continue;
311 }
312 doc.insert(bits, order);
313 order += 1;
314 if let Some(&ri) = by_entity.get(&bits) {
315 for child in records[ri].children.iter().rev() {
316 stack.push(*child);
317 }
318 }
319 }
320 for r in &mut records {
321 r.doc_order = doc.get(&r.entity.to_bits()).copied().unwrap_or(u32::MAX);
322 }
323
324 // id -> entity, first in document order wins.
325 let mut ordered: Vec<usize> = (0..records.len()).collect();
326 ordered.sort_by_key(|&i| records[i].doc_order);
327 let mut by_id: HashMap<String, Entity> = HashMap::new();
328 for &i in &ordered {
329 if let Some(id) = &records[i].id {
330 by_id.entry(id.clone()).or_insert(records[i].entity);
331 }
332 }
333
334 Self {
335 records,
336 by_entity,
337 by_id,
338 roots,
339 }
340 }
341
342 /// All records, unspecified order. Query callers sort by
343 /// [`DomRecord::doc_order`].
344 pub fn records(&self) -> &[DomRecord] {
345 &self.records
346 }
347
348 /// Look up a record by entity.
349 pub fn record(&self, entity: Entity) -> Option<&DomRecord> {
350 self.by_entity
351 .get(&entity.to_bits())
352 .map(|&i| &self.records[i])
353 }
354
355 /// Fast id lookup (`get_by_id`), first match in document order.
356 pub fn get_by_id(&self, id: &str) -> Option<Entity> {
357 self.by_id.get(id).copied()
358 }
359
360 /// Root elements, ordered.
361 pub fn roots(&self) -> &[Entity] {
362 &self.roots
363 }
364
365 /// The document root (`document()`), the first root in order.
366 pub fn document(&self) -> Option<Entity> {
367 self.roots.first().copied()
368 }
369
370 /// Parent of `entity`, if any (`node.parent()`).
371 pub fn parent(&self, entity: Entity) -> Option<Entity> {
372 self.record(entity).and_then(|r| r.parent)
373 }
374
375 /// Ordered children of `entity` (`node.children()`).
376 pub fn children(&self, entity: Entity) -> Vec<Entity> {
377 self.record(entity)
378 .map(|r| r.children.clone())
379 .unwrap_or_default()
380 }
381
382 /// First child (`node.first_child()`).
383 pub fn first_child(&self, entity: Entity) -> Option<Entity> {
384 self.record(entity)
385 .and_then(|r| r.children.first().copied())
386 }
387
388 /// Last child (`node.last_child()`).
389 pub fn last_child(&self, entity: Entity) -> Option<Entity> {
390 self.record(entity).and_then(|r| r.children.last().copied())
391 }
392
393 /// The sibling list `entity` belongs to (its parent's children, or the
394 /// root list when it has no parent in the snapshot).
395 fn siblings_of(&self, entity: Entity) -> &[Entity] {
396 match self.parent(entity) {
397 Some(p) => self.record(p).map(|r| r.children.as_slice()).unwrap_or(&[]),
398 None => &self.roots,
399 }
400 }
401
402 /// Next sibling in document order (`node.next()`).
403 pub fn next_sibling(&self, entity: Entity) -> Option<Entity> {
404 let sibs = self.siblings_of(entity);
405 let pos = sibs.iter().position(|e| *e == entity)?;
406 sibs.get(pos + 1).copied()
407 }
408
409 /// Previous sibling in document order (`node.prev()`).
410 pub fn prev_sibling(&self, entity: Entity) -> Option<Entity> {
411 let sibs = self.siblings_of(entity);
412 let pos = sibs.iter().position(|e| *e == entity)?;
413 if pos == 0 {
414 None
415 } else {
416 sibs.get(pos - 1).copied()
417 }
418 }
419
420 /// The ancestor chain of `entity`, closest-parent-first (not including
421 /// `entity`). Used by `lumen-script` to build the root-first
422 /// `AncestorInfo` slice the selector matcher consumes.
423 pub fn ancestors(&self, entity: Entity) -> Vec<Entity> {
424 let mut out = Vec::new();
425 let mut cur = entity;
426 for _ in 0..256 {
427 match self.parent(cur) {
428 Some(p) => {
429 out.push(p);
430 cur = p;
431 }
432 None => break,
433 }
434 }
435 out
436 }
437}
438
439// ---------------------------------------------------------------------------
440// Process-shared snapshot cache
441// ---------------------------------------------------------------------------
442
443static DOM_INDEX_CACHE: OnceLock<RwLock<Arc<DomIndex>>> = OnceLock::new();
444
445fn dom_index_cache() -> &'static RwLock<Arc<DomIndex>> {
446 DOM_INDEX_CACHE.get_or_init(|| RwLock::new(Arc::new(DomIndex::default())))
447}
448
449/// Publish a freshly-built snapshot for cross-thread readers. The runtime
450/// calls this each tick from `build_dom_index`, before event dispatch, so
451/// a query issued inside a handler sees the current tree.
452pub fn publish_dom_index(index: DomIndex) {
453 if let Ok(mut guard) = dom_index_cache().write() {
454 *guard = Arc::new(index);
455 }
456}
457
458/// Read the current snapshot. Returns a cheap `Arc` clone so the caller
459/// drops the lock immediately. Script hosts and the C-ABI read here; they
460/// hold no `&World` at call time.
461pub fn dom_index_snapshot() -> Arc<DomIndex> {
462 dom_index_cache()
463 .read()
464 .map(|g| g.clone())
465 .unwrap_or_else(|_| Arc::new(DomIndex::default()))
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use bevy_ecs::world::World;
472
473 fn rec(
474 entity: Entity,
475 tag: &str,
476 id: Option<&str>,
477 classes: &[&str],
478 parent: Option<Entity>,
479 children: &[Entity],
480 ) -> DomRecord {
481 DomRecord {
482 entity,
483 generation: entity.generation().to_bits(),
484 tag: tag.to_string(),
485 id: id.map(str::to_string),
486 classes: classes.iter().map(|s| s.to_string()).collect(),
487 parent,
488 children: children.to_vec(),
489 child_index: 0,
490 sibling_count: 0,
491 doc_order: 0,
492 }
493 }
494
495 #[test]
496 fn handle_packs_and_validates() {
497 let mut w = World::new();
498 let e = w.spawn_empty().id();
499 let h = NodeHandle::new(e);
500 let packed = h.pack();
501 let back = NodeHandle::unpack(packed).unwrap();
502 assert_eq!(back.entity, e);
503 assert_eq!(h.validate(w.entities()), Some(e));
504 w.despawn(e);
505 // Stale handle: same bits, but the entity is gone.
506 assert_eq!(h.validate(w.entities()), None);
507 }
508
509 #[test]
510 fn unpack_rejects_garbage_without_panicking() {
511 assert!(NodeHandle::unpack(0).is_none());
512 }
513
514 #[test]
515 fn side_table_is_idempotent() {
516 let mut w = World::new();
517 let e = w.spawn_empty().id();
518 let mut t = NodeHandles::default();
519 let a = t.intern(e, e.generation().to_bits());
520 let b = t.intern(e, e.generation().to_bits());
521 assert_eq!(a, b);
522 assert!(a >= 1);
523 assert_eq!(t.resolve(a).unwrap().entity, e);
524 assert!(t.resolve(0).is_none());
525 }
526
527 #[test]
528 fn index_computes_tree_shape() {
529 let mut w = World::new();
530 let root = w.spawn_empty().id();
531 let a = w.spawn_empty().id();
532 let b = w.spawn_empty().id();
533 let recs = vec![
534 rec(root, "root", Some("app"), &[], None, &[a, b]),
535 rec(a, "button", Some("save"), &["row"], Some(root), &[]),
536 rec(b, "button", Some("cancel"), &["row"], Some(root), &[]),
537 ];
538 let idx = DomIndex::build(recs);
539 assert_eq!(idx.document(), Some(root));
540 assert_eq!(idx.get_by_id("save"), Some(a));
541 assert_eq!(idx.parent(a), Some(root));
542 assert_eq!(idx.children(root), vec![a, b]);
543 assert_eq!(idx.first_child(root), Some(a));
544 assert_eq!(idx.last_child(root), Some(b));
545 assert_eq!(idx.next_sibling(a), Some(b));
546 assert_eq!(idx.prev_sibling(b), Some(a));
547 assert_eq!(idx.next_sibling(b), None);
548 assert_eq!(idx.ancestors(a), vec![root]);
549 // Positions.
550 assert_eq!(idx.record(a).unwrap().child_index, 1);
551 assert_eq!(idx.record(b).unwrap().child_index, 2);
552 assert_eq!(idx.record(b).unwrap().sibling_count, 2);
553 // Document order: root, a, b.
554 assert!(idx.record(root).unwrap().doc_order < idx.record(a).unwrap().doc_order);
555 assert!(idx.record(a).unwrap().doc_order < idx.record(b).unwrap().doc_order);
556 }
557
558 #[test]
559 fn reserved_tokens_are_distinct_and_flagged() {
560 let a = reserve_node_token();
561 let b = reserve_node_token();
562 assert_ne!(a, b, "each reserved token is unique");
563 assert!(is_reserved_token(a));
564 assert!(is_reserved_token(b));
565 // A real entity handle is never mistaken for a reserved token.
566 let mut w = World::new();
567 let e = w.spawn_empty().id();
568 assert!(!is_reserved_token(NodeHandle::new(e).pack()));
569 }
570
571 #[test]
572 fn raw_intern_round_trips_real_and_reserved() {
573 let mut w = World::new();
574 let e = w.spawn_empty().id();
575 let real = NodeHandle::new(e).pack();
576 let token = reserve_node_token();
577 let mut t = NodeHandles::default();
578 let id_real = t.intern_raw(real);
579 let id_tok = t.intern_raw(token);
580 assert_ne!(id_real, id_tok);
581 assert_eq!(t.resolve_raw(id_real), Some(real));
582 assert_eq!(t.resolve_raw(id_tok), Some(token));
583 // `resolve` (element-only) yields the handle for the real node and
584 // nothing for the reserved token.
585 assert_eq!(t.resolve(id_real).unwrap().entity, e);
586 assert!(t.resolve(id_tok).is_none());
587 }
588
589 #[test]
590 fn global_cache_round_trips() {
591 let mut w = World::new();
592 let root = w.spawn_empty().id();
593 publish_dom_index(DomIndex::build(vec![rec(
594 root,
595 "root",
596 Some("r"),
597 &[],
598 None,
599 &[],
600 )]));
601 assert_eq!(dom_index_snapshot().get_by_id("r"), Some(root));
602 }
603}