1use 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#[derive(Component, Clone, Debug)]
41pub struct RadioButton {
42 pub group: String,
45 pub value: String,
47}
48
49#[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#[derive(Component, Clone, Copy, Debug, PartialEq)]
63pub struct RadioStyle {
64 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
76pub struct RadioPlugin;
79
80impl Plugin for RadioPlugin {
81 fn build(self, app: &mut App) {
82 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 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
111fn 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
128pub 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 commands
166 .entity(root)
167 .insert(Focused)
168 .remove::<lumen_core::input::FocusVisible>();
169 tracker.0 = Some(root);
170 }
171 }
172}
173
174#[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 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 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#[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#[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 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 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
359pub 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 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 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 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 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}