Skip to main content

lumen_scene/
dom.rs

1//! The dynamic DOM: the tree a script reads, and the mutations it issues.
2//!
3//! A script reaches the scene through two channels, and both are here because
4//! neither knows anything about the platform underneath. The read side is
5//! [`build_dom_index`], which republishes the live tree each tick so a
6//! `query()` from a handler sees this tick's nodes. The write side is a pair:
7//! [`collect_dom_commands`] gathers this tick's mutations in issue order, and
8//! [`apply_dom_commands`] materializes them against the world.
9//!
10//! Script-issued mutations arrive as
11//! [`ScriptCommandEvent`](lumen_script::runtime::ScriptCommandEvent)s; C-ABI
12//! and SDK mutations arrive on the process-global external DOM bus
13//! ([`lumen_script::node_query::drain_external_dom_commands`]). Both funnel
14//! into one FIFO pass so a fluent chain's `spawn` plus its queued mutations
15//! apply together, keyed by the reserved token the host minted synchronously.
16//!
17//! Structure edits reuse the same spawn / despawn / hierarchy path the
18//! `<for>` reconciler uses, so layout, style and paint stay consistent. A
19//! class, id, disabled or inline-style change marks the element
20//! [`StyleDirty`](lumen_core::components::StyleDirty), and a structure edit
21//! bumps [`StyleVersion`](lumen_core::components::StyleVersion); those are
22//! what a host with a cascade of its own listens on. A host whose platform
23//! resolves CSS itself, such as a browser page, has nothing reading them.
24
25use std::collections::HashMap;
26
27use bevy_ecs::hierarchy::{ChildOf, Children};
28use bevy_ecs::prelude::*;
29use lumen_core::components::{
30    DirtyLayout, Disabled, InlineStyle, LumenAttributes, LumenClasses, LumenId, LumenTag,
31    StyleDirty, StyleVersion, TextContent, TextInput,
32};
33use lumen_core::node::{DomIndex, DomRecord, NodeHandle, is_reserved_token, publish_dom_index};
34use lumen_core::prelude::{App, TickStage};
35use lumen_core::property_store::PropertyStore;
36use lumen_core::window_state::{set_size, set_title};
37use lumen_ir::fragment::FragmentTable;
38use lumen_ir::interpolate::Scope;
39use lumen_ir::layout_ir::Element;
40use lumen_script::event::{register_host_binding, unregister_binding};
41use lumen_script::node_query::drain_external_dom_commands;
42use lumen_script::runtime::{ScriptCommandEvent, register_script_commands};
43use lumen_script::{
44    EventHosts, PendingDomEvents, ScriptCommand, ScriptSet, deliver_input_events,
45    deliver_state_events, queue_pointer_and_key_events, queue_state_events,
46};
47
48use crate::fragments::{
49    FragmentFault, FragmentInstance, FragmentLibrary, SlotPlaceholder, bind_args, instance_body,
50    report_once,
51};
52use crate::source_parser::RuntimeParser;
53use crate::spawn::Placeholders;
54
55/// Install the dynamic DOM pipeline on `app`.
56///
57/// One call for every host, because the pieces only work as a set: the index
58/// has to be published before anything reads it, the collector has to run
59/// after every producer of commands, and the applier has to run after the
60/// collector. A host that installed two of the three would have a `query()`
61/// that answers about last tick, or a `mount()` that never lands.
62///
63/// The read publish runs before the handlers so a query issued from one sees
64/// this tick's tree, and before the key dispatch for the same reason.
65pub fn install_dom(app: &mut App) {
66    // The collector reads `ScriptCommandEvent`. The script plugin registers
67    // that message only when a host is installed, so an app with no script
68    // must self-register it here or the reader fails parameter validation.
69    register_script_commands(&mut app.world);
70    app.world.init_resource::<PendingDomCommands>();
71    app.add_systems(
72        TickStage::Systems,
73        build_dom_index
74            .before(ScriptSet::Dispatch)
75            .before(lumen_input::dispatch_focused_keys),
76    );
77    // `pointer_state()`, published from what the input layer keeps, which a
78    // window and a page both maintain. Before anything that runs a handler,
79    // so a read from one sees this tick's pointer.
80    app.add_systems(
81        TickStage::Systems,
82        lumen_script::introspect::publish_pointer_state
83            .before(ScriptSet::Dispatch)
84            .before(ScriptSet::DomInput)
85            .before(ScriptSet::DomState)
86            .before(lumen_input::dispatch_focused_keys),
87    );
88    app.add_systems(
89        TickStage::Systems,
90        collect_dom_commands
91            .after(ScriptSet::Tick)
92            .after(ScriptSet::Dispatch)
93            .after(ScriptSet::Ready)
94            .after(ScriptSet::DomInput)
95            .after(ScriptSet::DomState),
96    );
97    app.add_systems(
98        TickStage::Systems,
99        apply_dom_commands.after(collect_dom_commands),
100    );
101    // The handlers bound with `on(type, handler)`, for an app with a script
102    // and for one queried and driven over the C ABI alike.
103    install_dom_events(app);
104}
105
106/// Deliver DOM events to the handlers bound with `on(type, handler)`: turn the
107/// input messages into DOM events and run capture -> target -> bubble
108/// propagation over the binding registry, once per event. A handler runs
109/// natively (the C ABI, the SDKs) or in the script host that bound it; each
110/// host joins through [`lumen_script::register_event_host`] when it installs.
111///
112/// The same on every platform: the desktop's window backend and a page's
113/// event listeners write the same input messages, and these read them.
114///
115/// Ordered after the input producers and the snapshot build, and before
116/// [`collect_dom_commands`], so a handler's queued DOM mutations apply this
117/// same tick. Before [`navigate_on_anchor_click`](crate::routing::navigate_on_anchor_click),
118/// so a `prevent_default` on a link click is seen before the anchor-navigation
119/// executor runs.
120fn install_dom_events(app: &mut App) {
121    lumen_script::register_dom_event_messages(&mut app.world);
122    app.world.init_resource::<PendingDomEvents>();
123    app.world.init_resource::<EventHosts>();
124    app.add_systems(
125        TickStage::Systems,
126        (queue_pointer_and_key_events, deliver_input_events)
127            .chain()
128            .in_set(ScriptSet::DomInput)
129            .after(build_dom_index)
130            .after(lumen_input::dispatch_clicks)
131            .after(lumen_input::dispatch_focused_keys)
132            .before(crate::routing::navigate_on_anchor_click),
133    );
134    app.add_systems(
135        TickStage::Systems,
136        (queue_state_events, deliver_state_events)
137            .chain()
138            .in_set(ScriptSet::DomState)
139            .after(build_dom_index)
140            // `input` is derived from the edit stream, so this reads the
141            // messages the text mutator writes. Anchored on the shared set
142            // label, so an edit applied this tick raises `input` on the same
143            // tick, in one fixed order. Inert when no text plugin is
144            // installed (empty set).
145            .after(lumen_core::text_events::TextEditSet::Apply),
146    );
147}
148
149/// Rebuild the per-tick [`DomIndex`] snapshot from the live tree and publish
150/// it for cross-thread readers (script hosts, the C-ABI).
151///
152/// Every spawned element carries a [`LumenTag`], so the query walks all
153/// selector-reachable entities; a parent or child that is not itself tagged,
154/// such as the window root, is dropped from the element tree.
155#[allow(clippy::type_complexity)]
156pub fn build_dom_index(
157    query: Query<
158        (
159            Entity,
160            &LumenTag,
161            Option<&LumenClasses>,
162            Option<&LumenId>,
163            Option<&ChildOf>,
164            Option<&Children>,
165        ),
166        Without<lumen_core::components::DomHidden>,
167    >,
168) {
169    use std::collections::HashSet;
170    let indexed: HashSet<u64> = query.iter().map(|(e, ..)| e.to_bits()).collect();
171    let mut records: Vec<DomRecord> = Vec::with_capacity(indexed.len());
172    for (entity, tag, classes, id, child_of, children) in query.iter() {
173        let parent = child_of
174            .map(|c| c.parent())
175            .filter(|p| indexed.contains(&p.to_bits()));
176        let kids: Vec<Entity> = children
177            .map(|c| {
178                c.iter()
179                    .filter(|e| indexed.contains(&e.to_bits()))
180                    .collect()
181            })
182            .unwrap_or_default();
183        records.push(DomRecord {
184            entity,
185            generation: entity.generation().to_bits(),
186            tag: tag.0.to_string(),
187            id: id.map(|i| i.0.clone()),
188            classes: classes
189                .map(|c| c.0.iter().map(|s| s.to_string()).collect())
190                .unwrap_or_default(),
191            parent,
192            children: kids,
193            child_index: 0,
194            sibling_count: 0,
195            doc_order: 0,
196        });
197    }
198    publish_dom_index(DomIndex::build(records));
199}
200
201/// Commands gathered this tick, in issue order, for [`apply_dom_commands`].
202#[derive(Resource, Default)]
203pub struct PendingDomCommands(Vec<ScriptCommand>);
204
205/// Collect this tick's DOM / window commands from the script event stream
206/// and the external bus into [`PendingDomCommands`], preserving order.
207pub fn collect_dom_commands(
208    mut events: MessageReader<ScriptCommandEvent>,
209    mut pending: ResMut<PendingDomCommands>,
210) {
211    for ev in events.read() {
212        if ev.0.mutates_dom() {
213            pending.0.push(ev.0.clone());
214        }
215    }
216    pending.0.extend(drain_external_dom_commands());
217}
218
219/// Apply the collected DOM / window commands against the live world.
220pub fn apply_dom_commands(world: &mut World) {
221    let commands = {
222        let Some(mut pending) = world.get_resource_mut::<PendingDomCommands>() else {
223            return;
224        };
225        if pending.0.is_empty() {
226            return;
227        }
228        std::mem::take(&mut pending.0)
229    };
230
231    // Reserved token -> spawned entity, filled as `Spawn` / `CloneNode`
232    // materialize; subsequent commands in the same tick resolve through it.
233    let mut reserved: HashMap<u64, Entity> = HashMap::new();
234    let mut style_dirty = false;
235
236    for cmd in commands {
237        match cmd {
238            ScriptCommand::Spawn { tag, reserved: tok } => {
239                let el = Element {
240                    tag,
241                    ..Default::default()
242                };
243                let entity =
244                    crate::spawn::spawn_subtree(world, &el, None, Placeholders::Unresolved);
245                reserved.insert(tok, entity);
246                style_dirty = true;
247            }
248            ScriptCommand::CloneNode {
249                source,
250                reserved: tok,
251            } => {
252                if let Some(src) = resolve(world, &reserved, source)
253                    && let Some(el) = element_from_entity(world, src)
254                {
255                    // Read back out of a live entity, so its strings are
256                    // the resolved ones the original was built with.
257                    let entity =
258                        crate::spawn::spawn_subtree(world, &el, None, Placeholders::Resolved);
259                    reserved.insert(tok, entity);
260                    style_dirty = true;
261                }
262            }
263            ScriptCommand::Insert {
264                parent,
265                node,
266                before,
267            } => {
268                if let (Some(parent), Some(node)) = (
269                    resolve(world, &reserved, parent),
270                    resolve(world, &reserved, node),
271                ) {
272                    attach(world, parent, node, resolve(world, &reserved, before));
273                    style_dirty = true;
274                }
275            }
276            ScriptCommand::ReplaceWith { old, new } => {
277                if let (Some(old), Some(new)) = (
278                    resolve(world, &reserved, old),
279                    resolve(world, &reserved, new),
280                ) {
281                    replace_node(world, old, new);
282                    style_dirty = true;
283                }
284            }
285            ScriptCommand::SpawnFragment {
286                key,
287                args,
288                children,
289                reserved: tok,
290            } => {
291                let children: Vec<(String, Entity)> = children
292                    .into_iter()
293                    .filter_map(|(slot, handle)| Some((slot, resolve(world, &reserved, handle)?)))
294                    .collect();
295                if let Some(entity) = spawn_fragment(world, &key, &args, &children) {
296                    reserved.insert(tok, entity);
297                    style_dirty = true;
298                }
299            }
300            ScriptCommand::RemoveNode { node } => {
301                if let Some(node) = resolve(world, &reserved, node) {
302                    world.entity_mut(node).despawn();
303                    style_dirty = true;
304                }
305            }
306            ScriptCommand::SetInnerMarkup { node, markup } => {
307                if let Some(entity) = resolve(world, &reserved, node) {
308                    style_dirty |= apply_inner_markup(world, entity, &markup);
309                }
310            }
311            ScriptCommand::SetNodeText { node, text } => {
312                if let Some(entity) = resolve(world, &reserved, node) {
313                    set_text(world, entity, &text);
314                }
315            }
316            ScriptCommand::SetAttr { node, name, value } => {
317                if let Some(entity) = resolve(world, &reserved, node)
318                    && set_attr(world, entity, &name, &value)
319                {
320                    world.entity_mut(entity).insert(StyleDirty);
321                }
322            }
323            ScriptCommand::RemoveAttr { node, name } => {
324                if let Some(entity) = resolve(world, &reserved, node)
325                    && remove_attr(world, entity, &name)
326                {
327                    world.entity_mut(entity).insert(StyleDirty);
328                }
329            }
330            ScriptCommand::SetClasses { target_id, classes } => {
331                for entity in set_classes(world, &target_id, &classes) {
332                    world.entity_mut(entity).insert(StyleDirty);
333                }
334            }
335            ScriptCommand::ClassAdd { node, class } => {
336                if let Some(entity) = resolve(world, &reserved, node) {
337                    class_edit(world, entity, &class, ClassOp::Add);
338                    world.entity_mut(entity).insert(StyleDirty);
339                }
340            }
341            ScriptCommand::ClassRemove { node, class } => {
342                if let Some(entity) = resolve(world, &reserved, node) {
343                    class_edit(world, entity, &class, ClassOp::Remove);
344                    world.entity_mut(entity).insert(StyleDirty);
345                }
346            }
347            ScriptCommand::ClassToggle { node, class } => {
348                if let Some(entity) = resolve(world, &reserved, node) {
349                    class_edit(world, entity, &class, ClassOp::Toggle);
350                    world.entity_mut(entity).insert(StyleDirty);
351                }
352            }
353            ScriptCommand::SetStyleProp { node, name, value } => {
354                if let Some(entity) = resolve(world, &reserved, node) {
355                    let mut e = world.entity_mut(entity);
356                    let mut style = e.take::<InlineStyle>().unwrap_or_default();
357                    style.set(&name, value);
358                    e.insert((style, StyleDirty));
359                }
360            }
361            ScriptCommand::RemoveStyleProp { node, name } => {
362                if let Some(entity) = resolve(world, &reserved, node) {
363                    let mut e = world.entity_mut(entity);
364                    if let Some(mut style) = e.take::<InlineStyle>() {
365                        style.remove(&name);
366                        e.insert(style);
367                    }
368                    e.insert(StyleDirty);
369                }
370            }
371            ScriptCommand::BindEvent {
372                node,
373                event_type,
374                capture,
375                token,
376            } => {
377                // Resolve a reserved spawn token to the real handle so the
378                // dispatcher, which sees live entities, matches the binding.
379                let handle = resolve(world, &reserved, node)
380                    .map(|e| NodeHandle::new(e).pack())
381                    .unwrap_or(node);
382                register_host_binding(token, handle, event_type, capture);
383            }
384            ScriptCommand::UnbindEvent { token } => {
385                unregister_binding(token);
386            }
387            ScriptCommand::WindowSetTitle { title } => {
388                set_title(&title);
389            }
390            ScriptCommand::WindowSetSize { width, height } => {
391                set_size(width, height);
392            }
393            _ => {}
394        }
395    }
396
397    if style_dirty {
398        StyleVersion::bump(world);
399    }
400}
401
402/// Resolve a packed handle or reserved token to a live entity. Reserved
403/// tokens map through this tick's spawn table; real handles must still be
404/// present in the world.
405fn resolve(world: &World, reserved: &HashMap<u64, Entity>, handle: u64) -> Option<Entity> {
406    if handle == 0 {
407        return None;
408    }
409    if is_reserved_token(handle) {
410        return reserved.get(&handle).copied();
411    }
412    let entity = NodeHandle::unpack(handle)?.entity;
413    world.entities().contains(entity).then_some(entity)
414}
415
416/// Move `new` into `old`'s place among its siblings, then despawn `old`
417/// and its subtree. A detached `old` has no place to take, so `new` stays
418/// where it is and only the despawn happens.
419fn replace_node(world: &mut World, old: Entity, new: Entity) {
420    if let Some(parent) = world.get::<ChildOf>(old).map(|c| c.parent()) {
421        let index = child_index(world, parent, old);
422        attach(world, parent, new, None);
423        if let Some(i) = index {
424            world.entity_mut(parent).insert_child(i, new);
425        }
426    }
427    world.entity_mut(old).despawn();
428}
429
430/// Instantiate the fragment `key` into a fresh detached subtree and return
431/// its root, or report why it could not and return `None`.
432///
433/// The body is cloned and its placeholders resolved against the bound
434/// arguments before anything spawns, so the tree that reaches the world is
435/// already the instance's own. Each `<slot>` the caller passed a child for
436/// is replaced by that child, in place; a slot nothing filled keeps the
437/// fallback content the body wrote inside it.
438///
439/// The result is detached, the way `Spawn` is: a following `Insert` puts it
440/// in the tree.
441fn spawn_fragment(
442    world: &mut World,
443    key: &str,
444    args: &[(String, String)],
445    children: &[(String, Entity)],
446) -> Option<Entity> {
447    // An app built without a library declares nothing, which the key lookup
448    // below reports the same way as a key nobody wrote.
449    let library = world
450        .get_resource::<FragmentLibrary>()
451        .cloned()
452        .unwrap_or_default();
453    let Some(fragment) = library.get(key) else {
454        report_once(key, &FragmentFault::UnknownKey);
455        return None;
456    };
457    let body = match instance_body(fragment) {
458        Ok(body) => body,
459        Err(fault) => {
460            report_once(key, &fault);
461            return None;
462        }
463    };
464    let bound = bind_args(fragment, args);
465
466    let instance = {
467        let empty = PropertyStore::default();
468        let store = world.get_resource::<PropertyStore>().unwrap_or(&empty);
469        let scope = Scope::new(store).with_args(&bound);
470        crate::spawn::substitute_in_element_with_css(body, &scope, None)
471    };
472    let root = crate::spawn::spawn_subtree(world, &instance, None, Placeholders::Resolved);
473
474    if !children.is_empty() {
475        let slots: Vec<(String, Entity)> = descendants(world, root)
476            .into_iter()
477            .filter_map(|e| Some((world.get::<SlotPlaceholder>(e)?.0.clone(), e)))
478            .collect();
479        for (name, child) in children {
480            match slots.iter().find(|(slot, _)| slot == name) {
481                Some((_, slot)) => replace_node(world, *slot, *child),
482                None => report_once(key, &FragmentFault::UnknownSlot(name.clone())),
483            }
484        }
485    }
486
487    world.entity_mut(root).insert(FragmentInstance {
488        key: key.to_string(),
489        args: bound,
490    });
491    Some(root)
492}
493
494/// Every entity under `root`, `root` itself included, parents before
495/// children.
496fn descendants(world: &World, root: Entity) -> Vec<Entity> {
497    let mut out = vec![root];
498    let mut i = 0;
499    while i < out.len() {
500        if let Some(kids) = world.get::<Children>(out[i]) {
501            out.extend(kids.iter());
502        }
503        i += 1;
504    }
505    out
506}
507
508/// Attach `node` under `parent`, before `before` when given (else append).
509/// bevy's `ChildOf` relationship reparents automatically.
510fn attach(world: &mut World, parent: Entity, node: Entity, before: Option<Entity>) {
511    match before.and_then(|b| child_index(world, parent, b)) {
512        Some(index) => {
513            world.entity_mut(parent).insert_child(index, node);
514        }
515        None => {
516            world.entity_mut(parent).add_child(node);
517        }
518    }
519    world.entity_mut(node).insert(DirtyLayout);
520}
521
522/// Index of `child` in `parent`'s ordered children, if present.
523fn child_index(world: &World, parent: Entity, child: Entity) -> Option<usize> {
524    world
525        .get::<Children>(parent)
526        .and_then(|c| c.iter().position(|e| e == child))
527}
528
529fn set_text(world: &mut World, entity: Entity, text: &str) {
530    let mut e = world.entity_mut(entity);
531    e.insert(TextContent(text.to_string()));
532    if let Some(mut input) = e.get_mut::<TextInput>() {
533        input.cursor = text.len();
534    }
535}
536
537/// Set an attribute, routing known names to typed components. Returns
538/// whether the change can affect the cascade (needs a restyle).
539fn set_attr(world: &mut World, entity: Entity, name: &str, value: &str) -> bool {
540    match name {
541        "id" => {
542            world.entity_mut(entity).insert(LumenId(value.to_string()));
543            true
544        }
545        "class" => {
546            let classes: Vec<String> = value.split_whitespace().map(str::to_string).collect();
547            world.entity_mut(entity).insert(LumenClasses::from(classes));
548            true
549        }
550        "text" => {
551            set_text(world, entity, value);
552            false
553        }
554        "disabled" => {
555            let on = matches!(value, "true" | "" | "disabled");
556            if on {
557                world.entity_mut(entity).insert(Disabled);
558            } else {
559                world.entity_mut(entity).remove::<Disabled>();
560            }
561            true
562        }
563        _ => {
564            let mut e = world.entity_mut(entity);
565            let mut attrs = e.take::<LumenAttributes>().unwrap_or_default();
566            attrs.set(name, value);
567            e.insert(attrs);
568            false
569        }
570    }
571}
572
573fn remove_attr(world: &mut World, entity: Entity, name: &str) -> bool {
574    match name {
575        "id" => {
576            world.entity_mut(entity).remove::<LumenId>();
577            true
578        }
579        "class" => {
580            world.entity_mut(entity).remove::<LumenClasses>();
581            true
582        }
583        "disabled" => {
584            world.entity_mut(entity).remove::<Disabled>();
585            true
586        }
587        _ => {
588            let mut e = world.entity_mut(entity);
589            if let Some(mut attrs) = e.take::<LumenAttributes>() {
590                attrs.remove(name);
591                e.insert(attrs);
592            }
593            false
594        }
595    }
596}
597
598/// Assign the whole class list of every element `target_id` names
599/// (`set_class`), or of the page root when it names `<root>`
600/// (`set_root_class`). Returns the elements whose list changed, which are
601/// the ones to restyle.
602///
603/// The root has no `id` to look up, so it is read from
604/// [`DocumentRoot`](crate::spawn::DocumentRoot), which the spawn pass
605/// records for every run.
606///
607/// A non-root element has no equivalent of the desktop's root-class watcher
608/// (`reapply_styles_on_root_class_change`), so rewriting its class list only
609/// restyles if the caller marks it [`StyleDirty`]. Unmarked, the cascade
610/// re-resolver never re-walks the entity, the new class's rules never land,
611/// and a `transition` on the swapped property never runs.
612fn set_classes(world: &mut World, target_id: &str, classes: &str) -> Vec<Entity> {
613    let new_classes: Vec<String> = classes.split_whitespace().map(str::to_string).collect();
614    if target_id == "<root>" {
615        let Some(root) = world
616            .get_resource::<crate::spawn::DocumentRoot>()
617            .map(|r| r.0)
618        else {
619            return Vec::new();
620        };
621        world
622            .entity_mut(root)
623            .insert(LumenClasses::from(new_classes));
624        // The root's own restyle is the watcher's, on the host that has one.
625        return Vec::new();
626    }
627    let mut query = world.query::<(Entity, &LumenId, Option<&LumenClasses>)>();
628    let targets: Vec<(Entity, bool)> = query
629        .iter(world)
630        .filter(|(_, id, _)| id.0 == target_id)
631        .map(|(entity, _, current)| (entity, class_list_differs(current, &new_classes)))
632        .collect();
633    let mut changed = Vec::new();
634    for (entity, differs) in targets {
635        if differs {
636            changed.push(entity);
637        }
638        world
639            .entity_mut(entity)
640            .insert(LumenClasses::from(new_classes.clone()));
641    }
642    changed
643}
644
645/// Whether writing `new` over `current` changes the class list. Order
646/// matters: `set_class` assigns the whole list, so `"a b"` and `"b a"`
647/// are different writes.
648fn class_list_differs(current: Option<&LumenClasses>, new: &[String]) -> bool {
649    match current {
650        Some(c) => {
651            c.0.len() != new.len() || c.0.iter().zip(new).any(|(a, b)| a.as_ref() != b.as_str())
652        }
653        None => !new.is_empty(),
654    }
655}
656
657enum ClassOp {
658    Add,
659    Remove,
660    Toggle,
661}
662
663fn class_edit(world: &mut World, entity: Entity, class: &str, op: ClassOp) {
664    let mut e = world.entity_mut(entity);
665    let mut classes = e.take::<LumenClasses>().unwrap_or_default();
666    let present = classes.0.iter().any(|c| c.as_ref() == class);
667    let want = match op {
668        ClassOp::Add => true,
669        ClassOp::Remove => false,
670        ClassOp::Toggle => !present,
671    };
672    if want && !present {
673        classes.0.push(class.into());
674    } else if !want && present {
675        classes.0.retain(|c| c.as_ref() != class);
676    }
677    e.insert(classes);
678}
679
680/// Reconstruct a minimal [`Element`] subtree from a live entity: tag,
681/// identity, text, generic attrs, inline style, and children (deep). Used
682/// by `clone_deep`. Runtime-only components (interaction / layout state)
683/// are re-derived by the spawn path, not copied.
684fn element_from_entity(world: &World, entity: Entity) -> Option<Element> {
685    let tag = world.get::<LumenTag>(entity)?.0.to_string();
686    let mut el = Element {
687        tag,
688        ..Default::default()
689    };
690    if let Some(c) = world.get::<LumenClasses>(entity) {
691        el.attrs.classes = c.0.iter().map(|s| s.to_string()).collect();
692    }
693    if let Some(i) = world.get::<LumenId>(entity) {
694        el.attrs.id = Some(i.0.clone());
695    }
696    if let Some(t) = world.get::<TextContent>(entity) {
697        el.attrs.text = Some(t.0.clone());
698    }
699    if let Some(children) = world.get::<Children>(entity) {
700        for child in children.iter() {
701            if let Some(child_el) = element_from_entity(world, child) {
702                el.children.push(child_el);
703            }
704        }
705    }
706    Some(el)
707}
708
709/// Replace `entity`'s children with the subtree parsed from `markup`
710/// (`set_inner_markup` / `element.innerHTML`). Returns whether the tree
711/// changed (needs a restyle).
712///
713/// Guarded: parsing needs the injected markup front-end
714/// ([`RuntimeParser`](crate::source_parser::RuntimeParser)), which the
715/// from-source run path installs and the precompiled-artifact path does not.
716/// Absent, this is a no-op with a one-time warning, the documented
717/// limitation. The markup is live and unsanitized: callers must not feed it
718/// untrusted content.
719fn apply_inner_markup(world: &mut World, entity: Entity, markup: &str) -> bool {
720    let Some(parser) = world.get_resource::<RuntimeParser>().map(|p| p.0.clone()) else {
721        warn_inner_markup_unavailable();
722        return false;
723    };
724    // Wrap the fragment in a throwaway root so multiple top-level nodes (or a
725    // bare text run) parse under one element the front-end accepts; only the
726    // wrapper's children are spawned.
727    let wrapped = format!("<div>{markup}</div>");
728    let ir = match parser.parse_html(&wrapped, &FragmentTable::new()) {
729        Ok(ir) => ir,
730        Err(e) => {
731            eprintln!("set_inner_markup: parse error: {e}");
732            return false;
733        }
734    };
735    // Despawn the current children (each with its whole subtree).
736    let existing: Vec<Entity> = world
737        .get::<Children>(entity)
738        .map(|c| c.iter().collect())
739        .unwrap_or_default();
740    for child in existing {
741        world.entity_mut(child).despawn();
742    }
743    // Spawn the parsed children under `entity` through the same path the
744    // `<for>` reconciler and `Spawn` use.
745    let root = &ir.root;
746    if root.children.is_empty() {
747        if let Some(text) = &root.attrs.text {
748            set_text(world, entity, text);
749        }
750    } else {
751        for child_el in &root.children {
752            crate::spawn::spawn_subtree(world, child_el, Some(entity), Placeholders::Unresolved);
753        }
754    }
755    world.entity_mut(entity).insert(DirtyLayout);
756    true
757}
758
759/// One-time warning when `set_inner_markup` runs without an injected parser,
760/// which is every app running from a precompiled artifact. Keeps the log from
761/// flooding on a per-tick caller.
762fn warn_inner_markup_unavailable() {
763    use std::sync::Once;
764    static WARNED: Once = Once::new();
765    WARNED.call_once(|| {
766        eprintln!(
767            "set_inner_markup: no markup parser is linked on this run path \
768             (precompiled artifact); the call is a no-op. It works on the \
769             dev / from-source run path."
770        );
771    });
772}