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//! ## Label styling
14//!
15//! The label is `Body` / `Primary` and the caption `Small` / `Secondary` by
16//! default. [`RadioButton::label_style`] / [`RadioButton::label_color`] and
17//! [`RadioButton::caption_style`] / [`RadioButton::caption_color`] override
18//! them. A theme role colour still dims when the button is disabled; a literal
19//! `Color` stays as given.
20//!
21//! ## Touch and pen
22//!
23//! Same shape as [`Checkbox`](crate::Checkbox): the pressed state is the
24//! framework's (`docs/touch-and-pen.md` §7.1), selection lands on the release,
25//! and the 24 dp `MinSize` around the 19 dp dot already clears the conformance
26//! floor at Compact.
27//!
28//! ## Accessibility
29//!
30//! Reports `Role::RadioButton` with `set_toggled` mirroring the selected
31//! state. Responds to `Action::Click` from assistive technology. The focus
32//! ring is keyboard-only (`:focus-visible` gated by the input-modality
33//! signal). When wrapped in `RadioGroup`, each button emits
34//! `push_to_radio_group([sibling_ids])` so screen readers can announce
35//! positional membership.
36//!
37//! ```rust
38//! # use teksilo_widgets::RadioButton;
39//! # use teksilo_core::signal::Signal;
40//! # use teksilo_i18n::lit;
41//! let selected = Signal::new(0_usize);
42//! let _r0 = RadioButton::new(0, selected.clone()).label(lit!("Light"));
43//! let _r1 = RadioButton::new(1, selected.clone()).label(lit!("Dark"));
44//! let _r2 = RadioButton::new(2, selected.clone()).label(lit!("System"));
45//! ```
46
47use 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
66/// A single radio button option that writes `value` into a shared `Signal<usize>` on selection.
67pub struct RadioButton {
68    label: Option<LocalizedString>,
69    caption: Option<LocalizedString>,
70    value: usize,
71    selected: Signal<usize>,
72    /// Enabled state, static or reactive; forwarded to the arena at
73    /// build time.
74    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    /// Shared radio-group sibling id buffer populated by an enclosing
87    /// `RadioGroup`. When set, `accessibility()` emits
88    /// `push_to_radio_group(sibling_id)` for every id in the buffer
89    /// so screen readers can announce "2 of 3" positional info.
90    /// Loose radios not wrapped in a RadioGroup leave this `None`
91    /// and drop the group membership metadata.
92    group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
93}
94
95impl RadioButton {
96    /// Run `f` when the **user** selects this button and it was not already
97    /// selected, with this button's value and an `EventContext`, so it can do
98    /// what a bare `Signal` write cannot (`ctx.send_intent(...)`,
99    /// `ctx.set_locale(...)`, opening a window). Fires for the pointer, for
100    /// `Space`, and for an assistive-technology `Click`.
101    ///
102    /// Re-activating the selected button writes the signal, as every path
103    /// does, but reports nothing: that is not a change. Programmatic writes to
104    /// the bound signal report nothing either — there is no event in flight to
105    /// carry. Observe the signal for those.
106    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    /// Create a radio button with the given `value` and shared selection signal.
112    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    /// Called by `RadioGroup` at build time to install the shared
135    /// sibling-id buffer. Not part of the public fluent API —
136    /// users wrap radios in `RadioGroup::new().radio(...)` rather
137    /// than threading the buffer manually.
138    pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
139        self.group_ids = Some(ids);
140    }
141
142    /// Set the visible label text displayed to the right of the radio circle.
143    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    /// Secondary explanatory text rendered below the label, left-aligned
150    /// with the label (not the radio circle). Uses the `small` /
151    /// `text_secondary` style. Has no effect unless `label(...)` is also set.
152    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    /// Override the label's text style (font, size, weight). Accepts a
159    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default is
160    /// `TextStyleRole::Body`.
161    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    /// Override the label's text color. Accepts `Color`, a theme role, or a
170    /// `Signal` of either. Default is `TextRole::Primary`. A role still dims
171    /// to the disabled color when the radio button is disabled; a literal `Color`
172    /// stays as given.
173    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    /// Override the caption's text style. Default is `TextStyleRole::Small`.
179    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    /// Override the caption's text color. Accepts `Color`, a theme role, or
188    /// a `Signal` of either. Default is `TextRole::Secondary`. A role still
189    /// dims to the disabled color when the radio button is disabled; a literal
190    /// `Color` stays as given.
191    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    /// Set the enabled state, statically or reactively. Forwarded to
197    /// the arena via `ctx.enabled_when(self_id, self.enabled.clone())`
198    /// at build time.
199    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
200        self.enabled = enabled.into();
201        self
202    }
203
204    /// Pick the design-language variant. Default `Circle`. The active
205    /// `RadioStyle` impl decides what the variant means visually.
206    pub fn variant(mut self, variant: RadioVariant) -> Self {
207        self.variant = variant;
208        self
209    }
210
211    /// Per-call style override. Replaces the theme-wide default
212    /// `RadioStyle` for just this RadioButton instance.
213    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    /// Attach a plain single-line tooltip shown on hover.
219    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    /// Attach a rich tooltip resolved from the app-wide tooltip
227    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
228    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    /// Attach a rich tooltip driven by inline `TooltipContent`.
236    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    /// Attach a composite tooltip — third tier, hosting an arbitrary
244    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
245    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
270/// Internal interaction state — local to this widget's handlers; the
271/// active `RadioStyle` only sees the four derived boolean signals
272impl 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        // Forward the enabled state into the arena; see IconButton.
281        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        // `accessibility()` reads the selection only when the tree is walked,
287        // and the style's repaint does not walk it; see `Checkbox::build`.
288        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        // `:focus-visible`: reveal the focus ring during keyboard navigation
298        // only, not on a mouse click. Gate raw focus on the input-modality
299        // signal (true after a key event, false after pointer-down).
300        let is_focused = interaction
301            .map(|s| matches!(s, InteractionState::Focused))
302            .and(&ctx.focus_visible());
303        // is_disabled derives from the arena.
304        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        // Top-align so the radio circle sits next to the label's first line
349        // instead of the vertical center of the label+caption column.
350        if self.caption.is_some() && self.label.is_some() {
351            row = row.alignment(VAlignment::Top);
352        }
353
354        // The hit box comes from the same recipe the chrome was built from, so
355        // a density switch moves both together.
356        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        // --- V2 attached handlers ---
374        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                // Re-activating the button that is already selected is not a
380                // change, and `on_change` says change. The write still happens
381                // so every path stays idempotent.
382                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        // The pointer press is the framework's, not this control's own: the
400        // router knows about a press that slid off its target, one that slid
401        // back on, and one a pan claimant took away with no release to reset
402        // from — none of which a `PointerDown` / `PointerUp` pair here can
403        // see. `docs/touch-and-pen.md` §7.1. `pointer_over` carries the hover
404        // truth across the press, so a press that ends without an activation
405        // rests on the right state.
406        let pointer_over = Rc::new(Cell::new(false));
407        crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
408
409        // Framework gates events on arena.is_enabled; no per-handler
410        // snapshot guards anymore.
411        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                            // Lone-KeyUp guard: only select if we saw the
448                            // matching KeyDown (state is Pressed). A stray KeyUp
449                            // — e.g. a shortcut consumed the KeyDown and focus
450                            // returned here — must NOT select.
451                            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    /// The first implementer of the hit-targeting **shape** hook: a labelled
520    /// radio is its whole row, but a bare one is a disc inside a square box.
521    ///
522    /// A `RadioButton` with a label is tappable across the label too — the row
523    /// *is* the target — so the default rectangular distance is exactly right
524    /// and this returns it unchanged. A **bare** radio (a cell in a table, a
525    /// tight option grid) is a 19 dp disc centred in a 24 dp box, and measuring
526    /// a near miss to the box would offer the same reach diagonally past its
527    /// corner as straight out from its edge — where the corner is 3 dp further
528    /// from the thing the user aimed at. Measuring to the disc makes the slop
529    /// follow the silhouette, so a miss past the corner loses to a neighbour
530    /// that is genuinely nearer.
531    ///
532    /// This is consulted **only** by the miss-only slop pass, never by the
533    /// exact one, so a click inside the box's corner still selects the radio
534    /// exactly as it always has — `hit_shape` is deliberately left alone.
535    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        // ARIA role="radio" uses aria-checked (→ AccessKit `toggled`),
561        // not aria-selected. `selected` is for options, tabs, and grid cells.
562        builder.set_toggled(self.is_selected());
563        // Publish radio-group membership if this button was wrapped
564        // in a `RadioGroup`. Each button declares every sibling
565        // (including itself) so AT can announce "2 of 3".
566        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        // Framework a11y walker sets `set_disabled` from arena state.
572        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    /// Records the size every text run was shaped at, so a test can see which
589    /// style reached the label and which the caption. The mock backend
590    /// ignores styles.
591    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        // Space on a radio button, or a screen reader's own click, moved the
743        // selection and told no platform: both buttons kept their old state
744        // until something unrelated walked the tree again.
745        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        // Already selected: the write still lands, but nothing changed, so
813        // nothing is reported.
814        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        // Lone-KeyUp guard: a KeyUp with no matching KeyDown must NOT select.
864        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    // -----------------------------------------------------------------
910    // The framework press (docs/touch-and-pen.md §7.1)
911    // -----------------------------------------------------------------
912
913    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    /// Where the radio comes to rest after a selection — its own copy of the
952    /// button family's `on_tap` resting-state rule, which is duplicated per
953    /// control rather than shared, so it needs its own probe.
954    #[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    /// The mouse path: press lights the state the style has always been given,
983    /// release selects.
984    #[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    /// And a finger, with the slide-off abort in the middle.
998    #[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    /// A 24 dp hit box around a 19 dp dot: at the floor already, so no widening
1028    /// mechanism is involved.
1029    #[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    /// A **bare** radio measures a near miss to its disc, so a press past its
1054    /// box's corner is further away than one past its edge.
1055    ///
1056    /// Measured on the widget directly, the way its labelled twin below is.
1057    /// The tree-level version this replaced placed its probe points *outside*
1058    /// the radio's box but within the disc's reach, and that arrangement only
1059    /// existed while the box was smaller than the density's `target_size`:
1060    /// P20's density projection makes a radio's box exactly `target_size` at
1061    /// every density, so the miss-only slop pass — whose per-node top-up is
1062    /// `(target_size - min(w, h)) / 2` — now has nothing left to add for this
1063    /// control, and no point outside the box is in reach of a disc that stayed
1064    /// 19 dp. What the test is actually about is the *shape* of the measure,
1065    /// which this asserts without depending on the reach at all.
1066    #[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        // Straight out from the edge, and diagonally past the corner at the
1074        // same axis distance. A rectangle would call these equally far; a disc
1075        // does not.
1076        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        // And it really is the disc, not the box: a point on the box's own edge
1090        // is already a positive distance from the disc inside it.
1091        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    /// A **labelled** radio is its whole row, so it keeps the rectangular
1099    /// measure — the corner of a row is not further from the target than its
1100    /// edge, because the row IS the target.
1101    #[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    /// The exact pass is untouched: a click inside the box's corner still
1117    /// selects the radio, exactly as it always has.
1118    #[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        // The exact pass resolves to the deepest node, which is inside the
1129        // radio's own subtree — what matters is that the corner is still hit at
1130        // all, and that clicking it still selects.
1131        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}