Skip to main content

lumen_primitives/
radio.rs

1//! `<radio>` behavior: name-grouped exclusive selection with roving
2//! tab index and wrapping arrow-key navigation (Qt `QRadioButton` in
3//! an auto-exclusive group / WAI-ARIA radiogroup).
4//!
5//! The markup parser desugars `<radio group="g" value="v" label="...">`
6//! into a row (tag `radio`, carrying [`RadioButton`]) with a
7//! `.radio-dot` tile child ([`RadioDot`]) and a `.radio-label` label,
8//! so every visual is CSS-reachable:
9//!
10//! ```css
11//! .radio-dot     { width: 18; height: 18; radius: 9; bg: ...; border: ...; }
12//! radio:selected { bg: var(--lumen-accent); }   /* dot fill when on */
13//! radio:focus    { outline: 2 var(--lumen-accent); }
14//! ```
15//!
16//! Contract:
17//! - The GROUP is the set of `<radio>` elements sharing a `group`
18//!   string; the group's selected value lives in the `PropertyStore`
19//!   global of that name (bindable / script-readable like any signal).
20//! - Exactly one selected per group: [`sync_radio_selected`] seeds the
21//!   first enabled member when the signal is unset.
22//! - Click (row, dot, or label) and Space select. Arrow keys move
23//!   selection to the next / previous enabled member, wrapping at the
24//!   ends and skipping disabled members (web radiogroup behavior;
25//!   selection follows focus, as in Qt/GTK).
26//! - Roving tabindex: only the selected (else first enabled) member is
27//!   in the Tab chain, so Tab enters and leaves the GROUP, not each
28//!   item ([`sync_radio_tab_index`]).
29
30use bevy_ecs::message::MessageReader;
31use bevy_ecs::prelude::*;
32use lumen_core::components::{Color, DocumentOrder, Selected, Visuals};
33use lumen_core::style_overrides::{StyleOverrides, StyleOwner};
34
35use crate::held::{hold_fill, release_fill};
36use lumen_core::prelude::*;
37use lumen_core::property_store::PropertyStore;
38
39/// One member of a radio group.
40#[derive(Component, Clone, Debug)]
41pub struct RadioButton {
42    /// Group signal name (`group="..."`): the PropertyStore global that
43    /// holds the group's selected value.
44    pub group: String,
45    /// This member's value, written to the group signal on select.
46    pub value: String,
47}
48
49/// Marker on the `.radio-dot` child tile. Its selected fill is held in
50/// [`StyleOverrides`] over the dot's own.
51#[derive(Component, Clone, Copy, Debug, Default)]
52#[require(StyleOverrides)]
53pub struct RadioDot;
54
55impl StyleOwner for RadioDot {
56    const RANK: u8 = 20;
57}
58
59/// Per-radio fill for the selected dot state, resolved at spawn from
60/// `radio:selected { bg: ... }` (routed through
61/// `Attributes::selected_bg`) with the accent fallback.
62#[derive(Component, Clone, Copy, Debug, PartialEq)]
63pub struct RadioStyle {
64    /// Dot fill while this member is selected.
65    pub selected_bg: Color,
66}
67
68impl Default for RadioStyle {
69    fn default() -> Self {
70        Self {
71            selected_bg: crate::controls::TOGGLE_CHECKED_BG,
72        }
73    }
74}
75
76/// Plugin: registers selection dispatch, marker/visual sync, roving
77/// tab index, and arrow-key navigation.
78pub struct RadioPlugin;
79
80impl Plugin for RadioPlugin {
81    fn build(self, app: &mut App) {
82        // Click consumer after the producer so a click selects on the
83        // same tick (no-op edge when the host never registers it).
84        app.add_systems(
85            TickStage::Systems,
86            dispatch_radio_clicks.after(lumen_input::dispatch_clicks),
87        );
88        app.add_systems(
89            TickStage::Systems,
90            radio_group_keys.after(lumen_input::dispatch_focused_keys),
91        );
92        // Marker + visuals + roving tabindex settle after both input
93        // paths so this tick's selection is reflected this tick.
94        app.add_systems(
95            TickStage::Systems,
96            sync_radio_selected
97                .after(dispatch_radio_clicks)
98                .after(radio_group_keys),
99        );
100        app.add_systems(
101            TickStage::Systems,
102            sync_radio_tab_index.after(sync_radio_selected),
103        );
104        app.add_systems(
105            TickStage::Systems,
106            sync_radio_visuals.after(sync_radio_selected),
107        );
108    }
109}
110
111/// Resolve a click target to the radio root that owns it (the dot /
112/// label children are the usual hit-test winners).
113fn owning_radio(
114    start: Entity,
115    parents: &Query<&ChildOf>,
116    radios: &Query<&RadioButton>,
117) -> Option<Entity> {
118    let mut cur = Some(start);
119    while let Some(e) = cur {
120        if radios.contains(e) {
121            return Some(e);
122        }
123        cur = parents.get(e).ok().map(|c| c.parent());
124    }
125    None
126}
127
128/// Click / Space (via the focused-key `ClickEvent` path) on a radio -
129/// or any of its children - writes the member's value to the group
130/// signal AND moves focus to the radio root (Qt: clicking a radio
131/// focuses it, so arrow-key navigation picks up right where the
132/// pointer left off). Focus is set here as well as on the press because a
133/// click raised without one, from the keyboard or a script, still moves it.
134///
135/// Disabled members are unreachable by pointer already
136/// (`dispatch_clicks` filters), but the ancestor resolve could cross a
137/// disabled root from an enabled child, so re-check here.
138pub fn dispatch_radio_clicks(
139    mut commands: Commands,
140    mut clicks: MessageReader<ClickEvent>,
141    mut tracker: ResMut<FocusTracker>,
142    radios: Query<&RadioButton>,
143    disabled: Query<(), With<lumen_core::components::Disabled>>,
144    parents: Query<&ChildOf>,
145    mut store: ResMut<PropertyStore>,
146) {
147    for click in clicks.read() {
148        let Some(root) = owning_radio(click.entity, &parents, &radios) else {
149            continue;
150        };
151        if disabled.contains(root) {
152            continue;
153        }
154        let Ok(radio) = radios.get(root) else {
155            continue;
156        };
157        store.set_global_str(&radio.group, radio.value.as_str());
158        if tracker.0 != Some(root) {
159            if let Some(prev) = tracker.0 {
160                commands
161                    .entity(prev)
162                    .remove::<(Focused, lumen_core::input::FocusVisible)>();
163            }
164            // Pointer focus never carries the keyboard-only marker.
165            commands
166                .entity(root)
167                .insert(Focused)
168                .remove::<lumen_core::input::FocusVisible>();
169            tracker.0 = Some(root);
170        }
171    }
172}
173
174/// Mirror each group's signal onto the [`Selected`] marker, and
175/// enforce exactly-one-selected: a group whose signal is unset (or
176/// empty) gets its first enabled member (markup order) selected and
177/// the signal seeded.
178#[allow(clippy::type_complexity)]
179pub fn sync_radio_selected(
180    mut commands: Commands,
181    mut store: ResMut<PropertyStore>,
182    radios: Query<(
183        Entity,
184        &RadioButton,
185        Option<&Selected>,
186        Option<&DocumentOrder>,
187        Has<lumen_core::components::Disabled>,
188    )>,
189) {
190    // Seed pass: collect groups with no usable selection.
191    let mut groups: std::collections::BTreeMap<&str, Vec<(u32, Entity, &str, bool)>> =
192        std::collections::BTreeMap::new();
193    for (e, r, _, doc, dis) in &radios {
194        groups.entry(r.group.as_str()).or_default().push((
195            doc.map(|d| d.0).unwrap_or(u32::MAX),
196            e,
197            r.value.as_str(),
198            dis,
199        ));
200    }
201    for (group, mut members) in groups {
202        members.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
203        let current = store.get_global_str(group);
204        let value_known = current
205            .as_deref()
206            .map(|v| members.iter().any(|(_, _, mv, _)| *mv == v))
207            .unwrap_or(false);
208        if !value_known && let Some((_, _, first, _)) = members.iter().find(|(.., dis)| !dis) {
209            store.set_global_str(group, *first);
210        }
211    }
212    // Marker pass (same shape as `tabs::sync_tab_selected`).
213    for (e, r, selected, ..) in &radios {
214        let active = store.get_global_str(&r.group).as_deref() == Some(r.value.as_str());
215        match (active, selected.is_some()) {
216            (true, false) => {
217                commands.entity(e).insert(Selected);
218            }
219            (false, true) => {
220                commands.entity(e).remove::<Selected>();
221            }
222            _ => {}
223        }
224    }
225}
226
227/// Roving tabindex: exactly one member per group sits in the Tab chain:
228/// the selected member, else the first enabled one. Everyone else
229/// holds `TabIndex(-1)`, which `cycle_focus_on_tab` skips, so Tab
230/// jumps over the group as a unit in both directions.
231#[allow(clippy::type_complexity)]
232pub fn sync_radio_tab_index(
233    store: Res<PropertyStore>,
234    mut radios: Query<(
235        Entity,
236        &RadioButton,
237        &mut TabIndex,
238        Option<&DocumentOrder>,
239        Has<lumen_core::components::Disabled>,
240    )>,
241) {
242    let mut groups: std::collections::BTreeMap<String, Vec<(u32, Entity, String, bool)>> =
243        std::collections::BTreeMap::new();
244    for (e, r, _, doc, dis) in &radios {
245        groups.entry(r.group.clone()).or_default().push((
246            doc.map(|d| d.0).unwrap_or(u32::MAX),
247            e,
248            r.value.clone(),
249            dis,
250        ));
251    }
252    for (group, mut members) in groups {
253        members.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
254        let current = store.get_global_str(&group);
255        let holder = members
256            .iter()
257            .find(|(_, _, v, dis)| !dis && current.as_deref() == Some(v.as_str()))
258            .or_else(|| members.iter().find(|(.., dis)| !dis))
259            .map(|(_, e, ..)| *e);
260        for (_, e, ..) in &members {
261            if let Ok((_, _, mut ti, ..)) = radios.get_mut(*e) {
262                let want = if Some(*e) == holder { 0 } else { -1 };
263                if ti.0 != want {
264                    ti.0 = want;
265                }
266            }
267        }
268    }
269}
270
271/// Arrow keys while a radio holds focus: move selection to the next /
272/// previous enabled group member, WRAPPING at the ends (web
273/// radiogroup; unlike tabs, which clamp). Selection follows focus -
274/// the newly focused member is selected immediately (Qt/GTK). Also
275/// moves the `Focused` + `FocusVisible` markers and the tracker.
276#[allow(clippy::too_many_arguments)]
277pub fn radio_group_keys(
278    mut commands: Commands,
279    mut keys: MessageReader<KeyPressed>,
280    mut tracker: ResMut<FocusTracker>,
281    mut store: ResMut<PropertyStore>,
282    radios: Query<(Entity, &RadioButton)>,
283    disableds: Query<(), With<lumen_core::components::Disabled>>,
284    orders: Query<&DocumentOrder>,
285) {
286    // Drain on every early-out so stale arrow presses from a tick where
287    // focus wasn't on a radio can't replay later (same discipline as
288    // `tabs::dispatch_tab_keys`).
289    let Some(current) = tracker.0 else {
290        keys.read().for_each(drop);
291        return;
292    };
293    let Ok((_, current_radio)) = radios.get(current) else {
294        keys.read().for_each(drop);
295        return;
296    };
297    let group = current_radio.group.clone();
298    let mut members: Vec<(u32, Entity, String, bool)> = radios
299        .iter()
300        .filter(|(_, r)| r.group == group)
301        .map(|(e, r)| {
302            (
303                orders.get(e).map(|d| d.0).unwrap_or(u32::MAX),
304                e,
305                r.value.clone(),
306                disableds.contains(e),
307            )
308        })
309        .collect();
310    members.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
311    let Some(current_index) = members.iter().position(|(_, e, ..)| *e == current) else {
312        keys.read().for_each(drop);
313        return;
314    };
315    // Next enabled member from `from` (exclusive) stepping `dir`,
316    // wrapping - `None` only when every OTHER member is disabled.
317    let step = |from: usize, dir: i64| -> Option<usize> {
318        let n = members.len() as i64;
319        let mut i = (from as i64 + dir).rem_euclid(n);
320        while i as usize != from {
321            if !members[i as usize].3 {
322                return Some(i as usize);
323            }
324            i = (i + dir).rem_euclid(n);
325        }
326        None
327    };
328    let mut target: Option<usize> = None;
329    for ev in keys.read() {
330        match &ev.key {
331            Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowDown) => {
332                if let Some(i) = step(target.unwrap_or(current_index), 1) {
333                    target = Some(i);
334                }
335            }
336            Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowUp) => {
337                if let Some(i) = step(target.unwrap_or(current_index), -1) {
338                    target = Some(i);
339                }
340            }
341            _ => {}
342        }
343    }
344    if let Some(i) = target
345        && members[i].1 != current
346    {
347        let (_, next, value, _) = &members[i];
348        commands
349            .entity(current)
350            .remove::<(Focused, lumen_core::input::FocusVisible)>();
351        commands
352            .entity(*next)
353            .insert((Focused, lumen_core::input::FocusVisible));
354        tracker.0 = Some(*next);
355        store.set_global_str(&group, value.as_str());
356    }
357}
358
359/// Keep every `.radio-dot` child in step with its parent's [`Selected`]
360/// marker: `selected_bg` while selected, the dot's own fill otherwise.
361pub fn sync_radio_visuals(
362    parents_q: Query<(Has<Selected>, &RadioStyle), With<RadioButton>>,
363    mut dots: Query<(&ChildOf, &mut Visuals, &mut StyleOverrides), With<RadioDot>>,
364) {
365    for (child_of, mut vis, mut overrides) in &mut dots {
366        let Ok((selected, style)) = parents_q.get(child_of.parent()) else {
367            continue;
368        };
369        if selected {
370            hold_fill::<RadioDot>(&mut overrides, &mut vis, style.selected_bg);
371        } else {
372            release_fill::<RadioDot>(&mut overrides, &mut vis);
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use bevy_ecs::message::Messages;
381    use bevy_ecs::system::RunSystemOnce;
382
383    fn radio(world: &mut World, group: &str, value: &str, order: u32) -> Entity {
384        world
385            .spawn((
386                RadioButton {
387                    group: group.into(),
388                    value: value.into(),
389                },
390                RadioStyle::default(),
391                TabIndex(-1),
392                DocumentOrder(order),
393            ))
394            .id()
395    }
396
397    fn group_world() -> (World, [Entity; 4]) {
398        let mut world = World::new();
399        world.insert_resource(PropertyStore::default());
400        world.insert_resource(FocusTracker(None));
401        world.init_resource::<Messages<KeyPressed>>();
402        world.init_resource::<Messages<ClickEvent>>();
403        let a = radio(&mut world, "fruit", "apple", 0);
404        let b = radio(&mut world, "fruit", "banana", 1);
405        let c = radio(&mut world, "fruit", "cherry", 2);
406        world.entity_mut(c).insert(lumen_core::components::Disabled);
407        let d = radio(&mut world, "fruit", "date", 3);
408        (world, [a, b, c, d])
409    }
410
411    fn selected_value(world: &World) -> Option<String> {
412        world
413            .resource::<PropertyStore>()
414            .get_global_str("fruit")
415            .map(|s| s.to_string())
416    }
417
418    fn press(world: &mut World, key: NamedKey) {
419        world
420            .resource_mut::<Messages<KeyPressed>>()
421            .write(KeyPressed {
422                key: Key::Named(key),
423                modifiers: Modifiers::default(),
424                repeat: false,
425            });
426    }
427
428    fn run_keys(world: &mut World) {
429        world.run_system_once(radio_group_keys).unwrap();
430        world.resource_mut::<Messages<KeyPressed>>().clear();
431    }
432
433    #[test]
434    fn unset_group_seeds_first_enabled_member() {
435        let (mut world, [a, ..]) = group_world();
436        world.run_system_once(sync_radio_selected).unwrap();
437        assert_eq!(selected_value(&world).as_deref(), Some("apple"));
438        assert!(
439            world.get::<Selected>(a).is_some(),
440            "marker follows the seed"
441        );
442    }
443
444    #[test]
445    fn exactly_one_selected_marker_per_group() {
446        let (mut world, [a, b, ..]) = group_world();
447        world
448            .resource_mut::<PropertyStore>()
449            .set_global_str("fruit", "banana");
450        world.run_system_once(sync_radio_selected).unwrap();
451        assert!(world.get::<Selected>(b).is_some());
452        assert!(world.get::<Selected>(a).is_none());
453        // Move selection: marker moves, never duplicates.
454        world
455            .resource_mut::<PropertyStore>()
456            .set_global_str("fruit", "apple");
457        world.run_system_once(sync_radio_selected).unwrap();
458        assert!(world.get::<Selected>(a).is_some());
459        assert!(world.get::<Selected>(b).is_none());
460    }
461
462    #[test]
463    fn click_on_member_selects_its_value() {
464        let (mut world, [_, b, ..]) = group_world();
465        world
466            .resource_mut::<Messages<ClickEvent>>()
467            .write(ClickEvent {
468                entity: b,
469                position: glam::Vec2::ZERO,
470                button: PointerButton::Primary,
471                local: None,
472            });
473        world.run_system_once(dispatch_radio_clicks).unwrap();
474        assert_eq!(selected_value(&world).as_deref(), Some("banana"));
475        assert_eq!(
476            world.resource::<FocusTracker>().0,
477            Some(b),
478            "clicking a radio focuses it (Qt), so arrow nav continues from there"
479        );
480    }
481
482    #[test]
483    fn click_on_dot_child_resolves_to_member() {
484        let (mut world, [a, ..]) = group_world();
485        let dot = world.spawn((RadioDot, ChildOf(a))).id();
486        world
487            .resource_mut::<Messages<ClickEvent>>()
488            .write(ClickEvent {
489                entity: dot,
490                position: glam::Vec2::ZERO,
491                button: PointerButton::Primary,
492                local: None,
493            });
494        world.run_system_once(dispatch_radio_clicks).unwrap();
495        assert_eq!(selected_value(&world).as_deref(), Some("apple"));
496    }
497
498    #[test]
499    fn arrows_move_selection_and_skip_disabled() {
500        let (mut world, [_, b, _c, d]) = group_world();
501        world
502            .resource_mut::<PropertyStore>()
503            .set_global_str("fruit", "banana");
504        world.insert_resource(FocusTracker(Some(b)));
505        // banana -> (cherry disabled, skipped) -> date.
506        press(&mut world, NamedKey::ArrowRight);
507        run_keys(&mut world);
508        assert_eq!(selected_value(&world).as_deref(), Some("date"));
509        assert_eq!(
510            world.resource::<FocusTracker>().0,
511            Some(d),
512            "selection follows focus"
513        );
514        assert!(world.get::<Focused>(d).is_some());
515    }
516
517    #[test]
518    fn arrows_wrap_at_the_ends() {
519        let (mut world, [a, _, _, d]) = group_world();
520        world
521            .resource_mut::<PropertyStore>()
522            .set_global_str("fruit", "date");
523        world.insert_resource(FocusTracker(Some(d)));
524        press(&mut world, NamedKey::ArrowRight);
525        run_keys(&mut world);
526        assert_eq!(
527            selected_value(&world).as_deref(),
528            Some("apple"),
529            "wraps from last to first"
530        );
531        assert_eq!(world.resource::<FocusTracker>().0, Some(a));
532        // And back: apple <- wraps to date.
533        press(&mut world, NamedKey::ArrowLeft);
534        run_keys(&mut world);
535        assert_eq!(selected_value(&world).as_deref(), Some("date"));
536    }
537
538    #[test]
539    fn arrows_on_non_radio_focus_do_nothing() {
540        let (mut world, _) = group_world();
541        let other = world.spawn_empty().id();
542        world.insert_resource(FocusTracker(Some(other)));
543        press(&mut world, NamedKey::ArrowRight);
544        run_keys(&mut world);
545        assert_eq!(selected_value(&world), None);
546    }
547
548    #[test]
549    fn roving_tab_index_singles_out_selected_member() {
550        let (mut world, [a, b, c, d]) = group_world();
551        world
552            .resource_mut::<PropertyStore>()
553            .set_global_str("fruit", "banana");
554        world.run_system_once(sync_radio_tab_index).unwrap();
555        let ti = |world: &World, e| world.get::<TabIndex>(e).unwrap().0;
556        assert_eq!(ti(&world, b), 0, "selected member is the Tab stop");
557        assert_eq!(ti(&world, a), -1);
558        assert_eq!(ti(&world, c), -1);
559        assert_eq!(ti(&world, d), -1);
560    }
561
562    #[test]
563    fn roving_tab_index_falls_back_to_first_enabled() {
564        let (mut world, [a, ..]) = group_world();
565        // No selection written at all.
566        world.run_system_once(sync_radio_tab_index).unwrap();
567        assert_eq!(world.get::<TabIndex>(a).unwrap().0, 0);
568    }
569
570    #[test]
571    fn selected_dot_swaps_fill_and_restores_on_deselect() {
572        let (mut world, [a, ..]) = group_world();
573        let resting = Color::rgb(0.25, 0.25, 0.25);
574        let dot = world
575            .spawn((
576                RadioDot,
577                Visuals {
578                    fill: Some(Fill::Solid(resting)),
579                    ..Default::default()
580                },
581                ChildOf(a),
582            ))
583            .id();
584        world.run_system_once(sync_radio_visuals).unwrap();
585        world.entity_mut(a).insert(Selected);
586        world.run_system_once(sync_radio_visuals).unwrap();
587        let fill = |world: &World| {
588            world
589                .get::<Visuals>(dot)
590                .and_then(|v| v.fill.as_ref().and_then(Fill::as_solid))
591        };
592        assert_eq!(fill(&world), Some(RadioStyle::default().selected_bg));
593        world.entity_mut(a).remove::<Selected>();
594        world.run_system_once(sync_radio_visuals).unwrap();
595        assert_eq!(fill(&world), Some(resting));
596    }
597}