1use 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
55pub fn install_dom(app: &mut App) {
66 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 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 install_dom_events(app);
104}
105
106fn 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 .after(lumen_core::text_events::TextEditSet::Apply),
146 );
147}
148
149#[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#[derive(Resource, Default)]
203pub struct PendingDomCommands(Vec<ScriptCommand>);
204
205pub 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
219pub 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 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 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 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
402fn 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
416fn 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
430fn spawn_fragment(
442 world: &mut World,
443 key: &str,
444 args: &[(String, String)],
445 children: &[(String, Entity)],
446) -> Option<Entity> {
447 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
494fn 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
508fn 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
522fn 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
537fn 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
598fn 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 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
645fn 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
680fn 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
709fn 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 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 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 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
759fn 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}