Skip to main content

lumen_primitives/
popup_nav.rs

1//! Keyboard interaction for popup widgets: `<dropdown>` (combobox) and
2//! `<menu>` panels. Matches the Qt contract:
3//!
4//! **Dropdown, closed + focused header**
5//! ([`closed_dropdown_keys`]):
6//! - `ArrowUp` / `ArrowDown` step the committed value directly through
7//!   the enabled options (no wrap - `QComboBox` clamps at the ends).
8//! - `Alt+ArrowDown` opens the popup. `Space` / `Enter` open it through
9//!   the existing focused-activation -> click -> header-toggle path.
10//! - Type-ahead: printable characters jump (and commit) the selection
11//!   to the next option whose label starts with the typed prefix,
12//!   wrapping, with a reset timer ([`PopupNavConfig`]).
13//!
14//! **Open popup** ([`popup_nav_lifecycle`] + [`popup_nav_keys`] +
15//! [`follow_hover_highlight`]):
16//! - Opening moves keyboard focus into the panel: onto the option row
17//!   matching the committed value (dropdown - Qt highlights the current
18//!   item) or onto the panel itself (menu - no initial highlight, like
19//!   a fresh `QMenu`). The row highlight *is* the [`Focused`] marker,
20//!   so `.dropdown-option:hover`-style CSS (routed through
21//!   [`crate::hover::Interaction::hover_tint`], which treats `Focused`
22//!   like `Hovered`) and `:focus` rules both style it - highlight
23//!   styling stays CSS-reachable with zero new style plumbing.
24//! - `ArrowUp` / `ArrowDown` move the highlight without committing
25//!   (dropdown clamps like `QListView`; menus wrap like `QMenu`),
26//!   skipping disabled rows and separators. `Home` / `End` jump.
27//! - `Enter` / `Space` activate the highlighted row through the
28//!   existing focused-activation path (`activate_focused_on_enter`
29//!   synthesizes the click; the option / menu-item dispatcher commits
30//!   and closes) - so commit semantics live in exactly one place.
31//! - `Escape` closes without committing (the highlight never wrote the
32//!   value, so the pre-open selection is intact - Qt's revert
33//!   contract). `lumenc`'s `close_dialogs_on_escape` flips the open
34//!   signal; the lifecycle system here restores focus to the trigger.
35//! - `Alt+ArrowUp` also closes a dropdown popup (Qt).
36//! - Hovering a row moves the highlight to it; a keyboard move strips
37//!   the hover highlight - keyboard and hover highlight are one state,
38//!   last input wins (Qt).
39//! - Highlight movement scrolls the panel's internal scroller (skins
40//!   cap `.dropdown-panel` height via `--lumen-dropdown-max-height` +
41//!   `overflow-y: scroll`) so the highlighted row stays visible.
42
43use 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/// Interaction timings for popup keyboard navigation. This `Default`
54/// is the single Rust-side fallback (blank-no-css contract); embedders
55/// can override the resource before the first tick.
56#[derive(Resource, Clone, Copy, Debug)]
57pub struct PopupNavConfig {
58    /// Idle gap after which the type-ahead prefix resets. Qt's
59    /// `QAbstractItemView` keyboard-search interval (~1 s).
60    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/// The one live popup keyboard session (dropdown or menu panel). At
72/// most one popup owns the keyboard at a time - nested menus are a
73/// later wave; today's `<menu>` is a flat panel.
74#[derive(Clone, Debug)]
75pub struct PopupNavSession {
76    /// Open-state signal the session is keyed on
77    /// (`__dropdown_open:*` / `__menu_open:*`).
78    pub open_signal: String,
79    /// The [`PopupPanel`] entity.
80    pub panel: Entity,
81    /// Focus holder at open time, restored when a *menu* closes.
82    /// Dropdowns restore to their header instead (Qt returns focus to
83    /// the combobox).
84    pub prev_focus: Option<Entity>,
85    /// Whether the pre-open focus carried [`FocusVisible`] (keyboard
86    /// focus) - the restored focus mirrors it.
87    pub prev_focus_visible: bool,
88    /// Initial scroll-into-view still pending: the panel mounts lazily
89    /// on first open, so the highlighted row has no measured
90    /// [`Transform`] until a layout pass has run. Cleared once applied.
91    pub needs_scroll: bool,
92}
93
94/// Resource slot for the live [`PopupNavSession`].
95#[derive(Resource, Clone, Debug, Default)]
96pub struct ActivePopupNav(pub Option<PopupNavSession>);
97
98/// Accumulated type-ahead prefix, shared by the open-popup and
99/// closed-header paths (only one can be focused at a time).
100#[derive(Resource, Clone, Debug, Default)]
101pub struct TypeAheadBuffer {
102    /// Lower-cased prefix typed so far.
103    pub prefix: String,
104    /// Instant of the last accepted character; `None` = empty buffer.
105    pub last: Option<Instant>,
106}
107
108impl TypeAheadBuffer {
109    /// Fold `ch` into the prefix, resetting first when the reset
110    /// interval elapsed. Returns whether this press *extended* an
111    /// existing prefix (multi-char search keeps the current item
112    /// eligible, Qt behaviour).
113    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
128/// One highlightable row of an open panel, in visual (markup) order.
129struct NavRow {
130    entity: Entity,
131    label: String,
132    disabled: bool,
133}
134
135/// `true` when `key` is a printable type-ahead character: a single
136/// non-control char with no chording modifier. Multi-char
137/// `Key::Character` strings ("PageUp"-style forwarded names) never
138/// match.
139fn 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/// Collect the highlightable rows bound to `open_signal`, sorted by
155/// [`DocumentOrder`] (markup order - the tab-order the spawner stamps)
156/// then `Entity` as the deterministic fallback for hand-built worlds.
157/// Separators are spacers, not row components, so they're naturally
158/// absent; disabled rows are kept (a highlight must be able to *skip*
159/// them knowingly).
160#[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
209/// Move the keyboard focus marker (and [`FocusTracker`]) from `from`
210/// to `to`. `visible` controls the [`FocusVisible`] marker on the new
211/// holder (keyboard moves mark it; hover-following doesn't).
212fn 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
232/// Adjust the nearest scroll-bearing ancestor of `row` so the row's
233/// rect sits inside the ancestor's viewport (same math as the
234/// AT-driven `apply_a11y_scroll_into_view`). Returns `false` when the
235/// row has no measured layout yet, so callers can retry next tick.
236fn 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    // Nearest Scroll ancestor.
249    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        // No internal scroller (few options) - nothing to do.
260        return true;
261    };
262    let Ok((c_tf, mut scroll, mut offset)) = scrolls.get_mut(container) else {
263        return true;
264    };
265    // Row `absolute` is layout-space (pre-scroll); its visible band is
266    // `[row.y - offset, row.y + h - offset]` relative to layout space,
267    // so the target offset window keeping it fully inside the viewport
268    // is `[row_bottom - viewport_bottom, row_top - viewport_top]`.
269    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    // `lo > hi` = the row is taller than the viewport (or the panel is
274    // mid-layout with a degenerate height): align the row's top edge -
275    // `f32::clamp` would panic on the inverted range.
276    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
288/// `true` when `e` is `panel`, one of its rows, or any descendant.
289fn 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/// Open/close bookkeeping for popup keyboard sessions.
301///
302/// - A popup whose open signal flips `true` starts a session: focus
303///   moves into the panel (selected row for dropdowns, the panel
304///   itself for menus) and the highlight is scrolled into view once
305///   layout has measured the freshly-mounted panel.
306/// - A session whose signal flips `false` (Escape, commit click,
307///   outside-press dismissal, script write) ends: focus returns to the
308///   dropdown header / the menu's pre-open holder - but only when
309///   focus still sits inside the panel, so a close caused by focusing
310///   another widget doesn't yank focus back.
311/// - Focus escaping the panel while open (Tab, click-to-focus on an
312///   input) closes the popup - Qt popups dismiss on focus-out.
313#[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    // 1. Tend the live session. Taken out of the slot so ending it is
341    // a plain drop; a still-live session is put back at the end.
342    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            // Closed by Escape / commit / dismissal: hand focus back.
350            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            // Focus left the panel while open (Tab / click-to-focus
371            // elsewhere): dismiss without stealing focus back.
372            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    // 2. Start a session for a newly-open panel.
385    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    // Dropdown: highlight the committed option (Qt shows the current
401    // item highlighted + scrolled into view); fall back to the first
402    // enabled row. Menu: focus the panel itself - no initial highlight.
403    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/// Keyboard handling while a popup session is live: highlight
438/// movement, type-ahead, and `Alt+ArrowUp` close. Commit keys
439/// (`Enter` / `Space`) are deliberately not handled here - they flow
440/// through `lumen_input::activate_focused_on_enter` -> `ClickEvent` ->
441/// the option / menu-item click dispatchers, so keyboard and pointer
442/// commits share one code path.
443#[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    // Menus wrap on arrows (QMenu); dropdown popups clamp (QListView).
485    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    // Next enabled index from `from` (exclusive) toward `dir`,
496    // wrapping when `wrap`.
497    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            // No highlight yet (fresh menu): Down enters at the top,
502            // Up at the bottom.
503            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                // Alt+ArrowUp closes an open dropdown popup (Qt).
542                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                // Multi-char prefixes keep the current row eligible;
559                // single chars search strictly after it (Qt).
560                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        // Last input wins: a keyboard move strips the pointer-hover
582        // highlight so exactly one row presents highlighted (Qt).
583        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/// Pointer half of "keyboard highlight and hover highlight are the
597/// same state": hovering a row of the open panel moves the focus
598/// highlight onto it (no [`FocusVisible`] - pointer-driven), so the
599/// next `Enter` activates what the pointer indicated and arrow keys
600/// continue from it. Runs after `hit_test` so this tick's hover is
601/// visible.
602#[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
633/// Closed-combobox keyboard interaction on the focused dropdown
634/// header. Uses the option metadata mirrored onto [`DropdownButton`]
635/// (the panel body may never have mounted). See the module docs for
636/// the exact Qt contract.
637pub 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                // Alt+ArrowDown opens the popup (Qt).
671                store.set_global_bool(&header.open_signal, true);
672                return;
673            }
674            Key::Named(NamedKey::ArrowDown) => {
675                // Step to the next enabled option, clamping at the end
676                // (QComboBox does not wrap on closed-state stepping).
677                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            // Closed-state movement commits directly (Qt: the closed
708            // combobox has no separate highlight - arrows change the
709            // value).
710            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    /// Dropdown fixture: header + panel + three option rows
764    /// ("Light", "Medium", "Heavy"), medium committed.
765    /// Returns (header, panel, [rows]).
766    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        // Escape path: lumenc's close_dialogs_on_escape flips the
892        // signal; the lifecycle system observes the close.
893        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        // "l" wraps past the end back to Light.
957        world.resource_mut::<TypeAheadBuffer>().last = None; // expire prefix
958        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        // Simulate Tab / click-to-focus moving focus to an unrelated
1019        // widget.
1020        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        // Pointer rests on row 0 (hover highlight).
1044        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        // Panel is a 100x64 scroller; rows are 32 px tall, stacked.
1081        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    /// Menu fixture: panel + items Rename / Duplicate / Delete with
1106    /// Duplicate disabled. Returns (panel, [items]).
1107    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    // -- Closed-combobox keyboard interaction ------------------------
1210
1211    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        // Disable "heavy" in the header metadata.
1260        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}