1use bevy_ecs::message::{MessageReader, MessageWriter};
12use bevy_ecs::prelude::*;
13use lumen_core::input::{ClickEvent, FocusTracker, Key, KeyPressed, MenuClicked, NamedKey};
14use lumen_core::prelude::*;
15use lumen_core::style_overrides::{StyleOverrides, StyleOwner};
16
17use crate::held::hold_fill;
18
19#[derive(Component, Clone, Debug)]
23pub struct TabStripButton {
24 pub signal_name: String,
26 pub value: String,
28}
29
30pub const TAB_SELECTED_BG: Color = Color::rgb(0.20, 0.78, 0.81);
35
36#[derive(Component, Clone, Copy, Debug, PartialEq)]
42#[require(StyleOverrides)]
43pub struct TabButtonStyle {
44 pub selected_bg: Color,
47 pub unselected_bg: Color,
50}
51
52impl StyleOwner for TabButtonStyle {
53 const RANK: u8 = 20;
54}
55
56impl Default for TabButtonStyle {
57 fn default() -> Self {
58 Self {
59 selected_bg: TAB_SELECTED_BG,
60 unselected_bg: Color::rgba(0.0, 0.0, 0.0, 0.0),
61 }
62 }
63}
64
65pub struct TabsPlugin;
69
70impl Plugin for TabsPlugin {
71 fn build(self, app: &mut App) {
72 app.add_systems(
73 TickStage::Systems,
74 dispatch_tab_clicks.after(lumen_input::dispatch_clicks),
75 );
76 app.add_systems(
81 TickStage::Systems,
82 sync_tab_selected.after(dispatch_tab_clicks),
83 );
84 app.add_systems(
88 TickStage::Systems,
89 sync_tab_button_visuals.after(sync_tab_selected),
90 );
91 app.add_systems(
92 TickStage::Systems,
93 dispatch_dropdown_clicks.after(lumen_input::dispatch_clicks),
94 );
95 app.add_systems(
96 TickStage::Systems,
97 dispatch_menu_item_clicks.after(lumen_input::dispatch_clicks),
98 );
99 app.add_systems(
103 TickStage::Systems,
104 crate::popup::dismiss_popups_on_outside_press.after(lumen_input::dispatch_clicks),
105 );
106 app.add_systems(TickStage::Systems, crate::popup::flip_open_dropdown_panels);
109 app.add_systems(TickStage::Systems, dispatch_tab_keys);
119 app.world
126 .init_resource::<crate::popup_nav::ActivePopupNav>();
127 app.world
128 .init_resource::<crate::popup_nav::PopupNavConfig>();
129 app.world
130 .init_resource::<crate::popup_nav::TypeAheadBuffer>();
131 app.add_systems(
132 TickStage::Systems,
133 crate::popup_nav::closed_dropdown_keys.after(lumen_input::dispatch_clicks),
134 );
135 app.add_systems(
136 TickStage::Systems,
137 crate::popup_nav::popup_nav_keys.after(lumen_input::dispatch_clicks),
138 );
139 app.add_systems(
140 TickStage::Systems,
141 crate::popup_nav::follow_hover_highlight.after(lumen_input::hit_test),
142 );
143 app.add_systems(
144 TickStage::Systems,
145 crate::popup_nav::popup_nav_lifecycle
146 .after(crate::popup_nav::popup_nav_keys)
147 .after(crate::popup_nav::closed_dropdown_keys)
148 .after(crate::popup_nav::follow_hover_highlight)
149 .after(dispatch_dropdown_clicks)
150 .after(dispatch_menu_item_clicks)
151 .after(crate::popup::dismiss_popups_on_outside_press),
152 );
153 app.add_systems(TickStage::Systems, apply_a11y_expand_to_dropdown);
159 }
160}
161
162fn dispatch_tab_clicks(
177 mut clicks: MessageReader<ClickEvent>,
178 buttons: Query<&TabStripButton>,
179 newly_pressed: Query<Entity, Added<Pressed>>,
180 parents: Query<&ChildOf>,
181 mut store: ResMut<PropertyStore>,
182) {
183 for pressed in &newly_pressed {
188 if let Some(target) =
189 crate::controls::resolve_control(pressed, &parents, |e| buttons.contains(e))
190 && let Ok(btn) = buttons.get(target)
191 {
192 store.set_global_str(&btn.signal_name, btn.value.as_str());
193 }
194 }
195 for ev in clicks.read() {
196 if let Some(target) =
197 crate::controls::resolve_control(ev.entity, &parents, |e| buttons.contains(e))
198 && let Ok(btn) = buttons.get(target)
199 {
200 store.set_global_str(&btn.signal_name, btn.value.as_str());
201 }
202 }
203}
204
205pub fn sync_tab_selected(
210 mut commands: Commands,
211 store: Res<PropertyStore>,
212 buttons: Query<(Entity, &TabStripButton, Option<&Selected>)>,
213) {
214 for (entity, btn, selected) in &buttons {
215 let active = store.get_global_str(&btn.signal_name).as_deref() == Some(btn.value.as_str());
216 match (active, selected.is_some()) {
217 (true, false) => {
218 commands.entity(entity).insert(Selected);
219 }
220 (false, true) => {
221 commands.entity(entity).remove::<Selected>();
222 }
223 _ => {}
224 }
225 }
226}
227
228#[allow(clippy::type_complexity)]
245pub fn sync_tab_button_visuals(
246 mut buttons: Query<
247 (
248 &TabButtonStyle,
249 Option<&Selected>,
250 &mut Visuals,
251 &mut StyleOverrides,
252 ),
253 With<TabStripButton>,
254 >,
255) {
256 for (style, selected, mut vis, mut overrides) in &mut buttons {
257 let target = if selected.is_some() {
258 style.selected_bg
259 } else {
260 style.unselected_bg
261 };
262 hold_fill::<TabButtonStyle>(&mut overrides, &mut vis, target);
263 }
264}
265
266#[derive(Clone, Debug, PartialEq, Eq)]
272pub struct DropdownOptionSpec {
273 pub value: String,
275 pub label: String,
278 pub disabled: bool,
281}
282
283#[derive(Component, Clone, Debug)]
286pub struct DropdownButton {
287 pub open_signal: String,
290 pub value_signal: String,
293 pub options: Vec<DropdownOptionSpec>,
295}
296
297impl DropdownButton {
298 pub fn new(open_signal: impl Into<String>) -> Self {
302 Self {
303 open_signal: open_signal.into(),
304 value_signal: String::new(),
305 options: Vec::new(),
306 }
307 }
308}
309
310#[derive(Component, Clone, Debug)]
312pub struct DropdownOptionButton {
313 pub value_signal: String,
315 pub value: String,
317 pub open_signal: String,
319}
320
321pub fn dispatch_dropdown_clicks(
329 mut clicks: MessageReader<ClickEvent>,
330 headers: Query<&DropdownButton>,
331 options: Query<&DropdownOptionButton>,
332 parents: Query<&ChildOf>,
333 mut store: ResMut<PropertyStore>,
334) {
335 for ev in clicks.read() {
336 if let Some(target) =
344 crate::controls::resolve_control(ev.entity, &parents, |e| options.contains(e))
345 && let Ok(opt) = options.get(target)
346 {
347 store.set_global_str(&opt.value_signal, opt.value.as_str());
348 store.set_global_bool(&opt.open_signal, false);
349 continue;
350 }
351 if let Some(target) =
352 crate::controls::resolve_control(ev.entity, &parents, |e| headers.contains(e))
353 && let Ok(btn) = headers.get(target)
354 {
355 let next = !store.get_global_bool(&btn.open_signal).unwrap_or(false);
360 store.set_global_bool(&btn.open_signal, next);
361 }
362 }
363}
364
365#[derive(Component, Clone, Debug)]
367pub struct MenuItemButton {
368 pub open_signal: String,
370 pub item_id: String,
372}
373
374pub fn dispatch_menu_item_clicks(
376 mut clicks: MessageReader<ClickEvent>,
377 items: Query<&MenuItemButton>,
378 parents: Query<&ChildOf>,
379 mut store: ResMut<PropertyStore>,
380 mut menu_out: MessageWriter<MenuClicked>,
381) {
382 for ev in clicks.read() {
383 if let Some(target) =
386 crate::controls::resolve_control(ev.entity, &parents, |e| items.contains(e))
387 && let Ok(item) = items.get(target)
388 {
389 menu_out.write(MenuClicked {
390 id: item.item_id.clone(),
391 });
392 store.set_global_bool(&item.open_signal, false);
393 }
394 }
395}
396
397#[allow(clippy::too_many_arguments)]
420pub fn dispatch_tab_keys(
421 mut commands: Commands,
422 mut keys: MessageReader<KeyPressed>,
423 mut tracker: ResMut<FocusTracker>,
424 mut store: ResMut<PropertyStore>,
425 buttons: Query<(Entity, &TabStripButton)>,
426 disableds: Query<(), With<lumen_core::components::Disabled>>,
427 orders: Query<&lumen_core::components::DocumentOrder>,
428 parents: Query<&ChildOf>,
429) {
430 let Some(current) = tracker.0 else {
431 keys.read().for_each(drop);
432 return;
433 };
434 let Ok((_, current_btn)) = buttons.get(current) else {
439 keys.read().for_each(drop);
440 return;
441 };
442 let signal_name = current_btn.signal_name.clone();
443 let current_value = current_btn.value.clone();
444 let Ok(child_of_current) = parents.get(current) else {
445 keys.read().for_each(drop);
446 return;
447 };
448 let parent_id = child_of_current.parent();
449 let mut siblings: Vec<(u32, Entity, String, bool)> = buttons
456 .iter()
457 .filter_map(|(e, btn)| {
458 let Ok(co) = parents.get(e) else {
459 return None;
460 };
461 if co.parent() != parent_id || btn.signal_name != signal_name {
462 return None;
463 }
464 Some((
465 orders.get(e).map(|d| d.0).unwrap_or(u32::MAX),
466 e,
467 btn.value.clone(),
468 disableds.contains(e),
469 ))
470 })
471 .collect();
472 siblings.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
479 let siblings: Vec<(Entity, String, bool)> = siblings
480 .into_iter()
481 .map(|(_, e, value, disabled)| (e, value, disabled))
482 .collect();
483 if siblings.is_empty() {
484 keys.read().for_each(drop);
485 return;
486 }
487 let Some(current_index) = siblings.iter().position(|(e, ..)| *e == current) else {
488 keys.read().for_each(drop);
489 return;
490 };
491
492 let step = |from: usize, dir: i64| -> Option<usize> {
496 let mut i = from as i64 + dir;
497 while i >= 0 && (i as usize) < siblings.len() {
498 if !siblings[i as usize].2 {
499 return Some(i as usize);
500 }
501 i += dir;
502 }
503 None
504 };
505
506 let mut focus_target: Option<usize> = None;
507 let mut should_activate = false;
508 for ev in keys.read() {
509 match &ev.key {
510 Key::Named(NamedKey::ArrowRight) => {
511 if let Some(idx) = step(focus_target.unwrap_or(current_index), 1) {
512 focus_target = Some(idx);
513 }
514 }
515 Key::Named(NamedKey::ArrowLeft) => {
516 if let Some(idx) = step(focus_target.unwrap_or(current_index), -1) {
517 focus_target = Some(idx);
518 }
519 }
520 Key::Named(NamedKey::Home) => {
521 focus_target = siblings.iter().position(|(.., d)| !d);
522 }
523 Key::Named(NamedKey::End) => {
524 focus_target = siblings.iter().rposition(|(.., d)| !d);
525 }
526 Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
527 should_activate = true;
528 }
529 _ => {}
530 }
531 }
532
533 if should_activate {
534 store.set_global_str(&signal_name, current_value.as_str());
536 }
537 if let Some(idx) = focus_target
538 && siblings[idx].0 != current
539 {
540 let (next_entity, next_value, _) = &siblings[idx];
541 commands
542 .entity(current)
543 .remove::<(Focused, lumen_core::input::FocusVisible)>();
544 commands
547 .entity(*next_entity)
548 .insert((Focused, lumen_core::input::FocusVisible));
549 tracker.0 = Some(*next_entity);
550 store.set_global_str(&signal_name, next_value.as_str());
553 }
554}
555
556pub fn apply_a11y_expand_to_dropdown(
567 headers: Query<(&DropdownButton, &A11yState), Changed<A11yState>>,
568 mut store: ResMut<PropertyStore>,
569) {
570 for (btn, state) in &headers {
571 let expanded = state.contains(A11yState::EXPANDED);
572 store.set_global_bool(&btn.open_signal, expanded);
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use bevy_ecs::schedule::Schedule;
580 use bevy_ecs::world::World;
581
582 #[test]
583 fn selected_marker_follows_active_tab_signal() {
584 let mut world = World::new();
585 world.insert_resource(PropertyStore::default());
586 let strip = |value: &str| TabStripButton {
587 signal_name: "active".to_string(),
588 value: value.to_string(),
589 };
590 let general = world.spawn(strip("general")).id();
591 let advanced = world.spawn(strip("advanced")).id();
592
593 let mut schedule = Schedule::default();
594 schedule.add_systems(sync_tab_selected);
595
596 world
597 .resource_mut::<PropertyStore>()
598 .set_global_str("active", "general");
599 schedule.run(&mut world);
600 assert!(world.get::<Selected>(general).is_some(), "general active");
601 assert!(world.get::<Selected>(advanced).is_none());
602
603 world
605 .resource_mut::<PropertyStore>()
606 .set_global_str("active", "advanced");
607 schedule.run(&mut world);
608 assert!(
609 world.get::<Selected>(general).is_none(),
610 "previous tab sheds the marker"
611 );
612 assert!(
613 world.get::<Selected>(advanced).is_some(),
614 "new tab gains it"
615 );
616 }
617
618 #[test]
619 fn sync_tab_button_visuals_repaints_on_selected_add_and_remove() {
620 let mut world = World::new();
621 world.insert_resource(PropertyStore::default());
622 let style = TabButtonStyle {
623 selected_bg: Color::rgb(1.0, 0.0, 0.0),
624 unselected_bg: Color::rgb(0.0, 0.0, 1.0),
625 };
626 let strip = |value: &str| {
627 (
628 TabStripButton {
629 signal_name: "active".to_string(),
630 value: value.to_string(),
631 },
632 style,
633 Visuals {
634 fill: Some(Fill::Solid(style.unselected_bg)),
635 radius: 0.0,
636 corner_radii: None,
637 shadows: Vec::new(),
638 border: None,
639 },
640 )
641 };
642 let general = world.spawn(strip("general")).id();
643 let advanced = world.spawn(strip("advanced")).id();
644
645 let mut schedule = Schedule::default();
646 schedule.add_systems((sync_tab_selected, sync_tab_button_visuals).chain());
647
648 let fill_of = |world: &World, e: Entity| -> Color {
649 world
650 .get::<Visuals>(e)
651 .and_then(|v| v.fill.as_ref())
652 .and_then(Fill::as_solid)
653 .expect("solid fill")
654 };
655
656 schedule.run(&mut world);
658 assert_eq!(fill_of(&world, general), style.unselected_bg);
659 assert_eq!(fill_of(&world, advanced), style.unselected_bg);
660
661 world
664 .resource_mut::<PropertyStore>()
665 .set_global_str("active", "general");
666 schedule.run(&mut world);
667 assert_eq!(
668 fill_of(&world, general),
669 style.selected_bg,
670 "selected tab repaints to selected_bg"
671 );
672 assert_eq!(fill_of(&world, advanced), style.unselected_bg);
673 let live_vis = world.get::<Visuals>(general).unwrap().clone();
676 assert_eq!(
677 world
678 .get::<StyleOverrides>(general)
679 .unwrap()
680 .below::<crate::hover::HoverTween>(
681 lumen_core::style_overrides::StyleFields::FILL,
682 &lumen_core::style_overrides::Live {
683 visuals: Some(&live_vis),
684 ..Default::default()
685 },
686 )
687 .fill,
688 Some(Some(Fill::Solid(style.selected_bg))),
689 );
690
691 world
693 .resource_mut::<PropertyStore>()
694 .set_global_str("active", "advanced");
695 schedule.run(&mut world);
696 assert_eq!(
697 fill_of(&world, general),
698 style.unselected_bg,
699 "deselected tab reverts to unselected_bg"
700 );
701 assert_eq!(fill_of(&world, advanced), style.selected_bg);
702 }
703
704 mod tab_keys {
705 use super::*;
708 use bevy_ecs::message::Messages;
709 use bevy_ecs::system::RunSystemOnce;
710 use lumen_core::components::Disabled;
711 use lumen_core::input::{FocusTracker, Modifiers};
712
713 fn strip_world() -> (World, [Entity; 4]) {
716 let mut world = World::new();
717 world.init_resource::<Messages<KeyPressed>>();
718 world.init_resource::<Messages<ClickEvent>>();
719 world.insert_resource(PropertyStore::default());
720 let parent = world.spawn_empty().id();
721 let mut tabs = [Entity::PLACEHOLDER; 4];
722 for (i, value) in ["a", "b", "c", "d"].iter().enumerate() {
723 let mut e = world.spawn((
724 TabStripButton {
725 signal_name: "active".to_string(),
726 value: value.to_string(),
727 },
728 lumen_core::components::DocumentOrder(i as u32),
729 bevy_ecs::hierarchy::ChildOf(parent),
730 ));
731 if i == 2 {
732 e.insert(Disabled);
733 }
734 tabs[i] = e.id();
735 }
736 world.insert_resource(FocusTracker(Some(tabs[0])));
737 world.entity_mut(tabs[0]).insert(Focused);
738 world
739 .resource_mut::<PropertyStore>()
740 .set_global_str("active", "a");
741 (world, tabs)
742 }
743
744 fn press_key(world: &mut World, key: NamedKey) {
745 world
746 .resource_mut::<Messages<KeyPressed>>()
747 .write(KeyPressed {
748 key: Key::Named(key),
749 modifiers: Modifiers::default(),
750 repeat: false,
751 });
752 }
753
754 fn run_keys(world: &mut World) {
755 world.run_system_once(dispatch_tab_keys).unwrap();
756 world.resource_mut::<Messages<KeyPressed>>().clear();
757 }
758
759 fn active(world: &World) -> String {
760 world
761 .resource::<PropertyStore>()
762 .get_global_str("active")
763 .as_deref()
764 .unwrap_or("")
765 .to_string()
766 }
767
768 #[test]
769 fn arrow_moves_focus_and_selects_immediately() {
770 let (mut world, tabs) = strip_world();
771 press_key(&mut world, NamedKey::ArrowRight);
772 run_keys(&mut world);
773 assert_eq!(
774 world.resource::<FocusTracker>().0,
775 Some(tabs[1]),
776 "focus moved to the next tab"
777 );
778 assert_eq!(active(&world), "b", "Qt select-on-arrow: switches too");
779 }
780
781 #[test]
782 fn arrow_skips_disabled_and_clamps_at_the_end() {
783 let (mut world, tabs) = strip_world();
784 press_key(&mut world, NamedKey::ArrowRight);
786 run_keys(&mut world);
787 press_key(&mut world, NamedKey::ArrowRight);
789 run_keys(&mut world);
790 assert_eq!(
791 world.resource::<FocusTracker>().0,
792 Some(tabs[3]),
793 "disabled tab skipped"
794 );
795 assert_eq!(active(&world), "d");
796 press_key(&mut world, NamedKey::ArrowRight);
798 run_keys(&mut world);
799 assert_eq!(
800 world.resource::<FocusTracker>().0,
801 Some(tabs[3]),
802 "no wrap at the last tab (Qt)"
803 );
804 assert_eq!(active(&world), "d");
805 }
806
807 #[test]
808 fn home_and_end_jump_to_first_and_last_enabled() {
809 let (mut world, tabs) = strip_world();
810 press_key(&mut world, NamedKey::End);
811 run_keys(&mut world);
812 assert_eq!(
813 world.resource::<FocusTracker>().0,
814 Some(tabs[3]),
815 "End -> last enabled (c disabled, d is last)"
816 );
817 assert_eq!(active(&world), "d");
818 press_key(&mut world, NamedKey::Home);
819 run_keys(&mut world);
820 assert_eq!(world.resource::<FocusTracker>().0, Some(tabs[0]));
821 assert_eq!(active(&world), "a");
822 }
823
824 #[test]
825 fn tab_switches_on_press_not_release() {
826 let (mut world, tabs) = strip_world();
827 world.entity_mut(tabs[1]).insert(Pressed);
831 world.run_system_once(dispatch_tab_clicks).unwrap();
832 assert_eq!(active(&world), "b", "switched on press");
833 }
834 }
835
836 #[test]
837 fn a11y_expanded_round_trips_to_dropdown_open_signal() {
838 let mut world = World::new();
839 world.insert_resource(PropertyStore::default());
840
841 let entity = world
842 .spawn((
843 DropdownButton::new("__dropdown_open:menu"),
844 A11yState::default(),
845 ))
846 .id();
847
848 let mut schedule = Schedule::default();
849 schedule.add_systems(apply_a11y_expand_to_dropdown);
850
851 schedule.run(&mut world);
854 assert_eq!(
855 world
856 .resource::<PropertyStore>()
857 .get_global_bool("__dropdown_open:menu"),
858 Some(false),
859 "freshly-spawned A11yState default (no EXPANDED) yields open=false"
860 );
861
862 world
864 .get_mut::<A11yState>(entity)
865 .expect("A11yState present")
866 .insert(A11yState::EXPANDED);
867 schedule.run(&mut world);
868 assert_eq!(
869 world
870 .resource::<PropertyStore>()
871 .get_global_bool("__dropdown_open:menu"),
872 Some(true),
873 "EXPANDED flip on -> open=true"
874 );
875
876 world
878 .get_mut::<A11yState>(entity)
879 .expect("A11yState present")
880 .remove(A11yState::EXPANDED);
881 schedule.run(&mut world);
882 assert_eq!(
883 world
884 .resource::<PropertyStore>()
885 .get_global_bool("__dropdown_open:menu"),
886 Some(false),
887 "EXPANDED flip off -> open=false"
888 );
889 }
890}
891
892#[cfg(test)]
898mod hit_shadowing_tests {
899 use super::*;
900 use bevy_ecs::message::Messages;
901 use bevy_ecs::system::RunSystemOnce;
902
903 fn click(world: &mut World, target: Entity) {
904 world
905 .resource_mut::<Messages<ClickEvent>>()
906 .write(ClickEvent {
907 entity: target,
908 position: glam::Vec2::ZERO,
909 button: lumen_core::input::PointerButton::Primary,
910 local: None,
911 });
912 }
913
914 #[test]
915 fn click_on_header_text_child_opens_dropdown() {
916 let mut world = World::new();
917 world.insert_resource(PropertyStore::default());
918 world.init_resource::<Messages<ClickEvent>>();
919 let header = world
920 .spawn(DropdownButton::new("__dropdown_open:fruit"))
921 .id();
922 let text_child = world.spawn(ChildOf(header)).id();
923 click(&mut world, text_child);
924 world.run_system_once(dispatch_dropdown_clicks).unwrap();
925 assert_eq!(
926 world
927 .resource::<PropertyStore>()
928 .get_global_bool("__dropdown_open:fruit"),
929 Some(true),
930 "click landing on the header's text child must open the popup"
931 );
932 }
933
934 #[test]
935 fn click_on_option_text_child_commits_value_and_closes() {
936 let mut world = World::new();
937 world.insert_resource(PropertyStore::default());
938 world.init_resource::<Messages<ClickEvent>>();
939 world
940 .resource_mut::<PropertyStore>()
941 .set_global_bool("__dropdown_open:fruit", true);
942 let option = world
943 .spawn(DropdownOptionButton {
944 value_signal: "fruit".to_string(),
945 value: "mango".to_string(),
946 open_signal: "__dropdown_open:fruit".to_string(),
947 })
948 .id();
949 let text_child = world.spawn(ChildOf(option)).id();
950 click(&mut world, text_child);
951 world.run_system_once(dispatch_dropdown_clicks).unwrap();
952 let store = world.resource::<PropertyStore>();
953 assert_eq!(store.get_global_str("fruit").as_deref(), Some("mango"));
954 assert_eq!(
955 store.get_global_bool("__dropdown_open:fruit"),
956 Some(false),
957 "option child click commits AND closes"
958 );
959 }
960
961 #[test]
962 fn click_on_menu_item_text_child_fires_menu_clicked() {
963 let mut world = World::new();
964 world.insert_resource(PropertyStore::default());
965 world.init_resource::<Messages<ClickEvent>>();
966 world.init_resource::<Messages<MenuClicked>>();
967 world
968 .resource_mut::<PropertyStore>()
969 .set_global_bool("__menu_open:actions", true);
970 let item = world
971 .spawn(MenuItemButton {
972 open_signal: "__menu_open:actions".to_string(),
973 item_id: "rename".to_string(),
974 })
975 .id();
976 let text_child = world.spawn(ChildOf(item)).id();
977 click(&mut world, text_child);
978 world.run_system_once(dispatch_menu_item_clicks).unwrap();
979 let fired: Vec<MenuClicked> = world
980 .resource_mut::<Messages<MenuClicked>>()
981 .drain()
982 .collect();
983 assert_eq!(fired.len(), 1, "exactly one MenuClicked");
984 assert_eq!(fired[0].id, "rename");
985 assert_eq!(
986 world
987 .resource::<PropertyStore>()
988 .get_global_bool("__menu_open:actions"),
989 Some(false)
990 );
991 }
992
993 #[test]
994 fn click_on_tab_text_child_switches_tab() {
995 let mut world = World::new();
996 world.insert_resource(PropertyStore::default());
997 world.init_resource::<Messages<ClickEvent>>();
998 let tab = world
999 .spawn(TabStripButton {
1000 signal_name: "active".to_string(),
1001 value: "settings".to_string(),
1002 })
1003 .id();
1004 let text_child = world.spawn(ChildOf(tab)).id();
1005 click(&mut world, text_child);
1006 world.run_system_once(dispatch_tab_clicks).unwrap();
1007 assert_eq!(
1008 world
1009 .resource::<PropertyStore>()
1010 .get_global_str("active")
1011 .as_deref(),
1012 Some("settings")
1013 );
1014 }
1015}