Skip to main content

teksilo_widgets/
radio_button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RadioButton — mutually exclusive selection control.
5//!
6//! Multiple `RadioButton`s share a `Signal<usize>`; selecting one writes its
7//! `value` to the signal, which automatically deselects every sibling that
8//! observes the same signal. The widget is non-generic: values are `usize`
9//! indices into the caller's choice list. Wrap related buttons in a
10//! [`RadioGroup`](crate::radio_group::RadioGroup) to provide the AT "2 of 3"
11//! positional announcement required by ARIA.
12//!
13//! ## Touch and pen
14//!
15//! Same shape as [`Checkbox`](crate::Checkbox): the pressed state is the
16//! framework's (`docs/touch-and-pen.md` §7.1), selection lands on the release,
17//! and the 24 dp `MinSize` around the 19 dp dot already clears the conformance
18//! floor at Compact.
19//!
20//! ## Accessibility
21//!
22//! Reports `Role::RadioButton` with `set_toggled` mirroring the selected
23//! state. Responds to `Action::Click` from assistive technology. The focus
24//! ring is keyboard-only (`:focus-visible` gated by the input-modality
25//! signal). When wrapped in `RadioGroup`, each button emits
26//! `push_to_radio_group([sibling_ids])` so screen readers can announce
27//! positional membership.
28//!
29//! ```rust
30//! # use teksilo_widgets::RadioButton;
31//! # use teksilo_core::signal::Signal;
32//! # use teksilo_i18n::lit;
33//! let selected = Signal::new(0_usize);
34//! let _r0 = RadioButton::new(0, selected.clone()).label(lit!("Light"));
35//! let _r1 = RadioButton::new(1, selected.clone()).label(lit!("Dark"));
36//! let _r2 = RadioButton::new(2, selected.clone()).label(lit!("System"));
37//! ```
38
39use 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
57/// A single radio button option that writes `value` into a shared `Signal<usize>` on selection.
58pub struct RadioButton {
59    label: Option<LocalizedString>,
60    caption: Option<LocalizedString>,
61    value: usize,
62    selected: Signal<usize>,
63    /// Enabled state, static or reactive; forwarded to the arena at
64    /// build time.
65    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    /// Shared radio-group sibling id buffer populated by an enclosing
74    /// `RadioGroup`. When set, `accessibility()` emits
75    /// `push_to_radio_group(sibling_id)` for every id in the buffer
76    /// so screen readers can announce "2 of 3" positional info.
77    /// Loose radios not wrapped in a RadioGroup leave this `None`
78    /// and drop the group membership metadata.
79    group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
80}
81
82impl RadioButton {
83    /// Run `f` when the **user** selects this button and it was not already
84    /// selected, with this button's value and an `EventContext`, so it can do
85    /// Create a radio button with the given `value` and shared selection signal.
86    /// Run `f` when the **user** selects this button and it was not already
87    /// selected, with this button's value and an `EventContext`, so it can do
88    /// what a bare `Signal` write cannot (`ctx.send_intent(...)`,
89    /// `ctx.set_locale(...)`, opening a window). Fires for the pointer, for
90    /// `Space`, and for an assistive-technology `Click`.
91    ///
92    /// Re-activating the selected button writes the signal, as every path
93    /// does, but reports nothing: that is not a change. Programmatic writes to
94    /// the bound signal report nothing either — there is no event in flight to
95    /// carry. Observe the signal for those.
96    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    /// Called by `RadioGroup` at build time to install the shared
120    /// sibling-id buffer. Not part of the public fluent API —
121    /// users wrap radios in `RadioGroup::new().radio(...)` rather
122    /// than threading the buffer manually.
123    pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
124        self.group_ids = Some(ids);
125    }
126
127    /// Set the visible label text displayed to the right of the radio circle.
128    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    /// Secondary explanatory text rendered below the label, left-aligned
135    /// with the label (not the radio circle). Uses the `small` /
136    /// `text_secondary` style. Has no effect unless `label(...)` is also set.
137    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    /// Set the enabled state, statically or reactively. Forwarded to
144    /// the arena via `ctx.enabled_when(self_id, self.enabled.clone())`
145    /// at build time.
146    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
147        self.enabled = enabled.into();
148        self
149    }
150
151    /// Pick the design-language variant. Default `Circle`. The active
152    /// `RadioStyle` impl decides what the variant means visually.
153    pub fn variant(mut self, variant: RadioVariant) -> Self {
154        self.variant = variant;
155        self
156    }
157
158    /// Per-call style override. Replaces the theme-wide default
159    /// `RadioStyle` for just this RadioButton instance.
160    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    /// Attach a plain single-line tooltip shown on hover.
166    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    /// Attach a rich tooltip resolved from the app-wide tooltip
174    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
175    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    /// Attach a rich tooltip driven by inline `TooltipContent`.
183    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    /// Attach a composite tooltip — third tier, hosting an arbitrary
191    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
192    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
217/// Internal interaction state — local to this widget's handlers; the
218/// active `RadioStyle` only sees the four derived boolean signals
219impl 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        // Forward the enabled state into the arena; see IconButton.
228        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        // `:focus-visible`: reveal the focus ring during keyboard navigation
237        // only, not on a mouse click. Gate raw focus on the input-modality
238        // signal (true after a key event, false after pointer-down).
239        let is_focused = interaction
240            .map(|s| matches!(s, InteractionState::Focused))
241            .and(&ctx.focus_visible());
242        // is_disabled derives from the arena.
243        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        // Top-align so the radio circle sits next to the label's first line
288        // instead of the vertical center of the label+caption column.
289        if self.caption.is_some() && self.label.is_some() {
290            row = row.alignment(VAlignment::Top);
291        }
292
293        // The hit box comes from the same recipe the chrome was built from, so
294        // a density switch moves both together.
295        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        // --- V2 attached handlers ---
313        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                // Re-activating the button that is already selected is not a
319                // change, and `on_change` says change. The write still happens
320                // so every path stays idempotent.
321                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        // The pointer press is the framework's, not this control's own: the
339        // router knows about a press that slid off its target, one that slid
340        // back on, and one a pan claimant took away with no release to reset
341        // from — none of which a `PointerDown` / `PointerUp` pair here can
342        // see. `docs/touch-and-pen.md` §7.1. `pointer_over` carries the hover
343        // truth across the press, so a press that ends without an activation
344        // rests on the right state.
345        let pointer_over = Rc::new(Cell::new(false));
346        crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
347
348        // Framework gates events on arena.is_enabled; no per-handler
349        // snapshot guards anymore.
350        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                            // Lone-KeyUp guard: only select if we saw the
387                            // matching KeyDown (state is Pressed). A stray KeyUp
388                            // — e.g. a shortcut consumed the KeyDown and focus
389                            // returned here — must NOT select.
390                            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    /// The first implementer of the hit-targeting **shape** hook: a labelled
459    /// radio is its whole row, but a bare one is a disc inside a square box.
460    ///
461    /// A `RadioButton` with a label is tappable across the label too — the row
462    /// *is* the target — so the default rectangular distance is exactly right
463    /// and this returns it unchanged. A **bare** radio (a cell in a table, a
464    /// tight option grid) is a 19 dp disc centred in a 24 dp box, and measuring
465    /// a near miss to the box would offer the same reach diagonally past its
466    /// corner as straight out from its edge — where the corner is 3 dp further
467    /// from the thing the user aimed at. Measuring to the disc makes the slop
468    /// follow the silhouette, so a miss past the corner loses to a neighbour
469    /// that is genuinely nearer.
470    ///
471    /// This is consulted **only** by the miss-only slop pass, never by the
472    /// exact one, so a click inside the box's corner still selects the radio
473    /// exactly as it always has — `hit_shape` is deliberately left alone.
474    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        // ARIA role="radio" uses aria-checked (→ AccessKit `toggled`),
500        // not aria-selected. `selected` is for options, tabs, and grid cells.
501        builder.set_toggled(self.is_selected());
502        // Publish radio-group membership if this button was wrapped
503        // in a `RadioGroup`. Each button declares every sibling
504        // (including itself) so AT can announce "2 of 3".
505        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        // Framework a11y walker sets `set_disabled` from arena state.
511        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        // Already selected: the write still lands, but nothing changed, so
569        // nothing is reported.
570        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        // Lone-KeyUp guard: a KeyUp with no matching KeyDown must NOT select.
620        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    // -----------------------------------------------------------------
666    // The framework press (docs/touch-and-pen.md §7.1)
667    // -----------------------------------------------------------------
668
669    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    /// Where the radio comes to rest after a selection — its own copy of the
708    /// button family's `on_tap` resting-state rule, which is duplicated per
709    /// control rather than shared, so it needs its own probe.
710    #[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    /// The mouse path: press lights the state the style has always been given,
739    /// release selects.
740    #[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    /// And a finger, with the slide-off abort in the middle.
754    #[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    /// A 24 dp hit box around a 19 dp dot: at the floor already, so no widening
784    /// mechanism is involved.
785    #[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    /// A **bare** radio measures a near miss to its disc, so a press past its
810    /// box's corner is further away than one past its edge.
811    ///
812    /// Measured on the widget directly, the way its labelled twin below is.
813    /// The tree-level version this replaced placed its probe points *outside*
814    /// the radio's box but within the disc's reach, and that arrangement only
815    /// existed while the box was smaller than the density's `target_size`:
816    /// P20's density projection makes a radio's box exactly `target_size` at
817    /// every density, so the miss-only slop pass — whose per-node top-up is
818    /// `(target_size - min(w, h)) / 2` — now has nothing left to add for this
819    /// control, and no point outside the box is in reach of a disc that stayed
820    /// 19 dp. What the test is actually about is the *shape* of the measure,
821    /// which this asserts without depending on the reach at all.
822    #[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        // Straight out from the edge, and diagonally past the corner at the
830        // same axis distance. A rectangle would call these equally far; a disc
831        // does not.
832        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        // And it really is the disc, not the box: a point on the box's own edge
846        // is already a positive distance from the disc inside it.
847        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    /// A **labelled** radio is its whole row, so it keeps the rectangular
855    /// measure — the corner of a row is not further from the target than its
856    /// edge, because the row IS the target.
857    #[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    /// The exact pass is untouched: a click inside the box's corner still
873    /// selects the radio, exactly as it always has.
874    #[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        // The exact pass resolves to the deepest node, which is inside the
885        // radio's own subtree — what matters is that the corner is still hit at
886        // all, and that clicking it still selects.
887        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}