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 if let Some(node) = world.get_component_record(root) {
208 if node.component_type == component_type {
209 return Some(root);
210 }
211 }
212 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 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}