Another project
1use crate::a11y::{AccessNode, Role};
2use crate::frame::{FrameCtx, InteractDeclaration};
3use crate::hit_test::Sense;
4use crate::input::{KeyCode, NamedKey};
5use crate::layout::LayoutRect;
6use crate::strings::StringKey;
7use crate::widget_id::WidgetId;
8
9use super::keys::{TakeKey, take_activation, take_key};
10use super::paint::{GlyphMark, WidgetPaint};
11use super::visuals::{Indicator, IndicatorMark, push_indicator};
12
13#[derive(Copy, Clone, Debug, PartialEq, Eq)]
14pub enum RadioOrientation {
15 Vertical,
16 Horizontal,
17}
18
19#[derive(Clone, Debug, PartialEq)]
20pub struct RadioOption<T: Copy + PartialEq> {
21 pub id: WidgetId,
22 pub rect: LayoutRect,
23 pub label: StringKey,
24 pub value: T,
25}
26
27#[derive(Clone, Debug, PartialEq)]
28pub struct RadioGroup<T: Copy + PartialEq> {
29 pub options: Vec<RadioOption<T>>,
30 pub selected: T,
31 pub orientation: RadioOrientation,
32 pub disabled: bool,
33}
34
35impl<T: Copy + PartialEq> RadioGroup<T> {
36 #[must_use]
37 pub fn new(options: Vec<RadioOption<T>>, selected: T) -> Self {
38 Self {
39 options,
40 selected,
41 orientation: RadioOrientation::Vertical,
42 disabled: false,
43 }
44 }
45
46 #[must_use]
47 pub fn orientation(self, orientation: RadioOrientation) -> Self {
48 Self {
49 orientation,
50 ..self
51 }
52 }
53
54 #[must_use]
55 pub fn disabled(self, disabled: bool) -> Self {
56 Self { disabled, ..self }
57 }
58}
59
60#[derive(Clone, Debug, PartialEq)]
61pub struct RadioGroupResponse<T: Copy + PartialEq> {
62 pub selected: T,
63 pub changed: bool,
64 pub paint: Vec<WidgetPaint>,
65}
66
67#[derive(Copy, Clone, Debug, PartialEq, Eq)]
68enum NavAction {
69 Prev,
70 Next,
71 First,
72 Last,
73}
74
75#[must_use]
76pub fn show_radio_group<T: Copy + PartialEq>(
77 ctx: &mut FrameCtx<'_>,
78 group: RadioGroup<T>,
79) -> RadioGroupResponse<T> {
80 let RadioGroup {
81 options,
82 selected: initial_selected,
83 orientation,
84 disabled,
85 } = group;
86 if !disabled
87 && let Some(tab_stop) = options
88 .iter()
89 .find(|o| o.value == initial_selected)
90 .or_else(|| options.first())
91 .map(|o| o.id)
92 {
93 ctx.focus.register_tab_stop(tab_stop);
94 }
95 let mut paint = Vec::new();
96 let mut clicked: Option<T> = None;
97 options.iter().for_each(|option| {
98 let active = option.value == initial_selected;
99 let interaction = ctx.interact(
100 InteractDeclaration::new(option.id, option.rect, Sense::INTERACTIVE)
101 .focusable(false)
102 .disabled(disabled)
103 .active(active)
104 .a11y(
105 AccessNode::new(Role::RadioButton)
106 .with_label(option.label)
107 .with_disabled(disabled)
108 .with_selected(active),
109 ),
110 );
111 if !disabled && interaction.click() {
112 clicked = Some(option.value);
113 ctx.focus.request_focus(option.id);
114 }
115 let live_focused = ctx.is_focused(option.id);
116 push_indicator(
117 ctx,
118 &mut paint,
119 Indicator {
120 rect: option.rect,
121 label: option.label,
122 mark: active.then_some(IndicatorMark::Glyph(GlyphMark::RadioDot)),
123 active,
124 disabled,
125 radius: ctx.theme().radius.pill,
126 },
127 interaction,
128 live_focused,
129 );
130 });
131 let in_group_focus = ctx
132 .focus
133 .focused()
134 .is_some_and(|id| options.iter().any(|o| o.id == id));
135 if !disabled && in_group_focus {
136 if let Some(action) = take_navigation(ctx, orientation) {
137 let len = options.len();
138 let current = options
139 .iter()
140 .position(|o| Some(o.id) == ctx.focus.focused())
141 .unwrap_or(0);
142 let next = navigate(action, current, len);
143 if next != current
144 && let Some(target) = options.get(next)
145 {
146 ctx.focus.request_focus(target.id);
147 }
148 }
149 if take_activation(ctx.input)
150 && let Some(focused_id) = ctx.focus.focused()
151 && let Some(option) = options.iter().find(|o| o.id == focused_id)
152 {
153 clicked = Some(option.value);
154 }
155 }
156 let mut selected = initial_selected;
157 let changed = if let Some(value) = clicked {
158 selected = value;
159 value != initial_selected
160 } else {
161 false
162 };
163 RadioGroupResponse {
164 selected,
165 changed,
166 paint,
167 }
168}
169
170fn take_navigation(ctx: &mut FrameCtx<'_>, orientation: RadioOrientation) -> Option<NavAction> {
171 let (prev, next) = match orientation {
172 RadioOrientation::Vertical => (NamedKey::ArrowUp, NamedKey::ArrowDown),
173 RadioOrientation::Horizontal => (NamedKey::ArrowLeft, NamedKey::ArrowRight),
174 };
175 let event = take_key(
176 ctx.input,
177 &[
178 TakeKey::named(prev),
179 TakeKey::named(next),
180 TakeKey::named(NamedKey::Home),
181 TakeKey::named(NamedKey::End),
182 ],
183 )?;
184 Some(match event.code {
185 KeyCode::Named(key) if key == prev => NavAction::Prev,
186 KeyCode::Named(key) if key == next => NavAction::Next,
187 KeyCode::Named(NamedKey::Home) => NavAction::First,
188 KeyCode::Named(NamedKey::End) => NavAction::Last,
189 _ => unreachable!("take_key only returns the listed candidates"),
190 })
191}
192
193fn navigate(action: NavAction, current: usize, len: usize) -> usize {
194 if len == 0 {
195 return 0;
196 }
197 match action {
198 NavAction::Next => (current + 1) % len,
199 NavAction::Prev => (current + len - 1) % len,
200 NavAction::First => 0,
201 NavAction::Last => len - 1,
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use std::sync::Arc;
208
209 use super::{RadioGroup, RadioOption, RadioOrientation, show_radio_group};
210 use crate::focus::FocusManager;
211 use crate::frame::FrameCtx;
212 use crate::hit_test::{HitFrame, HitState, resolve};
213 use crate::hotkey::HotkeyTable;
214 use crate::input::{
215 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
216 PointerButtonMask, PointerSample,
217 };
218 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
219 use crate::strings::StringKey;
220 use crate::strings::StringTable;
221 use crate::theme::Theme;
222 use crate::widget_id::{WidgetId, WidgetKey};
223
224 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
225 enum Pick {
226 A,
227 B,
228 C,
229 }
230
231 fn group_id() -> WidgetId {
232 WidgetId::ROOT.child(WidgetKey::new("group"))
233 }
234
235 fn opt_rect(index: f32) -> LayoutRect {
236 LayoutRect::new(
237 LayoutPos::new(LayoutPx::new(index * 80.0), LayoutPx::ZERO),
238 LayoutSize::new(LayoutPx::new(72.0), LayoutPx::new(28.0)),
239 )
240 }
241
242 fn three_options() -> Vec<RadioOption<Pick>> {
243 vec![
244 RadioOption {
245 id: group_id().child(WidgetKey::new("a")),
246 rect: opt_rect(0.0),
247 label: StringKey::new("opt.a"),
248 value: Pick::A,
249 },
250 RadioOption {
251 id: group_id().child(WidgetKey::new("b")),
252 rect: opt_rect(1.0),
253 label: StringKey::new("opt.b"),
254 value: Pick::B,
255 },
256 RadioOption {
257 id: group_id().child(WidgetKey::new("c")),
258 rect: opt_rect(2.0),
259 label: StringKey::new("opt.c"),
260 value: Pick::C,
261 },
262 ]
263 }
264
265 fn at(option: usize) -> LayoutPos {
266 #[allow(clippy::cast_precision_loss, reason = "test option index < 16")]
267 let option_f32 = option as f32;
268 LayoutPos::new(LayoutPx::new(option_f32 * 80.0 + 10.0), LayoutPx::new(10.0))
269 }
270
271 fn press_at(pos: LayoutPos) -> InputSnapshot {
272 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
273 s.pointer = Some(PointerSample::new(pos));
274 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
275 s
276 }
277
278 fn release_at(pos: LayoutPos) -> InputSnapshot {
279 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
280 s.pointer = Some(PointerSample::new(pos));
281 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
282 s
283 }
284
285 fn idle_at(pos: LayoutPos) -> InputSnapshot {
286 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
287 s.pointer = Some(PointerSample::new(pos));
288 s
289 }
290
291 fn run_pick(target: usize) -> Pick {
292 let theme = Arc::new(Theme::light());
293 let table = HotkeyTable::new();
294 let mut focus = FocusManager::new();
295 let mut hits = HitFrame::new();
296 let mut state = HitState::new();
297 let mut selected = Pick::A;
298 [
299 press_at(at(target)),
300 release_at(at(target)),
301 idle_at(at(target)),
302 ]
303 .into_iter()
304 .for_each(|mut input| {
305 hits.clear();
306 let group = RadioGroup::new(three_options(), selected);
307 let response = {
308 let mut shaper = bone_text::Shaper::new();
309 let mut a11y = crate::a11y::AccessTreeBuilder::new();
310 let mut ctx = FrameCtx::new(
311 theme.clone(),
312 &mut input,
313 &mut focus,
314 &table,
315 StringTable::empty(),
316 &mut hits,
317 &state,
318 &mut a11y,
319 &mut shaper,
320 );
321 show_radio_group(&mut ctx, group)
322 };
323 selected = response.selected;
324 state = resolve(&state, &hits, &input, focus.focused());
325 });
326 selected
327 }
328
329 #[test]
330 fn click_picks_target_option() {
331 assert_eq!(run_pick(1), Pick::B);
332 assert_eq!(run_pick(2), Pick::C);
333 }
334
335 fn run_with_focus_and_keys(
336 options: Vec<RadioOption<Pick>>,
337 selected: Pick,
338 orientation: RadioOrientation,
339 focus_target: WidgetId,
340 events: Vec<KeyEvent>,
341 ) -> (super::RadioGroupResponse<Pick>, FocusManager) {
342 let theme = Arc::new(Theme::light());
343 let table = HotkeyTable::new();
344 let mut focus = FocusManager::new();
345 focus.register_focusable(focus_target);
346 focus.request_focus(focus_target);
347 focus.end_frame();
348 let mut hits = HitFrame::new();
349 let prev = HitState::new();
350 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
351 input.keys_pressed = events;
352 let group = RadioGroup::new(options, selected).orientation(orientation);
353 let response = {
354 let mut shaper = bone_text::Shaper::new();
355 let mut a11y = crate::a11y::AccessTreeBuilder::new();
356 let mut ctx = FrameCtx::new(
357 theme,
358 &mut input,
359 &mut focus,
360 &table,
361 StringTable::empty(),
362 &mut hits,
363 &prev,
364 &mut a11y,
365 &mut shaper,
366 );
367 show_radio_group(&mut ctx, group)
368 };
369 (response, focus)
370 }
371
372 #[test]
373 fn vertical_arrow_down_roves_focus_within_group() {
374 let options = three_options();
375 let first_id = options[0].id;
376 let second_id = options[1].id;
377 let (response, focus) = run_with_focus_and_keys(
378 options,
379 Pick::A,
380 RadioOrientation::Vertical,
381 first_id,
382 vec![KeyEvent::new(
383 KeyCode::Named(NamedKey::ArrowDown),
384 ModifierMask::NONE,
385 )],
386 );
387 assert!(!response.changed);
388 assert_eq!(focus.focused(), Some(second_id));
389 }
390
391 #[test]
392 fn horizontal_arrow_right_roves_focus() {
393 let options = three_options();
394 let first_id = options[0].id;
395 let second_id = options[1].id;
396 let (_, focus) = run_with_focus_and_keys(
397 options,
398 Pick::A,
399 RadioOrientation::Horizontal,
400 first_id,
401 vec![KeyEvent::new(
402 KeyCode::Named(NamedKey::ArrowRight),
403 ModifierMask::NONE,
404 )],
405 );
406 assert_eq!(focus.focused(), Some(second_id));
407 }
408
409 #[test]
410 fn vertical_ignores_horizontal_arrow() {
411 let options = three_options();
412 let first_id = options[0].id;
413 let arrow = KeyEvent::new(KeyCode::Named(NamedKey::ArrowRight), ModifierMask::NONE);
414 let (_, focus) = run_with_focus_and_keys(
415 options,
416 Pick::A,
417 RadioOrientation::Vertical,
418 first_id,
419 vec![arrow],
420 );
421 assert_eq!(
422 focus.focused(),
423 Some(first_id),
424 "vertical orientation ignores ArrowRight"
425 );
426 }
427
428 #[test]
429 fn horizontal_ignores_vertical_arrow() {
430 let options = three_options();
431 let first_id = options[0].id;
432 let (_, focus) = run_with_focus_and_keys(
433 options,
434 Pick::A,
435 RadioOrientation::Horizontal,
436 first_id,
437 vec![KeyEvent::new(
438 KeyCode::Named(NamedKey::ArrowDown),
439 ModifierMask::NONE,
440 )],
441 );
442 assert_eq!(focus.focused(), Some(first_id));
443 }
444
445 #[test]
446 fn home_jumps_to_first_option() {
447 let options = three_options();
448 let third_id = options[2].id;
449 let first_id = options[0].id;
450 let (_, focus) = run_with_focus_and_keys(
451 options,
452 Pick::C,
453 RadioOrientation::Vertical,
454 third_id,
455 vec![KeyEvent::new(
456 KeyCode::Named(NamedKey::Home),
457 ModifierMask::NONE,
458 )],
459 );
460 assert_eq!(focus.focused(), Some(first_id));
461 }
462
463 #[test]
464 fn end_jumps_to_last_option() {
465 let options = three_options();
466 let first_id = options[0].id;
467 let third_id = options[2].id;
468 let (_, focus) = run_with_focus_and_keys(
469 options,
470 Pick::A,
471 RadioOrientation::Vertical,
472 first_id,
473 vec![KeyEvent::new(
474 KeyCode::Named(NamedKey::End),
475 ModifierMask::NONE,
476 )],
477 );
478 assert_eq!(focus.focused(), Some(third_id));
479 }
480
481 #[test]
482 fn space_picks_focused_option() {
483 let options = three_options();
484 let second_id = options[1].id;
485 let (response, _) = run_with_focus_and_keys(
486 options,
487 Pick::A,
488 RadioOrientation::Vertical,
489 second_id,
490 vec![KeyEvent::new(
491 KeyCode::Named(NamedKey::Space),
492 ModifierMask::NONE,
493 )],
494 );
495 assert!(response.changed);
496 assert_eq!(response.selected, Pick::B);
497 }
498
499 #[test]
500 fn enter_picks_focused_option() {
501 let options = three_options();
502 let second_id = options[1].id;
503 let (response, _) = run_with_focus_and_keys(
504 options,
505 Pick::A,
506 RadioOrientation::Vertical,
507 second_id,
508 vec![KeyEvent::new(
509 KeyCode::Named(NamedKey::Enter),
510 ModifierMask::NONE,
511 )],
512 );
513 assert!(response.changed);
514 assert_eq!(response.selected, Pick::B);
515 }
516
517 #[test]
518 fn selected_outside_options_falls_back_to_first_tab_stop() {
519 let theme = Arc::new(Theme::light());
520 let table = HotkeyTable::new();
521 let mut focus = FocusManager::new();
522 let mut hits = HitFrame::new();
523 let state = HitState::new();
524 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
525 let two = vec![three_options()[0].clone(), three_options()[1].clone()];
526 let first_id = two[0].id;
527 let group = RadioGroup::new(two, Pick::C);
528 {
529 let mut shaper = bone_text::Shaper::new();
530 let mut a11y = crate::a11y::AccessTreeBuilder::new();
531 let mut ctx = FrameCtx::new(
532 theme,
533 &mut input,
534 &mut focus,
535 &table,
536 StringTable::empty(),
537 &mut hits,
538 &state,
539 &mut a11y,
540 &mut shaper,
541 );
542 let _ = show_radio_group(&mut ctx, group);
543 }
544 assert_eq!(
545 focus.tab_stops().iter().map(|(id, _)| *id).next(),
546 Some(first_id),
547 "selection not in options still leaves the group Tab-reachable",
548 );
549 }
550
551 #[test]
552 fn selected_option_is_only_parent_tab_stop() {
553 let theme = Arc::new(Theme::light());
554 let table = HotkeyTable::new();
555 let mut focus = FocusManager::new();
556 let mut hits = HitFrame::new();
557 let state = HitState::new();
558 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
559 let options = three_options();
560 let group = RadioGroup::new(options.clone(), Pick::B);
561 {
562 let mut shaper = bone_text::Shaper::new();
563 let mut a11y = crate::a11y::AccessTreeBuilder::new();
564 let mut ctx = FrameCtx::new(
565 theme,
566 &mut input,
567 &mut focus,
568 &table,
569 StringTable::empty(),
570 &mut hits,
571 &state,
572 &mut a11y,
573 &mut shaper,
574 );
575 let _ = show_radio_group(&mut ctx, group);
576 }
577 let stop_ids: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect();
578 assert_eq!(
579 stop_ids,
580 vec![options[1].id],
581 "only selected option enters tab order"
582 );
583 }
584
585 #[test]
586 fn keys_pass_through_when_focus_outside_group() {
587 let theme = Arc::new(Theme::light());
588 let table = HotkeyTable::new();
589 let mut focus = FocusManager::new();
590 let mut hits = HitFrame::new();
591 let state = HitState::new();
592 let arrow = KeyEvent::new(KeyCode::Named(NamedKey::ArrowDown), ModifierMask::NONE);
593 let space = KeyEvent::new(KeyCode::Named(NamedKey::Space), ModifierMask::NONE);
594 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
595 input.keys_pressed = vec![arrow, space];
596 let group = RadioGroup::new(three_options(), Pick::A);
597 {
598 let mut shaper = bone_text::Shaper::new();
599 let mut a11y = crate::a11y::AccessTreeBuilder::new();
600 let mut ctx = FrameCtx::new(
601 theme,
602 &mut input,
603 &mut focus,
604 &table,
605 StringTable::empty(),
606 &mut hits,
607 &state,
608 &mut a11y,
609 &mut shaper,
610 );
611 let _ = show_radio_group(&mut ctx, group);
612 }
613 assert_eq!(
614 input.keys_pressed,
615 vec![arrow, space],
616 "no in-group focus, keys preserved"
617 );
618 }
619
620 #[test]
621 fn disabled_group_does_not_change_selection_via_pointer() {
622 let theme = Arc::new(Theme::light());
623 let table = HotkeyTable::new();
624 let mut focus = FocusManager::new();
625 let mut hits = HitFrame::new();
626 let mut state = HitState::new();
627 let mut selected = Pick::A;
628 [press_at(at(1)), release_at(at(1)), idle_at(at(1))]
629 .into_iter()
630 .for_each(|mut input| {
631 hits.clear();
632 let group = RadioGroup::new(three_options(), selected).disabled(true);
633 let response = {
634 let mut shaper = bone_text::Shaper::new();
635 let mut a11y = crate::a11y::AccessTreeBuilder::new();
636 let mut ctx = FrameCtx::new(
637 theme.clone(),
638 &mut input,
639 &mut focus,
640 &table,
641 StringTable::empty(),
642 &mut hits,
643 &state,
644 &mut a11y,
645 &mut shaper,
646 );
647 show_radio_group(&mut ctx, group)
648 };
649 selected = response.selected;
650 state = resolve(&state, &hits, &input, focus.focused());
651 });
652 assert_eq!(selected, Pick::A);
653 }
654}