Skip to main content

lumen_script/
introspect.rs

1//! Low-level introspection read surface (design 4.7): post-layout
2//! geometry, full computed style + cascade provenance, typed component
3//! reads, tree serialization, and global runtime state.
4//!
5//! Like the rest of the DOM read side, every getter reads a process-global
6//! snapshot the runtime publishes each tick, so a `Send + Sync` script host
7//! with no `&World` at call time can inspect the live app. Geometry,
8//! component maps, frame state, and the signal set are published by the
9//! runtime's introspection system, and the pointer state by
10//! [`publish_pointer_state`], which every platform installs; `computed_style()` and
11//! `matched_rules()` re-run the cascade host-side over the same
12//! stylesheet + tree the phase-2 detail snapshot already carries.
13//!
14//! `computed_style()`, `matched_rules()`, `dump_tree()`, and
15//! `signals_all()` walk the whole tree or re-run the cascade; they are
16//! inspection calls, not a per-frame hot path.
17
18use bevy_ecs::entity::Entity;
19use lumen_core::node::{DomIndex, NodeHandle, dom_index_snapshot};
20use std::collections::HashMap;
21use std::sync::{Arc, OnceLock, RwLock};
22
23use crate::node_query::{
24    NodeRef, ancestor_infos, attributes_of_handle, dom_details_snapshot, resolved_attributes,
25};
26
27// ---------------------------------------------------------------------------
28// Value types
29// ---------------------------------------------------------------------------
30
31/// Post-layout box, `getBoundingClientRect`-class. `x` / `y` are local to
32/// the parent; `client_x` / `client_y` are window coordinates.
33#[derive(Debug, Clone, Copy, Default, PartialEq)]
34pub struct NodeRect {
35    /// Local x (relative to the parent's origin).
36    pub x: f32,
37    /// Local y.
38    pub y: f32,
39    /// Box width.
40    pub width: f32,
41    /// Box height.
42    pub height: f32,
43    /// Window-space x (client coordinates).
44    pub client_x: f32,
45    /// Window-space y.
46    pub client_y: f32,
47}
48
49/// Scroll offsets and their travel limits for a scroll container.
50#[derive(Debug, Clone, Copy, Default, PartialEq)]
51pub struct NodeScroll {
52    /// Current horizontal offset.
53    pub x: f32,
54    /// Current vertical offset.
55    pub y: f32,
56    /// Maximum horizontal offset (content overflow).
57    pub max_x: f32,
58    /// Maximum vertical offset.
59    pub max_y: f32,
60}
61
62/// Per-node geometry published into the snapshot each tick.
63#[derive(Debug, Clone, Copy, Default)]
64pub struct NodeGeometry {
65    /// Border-box rect.
66    pub rect: NodeRect,
67    /// Content-box rect (inner box minus padding + border).
68    pub content_rect: NodeRect,
69    /// Scroll offsets / limits.
70    pub scroll: NodeScroll,
71    /// Effective visibility after cull / `Visible(false)` / `display:none`.
72    pub visible: bool,
73    /// Resolved stacking order.
74    pub z_index: i32,
75}
76
77/// Pointer state: window position, pressed buttons, live modifiers.
78#[derive(Debug, Clone, Copy, Default)]
79pub struct PointerSnapshot {
80    /// Window-space x.
81    pub x: f32,
82    /// Window-space y.
83    pub y: f32,
84    /// `true` while the pointer is inside the window.
85    pub inside: bool,
86    /// Bit 0 set while the primary button is held.
87    pub buttons: u32,
88    /// Shift held.
89    pub shift: bool,
90    /// Control held.
91    pub ctrl: bool,
92    /// Alt held.
93    pub alt: bool,
94    /// Super / Command held.
95    pub super_: bool,
96}
97
98/// Per-frame counters for perf-aware scripts.
99#[derive(Debug, Clone, Copy, Default)]
100pub struct FrameInfo {
101    /// Monotonic tick counter.
102    pub frame: u64,
103    /// Milliseconds since the previous published frame.
104    pub dt_ms: f64,
105    /// Number of layout-dirty elements observed this tick.
106    pub dirty_count: u64,
107}
108
109/// One matched stylesheet rule with cascade provenance, for
110/// `matched_rules()`.
111#[derive(Debug, Clone)]
112pub struct MatchedRuleView {
113    /// The matched selector, serialized to CSS text.
114    pub selector: String,
115    /// Selectors-4 specificity `(a, b, c)`.
116    pub specificity: (u32, u32, u32),
117    /// `"author"` or `"user-agent"`.
118    pub source: String,
119    /// Source order within the stylesheet.
120    pub source_order: usize,
121    /// The rule's declarations as `(property, value)` pairs.
122    pub declarations: Vec<(String, String)>,
123}
124
125// ---------------------------------------------------------------------------
126// Published snapshots
127// ---------------------------------------------------------------------------
128
129/// A component's field map: `(field, value)` pairs.
130pub type ComponentFields = Vec<(String, String)>;
131/// Per-node component field maps: `(component_name, fields)` in registry
132/// order, keyed by packed handle.
133pub type ComponentMaps = HashMap<u64, Vec<(String, ComponentFields)>>;
134
135/// Geometry + component maps published each tick, keyed by packed handle.
136#[derive(Default)]
137pub struct IntrospectSnapshot {
138    geometry: HashMap<u64, NodeGeometry>,
139    components: ComponentMaps,
140    known_components: Vec<String>,
141    frame: FrameInfo,
142    signals: Vec<(String, String)>,
143}
144
145impl IntrospectSnapshot {
146    /// Assemble a snapshot from the runtime's per-tick reads.
147    pub fn new(
148        geometry: HashMap<u64, NodeGeometry>,
149        components: ComponentMaps,
150        known_components: Vec<String>,
151        frame: FrameInfo,
152        signals: Vec<(String, String)>,
153    ) -> Self {
154        Self {
155            geometry,
156            components,
157            known_components,
158            frame,
159            signals,
160        }
161    }
162}
163
164static INTROSPECT: OnceLock<RwLock<Arc<IntrospectSnapshot>>> = OnceLock::new();
165
166fn cell() -> &'static RwLock<Arc<IntrospectSnapshot>> {
167    INTROSPECT.get_or_init(|| RwLock::new(Arc::new(IntrospectSnapshot::default())))
168}
169
170/// Publish a freshly-built introspection snapshot for cross-thread readers.
171pub fn publish_introspection(snapshot: IntrospectSnapshot) {
172    if let Ok(mut g) = cell().write() {
173        *g = Arc::new(snapshot);
174    }
175}
176
177fn snapshot() -> Arc<IntrospectSnapshot> {
178    cell()
179        .read()
180        .map(|g| g.clone())
181        .unwrap_or_else(|_| Arc::new(IntrospectSnapshot::default()))
182}
183
184// ---------------------------------------------------------------------------
185// Geometry getters
186// ---------------------------------------------------------------------------
187
188/// `n.rect()`: post-layout border-box, local + client.
189pub fn node_rect(handle: u64) -> Option<NodeRect> {
190    snapshot().geometry.get(&handle).map(|g| g.rect)
191}
192
193/// `n.content_rect()`: inner box minus padding + border.
194pub fn node_content_rect(handle: u64) -> Option<NodeRect> {
195    snapshot().geometry.get(&handle).map(|g| g.content_rect)
196}
197
198/// `n.scroll()`: scroll offsets and their limits.
199pub fn node_scroll(handle: u64) -> Option<NodeScroll> {
200    snapshot().geometry.get(&handle).map(|g| g.scroll)
201}
202
203/// `n.is_visible()`: effective visibility. A handle absent from the
204/// snapshot (despawned) reads `false`.
205pub fn node_is_visible(handle: u64) -> bool {
206    snapshot()
207        .geometry
208        .get(&handle)
209        .map(|g| g.visible)
210        .unwrap_or(false)
211}
212
213/// `n.z_index()`: resolved stacking order (`0` when unset / unknown).
214pub fn node_z_index(handle: u64) -> i32 {
215    snapshot()
216        .geometry
217        .get(&handle)
218        .map(|g| g.z_index)
219        .unwrap_or(0)
220}
221
222// ---------------------------------------------------------------------------
223// Computed style + provenance
224// ---------------------------------------------------------------------------
225
226/// `n.computed_style()`: every modeled CSS property the cascade resolved,
227/// as `(name, value)` pairs. Re-runs the cascade host-side; an inspection
228/// call.
229pub fn node_computed_style_map(handle: u64) -> Vec<(String, String)> {
230    lumen_ir::css::computed_style_map(&resolved_attributes(handle))
231}
232
233/// `n.matched_rules()`: the stylesheet rules that matched this node with
234/// their cascade provenance, ascending in cascade order (last wins).
235pub fn node_matched_rules(handle: u64) -> Vec<MatchedRuleView> {
236    let Some(entity) = NodeRef(handle).entity() else {
237        return Vec::new();
238    };
239    let details = dom_details_snapshot();
240    let Some(sheet) = details.sheet() else {
241        return Vec::new();
242    };
243    let index = dom_index_snapshot();
244    let Some(rec) = index.record(entity) else {
245        return Vec::new();
246    };
247    let subject =
248        lumen_ir::css::AncestorInfo::new(rec.tag.clone(), rec.classes.clone(), rec.id.clone())
249            .with_position(crate::node_query::record_position(rec));
250    let ancestors = ancestor_infos(&index, entity);
251    let has_children = !rec.children.is_empty();
252    let text = crate::node_query::node_text(handle);
253    lumen_ir::css::matched_rules_for(
254        &subject,
255        sheet,
256        &details.media(),
257        &ancestors,
258        has_children,
259        text.as_deref(),
260    )
261    .into_iter()
262    .map(|m| MatchedRuleView {
263        selector: m.selector,
264        specificity: (m.specificity.a, m.specificity.b, m.specificity.c),
265        source: match m.origin {
266            lumen_ir::css::Origin::UserAgent => "user-agent".to_string(),
267            lumen_ir::css::Origin::Author => "author".to_string(),
268        },
269        source_order: m.source_order,
270        declarations: m.declarations,
271    })
272    .collect()
273}
274
275/// `n.inline_style()`: the `element.style` override map.
276pub fn node_inline_style(handle: u64) -> Vec<(String, String)> {
277    dom_details_snapshot().inline_style_of(handle)
278}
279
280/// `n.attrs()`: the full attribute map, `id` / `class` (from the index),
281/// `text`, then every generic attribute.
282pub fn node_attrs(handle: u64) -> Vec<(String, String)> {
283    let mut out: Vec<(String, String)> = Vec::new();
284    let index = dom_index_snapshot();
285    if let Some(entity) = NodeRef(handle).entity()
286        && let Some(rec) = index.record(entity)
287    {
288        if let Some(id) = &rec.id {
289            out.push(("id".to_string(), id.clone()));
290        }
291        if !rec.classes.is_empty() {
292            out.push(("class".to_string(), rec.classes.join(" ")));
293        }
294    }
295    if let Some(text) = crate::node_query::node_text(handle) {
296        out.push(("text".to_string(), text));
297    }
298    out.extend(attributes_of_handle(handle));
299    out
300}
301
302/// `n.classes()`: the full class list.
303pub fn node_classes(handle: u64) -> Vec<String> {
304    let Some(entity) = NodeRef(handle).entity() else {
305        return Vec::new();
306    };
307    dom_index_snapshot()
308        .record(entity)
309        .map(|r| r.classes.clone())
310        .unwrap_or_default()
311}
312
313// ---------------------------------------------------------------------------
314// ECS introspection
315// ---------------------------------------------------------------------------
316
317/// `n.entity_id()`: the raw `(index, generation)` for debugging / handle
318/// round-trip. `None` for a null / reserved-token / invalid handle.
319pub fn node_entity_id(handle: u64) -> Option<(u32, u32)> {
320    let h = NodeHandle::unpack(handle)?;
321    // Entity::to_bits packs the index in the low half, generation in the
322    // high half (see NodeHandle::pack).
323    let bits = h.entity.to_bits();
324    Some((bits as u32, (bits >> 32) as u32))
325}
326
327/// `n.components()`: the names of the whitelisted Lumen components present
328/// on this node.
329pub fn node_components(handle: u64) -> Vec<String> {
330    snapshot()
331        .components
332        .get(&handle)
333        .map(|list| list.iter().map(|(n, _)| n.clone()).collect())
334        .unwrap_or_default()
335}
336
337/// `n.component(name)`: that component's public fields as a value map.
338/// `Err` names an unknown / non-whitelisted component; `Ok(None)` means the
339/// component is whitelisted but absent from this node.
340pub fn node_component(handle: u64, name: &str) -> Result<Option<Vec<(String, String)>>, String> {
341    let snap = snapshot();
342    if !snap.known_components.iter().any(|n| n == name) {
343        return Err(format!(
344            "component(\"{name}\"): not a whitelisted component"
345        ));
346    }
347    Ok(snap.components.get(&handle).and_then(|list| {
348        list.iter()
349            .find(|(n, _)| n == name)
350            .map(|(_, map)| map.clone())
351    }))
352}
353
354// ---------------------------------------------------------------------------
355// Tree / document serialization
356// ---------------------------------------------------------------------------
357
358/// `n.outer_markup()`: serialize this subtree to `.lmn`-ish text.
359pub fn outer_markup(handle: u64) -> String {
360    let Some(entity) = NodeRef(handle).entity() else {
361        return String::new();
362    };
363    let index = dom_index_snapshot();
364    let mut out = String::new();
365    write_markup(&index, entity, 0, &mut out);
366    out
367}
368
369/// `n.inner_markup()`: serialize this node's children (not the node itself)
370/// to `.lmn`-ish text. The read half of `set_inner_markup`; reuses the same
371/// serializer over the child range.
372pub fn inner_markup(handle: u64) -> String {
373    let Some(entity) = NodeRef(handle).entity() else {
374        return String::new();
375    };
376    let index = dom_index_snapshot();
377    let Some(rec) = index.record(entity) else {
378        return String::new();
379    };
380    let mut out = String::new();
381    for child in &rec.children {
382        write_markup(&index, *child, 0, &mut out);
383    }
384    out
385}
386
387fn write_markup(index: &DomIndex, entity: Entity, depth: usize, out: &mut String) {
388    let Some(rec) = index.record(entity) else {
389        return;
390    };
391    let pad = "  ".repeat(depth);
392    let tag = if rec.tag.is_empty() { "div" } else { &rec.tag };
393    out.push_str(&pad);
394    out.push('<');
395    out.push_str(tag);
396    if let Some(id) = &rec.id {
397        out.push_str(&format!(" id=\"{id}\""));
398    }
399    if !rec.classes.is_empty() {
400        out.push_str(&format!(" class=\"{}\"", rec.classes.join(" ")));
401    }
402    let handle = NodeHandle::new(entity).pack();
403    for (k, v) in attributes_of_handle(handle) {
404        out.push_str(&format!(" {k}=\"{v}\""));
405    }
406    let text = crate::node_query::node_text(handle);
407    if rec.children.is_empty() && text.as_deref().unwrap_or("").is_empty() {
408        out.push_str("/>\n");
409        return;
410    }
411    out.push('>');
412    if let Some(t) = &text
413        && !t.is_empty()
414    {
415        out.push_str(t);
416    }
417    if rec.children.is_empty() {
418        out.push_str(&format!("</{tag}>\n"));
419        return;
420    }
421    out.push('\n');
422    for child in &rec.children {
423        write_markup(index, *child, depth + 1, out);
424    }
425    out.push_str(&pad);
426    out.push_str(&format!("</{tag}>\n"));
427}
428
429/// `dump_tree()`: a whole-tree structural dump (id / tag / classes / rect)
430/// for debugging. An inspection call.
431pub fn dump_tree() -> String {
432    let index = dom_index_snapshot();
433    let snap = snapshot();
434    let mut out = String::new();
435    for root in index.roots() {
436        write_dump(&index, &snap, *root, 0, &mut out);
437    }
438    out
439}
440
441fn write_dump(
442    index: &DomIndex,
443    snap: &IntrospectSnapshot,
444    entity: Entity,
445    depth: usize,
446    out: &mut String,
447) {
448    let Some(rec) = index.record(entity) else {
449        return;
450    };
451    let pad = "  ".repeat(depth);
452    let tag = if rec.tag.is_empty() { "div" } else { &rec.tag };
453    out.push_str(&pad);
454    out.push_str(tag);
455    if let Some(id) = &rec.id {
456        out.push('#');
457        out.push_str(id);
458    }
459    for c in &rec.classes {
460        out.push('.');
461        out.push_str(c);
462    }
463    let handle = NodeHandle::new(entity).pack();
464    if let Some(g) = snap.geometry.get(&handle) {
465        out.push_str(&format!(
466            " [{} {} {} {}]",
467            g.rect.client_x, g.rect.client_y, g.rect.width, g.rect.height
468        ));
469    }
470    out.push('\n');
471    for child in &rec.children {
472        write_dump(index, snap, *child, depth + 1, out);
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Global runtime state
478// ---------------------------------------------------------------------------
479
480/// `pointer_state()`: window position, buttons, modifiers.
481pub fn pointer_state() -> PointerSnapshot {
482    pointer_cell().read().map(|g| *g).unwrap_or_default()
483}
484
485static POINTER: OnceLock<RwLock<PointerSnapshot>> = OnceLock::new();
486
487fn pointer_cell() -> &'static RwLock<PointerSnapshot> {
488    POINTER.get_or_init(|| RwLock::new(PointerSnapshot::default()))
489}
490
491/// Publish the pointer state `pointer_state()` reads.
492pub fn publish_pointer(pointer: PointerSnapshot) {
493    if let Ok(mut g) = pointer_cell().write() {
494        *g = pointer;
495    }
496}
497
498/// Publish what the input layer knows about the pointer each tick, so a read
499/// from a handler sees this tick's position.
500///
501/// It reads only the pointer and modifier resources every input backend
502/// keeps, a window's or a page's, so every platform publishes through it.
503pub fn publish_pointer_state(
504    state: Option<bevy_ecs::system::Res<lumen_core::input::PointerState>>,
505    modifiers: Option<bevy_ecs::system::Res<lumen_core::input::ModifiersState>>,
506) {
507    let state = state.map(|s| *s).unwrap_or_default();
508    let m = modifiers.map(|m| m.0).unwrap_or_default();
509    let position = state.position.unwrap_or(glam::Vec2::ZERO);
510    publish_pointer(PointerSnapshot {
511        x: position.x,
512        y: position.y,
513        inside: state.position.is_some(),
514        buttons: u32::from(state.primary_down),
515        shift: m.shift,
516        ctrl: m.ctrl,
517        alt: m.alt,
518        super_: m.super_,
519    });
520}
521
522/// `frame_info()`: `{frame, dt_ms, dirty_count}`.
523pub fn frame_info() -> FrameInfo {
524    snapshot().frame
525}
526
527/// `signals_all()`: the whole PropertyStore signal set as `(name, value)`
528/// pairs. An inspection call.
529pub fn signals_all() -> Vec<(String, String)> {
530    snapshot().signals.clone()
531}