Skip to main content

teksilo_widgets/
radio_tile.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RadioTile — a "selectable card" radio option.
5//!
6//! A `RadioTile` behaves as a single radio button (`Role::RadioButton`,
7//! `set_toggled`) rendered as a bordered, rounded card: a leading icon, a
8//! bold title, an inline radio indicator, and a muted, wrapping description.
9//! Multiple tiles share a `Signal<usize>` — selecting one writes its `value`,
10//! which deselects every sibling observing the same signal (the `RadioButton`
11//! model). Group them with
12//! [`RadioTileGroup`](crate::radio_tile_group::RadioTileGroup) for layout,
13//! roving keyboard navigation, and the AT "N of M" positional announcement.
14//!
15//! ## Content model
16//!
17//! Typed slots cover the common case (matching the reference design):
18//! `.icon(..)`, `.title(..)`, `.description(..)`. For arbitrary content, the
19//! `.body(..)` slot replaces the description column with any widget subtree.
20//!
21//! ## Accessibility
22//!
23//! Reports `Role::RadioButton` with `set_toggled` mirroring selection, the
24//! title as the accessible name, and the description as the accessible
25//! description. When grouped, each tile emits
26//! `push_to_radio_group([sibling_ids])` plus `set_position_in_set` /
27//! the group's `set_size_of_set` for "N of M". Inside a `RadioTileGroup` the tile is not
28//! individually focusable — focus roves on the group (WAI-ARIA radiogroup),
29//! and the group publishes `active_descendant`. A standalone tile is
30//! focusable and responds to `Space` / `Action::Click`.
31//!
32//! ```ignore
33//! let selected = ctx.signal(0_usize);
34//! RadioTileGroup::new(selected)
35//!     .tile(RadioTile::new().icon(icon).title(tr!(single_file())).description(tr!(single_file_desc())))
36//!     .tile(RadioTile::new().icon(icon2).title(tr!(bundle())).description(tr!(bundle_desc())))
37//! ```
38//!
39//! ## Touch and pen
40//!
41//! A tile is a whole-card target, comfortably past the floor at every density, and
42//! it activates on the release. What the touch sweep changed is its **pressed**
43//! appearance: it was written by the `Space` path alone, so the recipe painted a
44//! state no pointer reached — on the one control where a press visual matters
45//! most, because there is no smaller affordance inside the card to look at.
46//!
47//! A tap by a contact also rests the tile idle rather than hovered, for the reason
48//! `ToolBox`'s header does: a finger sends no hover-leave, and the stale tint
49//! becomes visible as soon as the selection moves to another tile.
50
51use std::cell::RefCell;
52use std::rc::Rc;
53
54use teksilo_canvas::{Rect, SizeProposal};
55use teksilo_core::accessibility::AccessNodeBuilder;
56use teksilo_core::binding::BindingLevel;
57use teksilo_core::build_context::BuildContext;
58use teksilo_core::color_prop::{ColorProp, TextStyleProp};
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::signal::{Prop, Signal};
61use teksilo_core::styles::{
62    RadioStyleConfig, RadioTileStyle, RadioTileStyleConfig, RadioTileVariant, RadioVariant,
63    SharedRadioStyle, SharedRadioTileStyle,
64};
65use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
66use teksilo_core::widget_builder::HandlerSet;
67use teksilo_core::widget_id::WidgetId;
68use teksilo_tokens::{HAlignment, InputTokens, TextRole, TextStyleRole, VAlignment};
69
70use crate::button::InteractionState;
71use crate::primitives::{HStack, Spacer, TextWidget, VStack};
72use crate::styles::{RecipeRadioStyle, RecipeRadioTileStyle};
73use teksilo_core::styles::density::spacing;
74use teksilo_i18n::LocalizedString;
75
76/// Horizontal gap between the icon / title / indicator on a tile's top row.
77const TILE_ROW_GAP: f32 = 10.0;
78
79/// [`TILE_ROW_GAP`] scaled by the density's `spacing_factor`
80/// (1.00 / 1.15 / 1.30).
81fn tile_row_gap(tokens: &InputTokens) -> f32 {
82    spacing(TILE_ROW_GAP, tokens)
83}
84/// Vertical gap between the tile's title row and its description.
85const TILE_TITLE_DESC_GAP: f32 = 6.0;
86
87/// [`TILE_TITLE_DESC_GAP`] scaled by the density's `spacing_factor`
88/// (1.00 / 1.15 / 1.30).
89fn tile_title_desc_gap(tokens: &InputTokens) -> f32 {
90    spacing(TILE_TITLE_DESC_GAP, tokens)
91}
92
93/// Which side of the top row the radio indicator sits on. Defaults to
94/// `Trailing` (top-right in LTR), matching the reference design.
95#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
96pub enum RadioTileIndicatorSide {
97    /// Trailing edge of the row — top-right in LTR, top-left in RTL.
98    #[default]
99    Trailing,
100    /// Leading edge of the row — top-left in LTR, top-right in RTL.
101    Leading,
102}
103
104/// A single selectable-card radio option. See the [module docs](self).
105pub struct RadioTile {
106    value: usize,
107    selected: Signal<usize>,
108    icon: Option<Box<dyn Widget>>,
109    title: Option<LocalizedString>,
110    description: Option<LocalizedString>,
111    body: Option<Box<dyn Widget>>,
112    /// Right-aligned trailing meta text (e.g. "20 chapters") — tints to accent
113    /// when selected. Ignored when a `trailing_slot` is set.
114    trailing: Option<LocalizedString>,
115    trailing_slot: Option<Box<dyn Widget>>,
116    /// Compact single-line arrangement: `[indicator] [icon] [title] [Spacer]
117    /// [trailing]`, no description row (the vertical-list look). Set by
118    /// `RadioTileGroup::layout(TileLayout::Vertical)` or `.compact(true)`.
119    compact: bool,
120    title_style: Option<TextStyleProp>,
121    title_color: Option<ColorProp>,
122    description_style: Option<TextStyleProp>,
123    description_color: Option<ColorProp>,
124    /// Enabled state, static or reactive; forwarded to the arena at
125    /// build time.
126    enabled: Prop<bool>,
127    variant: RadioTileVariant,
128    show_indicator: bool,
129    indicator_side: RadioTileIndicatorSide,
130    tooltip_text: Option<LocalizedString>,
131    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
132    composite_tooltip_content: Option<Box<dyn Widget>>,
133    /// Where the tooltip opens relative to the tile. `Below` (default) suits
134    /// horizontal (`Row`) and 2-D (`Grid`) group layouts; a vertical group
135    /// (`Column` / `Vertical`) sets this to `Side` via
136    /// [`set_tooltip_placement`](Self::set_tooltip_placement) so the tooltip
137    /// doesn't cover the tile below.
138    tooltip_placement: crate::tooltip::TooltipPlacement,
139    style_override: Option<SharedRadioTileStyle>,
140    /// Set by `RadioTileGroup`: the tile is part of a roving radiogroup, so it
141    /// is not individually focusable and its focus ring follows the group.
142    grouped: bool,
143    group_focused: Option<Signal<bool>>,
144    group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
145    pos_in_set: Option<usize>,
146    root_child_id: Option<WidgetId>,
147}
148
149impl RadioTile {
150    /// Create a tile with no selection binding. The enclosing
151    /// [`RadioTileGroup`](crate::radio_tile_group::RadioTileGroup) assigns
152    /// this tile's `value` (its position) and shared selection signal. Use
153    /// [`selection`](Self::selection) for a standalone tile.
154    pub fn new() -> Self {
155        Self {
156            value: 0,
157            selected: Signal::new(0),
158            icon: None,
159            title: None,
160            description: None,
161            body: None,
162            trailing: None,
163            trailing_slot: None,
164            compact: false,
165            title_style: None,
166            title_color: None,
167            description_style: None,
168            description_color: None,
169            enabled: Prop::Static(true),
170            variant: RadioTileVariant::default(),
171            show_indicator: true,
172            indicator_side: RadioTileIndicatorSide::default(),
173            tooltip_text: None,
174            rich_tooltip_source: None,
175            composite_tooltip_content: None,
176            tooltip_placement: crate::tooltip::TooltipPlacement::Below,
177            style_override: None,
178            grouped: false,
179            group_focused: None,
180            group_ids: None,
181            pos_in_set: None,
182            root_child_id: None,
183        }
184    }
185
186    /// Bind this tile to an explicit `value` + shared `Signal<usize>` for use
187    /// **outside** a `RadioTileGroup`. Inside a group this is set automatically.
188    pub fn selection(mut self, value: usize, selected: Signal<usize>) -> Self {
189        self.value = value;
190        self.selected = selected;
191        self
192    }
193
194    /// Leading icon slot (top-left of the tile). Any widget — typically an
195    /// [`IconWidget`](crate::primitives::IconWidget).
196    pub fn icon(mut self, widget: impl Widget + 'static) -> Self {
197        self.icon = Some(Box::new(widget));
198        self
199    }
200
201    /// Leading icon slot, pre-boxed.
202    pub fn icon_boxed(mut self, widget: Box<dyn Widget>) -> Self {
203        self.icon = Some(widget);
204        self
205    }
206
207    /// Bold title text (the tile's accessible name).
208    pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
209        self.title = Some(title.into());
210        self
211    }
212
213    /// Muted, multi-line description (the tile's accessible description).
214    /// Ignored when a [`body`](Self::body) is set.
215    pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
216        self.description = Some(text.into());
217        self
218    }
219
220    /// Replace the description column with an arbitrary widget subtree. Takes
221    /// precedence over [`description`](Self::description). Note: a body's own
222    /// content is exposed to assistive technology as-is (unlike the typed
223    /// description, which is folded into the tile's accessible description).
224    pub fn body(mut self, widget: impl Widget + 'static) -> Self {
225        self.body = Some(Box::new(widget));
226        self
227    }
228
229    /// Custom body slot, pre-boxed.
230    pub fn body_boxed(mut self, widget: Box<dyn Widget>) -> Self {
231        self.body = Some(widget);
232        self
233    }
234
235    /// Right-aligned trailing meta text (e.g. "20 chapters", "free-form
236    /// notes"). Tints to the accent color when the tile is selected. Most
237    /// useful with the compact vertical arrangement. Ignored when a
238    /// [`trailing_slot`](Self::trailing_slot) is set.
239    pub fn trailing(mut self, text: impl Into<LocalizedString>) -> Self {
240        self.trailing = Some(text.into());
241        self
242    }
243
244    /// Arbitrary right-aligned trailing widget (badge, count, chevron, …).
245    /// Takes precedence over [`trailing`](Self::trailing).
246    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
247        self.trailing_slot = Some(Box::new(widget));
248        self
249    }
250
251    /// Compact single-line arrangement: `[indicator] [icon] [title] [Spacer]
252    /// [trailing]` with no description row — the vertical settings-list look.
253    /// `RadioTileGroup::layout(TileLayout::Vertical)` sets this automatically
254    /// (and moves the indicator to the leading edge).
255    pub fn compact(mut self, compact: bool) -> Self {
256        self.compact = compact;
257        self
258    }
259
260    /// Override the title text style (default `TextStyleRole::BodyBold`).
261    pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
262        self.title_style = Some(style.into());
263        self
264    }
265
266    /// Override the title text color (default `TextRole::Primary`).
267    pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
268        self.title_color = Some(color.into());
269        self
270    }
271
272    /// Override the description text style (default `TextStyleRole::Small`).
273    pub fn description_style(mut self, style: impl Into<TextStyleProp>) -> Self {
274        self.description_style = Some(style.into());
275        self
276    }
277
278    /// Override the description text color (default `TextRole::Secondary`).
279    pub fn description_color(mut self, color: impl Into<ColorProp>) -> Self {
280        self.description_color = Some(color.into());
281        self
282    }
283
284    /// Set the enabled state, statically or reactively. A disabled tile
285    /// is skipped by the group's keyboard navigation and cannot be
286    /// selected.
287    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
288        self.enabled = enabled.into();
289        self
290    }
291
292    /// Pick the card variant (default `Outlined`).
293    pub fn variant(mut self, variant: RadioTileVariant) -> Self {
294        self.variant = variant;
295        self
296    }
297
298    /// Whether to render the inline radio indicator (default `true`). When
299    /// `false`, the selection cue is the card highlight alone.
300    pub fn show_indicator(mut self, show: bool) -> Self {
301        self.show_indicator = show;
302        self
303    }
304
305    /// Which side of the top row the radio indicator sits on (default `Trailing`).
306    pub fn indicator_side(mut self, side: RadioTileIndicatorSide) -> Self {
307        self.indicator_side = side;
308        self
309    }
310
311    /// Per-call style override — replaces the theme-wide `RadioTileStyle`
312    /// for just this tile.
313    pub fn style(mut self, style: impl RadioTileStyle) -> Self {
314        self.style_override = Some(Rc::new(style));
315        self
316    }
317
318    /// Attach a plain single-line tooltip shown on hover.
319    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
320        self.tooltip_text = Some(text.into());
321        self.rich_tooltip_source = None;
322        self.composite_tooltip_content = None;
323        self
324    }
325
326    /// Attach a rich tooltip resolved from the app-wide tooltip registry.
327    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
328        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
329        self.tooltip_text = None;
330        self.composite_tooltip_content = None;
331        self
332    }
333
334    /// Attach a rich tooltip driven by inline `TooltipContent`.
335    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
336        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
337        self.tooltip_text = None;
338        self.composite_tooltip_content = None;
339        self
340    }
341
342    /// Attach a composite tooltip hosting an arbitrary widget tree.
343    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
344        self.composite_tooltip_content = Some(Box::new(content));
345        self.tooltip_text = None;
346        self.rich_tooltip_source = None;
347        self
348    }
349
350    // --- Injected by RadioTileGroup at build time (not public API) ---
351
352    pub(crate) fn set_selection(&mut self, value: usize, selected: Signal<usize>) {
353        self.value = value;
354        self.selected = selected;
355    }
356
357    /// `pos` is the tile's 1-based position. There is deliberately no `size`
358    /// parameter: AccessKit's `size_of_set` is a container property, so the
359    /// group publishes it on its own `Role::RadioGroup` node.
360    pub(crate) fn set_grouped(
361        &mut self,
362        group_focused: Signal<bool>,
363        group_ids: Rc<RefCell<Vec<WidgetId>>>,
364        pos: usize,
365    ) {
366        self.grouped = true;
367        self.group_focused = Some(group_focused);
368        self.group_ids = Some(group_ids);
369        self.pos_in_set = Some(pos);
370    }
371
372    pub(crate) fn is_enabled(&self) -> bool {
373        self.enabled.get()
374    }
375
376    /// Switch this tile to the compact vertical-list arrangement with a
377    /// leading radio indicator. Called by `RadioTileGroup` for
378    /// [`TileLayout::Vertical`](crate::radio_tile_group::TileLayout::Vertical).
379    pub(crate) fn set_vertical_arrangement(&mut self) {
380        self.compact = true;
381        self.indicator_side = RadioTileIndicatorSide::Leading;
382    }
383
384    /// Set where this tile's tooltip opens. Called by `RadioTileGroup` — a
385    /// vertical group (`Column` / `Vertical`) passes `Side` so the tooltip
386    /// opens beside the tile instead of covering the tile below.
387    pub(crate) fn set_tooltip_placement(&mut self, placement: crate::tooltip::TooltipPlacement) {
388        self.tooltip_placement = placement;
389    }
390
391    /// Apply a group-level style only when this tile has no per-call style of
392    /// its own (the tile's own `.style(...)` wins). Called by `RadioTileGroup`.
393    pub(crate) fn set_style_if_unset(&mut self, style: SharedRadioTileStyle) {
394        if self.style_override.is_none() {
395            self.style_override = Some(style);
396        }
397    }
398
399    fn is_selected(&self) -> bool {
400        self.selected.get() == self.value
401    }
402}
403
404impl Default for RadioTile {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410impl std::fmt::Debug for RadioTile {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        f.debug_struct("RadioTile")
413            .field("value", &self.value)
414            .field("title", &self.title)
415            .field("grouped", &self.grouped)
416            .finish()
417    }
418}
419
420impl Widget for RadioTile {
421    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
422        let selected = self.selected.clone();
423        let value = self.value;
424        let variant = self.variant;
425        let self_id = ctx.self_id();
426
427        ctx.enabled_when(self_id, self.enabled.clone());
428        let effective_enabled = ctx.effective_enabled_signal(self_id);
429
430        // Re-walk the AT tree when selection changes so `set_toggled` (and the
431        // group's `active_descendant`) stay current — selection is otherwise a
432        // repaint-only change. Matches GridView's selection binding.
433        {
434            let registry = ctx.binding_registry();
435            self.selected
436                .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
437        }
438
439        let interaction = ctx.signal(InteractionState::Idle);
440
441        let is_selected = selected.map(move |s| *s == value);
442        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
443        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
444        let is_disabled = effective_enabled.map(|on| !*on);
445        // Focus source: the group's focus when grouped (roving radiogroup),
446        // else this tile's own focus.
447        let is_focused = if let Some(gf) = &self.group_focused {
448            gf.clone()
449        } else {
450            interaction.map(|s| matches!(s, InteractionState::Focused))
451        };
452        let is_focus_visible = ctx.focus_visible();
453        let is_window_active = ctx.window_active_signal();
454
455        // --- Radio indicator: reuse the theme's RadioStyle so the glyph
456        // matches a standalone RadioButton. The glyph never draws its own
457        // focus ring (the tile owns the ring), so pass a constant `false`.
458        let indicator_id =
459            if self.show_indicator {
460                let radio_style: SharedRadioStyle =
461                    ctx.theme().style_slots.radio.clone().unwrap_or_else(|| {
462                        Rc::new(RecipeRadioStyle::for_tokens(&ctx.theme().input))
463                    });
464                let radio_cfg = RadioStyleConfig {
465                    is_selected: is_selected.clone(),
466                    is_hovered: is_hovered.clone(),
467                    is_pressed: is_pressed.clone(),
468                    is_focused: Signal::new(false),
469                    is_disabled: is_disabled.clone(),
470                    variant: RadioVariant::Circle,
471                };
472                Some(radio_style.make_body(&radio_cfg, ctx))
473            } else {
474                None
475            };
476
477        // --- Top row: [icon?] [title] [Spacer] [indicator?] (indicator side
478        // configurable; RTL handled by HStack + Spacer).
479        let mut top_row = HStack::new()
480            .spacing(tile_row_gap(&ctx.theme().input))
481            .alignment(VAlignment::Center);
482
483        if self.indicator_side == RadioTileIndicatorSide::Leading
484            && let Some(id) = indicator_id
485        {
486            top_row = top_row.child(id);
487        }
488        if let Some(icon) = self.icon.take() {
489            let icon_id = ctx.add_boxed(icon);
490            top_row = top_row.child(icon_id);
491        }
492        if let Some(title) = &self.title {
493            let title_widget = TextWidget::new(title.clone())
494                .style(
495                    self.title_style
496                        .clone()
497                        .unwrap_or(TextStyleProp::Role(TextStyleRole::BodyBold)),
498                )
499                .color(
500                    self.title_color
501                        .clone()
502                        .unwrap_or(ColorProp::TextRole(TextRole::Primary)),
503                )
504                .single_line()
505                .a11y_hidden();
506            let title_id = ctx.add(title_widget);
507            top_row = top_row.child(title_id);
508        }
509        top_row = top_row.child(ctx.add(Spacer::new()));
510        // Trailing meta (right-aligned). Typed text tints to accent when
511        // selected (the "20 chapters" cue); a custom slot is used as-is.
512        if let Some(slot) = self.trailing_slot.take() {
513            top_row = top_row.child(ctx.add_boxed(slot));
514        } else if let Some(trailing) = &self.trailing {
515            let trailing_color = is_selected.map(|s| {
516                if *s {
517                    TextRole::Accent
518                } else {
519                    TextRole::Secondary
520                }
521            });
522            let trailing_widget = TextWidget::new(trailing.clone())
523                .style(TextStyleProp::Role(TextStyleRole::Small))
524                .color(trailing_color)
525                .single_line()
526                .a11y_hidden();
527            top_row = top_row.child(ctx.add(trailing_widget));
528        }
529        if self.indicator_side == RadioTileIndicatorSide::Trailing
530            && let Some(id) = indicator_id
531        {
532            top_row = top_row.child(id);
533        }
534        let top_row_id = ctx.add(top_row);
535
536        // --- Content column: top row + (description|body, unless compact).
537        let mut content_col = VStack::new()
538            .spacing(tile_title_desc_gap(&ctx.theme().input))
539            .alignment(HAlignment::Leading)
540            .child(top_row_id);
541
542        if !self.compact {
543            if let Some(body) = self.body.take() {
544                let body_id = ctx.add_boxed(body);
545                content_col = content_col.child(body_id);
546            } else if let Some(description) = &self.description {
547                let desc_widget = TextWidget::new(description.clone())
548                    .style(
549                        self.description_style
550                            .clone()
551                            .unwrap_or(TextStyleProp::Role(TextStyleRole::Small)),
552                    )
553                    .color(
554                        self.description_color
555                            .clone()
556                            .unwrap_or(ColorProp::TextRole(TextRole::Secondary)),
557                    )
558                    .a11y_hidden();
559                let desc_id = ctx.add(desc_widget);
560                content_col = content_col.child(desc_id);
561            }
562        }
563        let content_id = ctx.add(content_col);
564
565        // --- Card chrome via the resolved RadioTileStyle.
566        let style: SharedRadioTileStyle = self
567            .style_override
568            .clone()
569            .or_else(|| ctx.theme().style_slots.radio_tile.clone())
570            .unwrap_or_else(|| Rc::new(RecipeRadioTileStyle::for_tokens(&ctx.theme().input)));
571        let cfg = RadioTileStyleConfig {
572            content: content_id,
573            is_selected: is_selected.clone(),
574            is_hovered: is_hovered.clone(),
575            is_pressed: is_pressed.clone(),
576            is_focused,
577            is_focus_visible,
578            is_disabled,
579            is_window_active,
580            variant,
581            is_compact: self.compact,
582        };
583        let root_id = style.make_body(&cfg, ctx);
584
585        // Placement is `Below` by default; a vertical group (`Column` /
586        // `Vertical`) injects `Side` via `set_tooltip_placement` so a tile's
587        // tooltip doesn't cover the tile below.
588        let tip_placement = self.tooltip_placement;
589        if let Some(content) = self.composite_tooltip_content.take() {
590            let delay = ctx.theme().motion.tooltip_delay_heavy;
591            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
592                ctx,
593                root_id,
594                content,
595                delay,
596                tip_placement,
597            );
598        } else if let Some(source) = self.rich_tooltip_source.take() {
599            let delay = ctx.theme().motion.tooltip_delay;
600            crate::tooltip::attach_rich_tooltip_source_with_placement(
601                ctx,
602                root_id,
603                source,
604                delay,
605                tip_placement,
606            );
607        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
608            let delay = ctx.theme().motion.tooltip_delay;
609            crate::tooltip::attach_plain_tooltip_with_placement(
610                ctx,
611                root_id,
612                tooltip_text,
613                delay,
614                tip_placement,
615            );
616        }
617
618        self.root_child_id = Some(root_id);
619
620        // --- Handlers. A grouped tile is not individually focusable (focus
621        // roves on the group); a standalone tile is focusable and takes Space.
622        let sel_tap = self.selected.clone();
623        let sel_access = self.selected.clone();
624        let int_tap = interaction.clone();
625        let int_hover = interaction.clone();
626        // The tile's pressed chrome, driven by the router's press record. It was
627        // previously written by the `Space` path alone, so the recipe's pressed
628        // appearance was unreachable for a mouse and for a finger alike — and a
629        // whole-card control is the one a press visual matters most on, because
630        // there is no smaller affordance inside it to look at. The framework
631        // press also survives a press sliding off the card and back on, and is
632        // withdrawn without a release when a surrounding scroller claims the
633        // pan, which is exactly what a tile in a scrolling settings pane needs.
634        let hovered_cell = Rc::new(std::cell::Cell::new(false));
635        {
636            let hovered = hovered_cell.clone();
637            crate::common::interaction::bind_press_state(
638                ctx,
639                interaction.clone(),
640                InteractionState::Pressed,
641                move || {
642                    if hovered.get() {
643                        InteractionState::Hovered
644                    } else {
645                        InteractionState::Idle
646                    }
647                },
648            );
649        }
650        let hovered_for_hover = hovered_cell.clone();
651
652        let mut handler_set = HandlerSet::new()
653            .on_tap(move |_pos, ctx: &mut EventContext| {
654                sel_tap.set(value);
655                // A mouse or a pen is still over the tile after the release; a
656                // finger is gone and sends no hover-leave to correct a
657                // `Hovered` state with.
658                int_tap.set(if ctx.pointer_kind().hovers() {
659                    InteractionState::Hovered
660                } else {
661                    InteractionState::Idle
662                });
663            })
664            .on_hover(move |entered: bool, _ctx: &mut EventContext| {
665                // Recorded beside the signal as well: while the tile is
666                // `Pressed` the hover truth has nowhere to live in the enum,
667                // and the press binding needs it to pick the resting state.
668                hovered_for_hover.set(entered);
669                if entered {
670                    int_hover.set(InteractionState::Hovered);
671                } else {
672                    int_hover.set(InteractionState::Idle);
673                }
674            })
675            .on_access_action(
676                move |action: teksilo_core::accesskit::Action, _ctx: &mut EventContext| {
677                    if action == teksilo_core::accesskit::Action::Click {
678                        sel_access.set(value);
679                        EventResponse::Handled
680                    } else {
681                        EventResponse::Ignored
682                    }
683                },
684            )
685            .cursor(CursorIcon::Pointer);
686
687        if !self.grouped {
688            let sel_key = self.selected.clone();
689            let int_key = interaction.clone();
690            let int_focus = interaction.clone();
691            handler_set = handler_set
692                .focusable(true)
693                .on_key(
694                    move |event: &WidgetEvent, _ctx: &mut EventContext| match event {
695                        WidgetEvent::KeyDown {
696                            key: Key::Space, ..
697                        } => {
698                            int_key.set(InteractionState::Pressed);
699                            EventResponse::Handled
700                        }
701                        WidgetEvent::KeyUp {
702                            key: Key::Space, ..
703                        } => {
704                            // Lone-KeyUp guard (see RadioButton).
705                            if int_key.get() != InteractionState::Pressed {
706                                return EventResponse::Ignored;
707                            }
708                            sel_key.set(value);
709                            int_key.set(InteractionState::Focused);
710                            EventResponse::Handled
711                        }
712                        _ => EventResponse::Ignored,
713                    },
714                )
715                .on_focus(move |gained: bool, _ctx: &mut EventContext| {
716                    if gained {
717                        if int_focus.get() == InteractionState::Idle {
718                            int_focus.set(InteractionState::Focused);
719                        }
720                    } else {
721                        int_focus.set(InteractionState::Idle);
722                    }
723                });
724        }
725
726        ctx.apply_self_handlers(handler_set);
727
728        vec![root_id]
729    }
730
731    fn layout_response(
732        &self,
733        proposal: SizeProposal,
734        ctx: &LayoutContext,
735    ) -> teksilo_core::widget::LayoutResponse {
736        if let Some(root) = self.root_child_id
737            && let Some(size) = ctx.child_size(root, proposal)
738        {
739            return size.into();
740        }
741        proposal.resolve(0.0, 0.0).into()
742    }
743
744    fn place_children(
745        &self,
746        bounds: Rect,
747        _proposal: SizeProposal,
748        children: &mut [WidgetPlacement],
749        _ctx: &LayoutContext,
750    ) {
751        for child in children.iter_mut() {
752            child.origin = bounds.origin();
753            child.size = bounds.size();
754        }
755    }
756
757    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
758        builder.set_role(teksilo_core::accesskit::Role::RadioButton);
759        if let Some(ref title) = self.title {
760            builder.set_name(title.resolve_now());
761        }
762        if let Some(ref description) = self.description {
763            builder.set_description(description.resolve_now());
764        } else if let Some(ref trailing) = self.trailing {
765            // In the compact arrangement the trailing meta carries the
766            // secondary info, so expose it as the accessible description.
767            builder.set_description(trailing.resolve_now());
768        }
769        // ARIA role="radio" uses aria-checked (→ AccessKit `toggled`).
770        builder.set_toggled(self.is_selected());
771        // "N of M" positional info (set by the group).
772        if let Some(pos) = self.pos_in_set {
773            builder.set_position_in_set(pos);
774        }
775        // The "of N" half lives on the `RadioTileGroup`'s own
776        // `Role::RadioGroup` node; `size_of_set` is a container property in
777        // AccessKit, unlike ARIA's per-item `aria-setsize`.
778        // Radio-group membership — each tile declares every sibling (incl.
779        // itself) so AT can announce positional info.
780        if let Some(group_ids) = &self.group_ids {
781            for &id in group_ids.borrow().iter() {
782                builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
783            }
784        }
785        builder.add_action(teksilo_core::accesskit::Action::Click);
786        // Only a standalone tile is a direct focus target; a grouped tile is
787        // reached via the group's roving `active_descendant`.
788        if !self.grouped {
789            builder.add_action(teksilo_core::accesskit::Action::Focus);
790        }
791    }
792
793    fn children(&self) -> Vec<WidgetId> {
794        self.root_child_id.into_iter().collect()
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use teksilo_core::event::Modifiers;
802    use teksilo_core::widget_tree::WidgetTree;
803    use teksilo_i18n::lit;
804    use teksilo_tokens::Color;
805
806    #[test]
807    fn standalone_tap_and_space_select() {
808        let selected = Signal::new(0_usize);
809        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
810        let t0 = tree.add(
811            RadioTile::new()
812                .selection(0, selected.clone())
813                .title(lit!("A")),
814        );
815        let t1 = tree.add(
816            RadioTile::new()
817                .selection(1, selected.clone())
818                .title(lit!("B")),
819        );
820        let _root = tree.add(crate::primitives::VStack::new().child(t0).child(t1));
821        tree.layout(SizeProposal::exact(300.0, 300.0));
822
823        assert_eq!(selected.get(), 0);
824        tree.click(t1);
825        assert_eq!(selected.get(), 1);
826
827        // A standalone tile is focusable and Space-selectable.
828        tree.focus(t0);
829        tree.press_key(Key::Space, Modifiers::NONE);
830        assert_eq!(selected.get(), 0);
831    }
832
833    #[test]
834    fn accessibility_role_and_toggled() {
835        let selected = Signal::new(1_usize);
836        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
837        let t0 = tree.add(
838            RadioTile::new()
839                .selection(0, selected.clone())
840                .title(lit!("A"))
841                .description(lit!("first choice")),
842        );
843        tree.layout(SizeProposal::exact(300.0, 200.0));
844        let info = tree.accessibility_node(t0);
845        assert_eq!(info.role(), teksilo_core::accesskit::Role::RadioButton);
846        assert_eq!(info.name(), Some("A"));
847        assert!(!info.is_toggled());
848    }
849
850    #[test]
851    fn compact_tile_omits_description_and_is_shorter() {
852        use crate::primitives::{FixedSize, VStack};
853        let long = "a long description that would wrap across several lines inside the tile body";
854        let selected = Signal::new(0_usize);
855        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
856        let compact = tree.add(
857            FixedSize::new().width(300.0).child(
858                RadioTile::new()
859                    .selection(0, selected.clone())
860                    .title(lit!("A"))
861                    .description(lit!(long))
862                    .compact(true),
863            ),
864        );
865        let card = tree.add(
866            FixedSize::new().width(300.0).child(
867                RadioTile::new()
868                    .selection(0, selected.clone())
869                    .title(lit!("B"))
870                    .description(lit!(long)),
871            ),
872        );
873        let _root = tree.add(VStack::new().child(compact).child(card));
874        tree.layout(SizeProposal::exact(320.0, 600.0));
875        let a = tree.find_by_label("A").unwrap();
876        let b = tree.find_by_label("B").unwrap();
877        assert!(
878            tree.bounds(a).height < tree.bounds(b).height,
879            "compact tile drops the wrapping description row, so it is shorter"
880        );
881    }
882
883    // Sentinel style painting a distinctive fill, to exercise Tier-3 precedence.
884    #[derive(Debug)]
885    struct SentinelTile(Color);
886    impl RadioTileStyle for SentinelTile {
887        fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
888            let rect = ctx.add(crate::primitives::RectWidget::new().background(self.0));
889            ctx.add(
890                crate::primitives::ZStack::new()
891                    .child(rect)
892                    .child(cfg.content),
893            )
894        }
895    }
896
897    fn renders_color(tree: &mut WidgetTree, color: Color) -> bool {
898        tree.layout(SizeProposal::exact(200.0, 100.0));
899        let frame = tree.render();
900        frame.shapes.iter().any(|s| s.color == color.to_array())
901    }
902
903    #[test]
904    fn theme_slot_supplies_style_when_no_override() {
905        let mut theme = teksilo_core::presets::intui::light();
906        theme.style_slots.radio_tile =
907            Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
908        let selected = Signal::new(0_usize);
909        let mut tree = WidgetTree::new().with_theme(theme);
910        tree.add(RadioTile::new().selection(0, selected).title(lit!("X")));
911        assert!(
912            renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
913            "theme slot style should paint the sentinel fill"
914        );
915    }
916
917    #[test]
918    fn per_call_style_override_wins_over_theme_slot() {
919        let mut theme = teksilo_core::presets::intui::light();
920        theme.style_slots.radio_tile =
921            Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
922        let per_call = Color::from_rgba(0.0, 1.0, 0.0, 1.0);
923        let selected = Signal::new(0_usize);
924        let mut tree = WidgetTree::new().with_theme(theme);
925        tree.add(
926            RadioTile::new()
927                .selection(0, selected)
928                .title(lit!("X"))
929                .style(SentinelTile(per_call)),
930        );
931        assert!(
932            renders_color(&mut tree, per_call),
933            "per-call .style() should win over the theme slot"
934        );
935        assert!(
936            !renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
937            "theme-slot fill must not appear when overridden per-call"
938        );
939    }
940}