Skip to main content

lumen_primitives/
tabs.rs

1//! `<tabs>` strip-button runtime.
2//!
3//! The markup-level `<tabs bind-value="active"> <tab name="general"
4//! label="General">...</tab> ... </tabs>` parser pass synthesises one
5//! `<button class="tab-btn">` per tab and tags each with a
6//! [`TabStripButton`] component. This plugin's tiny dispatcher
7//! watches `ClickEvent`s, looks up the matching button, and writes
8//! `Signals[signal_name] = tab_value` - no author-side script
9//! required for the basic "switch active tab" flow.
10
11use 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/// Strip-button marker. `(signal_name, tab_value)` - clicking writes
20/// `Signals[signal_name] = tab_value`. Authored only by the
21/// `<tabs>` parser pass; not exposed to markup directly.
22#[derive(Component, Clone, Debug)]
23pub struct TabStripButton {
24    /// Signal name the `<tabs bind-value>` attribute pointed at.
25    pub signal_name: String,
26    /// `name=` of the `<tab>` this button represents.
27    pub value: String,
28}
29
30/// Default fill for a `.tab-btn:selected` when no author / skin
31/// `:selected { bg: ... }` rule matched. Mirrors the default skin's
32/// `--lumen-accent` (`#33c7ce`) so a bare `<tabs>` with no CSS still
33/// shows which tab is active.
34pub const TAB_SELECTED_BG: Color = Color::rgb(0.20, 0.78, 0.81);
35
36/// Per-tab-button track fills, resolved at spawn from markup / CSS.
37/// [`sync_tab_button_visuals`] swaps [`Visuals::fill`] between the two
38/// as the [`Selected`] marker is added to / removed from the button -
39/// same pattern as [`crate::controls::ToggleStyle`] /
40/// [`crate::controls::sync_toggle_visuals`] for `<toggle>`.
41#[derive(Component, Clone, Copy, Debug, PartialEq)]
42#[require(StyleOverrides)]
43pub struct TabButtonStyle {
44    /// Fill shown while this button carries [`Selected`] (`:selected {
45    /// bg: ... }` or [`TAB_SELECTED_BG`]).
46    pub selected_bg: Color,
47    /// Fill shown while unselected (author `bg` / class default, or
48    /// fully transparent when neither supplied one).
49    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
65/// Plugin: registers the click dispatcher in `TickStage::Systems`
66/// after `lumen-input`'s `dispatch_clicks` so we observe the same
67/// frame's clicks.
68pub 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        // Mirror the bound signal onto the `Selected` marker so the
77        // active tab button is queryable / stylable regardless of
78        // whether the activation came from a click, keyboard nav, or a
79        // script write.
80        app.add_systems(
81            TickStage::Systems,
82            sync_tab_selected.after(dispatch_tab_clicks),
83        );
84        // Paint the marker: swap the button's fill the same tick
85        // `Selected` moves, so a click doesn't show a one-frame stale
86        // highlight on the old tab.
87        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        // Dismiss an open dropdown / menu panel when a primary press
100        // lands outside it (and outside its trigger). Runs after
101        // `dispatch_clicks` so it observes the same tick's hit-test.
102        app.add_systems(
103            TickStage::Systems,
104            crate::popup::dismiss_popups_on_outside_press.after(lumen_input::dispatch_clicks),
105        );
106        // Viewport edge-flip: anchor open dropdown panels below their
107        // trigger, flipping above near the viewport bottom.
108        app.add_systems(TickStage::Systems, crate::popup::flip_open_dropdown_panels);
109        // Roving-tabindex keyboard nav for the focused tab button:
110        // ArrowRight / ArrowLeft cycle siblings, Home / End jump to
111        // ends, Enter / Space activate (same effect as click). Reads
112        // `KeyPressed` directly so it observes the raw key bus before
113        // `dispatch_focused_keys` routes events through the focused
114        // entity - that path strips Tab specifically but otherwise
115        // preserves keys, so reading either bus works; the global bus
116        // is the simpler hookup because it doesn't require us to also
117        // be a TextInput.
118        app.add_systems(TickStage::Systems, dispatch_tab_keys);
119        // Popup keyboard nav (Wave 4 Qt polish): closed-combobox value
120        // stepping + type-ahead, open-popup highlight movement, hover <->
121        // keyboard highlight unification, and open/close focus
122        // hand-off. Lifecycle runs last so it observes the same tick's
123        // toggle clicks, outside-press dismissals, and Alt+Arrow
124        // open/close writes.
125        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        // AT-driven Expand / Collapse -> dropdown open signal. window-winit's accesskit handler
154        // flips A11yState::EXPANDED in response to assistive-tech `Action::Expand` /
155        // `Action::Collapse`; this system observes the bit flip and writes it through to the
156        // author-facing `__dropdown_open:<bind>` signal so screen-reader users can open / close
157        // the panel without faking a click event.
158        app.add_systems(TickStage::Systems, apply_a11y_expand_to_dropdown);
159    }
160}
161
162/// Switch tabs on **press**, not release - `QTabBar::mousePressEvent`
163/// changes the current tab the moment the button goes down, and Lumen
164/// matches that feel. The press path observes the [`Pressed`] capture
165/// marker `lumen_input::dispatch_clicks` inserts (pointer) or the Space
166/// FSM inserts (keyboard); the explicit `.after(dispatch_clicks)` edge
167/// in [`TabsPlugin`] flushes the marker command first, so the switch
168/// lands on the press tick. Disabled tabs never gain `Pressed`
169/// (`hit_test` + `dispatch_clicks` both skip them), so they can't
170/// switch.
171///
172/// The `ClickEvent` path is kept for keyboard-synthesized clicks
173/// (Enter via `activate_focused_on_enter`) and stays idempotent - a
174/// release on the already-switched tab re-writes the same value, which
175/// `PropertyStore::set` drops as a no-op.
176fn 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    // Hit-shadowing (W5): the press / click can land on a hit-testable
184    // CHILD of the tab button (its text child) rather than the button
185    // entity itself - resolve up the ancestor chain like
186    // `controls::resolve_control` does for the slider thumb.
187    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
205/// Maintain the [`Selected`] marker across every tab strip: the button
206/// whose `value` matches its bound signal's current value carries it;
207/// all its siblings shed it. Idempotent per tick - insert / remove only
208/// fire on actual transitions, so archetypes stay put on idle frames.
209pub 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/// Keep a tab-strip button's visuals in step with whether it currently
229/// carries [`Selected`]: swap [`Visuals::fill`] between
230/// [`TabButtonStyle::selected_bg`] / [`TabButtonStyle::unselected_bg`].
231/// The fill is held in [`StyleOverrides`] under the hover and press tints,
232/// so a tint releases onto the current state colour rather than a stale
233/// one - mirrors [`crate::controls::sync_track_fill`].
234///
235/// Runs every tick rather than gating on a change filter: unlike
236/// `Toggleable::checked` (a `bool` that only ever changes via an
237/// explicit flip), `Selected` is a marker that's inserted *and removed*
238/// as it moves between siblings, and there's no single component whose
239/// `Changed` filter would fire on both transitions. The write-if-target-
240/// differs guard below keeps idle frames from dirtying `Visuals` (and
241/// therefore triggering a repaint) even though the query has no filter -
242/// same technique [`sync_slider_thumb`](crate::controls::sync_slider_thumb)
243/// and the [`crate::controls::sync_toggle_visuals`] knob pass use.
244#[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/// One `<option>`'s metadata mirrored onto the dropdown header (markup
267/// order). Carried on [`DropdownButton`] so the *closed* combobox can
268/// run keyboard interaction - Up/Down value stepping and type-ahead -
269/// before the panel body (and its [`DropdownOptionButton`] entities)
270/// has ever mounted (`<if>` bodies spawn lazily on first open).
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub struct DropdownOptionSpec {
273    /// `value=` attribute - what gets written to the bound signal.
274    pub value: String,
275    /// `label=` attribute (falls back to `value` in the parser) - what
276    /// type-ahead matches against, mirroring Qt's text-based matching.
277    pub label: String,
278    /// `disabled="true"` on the source `<option>` - skipped by value
279    /// stepping and type-ahead.
280    pub disabled: bool,
281}
282
283/// `<dropdown>` toggle marker attached by the parser to the dropdown header button.
284/// Clicks flip `Signals[open_signal]` between `"true"` and `"false"`.
285#[derive(Component, Clone, Debug)]
286pub struct DropdownButton {
287    /// Internal "is panel open?" signal name (synthesised by the
288    /// parser as `__dropdown_open:<bind-value>`).
289    pub open_signal: String,
290    /// Author-bound value signal (`<dropdown bind-value>`), written by
291    /// closed-state Up/Down stepping and type-ahead.
292    pub value_signal: String,
293    /// Ordered option metadata (see [`DropdownOptionSpec`]).
294    pub options: Vec<DropdownOptionSpec>,
295}
296
297impl DropdownButton {
298    /// Header with an open signal only - no closed-state keyboard
299    /// metadata. Test fixtures and minimal embedders use this; `lumenc`
300    /// always supplies the full option list.
301    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/// `<option>` button inside a `<dropdown>` panel. Click writes `Signals[value_signal] = value` and closes the panel by writing `Signals[open_signal] = "false"`.
311#[derive(Component, Clone, Debug)]
312pub struct DropdownOptionButton {
313    /// Author-bound signal (matches the `<dropdown bind-value>` attr).
314    pub value_signal: String,
315    /// `value=` attribute on the source `<option>`.
316    pub value: String,
317    /// Internal "is panel open?" signal name.
318    pub open_signal: String,
319}
320
321/// `<dropdown>` click dispatcher. Toggles the open-panel signal on header clicks; writes the selected value and closes the panel on option clicks.
322///
323/// Open / close state goes through [`PropertyStore::set_global_bool`] /
324/// [`PropertyStore::get_global_bool`] so any typo-prone literal-string variants
325/// (`"True"`, `"TRUE"`, `"yes"`) are funnelled into the canonical
326/// `"true"` / `"false"` pair the compiler's `<if eq="true">` body
327/// comparator recognises. The on-the-wire signal repr is unchanged.
328pub 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        // Hit-shadowing (W5): pointer clicks resolve to the header's /
337        // option's hit-testable TEXT CHILD, not the button entity -
338        // walk the ancestor chain to the owning control (same fix as
339        // the slider thumb / toggle knob in `controls::resolve_control`).
340        // Options resolve first: an option is never an ancestor of a
341        // header, and a click inside the panel must not toggle the
342        // header.
343        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            // Default to closed (false) when the signal has never been
356            // written or carries a non-boolean value; otherwise flip
357            // the current state. Matches the previous behaviour where
358            // any non-`"true"` value collapsed to closed.
359            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/// `<menu>` item marker. Clicks emit [`MenuClicked`] with the item id and close the menu by writing the open signal to `"false"`.
366#[derive(Component, Clone, Debug)]
367pub struct MenuItemButton {
368    /// Menu's open-state signal (`__menu_open:<menu-id>`).
369    pub open_signal: String,
370    /// `id=` on the source `<menuitem>` - relayed to `on_menu(id)`.
371    pub item_id: String,
372}
373
374/// `<menu>` item click dispatcher. Emits [`MenuClicked`] and closes the menu panel in the same tick.
375pub 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        // Hit-shadowing (W5): resolve text-child hits up to the owning
384        // menu item - see `dispatch_dropdown_clicks`.
385        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/// Keyboard navigation for tab strips, scoped to children of a tab
398/// list (i.e. siblings of the focused [`TabStripButton`]). Matches the
399/// `QTabBar` keyboard contract:
400///
401/// - `ArrowRight` / `ArrowLeft`: move focus to the next / previous
402///   *enabled* sibling tab button - **and select it immediately**
403///   (Qt select-on-arrow; `QTabBar::keyPressEvent` calls
404///   `setCurrentIndex` as focus moves). No wrap-around: at either end
405///   the arrow is a no-op, exactly like Qt.
406/// - `Home` / `End`: jump to (and select) the first / last enabled
407///   sibling.
408/// - `Enter` / `Space`: activate the focused tab - the same effect as
409///   clicking it, i.e. writes `Signals[signal_name] = value`.
410/// - Disabled tabs are skipped by every movement, mirroring
411///   `QTabBar`'s enabled-only traversal.
412///
413/// Siblings are identified as all [`TabStripButton`] entities that
414/// share the focused tab's parent (the synthesised `tab-strip` row)
415/// and bind to the same `signal_name`. This keeps the nav strictly
416/// inside one tab strip even when several strips coexist in the same
417/// app, and never bleeds into non-tab focusables like inputs or
418/// buttons sitting alongside the strip.
419#[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    // Confirm the focused entity is a tab button before we touch any
435    // keys - otherwise the nav must stay silent so non-tab focusables
436    // (text inputs, generic buttons) don't have their ArrowLeft /
437    // ArrowRight stolen.
438    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    // Sibling set: every tab button whose parent matches the focused
450    // tab's parent AND whose `signal_name` matches. Filtering on the
451    // signal scopes nav to one strip even when several strips share a
452    // common ancestor (nested tabs). Disabled siblings stay in the
453    // list (so the focused tab's own index resolves) but are skipped
454    // by every movement below.
455    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    // Markup order: `DocumentOrder` is the spawner's monotonic
473    // walk-order counter, so it always matches the on-screen strip
474    // order. `Entity` is only the deterministic fallback for
475    // hand-built worlds - bevy_ecs 0.19's `Entity: Ord` compares a
476    // niche-optimized row index and does not track spawn order (the
477    // same trap `cycle_focus_on_tab` documents).
478    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    // Next enabled sibling after `from` (exclusive) in `dir` (+1 / -1);
493    // `None` when the edge is reached without finding one - Qt tab bars
494    // do not wrap.
495    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        // Activate the currently focused tab - same effect as a click.
535        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        // Roving-tabindex arrows are keyboard navigation - mark the new
545        // holder for `:focus-visible` styling.
546        commands
547            .entity(*next_entity)
548            .insert((Focused, lumen_core::input::FocusVisible));
549        tracker.0 = Some(*next_entity);
550        // Qt select-on-arrow: moving tab focus with the keyboard also
551        // switches to that tab immediately.
552        store.set_global_str(&signal_name, next_value.as_str());
553    }
554}
555
556/// Mirrors [`A11yState::EXPANDED`] onto the dropdown's open-state signal whenever the bit changes.
557///
558/// `window-winit`'s accesskit action handler responds to assistive-tech `Action::Expand` /
559/// `Action::Collapse` by flipping `A11yState::EXPANDED` on the receiving entity (today the
560/// dropdown header). Without a downstream consumer that bit flip was inert - the open signal that
561/// gates the panel body's `<if eq="true">` never moved. This system closes that loop: a
562/// `Changed<A11yState>` filter picks the entity up on the next tick and
563/// [`PropertyStore::set_global_bool`] writes `"true"` / `"false"` to the header's `open_signal`.
564/// `set_global_bool` no-ops when the value is unchanged, so click-driven updates that already
565/// wrote the signal don't bounce back through the AT path.
566pub 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        // Activation moves - the marker must move with it.
604        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        // Nothing active yet: both stay unselected.
657        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        // `general` becomes active: its fill swaps to the selected color;
662        // `advanced` stays unselected.
663        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        // A hover tint over it fades from, and lets go onto, the selected
674        // fill.
675        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        // Activation moves to `advanced`: `general` must revert.
692        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        //! Qt keyboard/press contract for tab strips: switch on press,
706        //! select-on-arrow, no wrap, disabled tabs skipped.
707        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        /// Strip with four tabs (c disabled), parent row, `a` focused.
714        /// Returns (world, [a, b, c, d]).
715        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            // a -> b.
785            press_key(&mut world, NamedKey::ArrowRight);
786            run_keys(&mut world);
787            // b -> d (c is disabled).
788            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            // d -> no wrap.
797            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            // Pointer press: dispatch_clicks inserts `Pressed` on the
828            // press tick; the switch must land the same tick, before
829            // any release.
830            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        // Initial tick: open_signal is undefined; the Changed<A11yState> filter still picks the
852        // freshly-spawned A11yState up and writes "false".
853        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        // Programmatically flip EXPANDED on -> mirrors the AT `Action::Expand` path.
863        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        // Flip EXPANDED off -> mirrors `Action::Collapse`.
877        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/// W5 hit-shadowing regression: pointer clicks resolve to the
893/// hit-testable TEXT CHILD of a control, not the control entity - every
894/// dispatcher must walk the ancestor chain (`controls::resolve_control`)
895/// or the control is a dead zone for real pointer input (keyboard paths
896/// synthesize clicks on the focused control entity and never noticed).
897#[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}