Skip to main content

mittens_engine/engine/ecs/system/
selection_system.rs

1use crate::engine::ecs::component::{
2    BoundsComponent, ColorComponent, Component, ComponentRef, DataComponent, EmissiveComponent,
3    LayoutComponent, OptionComponent, QueryRootMode, RenderableComponent, SelectionComponent,
4    SelectionEntry, SelectionMode, StyleComponent, TransformComponent, resolve_component_ref,
5};
6use crate::engine::ecs::{
7    ComponentId, EventSignal, IntentValue, RxWorld, SignalEmitter, SignalKind, World,
8};
9use crate::engine::graphics::bounds::{Aabb, mat4_identity, mat4_mul};
10
11const SELECTED_HIGHLIGHT_RGBA: [f32; 4] = [1.0, 0.84, 0.0, 1.0];
12const SELECTED_HIGHLIGHT_EMISSIVE: f32 = 3.0;
13const OVERLAY_HIGHLIGHT_Z_OFFSET: f32 = 0.01;
14const OVERLAY_HIGHLIGHT_Z_THICKNESS: f32 = 0.001;
15
16#[derive(Debug, Clone, Copy)]
17struct SelectionStyleStateComponent {
18    original_background_color: Option<[f32; 4]>,
19    component: Option<ComponentId>,
20}
21
22impl SelectionStyleStateComponent {
23    fn new(original_background_color: Option<[f32; 4]>) -> Self {
24        Self {
25            original_background_color,
26            component: None,
27        }
28    }
29}
30
31impl Component for SelectionStyleStateComponent {
32    fn set_id(&mut self, id: ComponentId) {
33        self.component = Some(id);
34    }
35
36    fn name(&self) -> &'static str {
37        "selection_style_state"
38    }
39
40    fn as_any(&self) -> &dyn std::any::Any {
41        self
42    }
43
44    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
45        self
46    }
47
48    fn to_mms_ast(
49        &self,
50        _world: &crate::engine::ecs::World,
51    ) -> crate::scripting::ast::ComponentExpression {
52        use crate::engine::ecs::component::ce_helpers::*;
53        ce("SelectionStyleState")
54    }
55}
56
57#[derive(Debug, Default)]
58pub struct SelectionSystem {
59    handlers_installed: bool,
60}
61
62impl SelectionSystem {
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    pub fn install_handlers(&mut self, rx: &mut RxWorld) {
68        if self.handlers_installed {
69            return;
70        }
71        self.handlers_installed = true;
72
73        rx.add_global_handler_closure(SignalKind::Click, move |world, emit, signal| {
74            let Some(EventSignal::Click { renderable, .. }) = signal.event.as_ref() else {
75                return;
76            };
77            let Some((selection_root, option_owner)) = resolve_selection_click(world, *renderable)
78            else {
79                return;
80            };
81            handle_selection_click(world, emit, selection_root, option_owner);
82        });
83    }
84}
85
86fn selection_marker_on_node(world: &World, node: ComponentId) -> Option<ComponentId> {
87    if world
88        .get_component_by_id_as::<SelectionComponent>(node)
89        .is_some()
90    {
91        return Some(node);
92    }
93    world.children_of(node).iter().copied().find(|&child| {
94        world
95            .get_component_by_id_as::<SelectionComponent>(child)
96            .is_some()
97    })
98}
99
100fn option_marker_on_node(world: &World, node: ComponentId) -> Option<ComponentId> {
101    if world
102        .get_component_by_id_as::<OptionComponent>(node)
103        .is_some()
104    {
105        return Some(node);
106    }
107    world.children_of(node).iter().copied().find(|&child| {
108        world
109            .get_component_by_id_as::<OptionComponent>(child)
110            .is_some()
111    })
112}
113
114fn selection_scope_owner(world: &World, selection_root: ComponentId) -> ComponentId {
115    if world.children_of(selection_root).is_empty() {
116        world.parent_of(selection_root).unwrap_or(selection_root)
117    } else {
118        selection_root
119    }
120}
121
122fn is_descendant_or_self(world: &World, ancestor: ComponentId, node: ComponentId) -> bool {
123    let mut current = Some(node);
124    while let Some(component_id) = current {
125        if component_id == ancestor {
126            return true;
127        }
128        current = world.parent_of(component_id);
129    }
130    false
131}
132
133fn resolve_selection_target_root(world: &World, selection_root: ComponentId) -> ComponentId {
134    let selection = world.get_component_by_id_as::<SelectionComponent>(selection_root);
135    let scope_owner = selection_scope_owner(world, selection_root);
136    let Some(source) = selection.and_then(|selection| selection.target_root_source.as_ref()) else {
137        return scope_owner;
138    };
139
140    match source {
141        ComponentRef::Guid(uuid) => world.component_id_by_guid(*uuid).unwrap_or(scope_owner),
142        ComponentRef::Query(selector) => resolve_component_ref(
143            world,
144            &ComponentRef::Query(selector.clone()),
145            Some(selection_root),
146            QueryRootMode::SelfSubtree,
147        )
148        .or_else(|| world.find_component(scope_owner, selector))
149        .unwrap_or(scope_owner),
150    }
151}
152
153fn selection_controls_for_node(world: &World, node: ComponentId) -> Vec<ComponentId> {
154    let mut out = Vec::new();
155    if let Some(selection) = selection_marker_on_node(world, node) {
156        out.push(selection);
157    }
158    for &child in world.children_of(node) {
159        if world
160            .get_component_by_id_as::<SelectionComponent>(child)
161            .is_some()
162        {
163            out.push(child);
164        }
165    }
166    out
167}
168
169fn nearest_enclosing_selection(world: &World, start: ComponentId) -> Option<ComponentId> {
170    let mut current = Some(start);
171    while let Some(node) = current {
172        for selection in selection_controls_for_node(world, node) {
173            let target_root = resolve_selection_target_root(world, selection);
174            if is_descendant_or_self(world, target_root, start) {
175                return Some(selection);
176            }
177        }
178        current = world.parent_of(node);
179    }
180    None
181}
182
183fn resolve_selection_click(
184    world: &World,
185    renderable: ComponentId,
186) -> Option<(ComponentId, ComponentId)> {
187    let mut current = Some(renderable);
188    while let Some(node) = current {
189        if option_marker_on_node(world, node).is_some() {
190            let selection_root = nearest_enclosing_selection(world, node)?;
191            return Some((selection_root, node));
192        }
193        if selection_marker_on_node(world, node).is_some() {
194            return None;
195        }
196        current = world.parent_of(node);
197    }
198    None
199}
200
201fn find_descendant_by_type(
202    world: &World,
203    root: ComponentId,
204    component_type: &str,
205) -> Option<ComponentId> {
206    // Check root itself first
207    if let Some(node) = world.get_component_record(root) {
208        if node.component_type == component_type {
209            return Some(root);
210        }
211    }
212    // Then check children recursively
213    for &child in world.children_of(root) {
214        if let Some(found) = find_descendant_by_type(world, child, component_type) {
215            return Some(found);
216        }
217    }
218    None
219}
220
221fn find_selected_item_index(
222    world: &World,
223    selection_root: ComponentId,
224    item_id: ComponentId,
225) -> Option<usize> {
226    let scope_root = resolve_selection_target_root(world, selection_root);
227    let mut index = 0usize;
228    for &child in world.children_of(scope_root) {
229        if option_marker_on_node(world, child).is_some() {
230            if child == item_id {
231                return Some(index);
232            }
233            index += 1;
234        }
235    }
236    None
237}
238
239fn immediate_style_child(world: &World, root: ComponentId) -> Option<ComponentId> {
240    world.children_of(root).iter().copied().find(|&child| {
241        world
242            .get_component_by_id_as::<StyleComponent>(child)
243            .is_some()
244    })
245}
246
247fn selection_style_state_child(world: &World, root: ComponentId) -> Option<ComponentId> {
248    world.children_of(root).iter().copied().find(|&child| {
249        world
250            .get_component_by_id_as::<SelectionStyleStateComponent>(child)
251            .is_some()
252    })
253}
254
255fn styled_option_target(world: &World, root: ComponentId) -> Option<ComponentId> {
256    immediate_style_child(world, root)
257}
258
259fn mark_nearest_layout_dirty(world: &mut World, start: ComponentId) {
260    let mut current = Some(start);
261    while let Some(component_id) = current {
262        if let Some(layout) = world.get_component_by_id_as_mut::<LayoutComponent>(component_id) {
263            layout.mark_dirty();
264            return;
265        }
266        current = world.parent_of(component_id);
267    }
268}
269
270fn set_styled_selection(
271    world: &mut World,
272    emit: &mut dyn SignalEmitter,
273    item_id: ComponentId,
274    selected: bool,
275) -> bool {
276    let Some(style_id) = styled_option_target(world, item_id) else {
277        return false;
278    };
279
280    if selected {
281        if selection_style_state_child(world, item_id).is_none() {
282            let original_background_color = world
283                .get_component_by_id_as::<StyleComponent>(style_id)
284                .map(|style| style.background_color)
285                .unwrap_or(None);
286            let state_id = world.add_component_boxed_named(
287                "selection_style_state",
288                Box::new(SelectionStyleStateComponent::new(original_background_color)),
289            );
290            let _ = world.add_child(item_id, state_id);
291            world.init_component_tree(state_id, emit);
292        }
293        if let Some(style) = world.get_component_by_id_as_mut::<StyleComponent>(style_id) {
294            style.background_color = Some(SELECTED_HIGHLIGHT_RGBA);
295        }
296        mark_nearest_layout_dirty(world, item_id);
297        return true;
298    }
299
300    let original_background_color =
301        selection_style_state_child(world, item_id).and_then(|state_id| {
302            world
303                .get_component_by_id_as::<SelectionStyleStateComponent>(state_id)
304                .map(|state| state.original_background_color)
305        });
306    if let Some(style) = world.get_component_by_id_as_mut::<StyleComponent>(style_id) {
307        style.background_color = original_background_color.unwrap_or(None);
308    }
309    if let Some(state_id) = selection_style_state_child(world, item_id) {
310        emit.push_intent_now(
311            state_id,
312            IntentValue::RemoveSubtree {
313                component_ids: vec![state_id],
314            },
315        );
316    }
317    mark_nearest_layout_dirty(world, item_id);
318    true
319}
320
321fn subtree_local_bounds(world: &World, root: ComponentId) -> Option<Aabb> {
322    fn visit(
323        world: &World,
324        node: ComponentId,
325        parent_to_root: [[f32; 4]; 4],
326        acc: &mut Option<Aabb>,
327    ) {
328        let mut local_to_root = parent_to_root;
329        if let Some(tc) = world.get_component_by_id_as::<TransformComponent>(node) {
330            local_to_root = mat4_mul(parent_to_root, tc.transform.model);
331        }
332        if world
333            .get_component_by_id_as::<RenderableComponent>(node)
334            .is_some()
335        {
336            for &child in world.children_of(node) {
337                if let Some(bounds) = world.get_component_by_id_as::<BoundsComponent>(child) {
338                    let transformed = bounds.local.transformed(local_to_root);
339                    *acc = Some(match acc {
340                        Some(prev) => prev.union(&transformed),
341                        None => transformed,
342                    });
343                    break;
344                }
345            }
346        }
347        for &child in world.children_of(node) {
348            if world.component_label(child) == Some("selection_highlight") {
349                continue;
350            }
351            visit(world, child, local_to_root, acc);
352        }
353    }
354
355    let mut acc = None;
356    visit(world, root, mat4_identity(), &mut acc);
357    acc
358}
359
360fn ensure_selection_overlay(world: &mut World, emit: &mut dyn SignalEmitter, item_id: ComponentId) {
361    let Some(bounds) = subtree_local_bounds(world, item_id) else {
362        return;
363    };
364
365    let highlight_id = world
366        .children_of(item_id)
367        .iter()
368        .copied()
369        .find(|&child| world.component_label(child) == Some("selection_highlight"))
370        .unwrap_or_else(|| {
371            let highlight = world.add_component_boxed_named(
372                "selection_highlight",
373                Box::new(TransformComponent::new()),
374            );
375            let color = world.add_component_boxed(Box::new(ColorComponent::rgba(
376                SELECTED_HIGHLIGHT_RGBA[0],
377                SELECTED_HIGHLIGHT_RGBA[1],
378                SELECTED_HIGHLIGHT_RGBA[2],
379                SELECTED_HIGHLIGHT_RGBA[3],
380            )));
381            let renderable = world.add_component_boxed(Box::new(RenderableComponent::square()));
382            let emissive = world.add_component_boxed(Box::new(EmissiveComponent::new(
383                SELECTED_HIGHLIGHT_EMISSIVE,
384            )));
385            let _ = world.add_child(highlight, color);
386            let _ = world.add_child(highlight, renderable);
387            let _ = world.add_child(highlight, emissive);
388            let _ = world.add_child(item_id, highlight);
389            world.init_component_tree(highlight, emit);
390            highlight
391        });
392
393    let center = bounds.center();
394    emit.push_intent_now(
395        highlight_id,
396        IntentValue::UpdateTransform {
397            component_ids: vec![highlight_id],
398            translation: [
399                center[0],
400                center[1],
401                bounds.max[2] + OVERLAY_HIGHLIGHT_Z_OFFSET,
402            ],
403            rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
404            scale: [
405                bounds.width().max(0.001),
406                bounds.height().max(0.001),
407                OVERLAY_HIGHLIGHT_Z_THICKNESS,
408            ],
409        },
410    );
411}
412
413fn remove_selection_overlay(world: &World, emit: &mut dyn SignalEmitter, item_id: ComponentId) {
414    for &child in world.children_of(item_id) {
415        if let Some(record) = world.get_component_record(child) {
416            if record.name == "selection_highlight" {
417                emit.push_intent_now(
418                    child,
419                    IntentValue::RemoveSubtree {
420                        component_ids: vec![child],
421                    },
422                );
423            }
424        }
425    }
426}
427
428fn add_selection_highlight(world: &mut World, emit: &mut dyn SignalEmitter, item_id: ComponentId) {
429    if set_styled_selection(world, emit, item_id, true) {
430        remove_selection_overlay(world, emit, item_id);
431        return;
432    }
433    ensure_selection_overlay(world, emit, item_id);
434}
435
436fn remove_selection_highlight(
437    world: &mut World,
438    emit: &mut dyn SignalEmitter,
439    item_id: ComponentId,
440) {
441    if set_styled_selection(world, emit, item_id, false) {
442        remove_selection_overlay(world, emit, item_id);
443        return;
444    }
445    remove_selection_overlay(world, emit, item_id);
446}
447
448pub fn emit_selection_events(
449    emit: &mut dyn SignalEmitter,
450    selection_root: ComponentId,
451    mode: SelectionMode,
452    old_entries: &[SelectionEntry],
453    old_selected_component: Option<ComponentId>,
454    old_selected_payload: Option<ComponentId>,
455    new_entries: Vec<SelectionEntry>,
456    new_selected_component: Option<ComponentId>,
457    new_selected_payload: Option<ComponentId>,
458) {
459    for entry in new_entries.iter() {
460        if !old_entries
461            .iter()
462            .any(|old_entry| old_entry.component == entry.component)
463        {
464            emit.push_event(
465                selection_root,
466                EventSignal::SelectionAdded {
467                    selection_root,
468                    entry: entry.clone(),
469                },
470            );
471        }
472    }
473
474    for entry in old_entries.iter() {
475        if !new_entries
476            .iter()
477            .any(|new_entry| new_entry.component == entry.component)
478        {
479            emit.push_event(
480                selection_root,
481                EventSignal::SelectionRemoved {
482                    selection_root,
483                    entry: entry.clone(),
484                },
485            );
486        }
487    }
488
489    if !old_entries.is_empty() && new_entries.is_empty() {
490        emit.push_event(
491            selection_root,
492            EventSignal::SelectionCleared { selection_root },
493        );
494    }
495
496    if old_entries != new_entries
497        || old_selected_component != new_selected_component
498        || old_selected_payload != new_selected_payload
499    {
500        emit.push_event(
501            selection_root,
502            EventSignal::SelectionChanged {
503                selection_root,
504                mode,
505                selected_entries: new_entries,
506                selected_component: new_selected_component,
507                selected_payload: new_selected_payload,
508            },
509        );
510    }
511}
512
513fn direct_option_payload(
514    world: &World,
515    selection_root: ComponentId,
516    selected_component: Option<ComponentId>,
517) -> Option<ComponentId> {
518    let row_root = selected_component?;
519    let option_root = option_marker_on_node(world, row_root).unwrap_or(row_root);
520    let mut matches = Vec::new();
521    for root in [row_root, option_root] {
522        for &child in world.children_of(root) {
523            if world
524                .get_component_by_id_as::<DataComponent>(child)
525                .is_some()
526                && !matches.contains(&child)
527            {
528                matches.push(child);
529            }
530        }
531    }
532    match matches.len() {
533        0 => None,
534        1 => matches.into_iter().next(),
535        _ => {
536            eprintln!(
537                "[selection] direct payload resolution found multiple Data children selection_root={selection_root:?} selected_component={row_root:?} option_root={option_root:?} count={}",
538                matches.len()
539            );
540            None
541        }
542    }
543}
544
545pub fn resolve_semantic_target_from_payload(
546    world: &World,
547    selected_payload: Option<ComponentId>,
548    selected_component: Option<ComponentId>,
549) -> Option<ComponentId> {
550    if let Some(payload) = selected_payload {
551        if let Some(data) = world.get_component_by_id_as::<DataComponent>(payload)
552            && let Some(target_component) = data.get_component("target_component")
553        {
554            return Some(target_component);
555        }
556        return Some(payload);
557    }
558    selected_component
559}
560
561pub fn apply_selection_set(
562    world: &mut World,
563    emit: &mut dyn SignalEmitter,
564    selection_root: ComponentId,
565    entries: Vec<SelectionEntry>,
566    primary: Option<ComponentId>,
567) {
568    let (
569        mode,
570        old_entries,
571        old_selected_component,
572        old_selected_payload,
573        new_entries,
574        new_selected_component,
575    ) = {
576        let selection = match world.get_component_by_id_as_mut::<SelectionComponent>(selection_root)
577        {
578            Some(selection) => selection,
579            None => return,
580        };
581
582        let old_entries = selection.selected_entries.clone();
583        let old_selected_component = selection.selected_component;
584        let old_selected_payload = selection.selected_payload;
585        let mut new_entries = entries;
586        if matches!(selection.mode, SelectionMode::Single) && new_entries.len() > 1 {
587            new_entries.truncate(1);
588        }
589
590        selection.selected_entries = new_entries.clone();
591        if let Some(primary_component) = primary {
592            if let Some(entry) = selection
593                .selected_entries
594                .iter()
595                .find(|entry| entry.component == primary_component)
596                .cloned()
597            {
598                selection.selected_index = entry.index;
599                selection.selected_component = Some(entry.component);
600            } else if let Some(entry) = selection.selected_entries.last().cloned() {
601                selection.selected_index = entry.index;
602                selection.selected_component = Some(entry.component);
603            } else {
604                selection.selected_index = None;
605                selection.selected_component = None;
606            }
607        } else if let Some(entry) = selection.selected_entries.last().cloned() {
608            selection.selected_index = entry.index;
609            selection.selected_component = Some(entry.component);
610        } else {
611            selection.selected_index = None;
612            selection.selected_component = None;
613        }
614
615        (
616            selection.mode,
617            old_entries,
618            old_selected_component,
619            old_selected_payload,
620            selection.selected_entries.clone(),
621            selection.selected_component,
622        )
623    };
624
625    let new_selected_payload = direct_option_payload(world, selection_root, new_selected_component);
626
627    if let Some(selection) = world.get_component_by_id_as_mut::<SelectionComponent>(selection_root)
628    {
629        selection.selected_payload = new_selected_payload;
630    }
631
632    for entry in old_entries.iter() {
633        if !new_entries
634            .iter()
635            .any(|new_entry| new_entry.component == entry.component)
636        {
637            remove_selection_highlight(world, emit, entry.component);
638        }
639    }
640
641    for entry in new_entries.iter() {
642        if !old_entries
643            .iter()
644            .any(|old_entry| old_entry.component == entry.component)
645        {
646            add_selection_highlight(world, emit, entry.component);
647        }
648    }
649
650    if matches!(mode, SelectionMode::Single) {
651        if let Some(selected_component) = new_selected_component {
652            if old_selected_component != Some(selected_component) {
653                add_selection_highlight(world, emit, selected_component);
654            }
655        }
656    }
657
658    emit_selection_events(
659        emit,
660        selection_root,
661        mode,
662        &old_entries,
663        old_selected_component,
664        old_selected_payload,
665        new_entries,
666        new_selected_component,
667        new_selected_payload,
668    );
669}
670
671fn handle_selection_click(
672    world: &mut World,
673    emit: &mut dyn SignalEmitter,
674    selection_root: ComponentId,
675    item_id: ComponentId,
676) {
677    let selected_index = find_selected_item_index(world, selection_root, item_id);
678
679    let entry = SelectionEntry {
680        index: selected_index,
681        component: item_id,
682    };
683
684    let (next_entries, next_primary) = {
685        let selection = match world.get_component_by_id_as::<SelectionComponent>(selection_root) {
686            Some(selection) => selection,
687            None => return,
688        };
689
690        if selection.is_multiple() {
691            let mut next_entries = selection.selected_entries.clone();
692            if let Some(index) = next_entries
693                .iter()
694                .position(|selected| selected.component == item_id)
695            {
696                next_entries.remove(index);
697                let next_primary = next_entries.last().map(|entry| entry.component);
698                (next_entries, next_primary)
699            } else {
700                next_entries.push(entry.clone());
701                (next_entries, Some(entry.component))
702            }
703        } else if selection.selected_entries.len() == 1
704            && selection.selected_entries[0].component == item_id
705        {
706            if selection.allow_empty_single {
707                (Vec::new(), None)
708            } else {
709                // Single-select reclick is a no-op — keep existing selection
710                return;
711            }
712        } else {
713            (vec![entry.clone()], Some(entry.component))
714        }
715    };
716
717    apply_selection_set(world, emit, selection_root, next_entries, next_primary);
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use crate::engine::ecs::command_queue::CommandQueue;
724    use crate::engine::ecs::component::{
725        DataComponent, DataValue, EditorComponent, OptionComponent, SelectionMode,
726        TransformComponent,
727    };
728    use crate::engine::ecs::system::SystemWorld;
729    use crate::engine::ecs::{EventSignal, IntentValue, SignalKind, World};
730    use crate::engine::graphics::{RenderAssets, VisualWorld};
731    use std::path::PathBuf;
732    use std::sync::{Arc, Mutex};
733    use std::time::{SystemTime, UNIX_EPOCH};
734
735    fn temp_asset_directory() -> PathBuf {
736        let now = SystemTime::now()
737            .duration_since(UNIX_EPOCH)
738            .expect("time went backwards")
739            .as_nanos();
740        let tmp_dir = std::env::temp_dir().join(format!("mittens_engine_assets_{}", now));
741        std::fs::create_dir_all(&tmp_dir).expect("create temp dir");
742        tmp_dir
743    }
744
745    fn find_named_root(world: &World, name: &str) -> ComponentId {
746        world
747            .all_components()
748            .find(|&component_id| {
749                world.parent_of(component_id).is_none()
750                    && world
751                        .component_label(component_id)
752                        .is_some_and(|label| label == name)
753            })
754            .unwrap_or_else(|| panic!("expected root named {name}"))
755    }
756
757    fn spawn_test_option_item(
758        world: &mut World,
759        parent: ComponentId,
760        name: &str,
761        with_style: bool,
762    ) -> (ComponentId, ComponentId, Option<ComponentId>) {
763        let item = world.add_component_boxed_named(name, Box::new(TransformComponent::new()));
764        let _ = world.add_child(parent, item);
765
766        let option = world.add_component_boxed(Box::new(OptionComponent::new()));
767        let _ = world.add_child(item, option);
768
769        let style_id = if with_style {
770            let style = world.add_component_boxed(Box::new(StyleComponent::default()));
771            let _ = world.add_child(item, style);
772            Some(style)
773        } else {
774            None
775        };
776
777        let renderable_root =
778            world.add_component_boxed_named("renderable_root", Box::new(TransformComponent::new()));
779        let _ = world.add_child(item, renderable_root);
780        let renderable = world.add_component_boxed(Box::new(RenderableComponent::square()));
781        let _ = world.add_child(renderable_root, renderable);
782        let bounds = world.add_component_boxed(Box::new(BoundsComponent::new(
783            Aabb::from_points(&[
784                [-0.5, -0.5, 0.0],
785                [0.5, -0.5, 0.0],
786                [-0.5, 0.5, 0.0],
787                [0.5, 0.5, 0.0],
788            ])
789            .expect("bounds"),
790        )));
791        let _ = world.add_child(renderable, bounds);
792
793        (item, renderable, style_id)
794    }
795
796    fn fit_bounds_content_scale(world: &World, item_id: ComponentId) -> [f32; 3] {
797        let fit = world
798            .children_of(item_id)
799            .iter()
800            .copied()
801            .find(|&child| {
802                world
803                    .get_component_by_id_as::<crate::engine::ecs::component::FitBoundsComponent>(
804                        child,
805                    )
806                    .is_some()
807            })
808            .expect("expected fit_bounds child");
809        let content = world
810            .children_of(fit)
811            .iter()
812            .copied()
813            .find(|&child| world.component_label(child) == Some("__fit_bounds_content"))
814            .expect("expected fit_bounds content transform");
815        world
816            .get_component_by_id_as::<TransformComponent>(content)
817            .expect("expected fit_bounds content transform component")
818            .transform
819            .scale
820    }
821
822    #[test]
823    fn selection_system_click_updates_selection_state() {
824        let tmp_dir = temp_asset_directory();
825        let asset_path = tmp_dir.join("test_asset.mms");
826        std::fs::write(
827            &asset_path,
828            r#"
829                export fn example() {
830                    let root = T {}
831                    return root
832                }
833            "#,
834        )
835        .expect("write asset file");
836
837        let mut world = World::default();
838        let mut emit = CommandQueue::new();
839        let mut visuals = VisualWorld::default();
840        let mut systems = SystemWorld::default();
841        let mut render_assets = crate::engine::graphics::RenderAssets::new();
842
843        systems
844            .asset_system
845            .scan_assets_dir(&tmp_dir)
846            .expect("scan assets dir");
847
848        systems.selection.install_handlers(&mut systems.rx);
849
850        let parent = world.add_component_boxed_named(
851            "parent",
852            Box::new(crate::engine::ecs::component::TransformComponent::new()),
853        );
854        let wrapper = systems
855            .asset_system
856            .spawn_assets_panel(
857                &mut world,
858                &mut render_assets,
859                &mut emit,
860                parent,
861                (0.0, 0.0, 0.0),
862            )
863            .expect("spawn assets panel");
864
865        let selection_root = world
866            .find_component(wrapper, "#assets_selection")
867            .expect("expected selection root");
868        let assets_content_area = world
869            .find_component(wrapper, "#assets_content_area")
870            .expect("expected assets content area");
871
872        fn print_subtree(world: &World, root: ComponentId, indent: usize) {
873            let prefix = "  ".repeat(indent);
874            let node = world.get_component_record(root).unwrap();
875            println!(
876                "{}node={:?} type={} name={:?}",
877                prefix, root, node.component_type, node.name
878            );
879            for &child in world.children_of(root) {
880                print_subtree(world, child, indent + 1);
881            }
882        }
883        print_subtree(&world, wrapper, 0);
884
885        let item = world
886            .find_component(assets_content_area, "[name='asset_item']")
887            .expect("expected asset item");
888        let item_text = world
889            .find_component(item, "[name='selection_item_label']")
890            .expect("expected item label");
891        let (resolved_selection, item) =
892            super::resolve_selection_click(&world, item_text).expect("expected option hit");
893        assert_eq!(resolved_selection, selection_root);
894
895        systems.rx.push_event(
896            item_text,
897            EventSignal::Click {
898                raycaster: item_text,
899                renderable: item_text,
900                hit_point: [0.0, 0.0, 0.0],
901                screen_pos_px: None,
902            },
903        );
904
905        let _ = systems.process_signals(
906            &mut world,
907            &mut visuals,
908            &mut render_assets,
909            &mut emit,
910            100_000,
911        );
912
913        let selection = world
914            .get_component_by_id_as::<SelectionComponent>(selection_root)
915            .expect("expected selection component");
916        let payload = world
917            .find_component(item, "[name='asset_payload']")
918            .expect("expected asset payload");
919
920        assert_eq!(selection.selected_component, Some(item));
921        assert_eq!(selection.selected_payload, Some(payload));
922        assert_eq!(selection.selected_index, Some(0));
923        assert_eq!(
924            world
925                .get_component_by_id_as::<DataComponent>(payload)
926                .and_then(|data| match data.get("label")? {
927                    crate::engine::ecs::component::DataValue::Text(label) => Some(label.as_str()),
928                    _ => None,
929                }),
930            Some("test_asset: example")
931        );
932    }
933
934    #[test]
935    fn selection_system_multiple_mode_toggles_membership() {
936        let tmp_dir = temp_asset_directory();
937        let asset_path = tmp_dir.join("test_asset.mms");
938        std::fs::write(
939            &asset_path,
940            r#"
941                export fn example() {
942                    let root = T {}
943                    return root
944                }
945
946                export fn second_example() {
947                    let root = T {}
948                    return root
949                }
950            "#,
951        )
952        .expect("write asset file");
953
954        let mut world = World::default();
955        let mut emit = CommandQueue::new();
956        let mut visuals = VisualWorld::default();
957        let mut systems = SystemWorld::default();
958        let mut render_assets = RenderAssets::new();
959
960        systems
961            .asset_system
962            .scan_assets_dir(&tmp_dir)
963            .expect("scan assets dir");
964        systems.selection.install_handlers(&mut systems.rx);
965
966        let parent = world.add_component_boxed_named(
967            "parent",
968            Box::new(crate::engine::ecs::component::TransformComponent::new()),
969        );
970        let wrapper = systems
971            .asset_system
972            .spawn_assets_panel(
973                &mut world,
974                &mut render_assets,
975                &mut emit,
976                parent,
977                (0.0, 0.0, 0.0),
978            )
979            .expect("spawn assets panel");
980
981        let selection_root = world
982            .find_component(wrapper, "#assets_selection")
983            .expect("expected selection root");
984        {
985            let selection = world
986                .get_component_by_id_as_mut::<SelectionComponent>(selection_root)
987                .expect("expected selection component");
988            *selection = SelectionComponent::multiple();
989        }
990
991        let assets_content_area = world
992            .find_component(wrapper, "#assets_content_area")
993            .expect("expected assets content area");
994        let items = world.find_all_components(assets_content_area, "[name='asset_item']");
995        assert!(items.len() >= 2, "expected at least two asset items");
996        let first = items[0];
997        let second = items[1];
998
999        systems.rx.push_event(
1000            first,
1001            EventSignal::Click {
1002                raycaster: first,
1003                renderable: first,
1004                hit_point: [0.0, 0.0, 0.0],
1005                screen_pos_px: None,
1006            },
1007        );
1008        systems.rx.push_event(
1009            second,
1010            EventSignal::Click {
1011                raycaster: second,
1012                renderable: second,
1013                hit_point: [0.0, 0.0, 0.0],
1014                screen_pos_px: None,
1015            },
1016        );
1017
1018        let _ = systems.process_signals(
1019            &mut world,
1020            &mut visuals,
1021            &mut render_assets,
1022            &mut emit,
1023            100_000,
1024        );
1025
1026        let selection = world
1027            .get_component_by_id_as::<SelectionComponent>(selection_root)
1028            .expect("expected selection component");
1029        assert_eq!(selection.mode, SelectionMode::Multiple);
1030        assert_eq!(selection.selected_entries.len(), 2);
1031        assert!(selection.contains(first));
1032        assert!(selection.contains(second));
1033        assert_eq!(selection.selected_component, Some(second));
1034
1035        systems.rx.push_event(
1036            first,
1037            EventSignal::Click {
1038                raycaster: first,
1039                renderable: first,
1040                hit_point: [0.0, 0.0, 0.0],
1041                screen_pos_px: None,
1042            },
1043        );
1044
1045        let _ = systems.process_signals(
1046            &mut world,
1047            &mut visuals,
1048            &mut render_assets,
1049            &mut emit,
1050            100_000,
1051        );
1052
1053        let selection = world
1054            .get_component_by_id_as::<SelectionComponent>(selection_root)
1055            .expect("expected selection component");
1056        assert_eq!(selection.selected_entries.len(), 1);
1057        assert!(!selection.contains(first));
1058        assert!(selection.contains(second));
1059        assert_eq!(selection.selected_component, Some(second));
1060    }
1061
1062    #[test]
1063    fn selection_optional_mode_reclick_clears_selection() {
1064        let mut world = World::default();
1065        let mut emit = CommandQueue::new();
1066
1067        let selection_root = world.add_component_boxed(Box::new(SelectionComponent::optional()));
1068        let item = world.add_component_boxed_named("item", Box::new(TransformComponent::new()));
1069        let option = world.add_component_boxed(Box::new(OptionComponent::new()));
1070        let _ = world.add_child(selection_root, item);
1071        let _ = world.add_child(item, option);
1072
1073        handle_selection_click(&mut world, &mut emit, selection_root, item);
1074        let selection = world
1075            .get_component_by_id_as::<SelectionComponent>(selection_root)
1076            .expect("selection");
1077        assert_eq!(selection.selected_component, Some(item));
1078
1079        handle_selection_click(&mut world, &mut emit, selection_root, item);
1080        let selection = world
1081            .get_component_by_id_as::<SelectionComponent>(selection_root)
1082            .expect("selection");
1083        assert_eq!(selection.selected_component, None);
1084        assert!(selection.selected_entries.is_empty());
1085    }
1086
1087    #[test]
1088    fn editor_panel_layout_selection_selects_one_panel_at_a_time() {
1089        let mut world = World::default();
1090        let mut emit = CommandQueue::new();
1091        let mut visuals = VisualWorld::default();
1092        let mut systems = SystemWorld::default();
1093        let mut render_assets = RenderAssets::new();
1094        let asset_system = crate::engine::ecs::system::AssetSystem::new();
1095
1096        systems.selection.install_handlers(&mut systems.rx);
1097
1098        let editor_root =
1099            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
1100        let scene_root =
1101            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
1102        let _ = world.add_child(editor_root, scene_root);
1103
1104        systems.editor_inspector.setup_panels_for_editor(
1105            &mut systems.rx,
1106            &mut world,
1107            &mut render_assets,
1108            &mut emit,
1109            editor_root,
1110            (-0.7, 1.6, -1.2),
1111            (-0.7, 1.6, -1.2),
1112            systems.editor_context.shared_state(),
1113            &asset_system,
1114        );
1115
1116        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1117
1118        let runtime_ui_root = find_named_root(&world, "editor_runtime_ui_root");
1119        let selection_root = world
1120            .find_component(runtime_ui_root, "#editor_panel_layout_selection")
1121            .expect("expected panel layout selection");
1122        let world_panel_root = world
1123            .find_component(runtime_ui_root, "#world_panel_root")
1124            .expect("expected world panel root");
1125        let paint_panel_root = world
1126            .find_component(runtime_ui_root, "#paint_panel_root")
1127            .expect("expected paint panel root");
1128
1129        systems.rx.push_event(
1130            world_panel_root,
1131            EventSignal::Click {
1132                raycaster: world_panel_root,
1133                renderable: world_panel_root,
1134                hit_point: [0.0, 0.0, 0.0],
1135                screen_pos_px: None,
1136            },
1137        );
1138
1139        let _ = systems.process_signals(
1140            &mut world,
1141            &mut visuals,
1142            &mut render_assets,
1143            &mut emit,
1144            100_000,
1145        );
1146
1147        let selection = world
1148            .get_component_by_id_as::<SelectionComponent>(selection_root)
1149            .expect("expected selection component");
1150        assert_eq!(selection.selected_component, Some(world_panel_root));
1151        assert_eq!(selection.selected_entries.len(), 1);
1152
1153        systems.rx.push_event(
1154            paint_panel_root,
1155            EventSignal::Click {
1156                raycaster: paint_panel_root,
1157                renderable: paint_panel_root,
1158                hit_point: [0.0, 0.0, 0.0],
1159                screen_pos_px: None,
1160            },
1161        );
1162
1163        let _ = systems.process_signals(
1164            &mut world,
1165            &mut visuals,
1166            &mut render_assets,
1167            &mut emit,
1168            100_000,
1169        );
1170
1171        let selection = world
1172            .get_component_by_id_as::<SelectionComponent>(selection_root)
1173            .expect("expected selection component");
1174        assert_eq!(selection.selected_component, Some(paint_panel_root));
1175        assert_eq!(selection.selected_entries.len(), 1);
1176    }
1177
1178    #[test]
1179    fn paint_tool_selection_selects_one_option_at_a_time() {
1180        let mut world = World::default();
1181        let mut emit = CommandQueue::new();
1182        let mut visuals = VisualWorld::default();
1183        let mut systems = SystemWorld::default();
1184        let mut render_assets = RenderAssets::new();
1185        let asset_system = crate::engine::ecs::system::AssetSystem::new();
1186
1187        systems.selection.install_handlers(&mut systems.rx);
1188
1189        let editor_root =
1190            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
1191        let scene_root =
1192            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
1193        let _ = world.add_child(editor_root, scene_root);
1194
1195        systems.editor_inspector.setup_panels_for_editor(
1196            &mut systems.rx,
1197            &mut world,
1198            &mut render_assets,
1199            &mut emit,
1200            editor_root,
1201            (-0.7, 1.6, -1.2),
1202            (-0.7, 1.6, -1.2),
1203            systems.editor_context.shared_state(),
1204            &asset_system,
1205        );
1206
1207        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1208
1209        let runtime_ui_root = find_named_root(&world, "editor_runtime_ui_root");
1210        let paint_panel_root = world
1211            .find_component(runtime_ui_root, "#paint_panel_root")
1212            .expect("expected paint panel root");
1213        let selection_root = world
1214            .find_component(paint_panel_root, "#paint_tool_selection")
1215            .expect("expected paint tool selection");
1216        let content_slot = world
1217            .find_component(paint_panel_root, "#content_slot")
1218            .expect("expected content slot");
1219        let items: Vec<ComponentId> = world
1220            .children_of(content_slot)
1221            .iter()
1222            .flat_map(|&child| world.children_of(child))
1223            .copied()
1224            .collect();
1225        assert!(items.len() >= 2, "expected at least two paint tool items");
1226
1227        let first = items[0];
1228        let second = items[1];
1229
1230        systems.layout.tick(&mut world, &mut emit);
1231        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1232        systems
1233            .fit_bounds
1234            .tick(&mut world, &mut render_assets, &mut emit);
1235
1236        let first_scale_before = fit_bounds_content_scale(&world, first);
1237        assert!(
1238            first_scale_before[0] > 1.0,
1239            "expected FitBounds to expand first icon before selection"
1240        );
1241
1242        systems.rx.push_event(
1243            first,
1244            EventSignal::Click {
1245                raycaster: first,
1246                renderable: first,
1247                hit_point: [0.0, 0.0, 0.0],
1248                screen_pos_px: None,
1249            },
1250        );
1251
1252        let _ = systems.process_signals(
1253            &mut world,
1254            &mut visuals,
1255            &mut render_assets,
1256            &mut emit,
1257            100_000,
1258        );
1259
1260        systems.layout.tick(&mut world, &mut emit);
1261        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1262        systems
1263            .fit_bounds
1264            .tick(&mut world, &mut render_assets, &mut emit);
1265
1266        let selection = world
1267            .get_component_by_id_as::<SelectionComponent>(selection_root)
1268            .expect("expected selection component");
1269        assert_eq!(selection.selected_component, Some(first));
1270        assert_eq!(selection.selected_entries.len(), 1);
1271        assert_eq!(fit_bounds_content_scale(&world, first), first_scale_before);
1272
1273        systems.rx.push_event(
1274            second,
1275            EventSignal::Click {
1276                raycaster: second,
1277                renderable: second,
1278                hit_point: [0.0, 0.0, 0.0],
1279                screen_pos_px: None,
1280            },
1281        );
1282
1283        let _ = systems.process_signals(
1284            &mut world,
1285            &mut visuals,
1286            &mut render_assets,
1287            &mut emit,
1288            100_000,
1289        );
1290
1291        systems.layout.tick(&mut world, &mut emit);
1292        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1293        systems
1294            .fit_bounds
1295            .tick(&mut world, &mut render_assets, &mut emit);
1296
1297        let selection = world
1298            .get_component_by_id_as::<SelectionComponent>(selection_root)
1299            .expect("expected selection component");
1300        assert_eq!(selection.selected_component, Some(second));
1301        assert_eq!(selection.selected_entries.len(), 1);
1302        assert_eq!(fit_bounds_content_scale(&world, first), first_scale_before);
1303
1304        let panel_layout_selection = world
1305            .find_component(runtime_ui_root, "#editor_panel_layout_selection")
1306            .expect("expected panel layout selection");
1307        let panel_selection = world
1308            .get_component_by_id_as::<SelectionComponent>(panel_layout_selection)
1309            .expect("expected panel layout selection state");
1310        assert_eq!(panel_selection.selected_component, Some(paint_panel_root));
1311    }
1312
1313    #[test]
1314    fn asset_item_click_focuses_assets_panel_shell() {
1315        let tmp_dir = temp_asset_directory();
1316        let asset_path = tmp_dir.join("test_asset.mms");
1317        std::fs::write(
1318            &asset_path,
1319            r#"
1320                export fn example() {
1321                    let root = T {}
1322                    return root
1323                }
1324            "#,
1325        )
1326        .expect("write asset file");
1327
1328        let mut world = World::default();
1329        let mut emit = CommandQueue::new();
1330        let mut visuals = VisualWorld::default();
1331        let mut systems = SystemWorld::default();
1332        let mut render_assets = RenderAssets::new();
1333
1334        systems
1335            .asset_system
1336            .scan_assets_dir(&tmp_dir)
1337            .expect("scan assets dir");
1338        systems.selection.install_handlers(&mut systems.rx);
1339
1340        let editor_root =
1341            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
1342        let scene_root =
1343            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
1344        let _ = world.add_child(editor_root, scene_root);
1345
1346        systems.editor_inspector.setup_panels_for_editor(
1347            &mut systems.rx,
1348            &mut world,
1349            &mut render_assets,
1350            &mut emit,
1351            editor_root,
1352            (-0.7, 1.6, -1.2),
1353            (-0.7, 1.6, -1.2),
1354            systems.editor_context.shared_state(),
1355            &systems.asset_system,
1356        );
1357
1358        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1359
1360        let runtime_ui_root = find_named_root(&world, "editor_runtime_ui_root");
1361        let assets_panel_root = world
1362            .find_component(runtime_ui_root, "#assets_root")
1363            .expect("expected assets panel root");
1364        let panel_layout_selection = world
1365            .find_component(runtime_ui_root, "#editor_panel_layout_selection")
1366            .expect("expected panel layout selection");
1367        let asset_item = world
1368            .find_component(assets_panel_root, "[name='asset_item']")
1369            .expect("expected asset item");
1370
1371        systems.rx.push_event(
1372            asset_item,
1373            EventSignal::Click {
1374                raycaster: asset_item,
1375                renderable: asset_item,
1376                hit_point: [0.0, 0.0, 0.0],
1377                screen_pos_px: None,
1378            },
1379        );
1380
1381        let _ = systems.process_signals(
1382            &mut world,
1383            &mut visuals,
1384            &mut render_assets,
1385            &mut emit,
1386            100_000,
1387        );
1388
1389        let selection = world
1390            .get_component_by_id_as::<SelectionComponent>(panel_layout_selection)
1391            .expect("expected panel layout selection state");
1392        assert_eq!(selection.selected_component, Some(assets_panel_root));
1393    }
1394
1395    #[test]
1396    fn world_panel_row_selection_uses_selection_and_option_components() {
1397        let mut world = World::default();
1398        let mut emit = CommandQueue::new();
1399        let mut visuals = VisualWorld::default();
1400        let mut systems = SystemWorld::default();
1401        let mut render_assets = RenderAssets::new();
1402        let asset_system = crate::engine::ecs::system::AssetSystem::new();
1403
1404        systems.selection.install_handlers(&mut systems.rx);
1405
1406        let editor_root =
1407            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
1408        let scene_root =
1409            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
1410        let scene_child =
1411            world.add_component_boxed_named("scene_child", Box::new(TransformComponent::new()));
1412        let _ = world.add_child(editor_root, scene_root);
1413        let _ = world.add_child(scene_root, scene_child);
1414
1415        systems.editor_inspector.setup_panels_for_editor(
1416            &mut systems.rx,
1417            &mut world,
1418            &mut render_assets,
1419            &mut emit,
1420            editor_root,
1421            (-0.7, 1.6, -1.2),
1422            (-0.7, 1.6, -1.2),
1423            systems.editor_context.shared_state(),
1424            &asset_system,
1425        );
1426
1427        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1428
1429        let runtime_ui_root = find_named_root(&world, "editor_runtime_ui_root");
1430        let world_panel_root = world
1431            .find_component(runtime_ui_root, "#world_panel_root")
1432            .expect("expected world panel root");
1433        let selection_root = world
1434            .find_component(world_panel_root, "#world_panel_selection")
1435            .expect("expected world panel selection");
1436        let panel_layout_selection = world
1437            .find_component(runtime_ui_root, "#editor_panel_layout_selection")
1438            .expect("expected panel layout selection");
1439        let world_panel_root = world
1440            .find_component(runtime_ui_root, "#world_panel_root")
1441            .expect("expected world panel root");
1442        let first_row = world
1443            .find_component(world_panel_root, "#item_1")
1444            .expect("expected first selectable row");
1445        assert!(
1446            world
1447                .get_component_by_id_as::<SelectionComponent>(selection_root)
1448                .is_some(),
1449            "expected Selection on world panel rows mount"
1450        );
1451        assert!(
1452            world
1453                .get_component_by_id_as::<OptionComponent>(first_row)
1454                .is_some()
1455                || world.children_of(first_row).iter().any(|&child| world
1456                    .get_component_by_id_as::<OptionComponent>(child)
1457                    .is_some()),
1458            "expected Option on selectable world panel row"
1459        );
1460
1461        systems.rx.push_event(
1462            first_row,
1463            EventSignal::Click {
1464                raycaster: first_row,
1465                renderable: first_row,
1466                hit_point: [0.0, 0.0, 0.0],
1467                screen_pos_px: None,
1468            },
1469        );
1470
1471        let _ = systems.process_signals(
1472            &mut world,
1473            &mut visuals,
1474            &mut render_assets,
1475            &mut emit,
1476            100_000,
1477        );
1478
1479        let selection_root = world
1480            .find_component(world_panel_root, "#world_panel_selection")
1481            .expect("expected world panel selection after rerender");
1482        let selection = world
1483            .get_component_by_id_as::<SelectionComponent>(selection_root)
1484            .expect("expected selection component");
1485        let first_payload = world
1486            .find_component(first_row, "[name='world_panel_payload']")
1487            .expect("expected world-panel payload");
1488        assert_eq!(selection.selected_component, Some(first_row));
1489        assert_eq!(selection.selected_payload, Some(first_payload));
1490        assert_eq!(
1491            resolve_semantic_target_from_payload(
1492                &world,
1493                selection.selected_payload,
1494                selection.selected_component,
1495            ),
1496            Some(scene_root)
1497        );
1498        assert_eq!(selection.selected_index, Some(1));
1499        assert_eq!(selection.selected_entries.len(), 1);
1500
1501        let panel_selection = world
1502            .get_component_by_id_as::<SelectionComponent>(panel_layout_selection)
1503            .expect("expected panel layout selection component");
1504        assert_eq!(panel_selection.selected_component, Some(world_panel_root));
1505        assert_eq!(panel_selection.selected_entries.len(), 1);
1506
1507        let second_row = world
1508            .find_component(world_panel_root, "#item_2")
1509            .expect("expected second selectable row after rerender");
1510        systems.rx.push_event(
1511            second_row,
1512            EventSignal::Click {
1513                raycaster: second_row,
1514                renderable: second_row,
1515                hit_point: [0.0, 0.0, 0.0],
1516                screen_pos_px: None,
1517            },
1518        );
1519
1520        let _ = systems.process_signals(
1521            &mut world,
1522            &mut visuals,
1523            &mut render_assets,
1524            &mut emit,
1525            100_000,
1526        );
1527
1528        let selection_root = world
1529            .find_component(world_panel_root, "#world_panel_selection")
1530            .expect("expected world panel selection after second rerender");
1531        let selection = world
1532            .get_component_by_id_as::<SelectionComponent>(selection_root)
1533            .expect("expected selection component");
1534        let second_payload = world
1535            .find_component(second_row, "[name='world_panel_payload']")
1536            .expect("expected world-panel payload");
1537        assert_eq!(selection.selected_component, Some(second_row));
1538        assert_eq!(selection.selected_payload, Some(second_payload));
1539        assert_eq!(
1540            resolve_semantic_target_from_payload(
1541                &world,
1542                selection.selected_payload,
1543                selection.selected_component,
1544            ),
1545            Some(scene_child)
1546        );
1547        assert_eq!(selection.selected_index, Some(2));
1548        assert_eq!(selection.selected_entries.len(), 1);
1549
1550        let panel_selection = world
1551            .get_component_by_id_as::<SelectionComponent>(panel_layout_selection)
1552            .expect("expected panel layout selection component");
1553        assert_eq!(panel_selection.selected_component, Some(world_panel_root));
1554        assert_eq!(panel_selection.selected_entries.len(), 1);
1555    }
1556
1557    #[test]
1558    fn world_panel_nested_text_click_keeps_ui_row_selection_and_authored_payload_target() {
1559        let mut world = World::default();
1560        let mut emit = CommandQueue::new();
1561        let mut visuals = VisualWorld::default();
1562        let mut systems = SystemWorld::default();
1563        let mut render_assets = RenderAssets::new();
1564        let asset_system = crate::engine::ecs::system::AssetSystem::new();
1565
1566        systems.selection.install_handlers(&mut systems.rx);
1567
1568        let editor_root =
1569            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
1570        let scene_root =
1571            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
1572        let scene_child =
1573            world.add_component_boxed_named("scene_child", Box::new(TransformComponent::new()));
1574        let _ = world.add_child(editor_root, scene_root);
1575        let _ = world.add_child(scene_root, scene_child);
1576
1577        systems.editor_inspector.setup_panels_for_editor(
1578            &mut systems.rx,
1579            &mut world,
1580            &mut render_assets,
1581            &mut emit,
1582            editor_root,
1583            (-0.7, 1.6, -1.2),
1584            (-0.7, 1.6, -1.2),
1585            systems.editor_context.shared_state(),
1586            &asset_system,
1587        );
1588        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1589
1590        let runtime_ui_root = find_named_root(&world, "editor_runtime_ui_root");
1591        let world_panel_root = world
1592            .find_component(runtime_ui_root, "#world_panel_root")
1593            .expect("expected world panel root");
1594        let selection_root = world
1595            .find_component(world_panel_root, "#world_panel_selection")
1596            .expect("expected world panel selection");
1597        let row = world
1598            .find_component(world_panel_root, "#item_2")
1599            .expect("expected selectable row");
1600        let nested_text = world
1601            .find_component(row, "Text")
1602            .expect("expected nested text");
1603
1604        systems.rx.push_event(
1605            nested_text,
1606            EventSignal::Click {
1607                raycaster: nested_text,
1608                renderable: nested_text,
1609                hit_point: [0.0, 0.0, 0.0],
1610                screen_pos_px: None,
1611            },
1612        );
1613        let _ = systems.process_signals(
1614            &mut world,
1615            &mut visuals,
1616            &mut render_assets,
1617            &mut emit,
1618            100_000,
1619        );
1620
1621        let selection = world
1622            .get_component_by_id_as::<SelectionComponent>(selection_root)
1623            .expect("selection");
1624        assert_eq!(selection.selected_component, Some(row));
1625        assert_eq!(
1626            resolve_semantic_target_from_payload(
1627                &world,
1628                selection.selected_payload,
1629                selection.selected_component,
1630            ),
1631            Some(scene_child)
1632        );
1633    }
1634
1635    #[test]
1636    fn inspector_panel_row_selection_uses_selection_and_option_components() {
1637        let mut world = World::default();
1638        let mut emit = CommandQueue::new();
1639        let mut visuals = VisualWorld::default();
1640        let mut systems = SystemWorld::default();
1641        let mut render_assets = RenderAssets::new();
1642        let asset_system = crate::engine::ecs::system::AssetSystem::new();
1643
1644        systems.selection.install_handlers(&mut systems.rx);
1645
1646        let editor_root =
1647            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
1648        let scene_root =
1649            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
1650        let scene_child =
1651            world.add_component_boxed_named("scene_child", Box::new(TransformComponent::new()));
1652        let _ = world.add_child(editor_root, scene_root);
1653        let _ = world.add_child(scene_root, scene_child);
1654
1655        systems.editor_inspector.setup_panels_for_editor(
1656            &mut systems.rx,
1657            &mut world,
1658            &mut render_assets,
1659            &mut emit,
1660            editor_root,
1661            (-0.7, 1.6, -1.2),
1662            (-0.7, 1.6, -1.2),
1663            systems.editor_context.shared_state(),
1664            &asset_system,
1665        );
1666
1667        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1668
1669        let runtime_ui_root = find_named_root(&world, "editor_runtime_ui_root");
1670        let world_panel_root = world
1671            .find_component(runtime_ui_root, "#world_panel_root")
1672            .expect("expected world panel root");
1673        let world_row = world
1674            .find_component(world_panel_root, "#item_1")
1675            .expect("expected first selectable world row");
1676
1677        systems.rx.push_event(
1678            world_row,
1679            EventSignal::Click {
1680                raycaster: world_row,
1681                renderable: world_row,
1682                hit_point: [0.0, 0.0, 0.0],
1683                screen_pos_px: None,
1684            },
1685        );
1686
1687        let _ = systems.process_signals(
1688            &mut world,
1689            &mut visuals,
1690            &mut render_assets,
1691            &mut emit,
1692            100_000,
1693        );
1694        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1695
1696        let inspector_panel_root = world
1697            .find_component(runtime_ui_root, "#inspector_panel_root")
1698            .expect("expected inspector panel root");
1699        let inspector_selection = world
1700            .find_component(inspector_panel_root, "#inspector_panel_selection")
1701            .expect("expected inspector panel selection");
1702        let inspector_panel_root = world
1703            .find_component(runtime_ui_root, "#inspector_panel_root")
1704            .expect("expected inspector panel root");
1705        let panel_layout_selection = world
1706            .find_component(runtime_ui_root, "#editor_panel_layout_selection")
1707            .expect("expected panel layout selection");
1708        let inspector_row = world
1709            .find_component(inspector_panel_root, "#inspector_item_1")
1710            .expect("expected first selectable inspector row");
1711
1712        assert!(
1713            world
1714                .get_component_by_id_as::<SelectionComponent>(inspector_selection)
1715                .is_some(),
1716            "expected Selection on inspector rows mount"
1717        );
1718        assert!(
1719            world
1720                .get_component_by_id_as::<OptionComponent>(inspector_row)
1721                .is_some()
1722                || world.children_of(inspector_row).iter().any(|&child| world
1723                    .get_component_by_id_as::<OptionComponent>(child)
1724                    .is_some()),
1725            "expected Option on selectable inspector row"
1726        );
1727
1728        systems.rx.push_event(
1729            inspector_row,
1730            EventSignal::Click {
1731                raycaster: inspector_row,
1732                renderable: inspector_row,
1733                hit_point: [0.0, 0.0, 0.0],
1734                screen_pos_px: None,
1735            },
1736        );
1737
1738        let _ = systems.process_signals(
1739            &mut world,
1740            &mut visuals,
1741            &mut render_assets,
1742            &mut emit,
1743            100_000,
1744        );
1745        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
1746
1747        let selection = world
1748            .get_component_by_id_as::<SelectionComponent>(inspector_selection)
1749            .expect("expected inspector selection component");
1750        assert_eq!(selection.selected_component, Some(inspector_row));
1751        assert_eq!(selection.selected_index, Some(1));
1752        assert_eq!(selection.selected_entries.len(), 1);
1753
1754        let panel_selection = world
1755            .get_component_by_id_as::<SelectionComponent>(panel_layout_selection)
1756            .expect("expected panel layout selection component");
1757        assert_eq!(
1758            panel_selection.selected_component,
1759            Some(inspector_panel_root)
1760        );
1761        assert_eq!(panel_selection.selected_entries.len(), 1);
1762    }
1763
1764    #[test]
1765    fn styled_option_selection_mutates_background_and_restores_previous_style() {
1766        let mut world = World::default();
1767        let mut emit = CommandQueue::new();
1768        let mut visuals = VisualWorld::default();
1769        let mut systems = SystemWorld::default();
1770        let mut render_assets = RenderAssets::new();
1771
1772        systems.selection.install_handlers(&mut systems.rx);
1773
1774        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
1775        let layout = world.add_component_boxed(Box::new(LayoutComponent::new(20.0)));
1776        let _ = world.add_child(root, layout);
1777        world
1778            .get_component_by_id_as_mut::<LayoutComponent>(layout)
1779            .expect("layout")
1780            .dirty = false;
1781        let selection = world.add_component_boxed(Box::new(SelectionComponent::new()));
1782        let _ = world.add_child(root, selection);
1783
1784        let (first, first_hit, first_style) =
1785            spawn_test_option_item(&mut world, root, "first_item", true);
1786        let (_second, second_hit, second_style) =
1787            spawn_test_option_item(&mut world, root, "second_item", true);
1788        let first_style = first_style.expect("first style");
1789        let second_style = second_style.expect("second style");
1790
1791        systems.rx.push_event(
1792            first_hit,
1793            EventSignal::Click {
1794                raycaster: first_hit,
1795                renderable: first_hit,
1796                hit_point: [0.0, 0.0, 0.0],
1797                screen_pos_px: None,
1798            },
1799        );
1800        let _ = systems.process_signals(
1801            &mut world,
1802            &mut visuals,
1803            &mut render_assets,
1804            &mut emit,
1805            100_000,
1806        );
1807
1808        assert_eq!(
1809            world
1810                .get_component_by_id_as::<StyleComponent>(first_style)
1811                .expect("first style")
1812                .background_color,
1813            Some(SELECTED_HIGHLIGHT_RGBA)
1814        );
1815        assert!(
1816            world
1817                .find_component(first, "[name='selection_style_state']")
1818                .is_some(),
1819            "expected cached style state helper on first selection"
1820        );
1821        assert!(
1822            world
1823                .get_component_by_id_as::<LayoutComponent>(layout)
1824                .expect("layout")
1825                .dirty,
1826            "expected selecting styled option to dirty the layout root"
1827        );
1828
1829        world
1830            .get_component_by_id_as_mut::<LayoutComponent>(layout)
1831            .expect("layout")
1832            .dirty = false;
1833
1834        systems.rx.push_event(
1835            second_hit,
1836            EventSignal::Click {
1837                raycaster: second_hit,
1838                renderable: second_hit,
1839                hit_point: [0.0, 0.0, 0.0],
1840                screen_pos_px: None,
1841            },
1842        );
1843        let _ = systems.process_signals(
1844            &mut world,
1845            &mut visuals,
1846            &mut render_assets,
1847            &mut emit,
1848            100_000,
1849        );
1850
1851        assert_eq!(
1852            world
1853                .get_component_by_id_as::<StyleComponent>(first_style)
1854                .expect("first style")
1855                .background_color,
1856            None
1857        );
1858        assert_eq!(
1859            world
1860                .get_component_by_id_as::<StyleComponent>(second_style)
1861                .expect("second style")
1862                .background_color,
1863            Some(SELECTED_HIGHLIGHT_RGBA)
1864        );
1865        assert!(
1866            world
1867                .find_component(first, "[name='selection_style_state']")
1868                .is_none(),
1869            "expected old styled selection cache to be removed on deselect"
1870        );
1871    }
1872
1873    #[test]
1874    fn unstyled_option_selection_spawns_bounds_driven_overlay() {
1875        let mut world = World::default();
1876        let mut emit = CommandQueue::new();
1877        let mut visuals = VisualWorld::default();
1878        let mut systems = SystemWorld::default();
1879        let mut render_assets = RenderAssets::new();
1880
1881        systems.selection.install_handlers(&mut systems.rx);
1882
1883        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
1884        let selection = world.add_component_boxed(Box::new(SelectionComponent::new()));
1885        let _ = world.add_child(root, selection);
1886
1887        let (item, hit, _) = spawn_test_option_item(&mut world, root, "unstyled_item", false);
1888
1889        systems.rx.push_event(
1890            hit,
1891            EventSignal::Click {
1892                raycaster: hit,
1893                renderable: hit,
1894                hit_point: [0.0, 0.0, 0.0],
1895                screen_pos_px: None,
1896            },
1897        );
1898        let _ = systems.process_signals(
1899            &mut world,
1900            &mut visuals,
1901            &mut render_assets,
1902            &mut emit,
1903            100_000,
1904        );
1905
1906        let highlight = world
1907            .find_component(item, "[name='selection_highlight']")
1908            .expect("expected bounds-driven selection overlay");
1909        let transform = world
1910            .get_component_by_id_as::<TransformComponent>(highlight)
1911            .expect("highlight transform");
1912        assert_eq!(
1913            transform.transform.translation,
1914            [0.0, 0.0, OVERLAY_HIGHLIGHT_Z_OFFSET]
1915        );
1916        assert_eq!(
1917            transform.transform.scale,
1918            [1.0, 1.0, OVERLAY_HIGHLIGHT_Z_THICKNESS]
1919        );
1920    }
1921
1922    #[test]
1923    fn selection_set_intent_updates_selection_state() {
1924        let mut world = World::default();
1925        let mut emit = CommandQueue::new();
1926        let mut visuals = VisualWorld::default();
1927        let mut systems = SystemWorld::default();
1928        let mut render_assets = RenderAssets::new();
1929
1930        systems.selection.install_handlers(&mut systems.rx);
1931
1932        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
1933        let selection_root = world.add_component_boxed(Box::new(SelectionComponent::multiple()));
1934        let _ = world.add_child(root, selection_root);
1935
1936        let (first, _, _) = spawn_test_option_item(&mut world, root, "first_item", false);
1937        let (second, _, _) = spawn_test_option_item(&mut world, root, "second_item", false);
1938
1939        systems.rx.push_intent_now(
1940            selection_root,
1941            IntentValue::SelectionSet {
1942                component_ids: vec![selection_root],
1943                entries: vec![
1944                    SelectionEntry {
1945                        index: Some(0),
1946                        component: first,
1947                    },
1948                    SelectionEntry {
1949                        index: Some(1),
1950                        component: second,
1951                    },
1952                ],
1953                primary: Some(second),
1954            },
1955        );
1956
1957        let _ = systems.process_signals(
1958            &mut world,
1959            &mut visuals,
1960            &mut render_assets,
1961            &mut emit,
1962            100_000,
1963        );
1964
1965        let selection = world
1966            .get_component_by_id_as::<SelectionComponent>(selection_root)
1967            .expect("expected selection component");
1968        assert_eq!(selection.selected_entries.len(), 2);
1969        assert_eq!(selection.selected_component, Some(second));
1970        assert!(selection.contains(first));
1971        assert!(selection.contains(second));
1972    }
1973
1974    #[test]
1975    fn selection_direct_option_payload_resolves_without_moving_highlight() {
1976        let mut world = World::default();
1977        let mut emit = CommandQueue::new();
1978        let mut visuals = VisualWorld::default();
1979        let mut systems = SystemWorld::default();
1980        let mut render_assets = RenderAssets::new();
1981
1982        systems.selection.install_handlers(&mut systems.rx);
1983
1984        let scene_target =
1985            world.add_component_boxed_named("scene_target", Box::new(TransformComponent::new()));
1986        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
1987        let selection_root = world.add_component_boxed(Box::new(SelectionComponent::new()));
1988        let _ = world.add_child(root, selection_root);
1989        let (row, hit, style_id) = spawn_test_option_item(&mut world, root, "item_0", true);
1990        let payload = world.add_component_boxed_named(
1991            "world_panel_payload",
1992            Box::new(
1993                DataComponent::new()
1994                    .with_entry("target_component", DataValue::Component(scene_target))
1995                    .with_entry("row_kind", DataValue::Text("component".to_string())),
1996            ),
1997        );
1998        let _ = world.add_child(row, payload);
1999        let style_id = style_id.expect("row style");
2000
2001        systems.rx.push_event(
2002            hit,
2003            EventSignal::Click {
2004                raycaster: hit,
2005                renderable: hit,
2006                hit_point: [0.0, 0.0, 0.0],
2007                screen_pos_px: None,
2008            },
2009        );
2010
2011        let _ = systems.process_signals(
2012            &mut world,
2013            &mut visuals,
2014            &mut render_assets,
2015            &mut emit,
2016            100_000,
2017        );
2018
2019        let selection = world
2020            .get_component_by_id_as::<SelectionComponent>(selection_root)
2021            .expect("selection");
2022        assert_eq!(selection.selected_component, Some(row));
2023        assert_eq!(selection.selected_payload, Some(payload));
2024        assert_eq!(
2025            resolve_semantic_target_from_payload(
2026                &world,
2027                selection.selected_payload,
2028                selection.selected_component,
2029            ),
2030            Some(scene_target)
2031        );
2032        assert_eq!(
2033            world
2034                .get_component_by_id_as::<StyleComponent>(style_id)
2035                .expect("style")
2036                .background_color,
2037            Some(SELECTED_HIGHLIGHT_RGBA)
2038        );
2039    }
2040
2041    #[test]
2042    fn selection_direct_option_payload_clears_for_zero_or_multiple_matches() {
2043        let mut world = World::default();
2044        let mut emit = CommandQueue::new();
2045
2046        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
2047        let selection_root = world.add_component_boxed(Box::new(SelectionComponent::new()));
2048        let _ = world.add_child(root, selection_root);
2049        let zero_row =
2050            world.add_component_boxed_named("item_0", Box::new(TransformComponent::new()));
2051        let _ = world.add_child(root, zero_row);
2052        let zero_option = world.add_component_boxed(Box::new(OptionComponent::new()));
2053        let _ = world.add_child(zero_row, zero_option);
2054
2055        apply_selection_set(
2056            &mut world,
2057            &mut emit,
2058            selection_root,
2059            vec![SelectionEntry {
2060                index: Some(0),
2061                component: zero_row,
2062            }],
2063            Some(zero_row),
2064        );
2065        assert_eq!(
2066            world
2067                .get_component_by_id_as::<SelectionComponent>(selection_root)
2068                .expect("selection")
2069                .selected_payload,
2070            None
2071        );
2072
2073        let multi_row =
2074            world.add_component_boxed_named("item_1", Box::new(TransformComponent::new()));
2075        let _ = world.add_child(root, multi_row);
2076        let multi_option = world.add_component_boxed(Box::new(OptionComponent::new()));
2077        let _ = world.add_child(multi_row, multi_option);
2078        let payload_a = world.add_component_boxed_named(
2079            "world_panel_payload",
2080            Box::new(
2081                DataComponent::new().with_entry("target_component", DataValue::Component(zero_row)),
2082            ),
2083        );
2084        let payload_b = world.add_component_boxed_named(
2085            "world_panel_payload",
2086            Box::new(
2087                DataComponent::new()
2088                    .with_entry("target_component", DataValue::Component(multi_row)),
2089            ),
2090        );
2091        let _ = world.add_child(multi_row, payload_a);
2092        let _ = world.add_child(multi_row, payload_b);
2093
2094        apply_selection_set(
2095            &mut world,
2096            &mut emit,
2097            selection_root,
2098            vec![SelectionEntry {
2099                index: Some(1),
2100                component: multi_row,
2101            }],
2102            Some(multi_row),
2103        );
2104        assert_eq!(
2105            world
2106                .get_component_by_id_as::<SelectionComponent>(selection_root)
2107                .expect("selection")
2108                .selected_payload,
2109            None
2110        );
2111    }
2112
2113    #[test]
2114    fn selection_root_target_can_be_sibling_scope() {
2115        let mut world = World::default();
2116        let mut emit = CommandQueue::new();
2117        let mut visuals = VisualWorld::default();
2118        let mut systems = SystemWorld::default();
2119        let mut render_assets = RenderAssets::new();
2120
2121        systems.selection.install_handlers(&mut systems.rx);
2122
2123        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
2124        let selection_root = world.add_component_boxed(Box::new({
2125            let mut selection = SelectionComponent::new();
2126            selection.target_root_source = Some(ComponentRef::Query("#rows_mount".to_string()));
2127            selection
2128        }));
2129        let _ = world.add_child(root, selection_root);
2130
2131        let rows_mount =
2132            world.add_component_boxed_named("rows_mount", Box::new(TransformComponent::new()));
2133        let _ = world.add_child(root, rows_mount);
2134
2135        let (row, hit, _style_id) = spawn_test_option_item(&mut world, rows_mount, "item_0", true);
2136        let option = option_marker_on_node(&world, row).expect("row option");
2137        let payload = world.add_component_boxed_named(
2138            "world_panel_payload",
2139            Box::new(
2140                DataComponent::new()
2141                    .with_entry("row_kind", DataValue::Text("component".to_string())),
2142            ),
2143        );
2144        let _ = world.add_child(option, payload);
2145
2146        systems.rx.push_event(
2147            hit,
2148            EventSignal::Click {
2149                raycaster: hit,
2150                renderable: hit,
2151                hit_point: [0.0, 0.0, 0.0],
2152                screen_pos_px: None,
2153            },
2154        );
2155
2156        let _ = systems.process_signals(
2157            &mut world,
2158            &mut visuals,
2159            &mut render_assets,
2160            &mut emit,
2161            100_000,
2162        );
2163
2164        let selection = world
2165            .get_component_by_id_as::<SelectionComponent>(selection_root)
2166            .expect("selection");
2167        assert_eq!(selection.selected_component, Some(row));
2168        assert_eq!(selection.selected_payload, Some(payload));
2169    }
2170
2171    #[test]
2172    fn selection_click_emits_selection_events() {
2173        let mut world = World::default();
2174        let mut emit = CommandQueue::new();
2175        let mut visuals = VisualWorld::default();
2176        let mut systems = SystemWorld::default();
2177        let mut render_assets = RenderAssets::new();
2178
2179        systems.selection.install_handlers(&mut systems.rx);
2180
2181        let seen = Arc::new(Mutex::new(Vec::new()));
2182        let seen_changed = Arc::clone(&seen);
2183        systems.rx.add_global_handler_closure(
2184            SignalKind::SelectionChanged,
2185            move |_world, _emit, signal| {
2186                if let Some(EventSignal::SelectionChanged {
2187                    selection_root,
2188                    selected_component,
2189                    ..
2190                }) = signal.event.as_ref()
2191                {
2192                    seen_changed
2193                        .lock()
2194                        .expect("selection events mutex poisoned")
2195                        .push(format!("{selection_root:?}:{selected_component:?}"));
2196                }
2197            },
2198        );
2199
2200        let seen_added = Arc::clone(&seen);
2201        systems.rx.add_global_handler_closure(
2202            SignalKind::SelectionAdded,
2203            move |_world, _emit, signal| {
2204                if let Some(EventSignal::SelectionAdded {
2205                    selection_root,
2206                    entry,
2207                }) = signal.event.as_ref()
2208                {
2209                    seen_added
2210                        .lock()
2211                        .expect("selection events mutex poisoned")
2212                        .push(format!("{selection_root:?}:+{:?}", entry.component));
2213                }
2214            },
2215        );
2216
2217        let root = world.add_component_boxed_named("root", Box::new(TransformComponent::new()));
2218        let selection_root = world.add_component_boxed(Box::new(SelectionComponent::new()));
2219        let _ = world.add_child(root, selection_root);
2220
2221        let (first, first_hit, _) = spawn_test_option_item(&mut world, root, "first_item", false);
2222
2223        systems.rx.push_event(
2224            first_hit,
2225            EventSignal::Click {
2226                raycaster: first_hit,
2227                renderable: first_hit,
2228                hit_point: [0.0, 0.0, 0.0],
2229                screen_pos_px: None,
2230            },
2231        );
2232
2233        let _ = systems.process_signals(
2234            &mut world,
2235            &mut visuals,
2236            &mut render_assets,
2237            &mut emit,
2238            100_000,
2239        );
2240
2241        let seen = seen.lock().expect("selection events mutex poisoned");
2242        assert!(
2243            seen.iter()
2244                .any(|value| value.contains(&format!("+{first:?}"))),
2245            "expected selection-added event"
2246        );
2247        assert!(
2248            seen.iter()
2249                .any(|value| value.contains(&format!("Some({first:?})"))),
2250            "expected selection-changed event"
2251        );
2252    }
2253}