1use std::cell::{Cell, RefCell};
48use std::rc::Rc;
49
50use teksilo_canvas::{Rect, Size, SizeProposal};
51use teksilo_core::accessibility::AccessNodeBuilder;
52use teksilo_core::binding::BindingLevel;
53use teksilo_core::build_context::BuildContext;
54use teksilo_core::event::{EventResponse, Key, WidgetEvent};
55use teksilo_core::signal::{Prop, Signal};
56use teksilo_core::styles::{RadioStyleConfig, RadioVariant, SharedRadioStyle};
57use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
58use teksilo_core::widget_builder::HandlerSet;
59use teksilo_core::widget_id::WidgetId;
60use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
61
62use crate::button::InteractionState;
63use crate::primitives::{HStack, MinSize, TextWidget, VStack};
64use teksilo_i18n::LocalizedString;
65
66pub struct RadioButton {
68 label: Option<LocalizedString>,
69 caption: Option<LocalizedString>,
70 value: usize,
71 selected: Signal<usize>,
72 enabled: Prop<bool>,
75 label_style: teksilo_core::color_prop::TextStyleProp,
76 label_color: teksilo_core::color_prop::ColorProp,
77 caption_style: teksilo_core::color_prop::TextStyleProp,
78 caption_color: teksilo_core::color_prop::ColorProp,
79 tooltip_text: Option<LocalizedString>,
80 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
81 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
82 variant: RadioVariant,
83 style_override: Option<SharedRadioStyle>,
84 on_change: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
85 root_child_id: Option<WidgetId>,
86 group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
93}
94
95impl RadioButton {
96 pub fn on_change(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
107 self.on_change = Some(Rc::new(f));
108 self
109 }
110
111 pub fn new(value: usize, selected: Signal<usize>) -> Self {
113 Self {
114 label: None,
115 caption: None,
116 value,
117 selected,
118 enabled: Prop::Static(true),
119 label_style: TextStyleRole::Body.into(),
120 label_color: TextRole::Primary.into(),
121 caption_style: TextStyleRole::Small.into(),
122 caption_color: TextRole::Secondary.into(),
123 tooltip_text: None,
124 rich_tooltip_source: None,
125 composite_tooltip_content: None,
126 variant: RadioVariant::default(),
127 style_override: None,
128 on_change: None,
129 root_child_id: None,
130 group_ids: None,
131 }
132 }
133
134 pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
139 self.group_ids = Some(ids);
140 }
141
142 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
144 let ls: LocalizedString = label.into();
145 self.label = Some(ls);
146 self
147 }
148
149 pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
153 let ls: LocalizedString = text.into();
154 self.caption = Some(ls);
155 self
156 }
157
158 pub fn label_style(
162 mut self,
163 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
164 ) -> Self {
165 self.label_style = style.into();
166 self
167 }
168
169 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
174 self.label_color = color.into();
175 self
176 }
177
178 pub fn caption_style(
180 mut self,
181 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
182 ) -> Self {
183 self.caption_style = style.into();
184 self
185 }
186
187 pub fn caption_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
192 self.caption_color = color.into();
193 self
194 }
195
196 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
200 self.enabled = enabled.into();
201 self
202 }
203
204 pub fn variant(mut self, variant: RadioVariant) -> Self {
207 self.variant = variant;
208 self
209 }
210
211 pub fn style(mut self, style: impl teksilo_core::styles::RadioStyle) -> Self {
214 self.style_override = Some(Rc::new(style));
215 self
216 }
217
218 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
220 self.tooltip_text = Some(text.into());
221 self.rich_tooltip_source = None;
222 self.composite_tooltip_content = None;
223 self
224 }
225
226 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
229 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
230 self.tooltip_text = None;
231 self.composite_tooltip_content = None;
232 self
233 }
234
235 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
237 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
238 self.tooltip_text = None;
239 self.composite_tooltip_content = None;
240 self
241 }
242
243 pub fn composite_tooltip(
246 mut self,
247 content: impl teksilo_core::widget::Widget + 'static,
248 ) -> Self {
249 self.composite_tooltip_content = Some(Box::new(content));
250 self.tooltip_text = None;
251 self.rich_tooltip_source = None;
252 self
253 }
254
255 fn is_selected(&self) -> bool {
256 self.selected.get() == self.value
257 }
258}
259
260impl std::fmt::Debug for RadioButton {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 f.debug_struct("RadioButton")
263 .field("label", &self.label)
264 .field("caption", &self.caption)
265 .field("value", &self.value)
266 .finish()
267 }
268}
269
270impl Widget for RadioButton {
273 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
274 use crate::styles::recipe_radio_style as radio_dims;
275 let selected = self.selected.clone();
276 let value = self.value;
277 let variant = self.variant;
278 let self_id = ctx.self_id();
279
280 ctx.enabled_when(self_id, self.enabled.clone());
282 let effective_enabled = ctx.effective_enabled_signal(self_id);
283
284 let interaction = ctx.signal(InteractionState::Idle);
285
286 selected.bind_to(
289 self_id,
290 ctx.binding_registry(),
291 BindingLevel::AccessibilityOnly,
292 );
293
294 let is_selected = selected.map(move |s| *s == value);
295 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
296 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
297 let is_focused = interaction
301 .map(|s| matches!(s, InteractionState::Focused))
302 .and(&ctx.focus_visible());
303 let is_disabled = effective_enabled.map(|on| !*on);
305
306 let style: SharedRadioStyle = self
307 .style_override
308 .clone()
309 .or_else(|| ctx.theme().style_slots.radio.clone())
310 .unwrap_or_else(|| {
311 Rc::new(crate::styles::RecipeRadioStyle::for_tokens(
312 &ctx.theme().input,
313 ))
314 });
315 let cfg = RadioStyleConfig {
316 is_selected,
317 is_hovered,
318 is_pressed,
319 is_focused,
320 is_disabled,
321 variant,
322 };
323 let body_id = style.make_body(&cfg, ctx);
324
325 let mut row = HStack::new()
326 .spacing(radio_dims::RADIO_LABEL_GAP)
327 .child(body_id);
328 if let Some(ref label) = self.label {
329 let label_widget = TextWidget::new(label.clone())
330 .style(self.label_style.clone())
331 .color(self.label_color.clone())
332 .single_line()
333 .a11y_hidden();
334 let label_id = ctx.add(label_widget);
335
336 let label_column_id = if let Some(ref caption) = self.caption {
337 let caption_widget = TextWidget::new(caption.clone())
338 .style(self.caption_style.clone())
339 .color(self.caption_color.clone())
340 .a11y_hidden();
341 let caption_id = ctx.add(caption_widget);
342 ctx.add(VStack::new().spacing(2.0).child(label_id).child(caption_id))
343 } else {
344 label_id
345 };
346 row = row.child(label_column_id);
347 }
348 if self.caption.is_some() && self.label.is_some() {
351 row = row.alignment(VAlignment::Top);
352 }
353
354 let hit_area = crate::styles::RadioRecipe::for_tokens(&ctx.theme().input).hit_area;
357 let row_id = ctx.add(row);
358 let root_id = ctx.add(MinSize::new(hit_area, hit_area).child(row_id));
359
360 if let Some(content) = self.composite_tooltip_content.take() {
361 let delay = ctx.theme().motion.tooltip_delay_heavy;
362 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
363 } else if let Some(source) = self.rich_tooltip_source.take() {
364 let delay = ctx.theme().motion.tooltip_delay;
365 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
366 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
367 let delay = ctx.theme().motion.tooltip_delay;
368 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
369 }
370
371 self.root_child_id = Some(root_id);
372
373 let select = {
375 let selected = self.selected.clone();
376 let on_change = self.on_change.clone();
377 let value = self.value;
378 move |ctx: &mut EventContext| {
379 let was = selected.get();
383 selected.set(value);
384 if was != value
385 && let Some(ref f) = on_change
386 {
387 f(value, ctx);
388 }
389 }
390 };
391 let select_tap = select.clone();
392 let select_key = select.clone();
393 let select_access = select;
394 let int_tap = interaction.clone();
395 let int_hover = interaction.clone();
396 let int_key = interaction.clone();
397 let int_focus = interaction.clone();
398
399 let pointer_over = Rc::new(Cell::new(false));
407 crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
408
409 let handler_set = HandlerSet::new()
412 .on_tap({
413 let hovering = pointer_over.clone();
414 move |_pos, ctx: &mut EventContext| {
415 select_tap(ctx);
416 int_tap.set(if ctx.pointer_kind().hovers() {
417 hovering.set(true);
418 InteractionState::Hovered
419 } else {
420 InteractionState::Idle
421 });
422 }
423 })
424 .on_hover({
425 let hovering = pointer_over.clone();
426 move |entered: bool, _ctx: &mut EventContext| {
427 hovering.set(entered);
428 if entered {
429 int_hover.set(InteractionState::Hovered);
430 } else {
431 int_hover.set(InteractionState::Idle);
432 }
433 }
434 })
435 .on_key({
436 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
437 match event {
438 WidgetEvent::KeyDown {
439 key: Key::Space, ..
440 } => {
441 int_key.set(InteractionState::Pressed);
442 EventResponse::Handled
443 }
444 WidgetEvent::KeyUp {
445 key: Key::Space, ..
446 } => {
447 if int_key.get() != InteractionState::Pressed {
452 return EventResponse::Ignored;
453 }
454 select_key(ctx);
455 int_key.set(InteractionState::Focused);
456 EventResponse::Handled
457 }
458 _ => EventResponse::Ignored,
459 }
460 }
461 })
462 .on_focus({
463 move |gained: bool, _ctx: &mut EventContext| {
464 if gained {
465 if int_focus.get() == InteractionState::Idle {
466 int_focus.set(InteractionState::Focused);
467 }
468 } else {
469 int_focus.set(InteractionState::Idle);
470 }
471 }
472 })
473 .on_access_action({
474 move |action: teksilo_core::accesskit::Action,
475 ctx: &mut EventContext|
476 -> EventResponse {
477 if action == teksilo_core::accesskit::Action::Click {
478 select_access(ctx);
479 EventResponse::Handled
480 } else {
481 EventResponse::Ignored
482 }
483 }
484 })
485 .focusable(true)
486 .cursor(CursorIcon::Pointer);
487
488 ctx.apply_self_handlers(handler_set);
489
490 vec![root_id]
491 }
492
493 fn layout_response(
494 &self,
495 proposal: SizeProposal,
496 ctx: &LayoutContext,
497 ) -> teksilo_core::widget::LayoutResponse {
498 if let Some(root) = self.root_child_id
499 && let Some(size) = ctx.child_size(root, proposal)
500 {
501 return (size).into();
502 }
503 proposal.resolve(0.0, 0.0).into()
504 }
505
506 fn place_children(
507 &self,
508 bounds: Rect,
509 _proposal: SizeProposal,
510 children: &mut [WidgetPlacement],
511 _ctx: &LayoutContext,
512 ) {
513 for child in children.iter_mut() {
514 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
515 child.size = Size::new(bounds.width, bounds.height);
516 }
517 }
518
519 fn hit_distance(&self, local_point: teksilo_canvas::Point, bounds: Rect) -> Option<f32> {
536 if self.label.is_some() {
537 return Some(teksilo_core::pointer::hit_slop::rect_distance(
538 bounds,
539 local_point,
540 ));
541 }
542 let diameter = crate::styles::recipe_radio_style::RADIO_VISUAL_SIZE
543 .min(bounds.width)
544 .min(bounds.height);
545 Some(teksilo_core::pointer::hit_slop::circle_distance(
546 bounds.center(),
547 diameter / 2.0,
548 local_point,
549 ))
550 }
551
552 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
553 builder.set_role(teksilo_core::accesskit::Role::RadioButton);
554 if let Some(ref label) = self.label {
555 builder.set_name(label.resolve_now());
556 }
557 if let Some(ref caption) = self.caption {
558 builder.set_description(caption.resolve_now());
559 }
560 builder.set_toggled(self.is_selected());
563 if let Some(group_ids) = &self.group_ids {
567 for &id in group_ids.borrow().iter() {
568 builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
569 }
570 }
571 builder.add_action(teksilo_core::accesskit::Action::Click);
573 builder.add_action(teksilo_core::accesskit::Action::Focus);
574 }
575
576 fn children(&self) -> Vec<WidgetId> {
577 self.root_child_id.into_iter().collect()
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use teksilo_core::event::Modifiers;
585 use teksilo_core::widget_tree::WidgetTree;
586 use teksilo_i18n::lit;
587
588 struct StyleSpy {
592 inner: teksilo_canvas::MockTextBackend,
593 shaped: Rc<std::cell::RefCell<Vec<(String, f32)>>>,
594 }
595
596 impl teksilo_canvas::TextBackend for StyleSpy {
597 fn layout_single_line(
598 &mut self,
599 text: &str,
600 style: &teksilo_tokens::TextStyle,
601 max_width: Option<f32>,
602 ) -> teksilo_canvas::TextLayout {
603 self.shaped
604 .borrow_mut()
605 .push((text.to_string(), style.size));
606 self.inner.layout_single_line(text, style, max_width)
607 }
608
609 fn ensure_glyphs(
610 &mut self,
611 layout: &teksilo_canvas::TextLayout,
612 ) -> Vec<teksilo_canvas::GlyphQuad> {
613 self.inner.ensure_glyphs(layout)
614 }
615 }
616
617 fn spy_tree() -> (WidgetTree, Rc<std::cell::RefCell<Vec<(String, f32)>>>) {
618 let shaped = Rc::new(std::cell::RefCell::new(Vec::new()));
619 let backend = StyleSpy {
620 inner: teksilo_canvas::MockTextBackend::new(),
621 shaped: shaped.clone(),
622 };
623 let tree = WidgetTree::new()
624 .with_theme(teksilo_core::presets::intui::light())
625 .with_text_backend(Rc::new(std::cell::RefCell::new(backend)));
626 (tree, shaped)
627 }
628
629 fn size_of(shaped: &Rc<std::cell::RefCell<Vec<(String, f32)>>>, text: &str) -> f32 {
630 shaped
631 .borrow()
632 .iter()
633 .rev()
634 .find(|(t, _)| t == text)
635 .unwrap_or_else(|| panic!("`{text}` was never shaped"))
636 .1
637 }
638
639 #[test]
640 fn label_and_caption_styles_reach_the_text() {
641 let big = teksilo_tokens::TextStyle {
642 size: 31.0,
643 ..Default::default()
644 };
645 let (mut tree, shaped) = spy_tree();
646 let _w = tree.add(
647 RadioButton::new(0, Signal::new(0_usize))
648 .label(lit!("Lbl"))
649 .caption(lit!("Cap"))
650 .label_style(big.clone())
651 .caption_style(TextStyleRole::BodyBold),
652 );
653 tree.layout(SizeProposal::exact(300.0, 100.0));
654 tree.render();
655 assert_eq!(size_of(&shaped, "Lbl"), 31.0);
656 let heading = teksilo_core::presets::intui::light()
657 .typography
658 .body_bold
659 .size;
660 assert_eq!(size_of(&shaped, "Cap"), heading);
661
662 let (mut tree, shaped) = spy_tree();
663 let _w = tree.add(
664 RadioButton::new(0, Signal::new(0_usize))
665 .label(lit!("Lbl"))
666 .caption(lit!("Cap")),
667 );
668 tree.layout(SizeProposal::exact(300.0, 100.0));
669 tree.render();
670 let typography = teksilo_core::presets::intui::light().typography;
671 assert_eq!(
672 size_of(&shaped, "Lbl"),
673 typography.body.size,
674 "default label"
675 );
676 assert_eq!(
677 size_of(&shaped, "Cap"),
678 typography.small.size,
679 "default caption"
680 );
681 }
682
683 #[test]
684 fn label_and_caption_colors_apply_and_a_role_still_dims_when_disabled() {
685 use std::cell::RefCell;
686 use teksilo_canvas::MockTextBackend;
687 let theme = teksilo_core::presets::intui::light();
688 let accent = theme.colors.accent.to_array();
689 let error = theme.colors.text_error.to_array();
690 let disabled = theme.colors.text_disabled.to_array();
691 let colors = |enabled: bool| {
692 let mut tree = WidgetTree::new()
693 .with_theme(theme.clone())
694 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
695 let _w = tree.add(
696 RadioButton::new(0, Signal::new(0_usize))
697 .label(lit!("Lbl"))
698 .caption(lit!("Cap"))
699 .label_color(TextRole::Accent)
700 .caption_color(TextRole::Error)
701 .enabled(enabled),
702 );
703 tree.layout(SizeProposal::exact(300.0, 100.0));
704 tree.render()
705 .glyphs
706 .iter()
707 .map(|g| g.color)
708 .collect::<Vec<_>>()
709 };
710 let on = colors(true);
711 assert!(on.contains(&accent), "label takes label_color: {on:?}");
712 assert!(on.contains(&error), "caption takes caption_color: {on:?}");
713 let off = colors(false);
714 assert!(
715 !off.contains(&accent) && !off.contains(&error) && off.contains(&disabled),
716 "disabled dims the role colours: {off:?}"
717 );
718 }
719
720 #[test]
721 fn selecting_one_deselects_others() {
722 use crate::primitives::VStack;
723 let selected = Signal::new(0_usize);
724 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
725 let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
726 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
727 let r2 = tree.add(RadioButton::new(2, selected.clone()).label(lit!("C")));
728 let _root = tree.add(VStack::new().child(r0).child(r1).child(r2));
729 tree.layout(SizeProposal::exact(200.0, 300.0));
730
731 assert_eq!(selected.get(), 0);
732 tree.click(r1);
733 assert_eq!(selected.get(), 1);
734 tree.click(r2);
735 assert_eq!(selected.get(), 2);
736 tree.click(r0);
737 assert_eq!(selected.get(), 0);
738 }
739
740 #[test]
741 fn a_reader_is_told_each_change_as_it_happens() {
742 use crate::common::heard_test::{Heard, Listener};
746 use crate::primitives::VStack;
747 use teksilo_core::accesskit::{Role, Toggled};
748
749 let selected = Signal::new(0_usize);
750 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
751 let a = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
752 let b = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
753 let _root = tree.add(VStack::new().child(a).child(b));
754 let p = SizeProposal::exact(200.0, 200.0);
755 tree.layout(p);
756 tree.focus(b);
757 tree.layout(p);
758 let mut listener = Listener::attach(&mut tree);
759
760 tree.press_key(teksilo_core::event::Key::Space, Modifiers::NONE);
761 tree.layout(p);
762 assert_eq!(
763 listener.heard(&mut tree),
764 vec![Heard::FocusToggled(Toggled::True)],
765 "Space"
766 );
767 assert_eq!(
768 listener.toggled(Role::RadioButton, "A"),
769 Some(Toggled::False),
770 "and the button it replaced is cleared on the platform too"
771 );
772
773 tree.dispatch_access_action(
774 teksilo_core::accessibility::widget_id_to_node_id(a),
775 teksilo_core::accesskit::Action::Click,
776 None,
777 &mut teksilo_core::NoopWindowOps,
778 );
779 tree.layout(p);
780 assert_eq!(
781 listener.heard(&mut tree),
782 vec![Heard::FocusToggled(Toggled::False)],
783 "a screen reader's click on the other button"
784 );
785 assert_eq!(
786 listener.toggled(Role::RadioButton, "A"),
787 Some(Toggled::True)
788 );
789 }
790
791 #[test]
792 fn on_change_reports_a_real_change_only() {
793 use crate::primitives::VStack;
794 use std::cell::RefCell;
795 use std::rc::Rc;
796
797 let selected = Signal::new(0_usize);
798 let seen: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
799 let mk = |v: usize, seen: &Rc<RefCell<Vec<usize>>>, sel: &Signal<usize>| {
800 let sink = seen.clone();
801 RadioButton::new(v, sel.clone())
802 .label(lit!("Option"))
803 .on_change(move |now, _ctx| sink.borrow_mut().push(now))
804 };
805 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
806 let r0 = tree.add(mk(0, &seen, &selected));
807 let r1 = tree.add(mk(1, &seen, &selected));
808 let _root = tree.add(VStack::new().child(r0).child(r1));
809 tree.layout(SizeProposal::exact(200.0, 200.0));
810
811 tree.click(r1);
812 tree.click(r1);
815 tree.focus(r0);
816 tree.press_key(teksilo_core::event::Key::Space, Modifiers::NONE);
817 tree.dispatch_access_action(
818 teksilo_core::accessibility::widget_id_to_node_id(r1),
819 teksilo_core::accesskit::Action::Click,
820 None,
821 &mut teksilo_core::NoopWindowOps,
822 );
823
824 assert_eq!(*seen.borrow(), vec![1, 0, 1]);
825 assert_eq!(selected.get(), 1);
826 }
827
828 #[test]
829 fn on_change_is_silent_for_a_programmatic_write() {
830 use std::cell::Cell;
831 use std::rc::Rc;
832
833 let selected = Signal::new(0_usize);
834 let fired = Rc::new(Cell::new(false));
835 let sink = fired.clone();
836 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
837 let _r = tree.add(
838 RadioButton::new(1, selected.clone())
839 .label(lit!("B"))
840 .on_change(move |_now, _ctx| sink.set(true)),
841 );
842 tree.layout(SizeProposal::exact(200.0, 100.0));
843
844 selected.set(1);
845 assert!(!fired.get());
846 }
847
848 #[test]
849 fn space_selects() {
850 let selected = Signal::new(0_usize);
851 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
852 let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
853 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
854 tree.layout(SizeProposal::exact(200.0, 200.0));
855
856 tree.focus(r1);
857 tree.press_key(Key::Space, Modifiers::NONE);
858 assert_eq!(selected.get(), 1);
859 }
860
861 #[test]
862 fn lone_keyup_does_not_select() {
863 let selected = Signal::new(0_usize);
865 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
866 let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
867 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
868 tree.layout(SizeProposal::exact(200.0, 200.0));
869
870 tree.focus(r1);
871 tree.dispatch_event(WidgetEvent::KeyUp {
872 key: Key::Space,
873 modifiers: Modifiers::NONE,
874 });
875 assert_eq!(selected.get(), 0, "a lone KeyUp must not select the radio");
876
877 tree.press_key(Key::Space, Modifiers::NONE);
878 assert_eq!(selected.get(), 1);
879 }
880
881 #[test]
882 fn accessibility() {
883 let selected = Signal::new(1_usize);
884 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
885 let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
886 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
887 tree.layout(SizeProposal::exact(200.0, 200.0));
888
889 let info0 = tree.accessibility_node(r0);
890 assert_eq!(info0.role(), teksilo_core::accesskit::Role::RadioButton);
891 assert!(!info0.is_toggled());
892
893 let info1 = tree.accessibility_node(r1);
894 assert!(info1.is_toggled());
895 }
896
897 #[test]
898 fn accessibility_has_actions() {
899 let selected = Signal::new(0_usize);
900 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
901 let r0 = tree.add(RadioButton::new(0, selected).label(lit!("A")));
902 tree.layout(SizeProposal::exact(200.0, 200.0));
903 let info = tree.accessibility_node(r0);
904 assert!(
905 info.actions()
906 .contains(&teksilo_core::accesskit::Action::Click)
907 );
908 }
909 struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
914
915 impl teksilo_core::styles::RadioStyle for PressProbe {
916 fn make_body(
917 &self,
918 cfg: &teksilo_core::styles::RadioStyleConfig,
919 ctx: &mut BuildContext,
920 ) -> WidgetId {
921 *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
922 ctx.add(crate::primitives::FixedSize::new().width(19.0).height(19.0))
923 }
924 }
925
926 #[allow(clippy::type_complexity)]
927 fn probed_radio_with_hover() -> (
928 WidgetTree,
929 WidgetId,
930 Signal<bool>,
931 Signal<bool>,
932 Signal<usize>,
933 ) {
934 let probe: std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>> =
935 std::rc::Rc::new(std::cell::RefCell::new(None));
936 let selected = Signal::new(9_usize);
937 let mut theme = teksilo_core::presets::intui::light();
938 theme.style_slots.radio = Some(std::rc::Rc::new(PressProbe(probe.clone())));
939 let mut tree = WidgetTree::new().with_theme(theme);
940 let rb = tree.add(RadioButton::new(0, selected.clone()).label(lit!("One")));
941 tree.layout(SizeProposal::exact(200.0, 60.0));
942 let (pressed, hovered) = probe.borrow().clone().expect("style ran");
943 (tree, rb, pressed, hovered, selected)
944 }
945
946 fn probed_radio() -> (WidgetTree, WidgetId, Signal<bool>, Signal<usize>) {
947 let (tree, rb, pressed, _hovered, selected) = probed_radio_with_hover();
948 (tree, rb, pressed, selected)
949 }
950
951 #[test]
955 fn a_mouse_selection_rests_hovered_and_a_finger_selection_rests_idle() {
956 use crate::button::press_test_support::touch_tap;
957
958 let (mut tree, rb, pressed, hovered, selected) = probed_radio_with_hover();
959 let at = tree.bounds(rb).center();
960 tree.pointer_move(at);
961 assert!(hovered.get(), "the pointer arrived over the dot");
962 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
963 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
964 assert_eq!(selected.get(), 0, "the release selected");
965 assert!(!pressed.get());
966 assert!(
967 hovered.get(),
968 "a mouse that clicked the dot is still on it, so it rests hovered",
969 );
970
971 let (mut tree, rb, pressed, hovered, selected) = probed_radio_with_hover();
972 let at = tree.bounds(rb).center();
973 touch_tap(&mut tree, at);
974 assert_eq!(selected.get(), 0, "the contact selected on its release");
975 assert!(!pressed.get());
976 assert!(
977 !hovered.get(),
978 "a finger leaves nothing behind, so the dot must rest idle",
979 );
980 }
981
982 #[test]
985 fn a_mouse_press_lights_the_pressed_state_and_the_release_selects() {
986 let (mut tree, rb, pressed, selected) = probed_radio();
987 let at = tree.bounds(rb).center();
988 tree.pointer_move(at);
989 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
990 assert!(pressed.get());
991 assert_eq!(selected.get(), 9, "the press selects nothing");
992 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
993 assert!(!pressed.get());
994 assert_eq!(selected.get(), 0);
995 }
996
997 #[test]
999 fn a_touch_tap_selects_on_release_and_a_slide_off_abandons_it() {
1000 use crate::button::press_test_support::{finger, touch};
1001 use teksilo_core::pointer::PointerPhase;
1002
1003 let (mut tree, rb, pressed, selected) = probed_radio();
1004 let bounds = tree.bounds(rb);
1005 let at = bounds.center();
1006 let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 80.0);
1007
1008 let id = finger();
1009 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1010 assert!(pressed.get());
1011 tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
1012 assert!(!pressed.get());
1013 tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
1014 assert_eq!(
1015 selected.get(),
1016 9,
1017 "a release off the control selects nothing"
1018 );
1019
1020 let id = finger();
1021 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 100));
1022 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 130));
1023 assert_eq!(selected.get(), 0);
1024 assert!(!pressed.get());
1025 }
1026
1027 #[test]
1030 fn the_radio_hit_box_clears_the_conformance_floor_at_compact() {
1031 let theme = teksilo_core::presets::intui::light();
1032 let floor = theme.input.min_target_conformance;
1033 let mut tree = WidgetTree::new().with_theme(theme);
1034 let rb = tree.add(RadioButton::new(0, Signal::new(0_usize)));
1035 tree.layout(SizeProposal::exact(200.0, 60.0));
1036 let b = tree.bounds(rb);
1037 assert!(b.width >= floor && b.height >= floor, "measured {b:?}");
1038 }
1039}
1040
1041#[cfg(test)]
1042mod hit_distance_tests {
1043 use super::*;
1044 use teksilo_canvas::Point;
1045 use teksilo_core::pointer::{EventTime, PointerId, PointerInfo};
1046 use teksilo_core::widget_tree::WidgetTree;
1047 use teksilo_i18n::lit;
1048
1049 fn finger() -> PointerInfo {
1050 PointerInfo::touch(PointerId::MOUSE, EventTime::ZERO)
1051 }
1052
1053 #[test]
1067 fn a_bare_radio_measures_a_near_miss_to_its_disc() {
1068 use teksilo_canvas::Rect;
1069 use teksilo_core::widget::Widget;
1070 let radio = RadioButton::new(0, Signal::new(0_usize));
1071 let bounds = Rect::new(0.0, 0.0, 24.0, 24.0);
1072
1073 let side = Point::new(bounds.right() + 4.0, bounds.center().y);
1077 let corner = Point::new(bounds.right() + 4.0, bounds.bottom() + 4.0);
1078
1079 let d_side = radio
1080 .hit_distance(side, bounds)
1081 .expect("a bare radio measures");
1082 let d_corner = radio
1083 .hit_distance(corner, bounds)
1084 .expect("a bare radio measures");
1085 assert!(
1086 d_corner > d_side,
1087 "the corner ({d_corner}) must be further from the disc than the edge ({d_side})"
1088 );
1089 let on_edge = Point::new(bounds.right(), bounds.center().y);
1092 assert!(
1093 radio.hit_distance(on_edge, bounds).expect("measures") > 0.0,
1094 "a rectangular measure would call the box's edge zero"
1095 );
1096 }
1097
1098 #[test]
1102 fn a_labelled_radio_keeps_the_rectangular_measure() {
1103 use teksilo_canvas::Rect;
1104 use teksilo_core::widget::Widget;
1105 let radio = RadioButton::new(0, Signal::new(0_usize)).label(lit!("Option"));
1106 let bounds = Rect::new(0.0, 0.0, 200.0, 24.0);
1107 let corner = Point::new(203.0, 28.0);
1108 assert_eq!(
1109 radio.hit_distance(corner, bounds),
1110 Some(teksilo_core::pointer::hit_slop::rect_distance(
1111 bounds, corner
1112 ))
1113 );
1114 }
1115
1116 #[test]
1119 fn the_exact_pass_still_accepts_the_corner_of_the_box() {
1120 use crate::primitives::Center;
1121 let selected = Signal::new(1_usize);
1122 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1123 let radio = tree.add(RadioButton::new(0, selected.clone()));
1124 tree.add(Center::new().child(radio));
1125 tree.layout(SizeProposal::exact(200.0, 200.0));
1126 let b = tree.bounds(radio);
1127 let corner = Point::new(b.x + 1.0, b.y + 1.0);
1128 let hit = tree.hit_test(corner);
1132 assert!(
1133 hit.is_some_and(
1134 |id| std::iter::successors(Some(id), |id| tree.parent(*id)).any(|id| id == radio)
1135 ),
1136 "the corner of a bare radio's box resolved to {hit:?}"
1137 );
1138 tree.click(radio);
1139 assert_eq!(selected.get(), 0);
1140 }
1141}