1use std::cell::{Cell, RefCell};
40use std::rc::Rc;
41
42use teksilo_canvas::{Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::build_context::BuildContext;
45use teksilo_core::event::{EventResponse, Key, WidgetEvent};
46use teksilo_core::signal::{Prop, Signal};
47use teksilo_core::styles::{RadioStyleConfig, RadioVariant, SharedRadioStyle};
48use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
49use teksilo_core::widget_builder::HandlerSet;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
52
53use crate::button::InteractionState;
54use crate::primitives::{HStack, MinSize, TextWidget, VStack};
55use teksilo_i18n::LocalizedString;
56
57pub struct RadioButton {
59 label: Option<LocalizedString>,
60 caption: Option<LocalizedString>,
61 value: usize,
62 selected: Signal<usize>,
63 enabled: Prop<bool>,
66 tooltip_text: Option<LocalizedString>,
67 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
68 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
69 variant: RadioVariant,
70 style_override: Option<SharedRadioStyle>,
71 on_change: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
72 root_child_id: Option<WidgetId>,
73 group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
80}
81
82impl RadioButton {
83 pub fn on_change(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
97 self.on_change = Some(Rc::new(f));
98 self
99 }
100
101 pub fn new(value: usize, selected: Signal<usize>) -> Self {
102 Self {
103 label: None,
104 caption: None,
105 value,
106 selected,
107 enabled: Prop::Static(true),
108 tooltip_text: None,
109 rich_tooltip_source: None,
110 composite_tooltip_content: None,
111 variant: RadioVariant::default(),
112 style_override: None,
113 on_change: None,
114 root_child_id: None,
115 group_ids: None,
116 }
117 }
118
119 pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
124 self.group_ids = Some(ids);
125 }
126
127 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
129 let ls: LocalizedString = label.into();
130 self.label = Some(ls);
131 self
132 }
133
134 pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
138 let ls: LocalizedString = text.into();
139 self.caption = Some(ls);
140 self
141 }
142
143 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
147 self.enabled = enabled.into();
148 self
149 }
150
151 pub fn variant(mut self, variant: RadioVariant) -> Self {
154 self.variant = variant;
155 self
156 }
157
158 pub fn style(mut self, style: impl teksilo_core::styles::RadioStyle) -> Self {
161 self.style_override = Some(Rc::new(style));
162 self
163 }
164
165 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
167 self.tooltip_text = Some(text.into());
168 self.rich_tooltip_source = None;
169 self.composite_tooltip_content = None;
170 self
171 }
172
173 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
176 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
177 self.tooltip_text = None;
178 self.composite_tooltip_content = None;
179 self
180 }
181
182 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
184 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
185 self.tooltip_text = None;
186 self.composite_tooltip_content = None;
187 self
188 }
189
190 pub fn composite_tooltip(
193 mut self,
194 content: impl teksilo_core::widget::Widget + 'static,
195 ) -> Self {
196 self.composite_tooltip_content = Some(Box::new(content));
197 self.tooltip_text = None;
198 self.rich_tooltip_source = None;
199 self
200 }
201
202 fn is_selected(&self) -> bool {
203 self.selected.get() == self.value
204 }
205}
206
207impl std::fmt::Debug for RadioButton {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 f.debug_struct("RadioButton")
210 .field("label", &self.label)
211 .field("caption", &self.caption)
212 .field("value", &self.value)
213 .finish()
214 }
215}
216
217impl Widget for RadioButton {
220 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
221 use crate::styles::recipe_radio_style as radio_dims;
222 let selected = self.selected.clone();
223 let value = self.value;
224 let variant = self.variant;
225 let self_id = ctx.self_id();
226
227 ctx.enabled_when(self_id, self.enabled.clone());
229 let effective_enabled = ctx.effective_enabled_signal(self_id);
230
231 let interaction = ctx.signal(InteractionState::Idle);
232
233 let is_selected = selected.map(move |s| *s == value);
234 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
235 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
236 let is_focused = interaction
240 .map(|s| matches!(s, InteractionState::Focused))
241 .and(&ctx.focus_visible());
242 let is_disabled = effective_enabled.map(|on| !*on);
244
245 let style: SharedRadioStyle = self
246 .style_override
247 .clone()
248 .or_else(|| ctx.theme().style_slots.radio.clone())
249 .unwrap_or_else(|| {
250 Rc::new(crate::styles::RecipeRadioStyle::for_tokens(
251 &ctx.theme().input,
252 ))
253 });
254 let cfg = RadioStyleConfig {
255 is_selected,
256 is_hovered,
257 is_pressed,
258 is_focused,
259 is_disabled,
260 variant,
261 };
262 let body_id = style.make_body(&cfg, ctx);
263
264 let mut row = HStack::new()
265 .spacing(radio_dims::RADIO_LABEL_GAP)
266 .child(body_id);
267 if let Some(ref label) = self.label {
268 let label_widget = TextWidget::new(label.clone())
269 .style(TextStyleRole::Body)
270 .color(TextRole::Primary)
271 .single_line()
272 .a11y_hidden();
273 let label_id = ctx.add(label_widget);
274
275 let label_column_id = if let Some(ref caption) = self.caption {
276 let caption_widget = TextWidget::new(caption.clone())
277 .style(TextStyleRole::Small)
278 .color(TextRole::Secondary)
279 .a11y_hidden();
280 let caption_id = ctx.add(caption_widget);
281 ctx.add(VStack::new().spacing(2.0).child(label_id).child(caption_id))
282 } else {
283 label_id
284 };
285 row = row.child(label_column_id);
286 }
287 if self.caption.is_some() && self.label.is_some() {
290 row = row.alignment(VAlignment::Top);
291 }
292
293 let hit_area = crate::styles::RadioRecipe::for_tokens(&ctx.theme().input).hit_area;
296 let row_id = ctx.add(row);
297 let root_id = ctx.add(MinSize::new(hit_area, hit_area).child(row_id));
298
299 if let Some(content) = self.composite_tooltip_content.take() {
300 let delay = ctx.theme().motion.tooltip_delay_heavy;
301 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
302 } else if let Some(source) = self.rich_tooltip_source.take() {
303 let delay = ctx.theme().motion.tooltip_delay;
304 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
305 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
306 let delay = ctx.theme().motion.tooltip_delay;
307 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
308 }
309
310 self.root_child_id = Some(root_id);
311
312 let select = {
314 let selected = self.selected.clone();
315 let on_change = self.on_change.clone();
316 let value = self.value;
317 move |ctx: &mut EventContext| {
318 let was = selected.get();
322 selected.set(value);
323 if was != value
324 && let Some(ref f) = on_change
325 {
326 f(value, ctx);
327 }
328 }
329 };
330 let select_tap = select.clone();
331 let select_key = select.clone();
332 let select_access = select;
333 let int_tap = interaction.clone();
334 let int_hover = interaction.clone();
335 let int_key = interaction.clone();
336 let int_focus = interaction.clone();
337
338 let pointer_over = Rc::new(Cell::new(false));
346 crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
347
348 let handler_set = HandlerSet::new()
351 .on_tap({
352 let hovering = pointer_over.clone();
353 move |_pos, ctx: &mut EventContext| {
354 select_tap(ctx);
355 int_tap.set(if ctx.pointer_kind().hovers() {
356 hovering.set(true);
357 InteractionState::Hovered
358 } else {
359 InteractionState::Idle
360 });
361 }
362 })
363 .on_hover({
364 let hovering = pointer_over.clone();
365 move |entered: bool, _ctx: &mut EventContext| {
366 hovering.set(entered);
367 if entered {
368 int_hover.set(InteractionState::Hovered);
369 } else {
370 int_hover.set(InteractionState::Idle);
371 }
372 }
373 })
374 .on_key({
375 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
376 match event {
377 WidgetEvent::KeyDown {
378 key: Key::Space, ..
379 } => {
380 int_key.set(InteractionState::Pressed);
381 EventResponse::Handled
382 }
383 WidgetEvent::KeyUp {
384 key: Key::Space, ..
385 } => {
386 if int_key.get() != InteractionState::Pressed {
391 return EventResponse::Ignored;
392 }
393 select_key(ctx);
394 int_key.set(InteractionState::Focused);
395 EventResponse::Handled
396 }
397 _ => EventResponse::Ignored,
398 }
399 }
400 })
401 .on_focus({
402 move |gained: bool, _ctx: &mut EventContext| {
403 if gained {
404 if int_focus.get() == InteractionState::Idle {
405 int_focus.set(InteractionState::Focused);
406 }
407 } else {
408 int_focus.set(InteractionState::Idle);
409 }
410 }
411 })
412 .on_access_action({
413 move |action: teksilo_core::accesskit::Action,
414 ctx: &mut EventContext|
415 -> EventResponse {
416 if action == teksilo_core::accesskit::Action::Click {
417 select_access(ctx);
418 EventResponse::Handled
419 } else {
420 EventResponse::Ignored
421 }
422 }
423 })
424 .focusable(true)
425 .cursor(CursorIcon::Pointer);
426
427 ctx.apply_self_handlers(handler_set);
428
429 vec![root_id]
430 }
431
432 fn layout_response(
433 &self,
434 proposal: SizeProposal,
435 ctx: &LayoutContext,
436 ) -> teksilo_core::widget::LayoutResponse {
437 if let Some(root) = self.root_child_id
438 && let Some(size) = ctx.child_size(root, proposal)
439 {
440 return (size).into();
441 }
442 proposal.resolve(0.0, 0.0).into()
443 }
444
445 fn place_children(
446 &self,
447 bounds: Rect,
448 _proposal: SizeProposal,
449 children: &mut [WidgetPlacement],
450 _ctx: &LayoutContext,
451 ) {
452 for child in children.iter_mut() {
453 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
454 child.size = Size::new(bounds.width, bounds.height);
455 }
456 }
457
458 fn hit_distance(&self, local_point: teksilo_canvas::Point, bounds: Rect) -> Option<f32> {
475 if self.label.is_some() {
476 return Some(teksilo_core::pointer::hit_slop::rect_distance(
477 bounds,
478 local_point,
479 ));
480 }
481 let diameter = crate::styles::recipe_radio_style::RADIO_VISUAL_SIZE
482 .min(bounds.width)
483 .min(bounds.height);
484 Some(teksilo_core::pointer::hit_slop::circle_distance(
485 bounds.center(),
486 diameter / 2.0,
487 local_point,
488 ))
489 }
490
491 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
492 builder.set_role(teksilo_core::accesskit::Role::RadioButton);
493 if let Some(ref label) = self.label {
494 builder.set_name(label.resolve_now());
495 }
496 if let Some(ref caption) = self.caption {
497 builder.set_description(caption.resolve_now());
498 }
499 builder.set_toggled(self.is_selected());
502 if let Some(group_ids) = &self.group_ids {
506 for &id in group_ids.borrow().iter() {
507 builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
508 }
509 }
510 builder.add_action(teksilo_core::accesskit::Action::Click);
512 builder.add_action(teksilo_core::accesskit::Action::Focus);
513 }
514
515 fn children(&self) -> Vec<WidgetId> {
516 self.root_child_id.into_iter().collect()
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use teksilo_core::event::Modifiers;
524 use teksilo_core::widget_tree::WidgetTree;
525 use teksilo_i18n::lit;
526
527 #[test]
528 fn selecting_one_deselects_others() {
529 use crate::primitives::VStack;
530 let selected = Signal::new(0_usize);
531 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
532 let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
533 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
534 let r2 = tree.add(RadioButton::new(2, selected.clone()).label(lit!("C")));
535 let _root = tree.add(VStack::new().child(r0).child(r1).child(r2));
536 tree.layout(SizeProposal::exact(200.0, 300.0));
537
538 assert_eq!(selected.get(), 0);
539 tree.click(r1);
540 assert_eq!(selected.get(), 1);
541 tree.click(r2);
542 assert_eq!(selected.get(), 2);
543 tree.click(r0);
544 assert_eq!(selected.get(), 0);
545 }
546
547 #[test]
548 fn on_change_reports_a_real_change_only() {
549 use crate::primitives::VStack;
550 use std::cell::RefCell;
551 use std::rc::Rc;
552
553 let selected = Signal::new(0_usize);
554 let seen: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
555 let mk = |v: usize, seen: &Rc<RefCell<Vec<usize>>>, sel: &Signal<usize>| {
556 let sink = seen.clone();
557 RadioButton::new(v, sel.clone())
558 .label(lit!("Option"))
559 .on_change(move |now, _ctx| sink.borrow_mut().push(now))
560 };
561 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
562 let r0 = tree.add(mk(0, &seen, &selected));
563 let r1 = tree.add(mk(1, &seen, &selected));
564 let _root = tree.add(VStack::new().child(r0).child(r1));
565 tree.layout(SizeProposal::exact(200.0, 200.0));
566
567 tree.click(r1);
568 tree.click(r1);
571 tree.focus(r0);
572 tree.press_key(teksilo_core::event::Key::Space, Modifiers::NONE);
573 tree.dispatch_access_action(
574 teksilo_core::accessibility::widget_id_to_node_id(r1),
575 teksilo_core::accesskit::Action::Click,
576 None,
577 &mut teksilo_core::NoopWindowOps,
578 );
579
580 assert_eq!(*seen.borrow(), vec![1, 0, 1]);
581 assert_eq!(selected.get(), 1);
582 }
583
584 #[test]
585 fn on_change_is_silent_for_a_programmatic_write() {
586 use std::cell::Cell;
587 use std::rc::Rc;
588
589 let selected = Signal::new(0_usize);
590 let fired = Rc::new(Cell::new(false));
591 let sink = fired.clone();
592 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
593 let _r = tree.add(
594 RadioButton::new(1, selected.clone())
595 .label(lit!("B"))
596 .on_change(move |_now, _ctx| sink.set(true)),
597 );
598 tree.layout(SizeProposal::exact(200.0, 100.0));
599
600 selected.set(1);
601 assert!(!fired.get());
602 }
603
604 #[test]
605 fn space_selects() {
606 let selected = Signal::new(0_usize);
607 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
608 let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
609 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
610 tree.layout(SizeProposal::exact(200.0, 200.0));
611
612 tree.focus(r1);
613 tree.press_key(Key::Space, Modifiers::NONE);
614 assert_eq!(selected.get(), 1);
615 }
616
617 #[test]
618 fn lone_keyup_does_not_select() {
619 let selected = Signal::new(0_usize);
621 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
622 let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
623 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
624 tree.layout(SizeProposal::exact(200.0, 200.0));
625
626 tree.focus(r1);
627 tree.dispatch_event(WidgetEvent::KeyUp {
628 key: Key::Space,
629 modifiers: Modifiers::NONE,
630 });
631 assert_eq!(selected.get(), 0, "a lone KeyUp must not select the radio");
632
633 tree.press_key(Key::Space, Modifiers::NONE);
634 assert_eq!(selected.get(), 1);
635 }
636
637 #[test]
638 fn accessibility() {
639 let selected = Signal::new(1_usize);
640 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
641 let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
642 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
643 tree.layout(SizeProposal::exact(200.0, 200.0));
644
645 let info0 = tree.accessibility_node(r0);
646 assert_eq!(info0.role(), teksilo_core::accesskit::Role::RadioButton);
647 assert!(!info0.is_toggled());
648
649 let info1 = tree.accessibility_node(r1);
650 assert!(info1.is_toggled());
651 }
652
653 #[test]
654 fn accessibility_has_actions() {
655 let selected = Signal::new(0_usize);
656 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
657 let r0 = tree.add(RadioButton::new(0, selected).label(lit!("A")));
658 tree.layout(SizeProposal::exact(200.0, 200.0));
659 let info = tree.accessibility_node(r0);
660 assert!(
661 info.actions()
662 .contains(&teksilo_core::accesskit::Action::Click)
663 );
664 }
665 struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
670
671 impl teksilo_core::styles::RadioStyle for PressProbe {
672 fn make_body(
673 &self,
674 cfg: &teksilo_core::styles::RadioStyleConfig,
675 ctx: &mut BuildContext,
676 ) -> WidgetId {
677 *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
678 ctx.add(crate::primitives::FixedSize::new().width(19.0).height(19.0))
679 }
680 }
681
682 #[allow(clippy::type_complexity)]
683 fn probed_radio_with_hover() -> (
684 WidgetTree,
685 WidgetId,
686 Signal<bool>,
687 Signal<bool>,
688 Signal<usize>,
689 ) {
690 let probe: std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>> =
691 std::rc::Rc::new(std::cell::RefCell::new(None));
692 let selected = Signal::new(9_usize);
693 let mut theme = teksilo_core::presets::intui::light();
694 theme.style_slots.radio = Some(std::rc::Rc::new(PressProbe(probe.clone())));
695 let mut tree = WidgetTree::new().with_theme(theme);
696 let rb = tree.add(RadioButton::new(0, selected.clone()).label(lit!("One")));
697 tree.layout(SizeProposal::exact(200.0, 60.0));
698 let (pressed, hovered) = probe.borrow().clone().expect("style ran");
699 (tree, rb, pressed, hovered, selected)
700 }
701
702 fn probed_radio() -> (WidgetTree, WidgetId, Signal<bool>, Signal<usize>) {
703 let (tree, rb, pressed, _hovered, selected) = probed_radio_with_hover();
704 (tree, rb, pressed, selected)
705 }
706
707 #[test]
711 fn a_mouse_selection_rests_hovered_and_a_finger_selection_rests_idle() {
712 use crate::button::press_test_support::touch_tap;
713
714 let (mut tree, rb, pressed, hovered, selected) = probed_radio_with_hover();
715 let at = tree.bounds(rb).center();
716 tree.pointer_move(at);
717 assert!(hovered.get(), "the pointer arrived over the dot");
718 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
719 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
720 assert_eq!(selected.get(), 0, "the release selected");
721 assert!(!pressed.get());
722 assert!(
723 hovered.get(),
724 "a mouse that clicked the dot is still on it, so it rests hovered",
725 );
726
727 let (mut tree, rb, pressed, hovered, selected) = probed_radio_with_hover();
728 let at = tree.bounds(rb).center();
729 touch_tap(&mut tree, at);
730 assert_eq!(selected.get(), 0, "the contact selected on its release");
731 assert!(!pressed.get());
732 assert!(
733 !hovered.get(),
734 "a finger leaves nothing behind, so the dot must rest idle",
735 );
736 }
737
738 #[test]
741 fn a_mouse_press_lights_the_pressed_state_and_the_release_selects() {
742 let (mut tree, rb, pressed, selected) = probed_radio();
743 let at = tree.bounds(rb).center();
744 tree.pointer_move(at);
745 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
746 assert!(pressed.get());
747 assert_eq!(selected.get(), 9, "the press selects nothing");
748 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
749 assert!(!pressed.get());
750 assert_eq!(selected.get(), 0);
751 }
752
753 #[test]
755 fn a_touch_tap_selects_on_release_and_a_slide_off_abandons_it() {
756 use crate::button::press_test_support::{finger, touch};
757 use teksilo_core::pointer::PointerPhase;
758
759 let (mut tree, rb, pressed, selected) = probed_radio();
760 let bounds = tree.bounds(rb);
761 let at = bounds.center();
762 let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 80.0);
763
764 let id = finger();
765 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
766 assert!(pressed.get());
767 tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
768 assert!(!pressed.get());
769 tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
770 assert_eq!(
771 selected.get(),
772 9,
773 "a release off the control selects nothing"
774 );
775
776 let id = finger();
777 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 100));
778 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 130));
779 assert_eq!(selected.get(), 0);
780 assert!(!pressed.get());
781 }
782
783 #[test]
786 fn the_radio_hit_box_clears_the_conformance_floor_at_compact() {
787 let theme = teksilo_core::presets::intui::light();
788 let floor = theme.input.min_target_conformance;
789 let mut tree = WidgetTree::new().with_theme(theme);
790 let rb = tree.add(RadioButton::new(0, Signal::new(0_usize)));
791 tree.layout(SizeProposal::exact(200.0, 60.0));
792 let b = tree.bounds(rb);
793 assert!(b.width >= floor && b.height >= floor, "measured {b:?}");
794 }
795}
796
797#[cfg(test)]
798mod hit_distance_tests {
799 use super::*;
800 use teksilo_canvas::Point;
801 use teksilo_core::pointer::{EventTime, PointerId, PointerInfo};
802 use teksilo_core::widget_tree::WidgetTree;
803 use teksilo_i18n::lit;
804
805 fn finger() -> PointerInfo {
806 PointerInfo::touch(PointerId::MOUSE, EventTime::ZERO)
807 }
808
809 #[test]
823 fn a_bare_radio_measures_a_near_miss_to_its_disc() {
824 use teksilo_canvas::Rect;
825 use teksilo_core::widget::Widget;
826 let radio = RadioButton::new(0, Signal::new(0_usize));
827 let bounds = Rect::new(0.0, 0.0, 24.0, 24.0);
828
829 let side = Point::new(bounds.right() + 4.0, bounds.center().y);
833 let corner = Point::new(bounds.right() + 4.0, bounds.bottom() + 4.0);
834
835 let d_side = radio
836 .hit_distance(side, bounds)
837 .expect("a bare radio measures");
838 let d_corner = radio
839 .hit_distance(corner, bounds)
840 .expect("a bare radio measures");
841 assert!(
842 d_corner > d_side,
843 "the corner ({d_corner}) must be further from the disc than the edge ({d_side})"
844 );
845 let on_edge = Point::new(bounds.right(), bounds.center().y);
848 assert!(
849 radio.hit_distance(on_edge, bounds).expect("measures") > 0.0,
850 "a rectangular measure would call the box's edge zero"
851 );
852 }
853
854 #[test]
858 fn a_labelled_radio_keeps_the_rectangular_measure() {
859 use teksilo_canvas::Rect;
860 use teksilo_core::widget::Widget;
861 let radio = RadioButton::new(0, Signal::new(0_usize)).label(lit!("Option"));
862 let bounds = Rect::new(0.0, 0.0, 200.0, 24.0);
863 let corner = Point::new(203.0, 28.0);
864 assert_eq!(
865 radio.hit_distance(corner, bounds),
866 Some(teksilo_core::pointer::hit_slop::rect_distance(
867 bounds, corner
868 ))
869 );
870 }
871
872 #[test]
875 fn the_exact_pass_still_accepts_the_corner_of_the_box() {
876 use crate::primitives::Center;
877 let selected = Signal::new(1_usize);
878 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
879 let radio = tree.add(RadioButton::new(0, selected.clone()));
880 tree.add(Center::new().child(radio));
881 tree.layout(SizeProposal::exact(200.0, 200.0));
882 let b = tree.bounds(radio);
883 let corner = Point::new(b.x + 1.0, b.y + 1.0);
884 let hit = tree.hit_test(corner);
888 assert!(
889 hit.is_some_and(
890 |id| std::iter::successors(Some(id), |id| tree.parent(*id)).any(|id| id == radio)
891 ),
892 "the corner of a bare radio's box resolved to {hit:?}"
893 );
894 tree.click(radio);
895 assert_eq!(selected.get(), 0);
896 }
897}