1use bevy_ecs::message::MessageReader;
44use bevy_ecs::prelude::*;
45use lumen_core::components::{Disabled, DocumentOrder};
46use lumen_core::input::{FocusVisible, Key, KeyPressed, NamedKey};
47use lumen_core::prelude::*;
48use lumen_core::time::{Duration, Instant};
49
50use crate::popup::PopupPanel;
51use crate::tabs::{DropdownButton, DropdownOptionButton, MenuItemButton};
52
53#[derive(Resource, Clone, Copy, Debug)]
57pub struct PopupNavConfig {
58 pub type_ahead_reset: Duration,
61}
62
63impl Default for PopupNavConfig {
64 fn default() -> Self {
65 Self {
66 type_ahead_reset: Duration::from_secs(1),
67 }
68 }
69}
70
71#[derive(Clone, Debug)]
75pub struct PopupNavSession {
76 pub open_signal: String,
79 pub panel: Entity,
81 pub prev_focus: Option<Entity>,
85 pub prev_focus_visible: bool,
88 pub needs_scroll: bool,
92}
93
94#[derive(Resource, Clone, Debug, Default)]
96pub struct ActivePopupNav(pub Option<PopupNavSession>);
97
98#[derive(Resource, Clone, Debug, Default)]
101pub struct TypeAheadBuffer {
102 pub prefix: String,
104 pub last: Option<Instant>,
106}
107
108impl TypeAheadBuffer {
109 fn push(&mut self, ch: &str, now: Instant, reset: Duration) -> bool {
114 let stale = self
115 .last
116 .map(|t| now.duration_since(t) > reset)
117 .unwrap_or(true);
118 if stale {
119 self.prefix.clear();
120 }
121 let extended = !self.prefix.is_empty();
122 self.prefix.push_str(&ch.to_lowercase());
123 self.last = Some(now);
124 extended
125 }
126}
127
128struct NavRow {
130 entity: Entity,
131 label: String,
132 disabled: bool,
133}
134
135fn type_ahead_char<'k>(key: &'k Key, modifiers: &lumen_core::input::Modifiers) -> Option<&'k str> {
140 if modifiers.ctrl || modifiers.alt || modifiers.super_ {
141 return None;
142 }
143 let Key::Character(s) = key else {
144 return None;
145 };
146 let mut chars = s.chars();
147 let first = chars.next()?;
148 if chars.next().is_some() || first.is_control() {
149 return None;
150 }
151 Some(s.as_str())
152}
153
154#[allow(clippy::type_complexity)]
161fn collect_rows(
162 open_signal: &str,
163 options: &Query<(
164 Entity,
165 &DropdownOptionButton,
166 Option<&TextContent>,
167 Has<Disabled>,
168 Option<&DocumentOrder>,
169 )>,
170 items: &Query<(
171 Entity,
172 &MenuItemButton,
173 Option<&TextContent>,
174 Has<Disabled>,
175 Option<&DocumentOrder>,
176 )>,
177) -> Vec<NavRow> {
178 let mut rows: Vec<(u32, Entity, NavRow)> = Vec::new();
179 for (e, opt, text, disabled, order) in options.iter() {
180 if opt.open_signal == open_signal {
181 rows.push((
182 order.map(|d| d.0).unwrap_or(u32::MAX),
183 e,
184 NavRow {
185 entity: e,
186 label: text.map(|t| t.0.clone()).unwrap_or_default(),
187 disabled,
188 },
189 ));
190 }
191 }
192 for (e, item, text, disabled, order) in items.iter() {
193 if item.open_signal == open_signal {
194 rows.push((
195 order.map(|d| d.0).unwrap_or(u32::MAX),
196 e,
197 NavRow {
198 entity: e,
199 label: text.map(|t| t.0.clone()).unwrap_or_default(),
200 disabled,
201 },
202 ));
203 }
204 }
205 rows.sort_by_key(|(order, e, _)| (*order, *e));
206 rows.into_iter().map(|(_, _, row)| row).collect()
207}
208
209fn move_focus(
213 commands: &mut Commands,
214 tracker: &mut FocusTracker,
215 from: Option<Entity>,
216 to: Entity,
217 visible: bool,
218) {
219 if let Some(prev) = from
220 && prev != to
221 {
222 commands.entity(prev).remove::<(Focused, FocusVisible)>();
223 }
224 if visible {
225 commands.entity(to).insert((Focused, FocusVisible));
226 } else {
227 commands.entity(to).insert(Focused).remove::<FocusVisible>();
228 }
229 tracker.0 = Some(to);
230}
231
232fn scroll_row_into_view(
237 row: Entity,
238 parents: &Query<&ChildOf>,
239 transforms: &Query<&Transform>,
240 scrolls: &mut Query<(&Transform, &mut Scroll, &mut ScrollOffset)>,
241) -> bool {
242 let Ok(row_tf) = transforms.get(row) else {
243 return false;
244 };
245 if row_tf.size.y <= 0.0 {
246 return false;
247 }
248 let mut cur = parents.get(row).ok().map(|c| c.parent());
250 let mut container = None;
251 while let Some(e) = cur {
252 if scrolls.contains(e) {
253 container = Some(e);
254 break;
255 }
256 cur = parents.get(e).ok().map(|c| c.parent());
257 }
258 let Some(container) = container else {
259 return true;
261 };
262 let Ok((c_tf, mut scroll, mut offset)) = scrolls.get_mut(container) else {
263 return true;
264 };
265 let top = row_tf.absolute.y - c_tf.absolute.y;
270 let bottom = top + row_tf.size.y;
271 let lo = (bottom - c_tf.size.y).max(0.0);
272 let hi = top.max(0.0);
273 let new_y = if lo <= hi {
277 offset.0.y.clamp(lo, hi)
278 } else {
279 hi
280 };
281 if (new_y - offset.0.y).abs() > f32::EPSILON {
282 offset.0.y = new_y;
283 scroll.velocity.y = 0.0;
284 }
285 true
286}
287
288fn in_panel_scope(e: Entity, panel: Entity, parents: &Query<&ChildOf>) -> bool {
290 let mut cur = Some(e);
291 while let Some(c) = cur {
292 if c == panel {
293 return true;
294 }
295 cur = parents.get(c).ok().map(|co| co.parent());
296 }
297 false
298}
299
300#[allow(clippy::type_complexity, clippy::too_many_arguments)]
314pub fn popup_nav_lifecycle(
315 mut commands: Commands,
316 mut nav: ResMut<ActivePopupNav>,
317 mut tracker: ResMut<FocusTracker>,
318 mut store: ResMut<PropertyStore>,
319 panels: Query<(Entity, &PopupPanel)>,
320 headers: Query<(Entity, &DropdownButton)>,
321 options: Query<(
322 Entity,
323 &DropdownOptionButton,
324 Option<&TextContent>,
325 Has<Disabled>,
326 Option<&DocumentOrder>,
327 )>,
328 items: Query<(
329 Entity,
330 &MenuItemButton,
331 Option<&TextContent>,
332 Has<Disabled>,
333 Option<&DocumentOrder>,
334 )>,
335 focus_visibles: Query<(), With<FocusVisible>>,
336 parents: Query<&ChildOf>,
337 transforms: Query<&Transform>,
338 mut scrolls: Query<(&Transform, &mut Scroll, &mut ScrollOffset)>,
339) {
340 if let Some(mut session) = nav.0.take() {
343 let still_open = store.get_global_bool(&session.open_signal) == Some(true);
344 let from = tracker.0;
345 let focus_inside = from
346 .map(|e| in_panel_scope(e, session.panel, &parents))
347 .unwrap_or(false);
348 if !still_open {
349 if focus_inside {
351 let target = headers
352 .iter()
353 .find(|(_, h)| h.open_signal == session.open_signal)
354 .map(|(e, _)| e)
355 .or(session.prev_focus);
356 if let Some(target) = target {
357 move_focus(
358 &mut commands,
359 &mut tracker,
360 from,
361 target,
362 session.prev_focus_visible,
363 );
364 } else if let Some(prev) = from {
365 commands.entity(prev).remove::<(Focused, FocusVisible)>();
366 tracker.0 = None;
367 }
368 }
369 } else if !focus_inside {
370 store.set_global_bool(&session.open_signal, false);
373 } else {
374 if session.needs_scroll
375 && let Some(row) = from.filter(|&e| e != session.panel)
376 && scroll_row_into_view(row, &parents, &transforms, &mut scrolls)
377 {
378 session.needs_scroll = false;
379 }
380 nav.0 = Some(session);
381 }
382 }
383
384 if nav.0.is_some() {
386 return;
387 }
388 let mut open_panels: Vec<(Entity, &PopupPanel)> = panels
389 .iter()
390 .filter(|(_, p)| store.get_global_bool(&p.open_signal) == Some(true))
391 .collect();
392 open_panels.sort_by_key(|(e, _)| *e);
393 let Some((panel_e, panel)) = open_panels.into_iter().next() else {
394 return;
395 };
396 let prev_focus = tracker.0;
397 let prev_focus_visible = prev_focus
398 .map(|e| focus_visibles.contains(e))
399 .unwrap_or(false);
400 let header = headers
404 .iter()
405 .find(|(_, h)| h.open_signal == panel.open_signal);
406 let rows = collect_rows(&panel.open_signal, &options, &items);
407 let initial = if let Some((_, h)) = header {
408 let committed = store.get_global_str(&h.value_signal);
409 let committed = committed.as_deref();
410 options
411 .iter()
412 .find(|(_, o, ..)| {
413 o.open_signal == panel.open_signal && Some(o.value.as_str()) == committed
414 })
415 .map(|(e, ..)| e)
416 .or_else(|| rows.iter().find(|r| !r.disabled).map(|r| r.entity))
417 .unwrap_or(panel_e)
418 } else {
419 panel_e
420 };
421 move_focus(
422 &mut commands,
423 &mut tracker,
424 prev_focus,
425 initial,
426 prev_focus_visible,
427 );
428 nav.0 = Some(PopupNavSession {
429 open_signal: panel.open_signal.clone(),
430 panel: panel_e,
431 prev_focus,
432 prev_focus_visible,
433 needs_scroll: initial != panel_e,
434 });
435}
436
437#[allow(clippy::type_complexity, clippy::too_many_arguments)]
444pub fn popup_nav_keys(
445 mut commands: Commands,
446 mut keys: MessageReader<KeyPressed>,
447 tick: Res<Tick>,
448 config: Res<PopupNavConfig>,
449 mut type_ahead: ResMut<TypeAheadBuffer>,
450 mut nav: ResMut<ActivePopupNav>,
451 mut tracker: ResMut<FocusTracker>,
452 mut store: ResMut<PropertyStore>,
453 options: Query<(
454 Entity,
455 &DropdownOptionButton,
456 Option<&TextContent>,
457 Has<Disabled>,
458 Option<&DocumentOrder>,
459 )>,
460 items: Query<(
461 Entity,
462 &MenuItemButton,
463 Option<&TextContent>,
464 Has<Disabled>,
465 Option<&DocumentOrder>,
466 )>,
467 hovered_rows: Query<
468 Entity,
469 (
470 With<Hovered>,
471 Or<(With<DropdownOptionButton>, With<MenuItemButton>)>,
472 ),
473 >,
474 parents: Query<&ChildOf>,
475 transforms: Query<&Transform>,
476 mut scrolls: Query<(&Transform, &mut Scroll, &mut ScrollOffset)>,
477) {
478 let Some(session) = nav.0.as_mut() else {
479 return;
480 };
481 if store.get_global_bool(&session.open_signal) != Some(true) {
482 return;
483 }
484 let is_menu = session.open_signal.starts_with("__menu_open:");
486 let rows = collect_rows(&session.open_signal, &options, &items);
487 if rows.is_empty() {
488 return;
489 }
490 let current: Option<usize> = tracker
491 .0
492 .and_then(|f| rows.iter().position(|r| r.entity == f));
493 let mut target: Option<usize> = None;
494
495 let step = |from: Option<usize>, dir: i64, wrap: bool| -> Option<usize> {
498 let n = rows.len() as i64;
499 let start = match from {
500 Some(i) => i as i64,
501 None => {
504 if dir > 0 {
505 -1
506 } else {
507 n
508 }
509 }
510 };
511 let mut i = start + dir;
512 let mut hops = 0;
513 while hops < n {
514 if wrap {
515 i = i.rem_euclid(n);
516 } else if i < 0 || i >= n {
517 return None;
518 }
519 if !rows[i as usize].disabled {
520 return Some(i as usize);
521 }
522 i += dir;
523 hops += 1;
524 }
525 None
526 };
527
528 for ev in keys.read() {
529 match &ev.key {
530 Key::Named(NamedKey::ArrowDown) if !ev.modifiers.alt => {
531 if let Some(idx) = step(target.or(current), 1, is_menu) {
532 target = Some(idx);
533 }
534 }
535 Key::Named(NamedKey::ArrowUp) if !ev.modifiers.alt => {
536 if let Some(idx) = step(target.or(current), -1, is_menu) {
537 target = Some(idx);
538 }
539 }
540 Key::Named(NamedKey::ArrowUp) => {
541 if !is_menu {
543 store.set_global_bool(&session.open_signal, false);
544 return;
545 }
546 }
547 Key::Named(NamedKey::Home) => {
548 target = rows.iter().position(|r| !r.disabled);
549 }
550 Key::Named(NamedKey::End) => {
551 target = rows.iter().rposition(|r| !r.disabled);
552 }
553 key => {
554 let Some(ch) = type_ahead_char(key, &ev.modifiers) else {
555 continue;
556 };
557 let extended = type_ahead.push(ch, tick.now, config.type_ahead_reset);
558 let anchor = target.or(current);
561 let start = match (anchor, extended) {
562 (Some(i), true) => i,
563 (Some(i), false) => (i + 1) % rows.len(),
564 (None, _) => 0,
565 };
566 let n = rows.len();
567 let hit = (0..n).map(|k| (start + k) % n).find(|&i| {
568 !rows[i].disabled
569 && rows[i].label.to_lowercase().starts_with(&type_ahead.prefix)
570 });
571 if let Some(idx) = hit {
572 target = Some(idx);
573 }
574 }
575 }
576 }
577
578 if let Some(idx) = target
579 && current != Some(idx)
580 {
581 for hovered in &hovered_rows {
584 if hovered != rows[idx].entity {
585 commands.entity(hovered).remove::<Hovered>();
586 }
587 }
588 let from = tracker.0;
589 move_focus(&mut commands, &mut tracker, from, rows[idx].entity, true);
590 if !scroll_row_into_view(rows[idx].entity, &parents, &transforms, &mut scrolls) {
591 session.needs_scroll = true;
592 }
593 }
594}
595
596#[allow(clippy::type_complexity)]
603pub fn follow_hover_highlight(
604 mut commands: Commands,
605 nav: Res<ActivePopupNav>,
606 mut tracker: ResMut<FocusTracker>,
607 newly_hovered: Query<
608 (
609 Entity,
610 Option<&DropdownOptionButton>,
611 Option<&MenuItemButton>,
612 ),
613 Added<Hovered>,
614 >,
615) {
616 let Some(session) = nav.0.as_ref() else {
617 return;
618 };
619 for (e, opt, item) in &newly_hovered {
620 let signal = opt
621 .map(|o| o.open_signal.as_str())
622 .or(item.map(|i| i.open_signal.as_str()));
623 if signal != Some(session.open_signal.as_str()) {
624 continue;
625 }
626 let from = tracker.0;
627 if from != Some(e) {
628 move_focus(&mut commands, &mut tracker, from, e, false);
629 }
630 }
631}
632
633pub fn closed_dropdown_keys(
638 mut keys: MessageReader<KeyPressed>,
639 tick: Res<Tick>,
640 config: Res<PopupNavConfig>,
641 mut type_ahead: ResMut<TypeAheadBuffer>,
642 tracker: Res<FocusTracker>,
643 headers: Query<&DropdownButton>,
644 mut store: ResMut<PropertyStore>,
645) {
646 let Some(focused) = tracker.0 else {
647 return;
648 };
649 let Ok(header) = headers.get(focused) else {
650 return;
651 };
652 if store.get_global_bool(&header.open_signal) == Some(true) {
653 return;
654 }
655 if header.options.is_empty() {
656 return;
657 }
658 let enabled = |i: usize| !header.options[i].disabled;
659 for ev in keys.read() {
660 let current = {
661 let committed = store.get_global_str(&header.value_signal);
662 let committed = committed.as_deref();
663 header
664 .options
665 .iter()
666 .position(|o| Some(o.value.as_str()) == committed)
667 };
668 let target: Option<usize> = match &ev.key {
669 Key::Named(NamedKey::ArrowDown) if ev.modifiers.alt => {
670 store.set_global_bool(&header.open_signal, true);
672 return;
673 }
674 Key::Named(NamedKey::ArrowDown) => {
675 match current {
678 Some(i) => ((i + 1)..header.options.len()).find(|&k| enabled(k)),
679 None => (0..header.options.len()).find(|&k| enabled(k)),
680 }
681 }
682 Key::Named(NamedKey::ArrowUp) => match current {
683 Some(i) => (0..i).rev().find(|&k| enabled(k)),
684 None => (0..header.options.len()).find(|&k| enabled(k)),
685 },
686 key => {
687 let Some(ch) = type_ahead_char(key, &ev.modifiers) else {
688 continue;
689 };
690 let extended = type_ahead.push(ch, tick.now, config.type_ahead_reset);
691 let n = header.options.len();
692 let start = match (current, extended) {
693 (Some(i), true) => i,
694 (Some(i), false) => (i + 1) % n,
695 (None, _) => 0,
696 };
697 (0..n).map(|k| (start + k) % n).find(|&i| {
698 enabled(i)
699 && header.options[i]
700 .label
701 .to_lowercase()
702 .starts_with(&type_ahead.prefix)
703 })
704 }
705 };
706 if let Some(idx) = target {
707 store.set_global_str(&header.value_signal, header.options[idx].value.as_str());
711 }
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use crate::popup::PopupPanel;
719 use crate::tabs::DropdownOptionSpec;
720 use bevy_ecs::message::Messages;
721 use bevy_ecs::system::RunSystemOnce;
722 use bevy_ecs::world::World;
723 use glam::Vec2;
724 use lumen_core::input::Modifiers;
725
726 const OPEN: &str = "__dropdown_open:fruit";
727 const MENU_OPEN: &str = "__menu_open:actions";
728
729 fn base_world() -> World {
730 let mut world = World::new();
731 world.init_resource::<Messages<KeyPressed>>();
732 world.insert_resource(PropertyStore::default());
733 world.insert_resource(FocusTracker::default());
734 world.init_resource::<ActivePopupNav>();
735 world.init_resource::<PopupNavConfig>();
736 world.init_resource::<TypeAheadBuffer>();
737 world.insert_resource(Tick::default());
738 world
739 }
740
741 fn press(world: &mut World, key: Key) {
742 press_mod(world, key, Modifiers::default());
743 }
744
745 fn press_mod(world: &mut World, key: Key, modifiers: Modifiers) {
746 world
747 .resource_mut::<Messages<KeyPressed>>()
748 .write(KeyPressed {
749 key,
750 modifiers,
751 repeat: false,
752 });
753 }
754
755 fn clear_keys(world: &mut World) {
756 world.resource_mut::<Messages<KeyPressed>>().clear();
757 }
758
759 fn focused(world: &World) -> Option<Entity> {
760 world.resource::<FocusTracker>().0
761 }
762
763 fn spawn_dropdown(world: &mut World) -> (Entity, Entity, [Entity; 3]) {
767 let header = world
768 .spawn(DropdownButton {
769 open_signal: OPEN.into(),
770 value_signal: "weight".into(),
771 options: vec![
772 DropdownOptionSpec {
773 value: "light".into(),
774 label: "Light".into(),
775 disabled: false,
776 },
777 DropdownOptionSpec {
778 value: "medium".into(),
779 label: "Medium".into(),
780 disabled: false,
781 },
782 DropdownOptionSpec {
783 value: "heavy".into(),
784 label: "Heavy".into(),
785 disabled: false,
786 },
787 ],
788 })
789 .id();
790 let panel = world
791 .spawn(PopupPanel {
792 open_signal: OPEN.into(),
793 })
794 .id();
795 let mut rows = [Entity::PLACEHOLDER; 3];
796 for (i, (value, label)) in [("light", "Light"), ("medium", "Medium"), ("heavy", "Heavy")]
797 .iter()
798 .enumerate()
799 {
800 rows[i] = world
801 .spawn((
802 DropdownOptionButton {
803 value_signal: "weight".into(),
804 value: (*value).into(),
805 open_signal: OPEN.into(),
806 },
807 TextContent((*label).into()),
808 DocumentOrder(i as u32),
809 ChildOf(panel),
810 ))
811 .id();
812 }
813 world
814 .resource_mut::<PropertyStore>()
815 .set_global_str("weight", "medium");
816 (header, panel, rows)
817 }
818
819 fn run_lifecycle(world: &mut World) {
820 world.run_system_once(popup_nav_lifecycle).unwrap();
821 }
822
823 fn run_keys(world: &mut World) {
824 world.run_system_once(popup_nav_keys).unwrap();
825 clear_keys(world);
826 }
827
828 #[test]
829 fn open_moves_highlight_to_committed_option() {
830 let mut world = base_world();
831 let (_, _, rows) = spawn_dropdown(&mut world);
832 world
833 .resource_mut::<PropertyStore>()
834 .set_global_bool(OPEN, true);
835 run_lifecycle(&mut world);
836 assert_eq!(
837 focused(&world),
838 Some(rows[1]),
839 "committed option (medium) gets the highlight on open"
840 );
841 assert!(
842 world.get::<Focused>(rows[1]).is_some(),
843 "highlight is the Focused marker"
844 );
845 }
846
847 #[test]
848 fn arrows_move_highlight_without_committing_and_clamp() {
849 let mut world = base_world();
850 let (_, _, rows) = spawn_dropdown(&mut world);
851 world
852 .resource_mut::<PropertyStore>()
853 .set_global_bool(OPEN, true);
854 run_lifecycle(&mut world);
855
856 press(&mut world, Key::Named(NamedKey::ArrowDown));
857 run_keys(&mut world);
858 assert_eq!(focused(&world), Some(rows[2]), "down: medium -> heavy");
859 press(&mut world, Key::Named(NamedKey::ArrowDown));
860 run_keys(&mut world);
861 assert_eq!(
862 focused(&world),
863 Some(rows[2]),
864 "dropdown highlight clamps at the last row (no wrap)"
865 );
866 press(&mut world, Key::Named(NamedKey::Home));
867 run_keys(&mut world);
868 assert_eq!(focused(&world), Some(rows[0]), "Home -> first enabled");
869 assert_eq!(
870 world
871 .resource::<PropertyStore>()
872 .get_global_str("weight")
873 .as_deref(),
874 Some("medium"),
875 "highlight movement never commits"
876 );
877 }
878
879 #[test]
880 fn escape_close_reverts_and_restores_header_focus() {
881 let mut world = base_world();
882 let (header, _, rows) = spawn_dropdown(&mut world);
883 world
884 .resource_mut::<PropertyStore>()
885 .set_global_bool(OPEN, true);
886 run_lifecycle(&mut world);
887 press(&mut world, Key::Named(NamedKey::ArrowDown));
888 run_keys(&mut world);
889 assert_eq!(focused(&world), Some(rows[2]));
890
891 world
894 .resource_mut::<PropertyStore>()
895 .set_global_bool(OPEN, false);
896 run_lifecycle(&mut world);
897 assert_eq!(
898 world
899 .resource::<PropertyStore>()
900 .get_global_str("weight")
901 .as_deref(),
902 Some("medium"),
903 "close without commit leaves the pre-open selection"
904 );
905 assert_eq!(
906 focused(&world),
907 Some(header),
908 "focus returns to the dropdown header"
909 );
910 assert!(
911 world.resource::<ActivePopupNav>().0.is_none(),
912 "session ended"
913 );
914 }
915
916 #[test]
917 fn disabled_rows_are_skipped_by_highlight_movement() {
918 let mut world = base_world();
919 let (_, _, rows) = spawn_dropdown(&mut world);
920 world.entity_mut(rows[2]).insert(Disabled);
921 world
922 .resource_mut::<PropertyStore>()
923 .set_global_bool(OPEN, true);
924 run_lifecycle(&mut world);
925 assert_eq!(focused(&world), Some(rows[1]));
926 press(&mut world, Key::Named(NamedKey::ArrowDown));
927 run_keys(&mut world);
928 assert_eq!(
929 focused(&world),
930 Some(rows[1]),
931 "the only row below is disabled -> highlight stays"
932 );
933 press(&mut world, Key::Named(NamedKey::End));
934 run_keys(&mut world);
935 assert_eq!(
936 focused(&world),
937 Some(rows[1]),
938 "End lands on the last ENABLED row"
939 );
940 }
941
942 #[test]
943 fn type_ahead_moves_highlight_and_wraps() {
944 let mut world = base_world();
945 let (_, _, rows) = spawn_dropdown(&mut world);
946 world
947 .resource_mut::<PropertyStore>()
948 .set_global_bool(OPEN, true);
949 run_lifecycle(&mut world);
950 assert_eq!(focused(&world), Some(rows[1]), "start at medium");
951
952 press(&mut world, Key::Character("h".into()));
953 run_keys(&mut world);
954 assert_eq!(focused(&world), Some(rows[2]), "'h' -> Heavy");
955
956 world.resource_mut::<TypeAheadBuffer>().last = None; press(&mut world, Key::Character("l".into()));
959 run_keys(&mut world);
960 assert_eq!(focused(&world), Some(rows[0]), "'l' wraps to Light");
961 }
962
963 #[test]
964 fn multi_char_prefix_keeps_current_row_eligible() {
965 let mut world = base_world();
966 let (_, _, rows) = spawn_dropdown(&mut world);
967 world
968 .resource_mut::<PropertyStore>()
969 .set_global_bool(OPEN, true);
970 run_lifecycle(&mut world);
971
972 press(&mut world, Key::Character("h".into()));
973 run_keys(&mut world);
974 assert_eq!(focused(&world), Some(rows[2]), "'h' -> Heavy");
975 press(&mut world, Key::Character("e".into()));
976 run_keys(&mut world);
977 assert_eq!(
978 focused(&world),
979 Some(rows[2]),
980 "'he' still matches Heavy - extending the prefix keeps the row"
981 );
982 }
983
984 #[test]
985 fn alt_up_closes_open_dropdown_without_commit() {
986 let mut world = base_world();
987 let (_, _, _rows) = spawn_dropdown(&mut world);
988 world
989 .resource_mut::<PropertyStore>()
990 .set_global_bool(OPEN, true);
991 run_lifecycle(&mut world);
992 press_mod(
993 &mut world,
994 Key::Named(NamedKey::ArrowUp),
995 Modifiers {
996 alt: true,
997 ..Default::default()
998 },
999 );
1000 run_keys(&mut world);
1001 let store = world.resource::<PropertyStore>();
1002 assert_eq!(store.get_global_bool(OPEN), Some(false), "popup closed");
1003 assert_eq!(
1004 store.get_global_str("weight").as_deref(),
1005 Some("medium"),
1006 "no commit"
1007 );
1008 }
1009
1010 #[test]
1011 fn focus_leaving_panel_closes_popup() {
1012 let mut world = base_world();
1013 let (_, _, _rows) = spawn_dropdown(&mut world);
1014 world
1015 .resource_mut::<PropertyStore>()
1016 .set_global_bool(OPEN, true);
1017 run_lifecycle(&mut world);
1018 let other = world.spawn_empty().id();
1021 world.resource_mut::<FocusTracker>().0 = Some(other);
1022 run_lifecycle(&mut world);
1023 assert_eq!(
1024 world.resource::<PropertyStore>().get_global_bool(OPEN),
1025 Some(false),
1026 "popup dismissed on focus-out"
1027 );
1028 assert_eq!(
1029 focused(&world),
1030 Some(other),
1031 "focus is NOT yanked back to the header"
1032 );
1033 }
1034
1035 #[test]
1036 fn keyboard_move_strips_hover_highlight() {
1037 let mut world = base_world();
1038 let (_, _, rows) = spawn_dropdown(&mut world);
1039 world
1040 .resource_mut::<PropertyStore>()
1041 .set_global_bool(OPEN, true);
1042 run_lifecycle(&mut world);
1043 world.entity_mut(rows[0]).insert(Hovered);
1045 press(&mut world, Key::Named(NamedKey::ArrowDown));
1046 run_keys(&mut world);
1047 assert_eq!(focused(&world), Some(rows[2]), "keyboard moved highlight");
1048 assert!(
1049 world.get::<Hovered>(rows[0]).is_none(),
1050 "keyboard move strips the stale hover highlight (last input wins)"
1051 );
1052 }
1053
1054 #[test]
1055 fn hover_moves_highlight_to_row() {
1056 let mut world = base_world();
1057 let (_, _, rows) = spawn_dropdown(&mut world);
1058 world
1059 .resource_mut::<PropertyStore>()
1060 .set_global_bool(OPEN, true);
1061 run_lifecycle(&mut world);
1062 assert_eq!(focused(&world), Some(rows[1]));
1063 world.entity_mut(rows[0]).insert(Hovered);
1064 world.run_system_once(follow_hover_highlight).unwrap();
1065 assert_eq!(
1066 focused(&world),
1067 Some(rows[0]),
1068 "hovering a row moves the shared highlight onto it"
1069 );
1070 assert!(
1071 world.get::<FocusVisible>(rows[0]).is_none(),
1072 "pointer-driven highlight carries no :focus-visible"
1073 );
1074 }
1075
1076 #[test]
1077 fn highlight_movement_scrolls_row_into_view() {
1078 let mut world = base_world();
1079 let (_, panel, rows) = spawn_dropdown(&mut world);
1080 world.entity_mut(panel).insert((
1082 Transform::new(Vec2::new(0.0, 0.0), Vec2::new(100.0, 64.0)),
1083 Scroll::vertical(),
1084 ScrollOffset::default(),
1085 ));
1086 for (i, row) in rows.iter().enumerate() {
1087 world.entity_mut(*row).insert(Transform::new(
1088 Vec2::new(0.0, i as f32 * 32.0),
1089 Vec2::new(100.0, 32.0),
1090 ));
1091 }
1092 world
1093 .resource_mut::<PropertyStore>()
1094 .set_global_bool(OPEN, true);
1095 run_lifecycle(&mut world);
1096 press(&mut world, Key::Named(NamedKey::End));
1097 run_keys(&mut world);
1098 let off = world.get::<ScrollOffset>(panel).unwrap().0.y;
1099 assert_eq!(
1100 off, 32.0,
1101 "row 2 (y=64..96) needs offset 32 to sit inside the 64-tall viewport"
1102 );
1103 }
1104
1105 fn spawn_menu(world: &mut World) -> (Entity, [Entity; 3]) {
1108 let panel = world
1109 .spawn(PopupPanel {
1110 open_signal: MENU_OPEN.into(),
1111 })
1112 .id();
1113 let mut items = [Entity::PLACEHOLDER; 3];
1114 for (i, label) in ["Rename", "Duplicate", "Delete"].iter().enumerate() {
1115 let mut e = world.spawn((
1116 MenuItemButton {
1117 open_signal: MENU_OPEN.into(),
1118 item_id: label.to_lowercase(),
1119 },
1120 TextContent((*label).into()),
1121 DocumentOrder(i as u32),
1122 ChildOf(panel),
1123 ));
1124 if i == 1 {
1125 e.insert(Disabled);
1126 }
1127 items[i] = e.id();
1128 }
1129 (panel, items)
1130 }
1131
1132 #[test]
1133 fn menu_opens_with_no_highlight_and_arrows_skip_disabled_with_wrap() {
1134 let mut world = base_world();
1135 let (panel, items) = spawn_menu(&mut world);
1136 world
1137 .resource_mut::<PropertyStore>()
1138 .set_global_bool(MENU_OPEN, true);
1139 run_lifecycle(&mut world);
1140 assert_eq!(
1141 focused(&world),
1142 Some(panel),
1143 "fresh menu: no item highlighted (focus parks on the panel)"
1144 );
1145
1146 press(&mut world, Key::Named(NamedKey::ArrowDown));
1147 run_keys(&mut world);
1148 assert_eq!(focused(&world), Some(items[0]), "first Down -> first item");
1149
1150 press(&mut world, Key::Named(NamedKey::ArrowDown));
1151 run_keys(&mut world);
1152 assert_eq!(
1153 focused(&world),
1154 Some(items[2]),
1155 "Duplicate is disabled -> skipped"
1156 );
1157
1158 press(&mut world, Key::Named(NamedKey::ArrowDown));
1159 run_keys(&mut world);
1160 assert_eq!(focused(&world), Some(items[0]), "menu wraps past the end");
1161
1162 press(&mut world, Key::Named(NamedKey::ArrowUp));
1163 run_keys(&mut world);
1164 assert_eq!(focused(&world), Some(items[2]), "wraps backwards too");
1165 }
1166
1167 #[test]
1168 fn menu_up_from_no_highlight_enters_at_bottom() {
1169 let mut world = base_world();
1170 let (_, items) = spawn_menu(&mut world);
1171 world
1172 .resource_mut::<PropertyStore>()
1173 .set_global_bool(MENU_OPEN, true);
1174 run_lifecycle(&mut world);
1175 press(&mut world, Key::Named(NamedKey::ArrowUp));
1176 run_keys(&mut world);
1177 assert_eq!(
1178 focused(&world),
1179 Some(items[2]),
1180 "Up with no highlight enters at the last enabled item"
1181 );
1182 }
1183
1184 #[test]
1185 fn menu_close_restores_previous_focus() {
1186 let mut world = base_world();
1187 let prev = world.spawn_empty().id();
1188 world.resource_mut::<FocusTracker>().0 = Some(prev);
1189 let (_, items) = spawn_menu(&mut world);
1190 world
1191 .resource_mut::<PropertyStore>()
1192 .set_global_bool(MENU_OPEN, true);
1193 run_lifecycle(&mut world);
1194 press(&mut world, Key::Named(NamedKey::ArrowDown));
1195 run_keys(&mut world);
1196 assert_eq!(focused(&world), Some(items[0]));
1197
1198 world
1199 .resource_mut::<PropertyStore>()
1200 .set_global_bool(MENU_OPEN, false);
1201 run_lifecycle(&mut world);
1202 assert_eq!(
1203 focused(&world),
1204 Some(prev),
1205 "menu close hands focus back to the pre-open holder"
1206 );
1207 }
1208
1209 fn run_closed(world: &mut World) {
1212 world.run_system_once(closed_dropdown_keys).unwrap();
1213 clear_keys(world);
1214 }
1215
1216 #[test]
1217 fn closed_arrows_step_value_directly_and_clamp() {
1218 let mut world = base_world();
1219 let (header, ..) = spawn_dropdown(&mut world);
1220 world.resource_mut::<FocusTracker>().0 = Some(header);
1221
1222 press(&mut world, Key::Named(NamedKey::ArrowDown));
1223 run_closed(&mut world);
1224 assert_eq!(
1225 world
1226 .resource::<PropertyStore>()
1227 .get_global_str("weight")
1228 .as_deref(),
1229 Some("heavy"),
1230 "closed Down commits the next option"
1231 );
1232 press(&mut world, Key::Named(NamedKey::ArrowDown));
1233 run_closed(&mut world);
1234 assert_eq!(
1235 world
1236 .resource::<PropertyStore>()
1237 .get_global_str("weight")
1238 .as_deref(),
1239 Some("heavy"),
1240 "clamps at the last option (no wrap)"
1241 );
1242 press(&mut world, Key::Named(NamedKey::ArrowUp));
1243 press(&mut world, Key::Named(NamedKey::ArrowUp));
1244 run_closed(&mut world);
1245 assert_eq!(
1246 world
1247 .resource::<PropertyStore>()
1248 .get_global_str("weight")
1249 .as_deref(),
1250 Some("light"),
1251 "two Ups walk back to the first option"
1252 );
1253 }
1254
1255 #[test]
1256 fn closed_arrows_skip_disabled_options() {
1257 let mut world = base_world();
1258 let (header, ..) = spawn_dropdown(&mut world);
1259 world.get_mut::<DropdownButton>(header).unwrap().options[2].disabled = true;
1261 world.resource_mut::<FocusTracker>().0 = Some(header);
1262 press(&mut world, Key::Named(NamedKey::ArrowDown));
1263 run_closed(&mut world);
1264 assert_eq!(
1265 world
1266 .resource::<PropertyStore>()
1267 .get_global_str("weight")
1268 .as_deref(),
1269 Some("medium"),
1270 "the only option below is disabled -> value unchanged"
1271 );
1272 }
1273
1274 #[test]
1275 fn closed_alt_down_opens_popup_instead_of_stepping() {
1276 let mut world = base_world();
1277 let (header, ..) = spawn_dropdown(&mut world);
1278 world.resource_mut::<FocusTracker>().0 = Some(header);
1279 press_mod(
1280 &mut world,
1281 Key::Named(NamedKey::ArrowDown),
1282 Modifiers {
1283 alt: true,
1284 ..Default::default()
1285 },
1286 );
1287 run_closed(&mut world);
1288 let store = world.resource::<PropertyStore>();
1289 assert_eq!(store.get_global_bool(OPEN), Some(true), "popup opened");
1290 assert_eq!(
1291 store.get_global_str("weight").as_deref(),
1292 Some("medium"),
1293 "value untouched"
1294 );
1295 }
1296
1297 #[test]
1298 fn closed_type_ahead_commits_matching_option() {
1299 let mut world = base_world();
1300 let (header, ..) = spawn_dropdown(&mut world);
1301 world.resource_mut::<FocusTracker>().0 = Some(header);
1302 press(&mut world, Key::Character("l".into()));
1303 run_closed(&mut world);
1304 assert_eq!(
1305 world
1306 .resource::<PropertyStore>()
1307 .get_global_str("weight")
1308 .as_deref(),
1309 Some("light"),
1310 "'l' jumps (and commits) to Light"
1311 );
1312 }
1313
1314 #[test]
1315 fn type_ahead_prefix_resets_after_interval() {
1316 let mut buf = TypeAheadBuffer::default();
1317 let t0 = Instant::now();
1318 let reset = Duration::from_secs(1);
1319 assert!(!buf.push("h", t0, reset), "first char starts a prefix");
1320 assert!(
1321 buf.push("e", t0 + Duration::from_millis(300), reset),
1322 "fast follow-up extends"
1323 );
1324 assert_eq!(buf.prefix, "he");
1325 assert!(
1326 !buf.push("l", t0 + Duration::from_millis(1500), reset),
1327 "past the reset interval the prefix restarts"
1328 );
1329 assert_eq!(buf.prefix, "l");
1330 }
1331
1332 #[test]
1333 fn type_ahead_char_rejects_chords_named_forwards_and_controls() {
1334 let none = Modifiers::default();
1335 assert_eq!(
1336 type_ahead_char(&Key::Character("h".into()), &none),
1337 Some("h")
1338 );
1339 assert_eq!(
1340 type_ahead_char(&Key::Character("PageUp".into()), &none),
1341 None,
1342 "multi-char forwarded key names never type-ahead"
1343 );
1344 assert_eq!(type_ahead_char(&Key::Named(NamedKey::Enter), &none), None);
1345 let ctrl = Modifiers {
1346 ctrl: true,
1347 ..Default::default()
1348 };
1349 assert_eq!(
1350 type_ahead_char(&Key::Character("h".into()), &ctrl),
1351 None,
1352 "chords are not type-ahead"
1353 );
1354 }
1355}