Skip to main content

teksilo_widgets/
radio_tile_group.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RadioTileGroup — an N-ary group of [`RadioTile`]s with single selection.
5//!
6//! Like [`SegmentedControl`](crate::segmented_control::SegmentedControl), the
7//! tile count is not fixed: add any number of tiles, all sharing one
8//! `Signal<usize>`. The group owns:
9//!
10//! - **Layout** — an equal-size [`TileLayout::Row`], an adaptive wrapping
11//!   [`TileLayout::Grid`], a full-width [`TileLayout::Column`], or a compact
12//!   fixed-height [`TileLayout::Vertical`] settings list. Row and Grid equalize
13//!   tile size (uniform width + the tallest tile's height) via a custom
14//!   `place_children` measuring each tile height-for-width — stacks have no
15//!   cross-axis stretch, so the group does the sizing.
16//! - **Keyboard** — the WAI-ARIA *roving radiogroup* pattern: the group is a
17//!   single Tab stop; Arrow keys move selection (selection follows focus),
18//!   Home/End jump, disabled tiles are skipped. `Increment`/`Decrement` AT
19//!   actions mirror the arrows for switch access.
20//! - **Accessibility** — `Role::RadioGroup` with `active_descendant` pointing
21//!   at the selected tile; each tile is `Role::RadioButton` and declares its
22//!   siblings via `push_to_radio_group` (for "N of M").
23//!
24//! ```ignore
25//! let selected = ctx.signal(0_usize);
26//! RadioTileGroup::new(selected)
27//!     .label(tr!(project_format()))
28//!     .tile(RadioTile::new().icon(a).title(tr!(single_file())).description(tr!(single_file_desc())))
29//!     .tile(RadioTile::new().icon(b).title(tr!(bundle())).description(tr!(bundle_desc())))
30//!     .layout(TileLayout::Row)
31//! ```
32
33use std::cell::{Cell, RefCell};
34use std::rc::Rc;
35
36use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::binding::BindingLevel;
39use teksilo_core::build_context::BuildContext;
40use teksilo_core::event::{EventResponse, Key, WidgetEvent};
41use teksilo_core::signal::{Prop, Signal};
42use teksilo_core::styles::SharedRadioTileStyle;
43use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
44use teksilo_core::widget_builder::HandlerSet;
45use teksilo_core::widget_id::WidgetId;
46use teksilo_tokens::CornerRadius;
47
48use crate::radio_tile::RadioTile;
49use crate::styles::{RADIO_TILE_CORNER_RADIUS, RADIO_TILE_VERTICAL_ROW_HEIGHT};
50use teksilo_i18n::LocalizedString;
51
52/// How a [`RadioTileGroup`] arranges its tiles.
53#[derive(Copy, Clone, Debug, PartialEq, Default)]
54pub enum TileLayout {
55    /// A single horizontal row of equal-width, equal-height tiles (the tiles
56    /// stretch to the tallest). The reference "two cards side-by-side" layout.
57    #[default]
58    Row,
59    /// A wrapping grid whose column count adapts to the available width:
60    /// `cols = floor((width + spacing) / (min_tile_width + spacing))`, at least
61    /// one. All cells share the same width and the tallest tile's height.
62    Grid {
63        /// Minimum width a tile may have before the grid drops a column.
64        min_tile_width: f32,
65    },
66    /// A vertical column of full-width tiles, each its natural height. Tiles
67    /// keep their full card content (icon + title + description).
68    Column,
69    /// A vertical list of **compact** fixed-height full-width rows: `[radio]
70    /// [icon] [title] [Spacer] [trailing]`, no description — the settings-list
71    /// look. Every row is a fixed height taken from the active
72    /// `RadioTileStyle` (the theme's `RadioTileRecipe::vertical_row_height`,
73    /// 44 dp by default; override per-group with [`RadioTileGroup::row_height`]),
74    /// and the group switches each tile to the compact arrangement (leading
75    /// radio) automatically.
76    Vertical,
77}
78
79/// Space (logical px) reserved around the tiles for the whole-group keyboard
80/// focus ring — the SegmentedControl envelope model.
81fn focus_ring_envelope(theme: &teksilo_core::Theme) -> f32 {
82    theme.shape.focus_ring_offset + theme.shape.focus_ring_width
83}
84
85/// An N-ary, single-selection group of selectable-card radios. See the
86/// [module docs](self).
87pub struct RadioTileGroup {
88    pending: Vec<RadioTile>,
89    selected: Signal<usize>,
90    label: Option<LocalizedString>,
91    layout: TileLayout,
92    /// Gap between tiles along the main axis (and between grid columns).
93    /// `None` uses a layout-appropriate default: 6 dp for the compact
94    /// `Vertical` list, 12 dp for `Row` / `Grid` / `Column`.
95    spacing: Option<f32>,
96    line_spacing: f32,
97    /// Fixed row height for [`TileLayout::Vertical`]; `None` takes the active
98    /// `RadioTileStyle`'s `vertical_row_height()`, falling back to
99    /// `RADIO_TILE_VERTICAL_ROW_HEIGHT`.
100    row_height: Option<f32>,
101    /// Enabled state for the whole group, static or reactive; forwarded
102    /// to the arena at build time.
103    enabled: Prop<bool>,
104    style_override: Option<SharedRadioTileStyle>,
105    /// Written by the group's `on_focus`.
106    group_focused: Signal<bool>,
107    /// `group_focused AND focus-visible` — drives the whole-group keyboard
108    /// focus ring (only after Tab navigation, not a mouse click). Computed in
109    /// `build()`, read in `paint()`.
110    ring_visible: Signal<bool>,
111    /// Shared sibling-id buffer (the `RadioGroup` pattern) for
112    /// `push_to_radio_group`.
113    group_ids: Rc<RefCell<Vec<WidgetId>>>,
114    tile_ids: Vec<WidgetId>,
115    /// Live column count, updated during layout and read by the Grid keyboard
116    /// navigation (which has no `LayoutContext`).
117    col_count: Rc<Cell<usize>>,
118}
119
120impl RadioTileGroup {
121    /// Create a group bound to the shared selection signal. Add tiles with
122    /// [`tile`](Self::tile) / [`tiles`](Self::tiles).
123    pub fn new(selected: Signal<usize>) -> Self {
124        Self {
125            pending: Vec::new(),
126            selected,
127            label: None,
128            layout: TileLayout::default(),
129            spacing: None,
130            line_spacing: 12.0,
131            row_height: None,
132            enabled: Prop::Static(true),
133            style_override: None,
134            group_focused: Signal::new(false),
135            ring_visible: Signal::new(false),
136            group_ids: Rc::new(RefCell::new(Vec::new())),
137            tile_ids: Vec::new(),
138            col_count: Rc::new(Cell::new(1)),
139        }
140    }
141
142    /// Accessible name for the group (announced before individual tiles).
143    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
144        self.label = Some(label.into());
145        self
146    }
147
148    /// Add a tile. Its `value` (position) and shared selection signal are
149    /// assigned automatically.
150    pub fn tile(mut self, tile: RadioTile) -> Self {
151        self.pending.push(tile);
152        self
153    }
154
155    /// Add several tiles from an iterator.
156    pub fn tiles(mut self, tiles: impl IntoIterator<Item = RadioTile>) -> Self {
157        self.pending.extend(tiles);
158        self
159    }
160
161    /// Choose the layout (default [`TileLayout::Row`]).
162    pub fn layout(mut self, layout: TileLayout) -> Self {
163        self.layout = layout;
164        self
165    }
166
167    /// Override the gap between tiles along the main axis (and grid columns).
168    /// Defaults to 6 dp for `TileLayout::Vertical`, 12 dp otherwise.
169    pub fn spacing(mut self, spacing: f32) -> Self {
170        self.spacing = Some(spacing);
171        self
172    }
173
174    /// Gap between rows in [`TileLayout::Grid`].
175    pub fn line_spacing(mut self, spacing: f32) -> Self {
176        self.line_spacing = spacing;
177        self
178    }
179
180    /// Override the fixed row height for [`TileLayout::Vertical`] compact rows.
181    /// Takes precedence over the theme value
182    /// (`RadioTileRecipe::vertical_row_height`, 44 dp by default). No effect on
183    /// other layouts.
184    pub fn row_height(mut self, height: f32) -> Self {
185        self.row_height = Some(height);
186        self
187    }
188
189    /// Set the enabled state for the whole group, statically or
190    /// reactively.
191    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
192        self.enabled = enabled.into();
193        self
194    }
195
196    /// Forward a `RadioTileStyle` to every tile that doesn't set its own
197    /// `.style(...)`.
198    pub fn style(mut self, style: impl teksilo_core::styles::RadioTileStyle) -> Self {
199        self.style_override = Some(Rc::new(style));
200        self
201    }
202
203    /// Next selectable index in `dir` (true = forward), wrapping and skipping
204    /// disabled tiles. Returns `current` if no other tile is enabled.
205    fn step(current: usize, forward: bool, disabled: &[bool]) -> usize {
206        let n = disabled.len();
207        if n == 0 {
208            return current;
209        }
210        let mut i = current;
211        for _ in 0..n {
212            i = if forward {
213                (i + 1) % n
214            } else {
215                (i + n - 1) % n
216            };
217            if !disabled[i] {
218                return i;
219            }
220        }
221        current
222    }
223
224    fn first_enabled(disabled: &[bool]) -> usize {
225        (0..disabled.len()).find(|&i| !disabled[i]).unwrap_or(0)
226    }
227
228    fn last_enabled(disabled: &[bool]) -> usize {
229        (0..disabled.len())
230            .rev()
231            .find(|&i| !disabled[i])
232            .unwrap_or(disabled.len().saturating_sub(1))
233    }
234
235    /// Vertical move by `±cols` in a grid, snapping to the nearest enabled tile
236    /// in that column-ish region; stays put if the move leaves the grid.
237    fn step_vertical(current: usize, down: bool, cols: usize, disabled: &[bool]) -> usize {
238        let n = disabled.len();
239        if n == 0 || cols == 0 {
240            return current;
241        }
242        let target = if down {
243            current + cols
244        } else if current >= cols {
245            current - cols
246        } else {
247            return current;
248        };
249        if target >= n {
250            return current;
251        }
252        if !disabled[target] {
253            return target;
254        }
255        // Landed on a disabled tile — scan forward to the nearest enabled one.
256        Self::step(target, true, disabled)
257    }
258
259    /// Compute per-tile rects (relative to the group origin) and the group's
260    /// total size for the given available width. Also refreshes `col_count`.
261    fn compute_layout(&self, avail_w: Option<f32>, ctx: &LayoutContext) -> (Vec<Rect>, Size) {
262        let n = self.tile_ids.len();
263        if n == 0 {
264            self.col_count.set(1);
265            return (Vec::new(), Size::new(0.0, 0.0));
266        }
267        let nf = n as f32;
268        let sp = self.spacing.unwrap_or(match self.layout {
269            TileLayout::Vertical => 6.0,
270            _ => 12.0,
271        });
272        let lsp = self.line_spacing;
273
274        let measure_h = |id: WidgetId, w: f32| -> f32 {
275            ctx.measure_intrinsic(
276                id,
277                SizeProposal {
278                    width: Some(w),
279                    height: None,
280                },
281            )
282            .map(|s| s.height)
283            .unwrap_or(0.0)
284        };
285
286        // Resolve an unbounded width to a natural single-row / single-column
287        // estimate so the group still reports a finite size.
288        let avail = avail_w.unwrap_or_else(|| {
289            let maxw = self
290                .tile_ids
291                .iter()
292                .map(|&id| {
293                    ctx.measure_intrinsic(id, SizeProposal::unspecified())
294                        .map(|s| s.width)
295                        .unwrap_or(0.0)
296                })
297                .fold(0.0_f32, f32::max);
298            match self.layout {
299                TileLayout::Column | TileLayout::Vertical => maxw,
300                _ => maxw * nf + (nf - 1.0) * sp,
301            }
302        });
303
304        match self.layout {
305            TileLayout::Row => {
306                self.col_count.set(n);
307                let tile_w = ((avail - (nf - 1.0) * sp) / nf).max(0.0);
308                let row_h = self
309                    .tile_ids
310                    .iter()
311                    .map(|&id| measure_h(id, tile_w))
312                    .fold(0.0_f32, f32::max);
313                let mut rects = Vec::with_capacity(n);
314                let mut x = 0.0;
315                for _ in 0..n {
316                    rects.push(Rect::new(x, 0.0, tile_w, row_h));
317                    x += tile_w + sp;
318                }
319                (rects, Size::new(avail, row_h))
320            }
321            TileLayout::Column => {
322                self.col_count.set(1);
323                let mut rects = Vec::with_capacity(n);
324                let mut y = 0.0;
325                for &id in &self.tile_ids {
326                    let h = measure_h(id, avail);
327                    rects.push(Rect::new(0.0, y, avail, h));
328                    y += h + sp;
329                }
330                let total_h = (y - sp).max(0.0);
331                (rects, Size::new(avail, total_h))
332            }
333            TileLayout::Vertical => {
334                // Compact rows are a fixed height (the settings-list
335                // convention) — not measured per tile. Precedence: an explicit
336                // `.row_height(..)` override, else the active `RadioTileStyle`'s
337                // theme value (group style → theme slot → recipe default).
338                self.col_count.set(1);
339                let h = self.row_height.unwrap_or_else(|| {
340                    self.style_override
341                        .as_ref()
342                        .or(ctx.theme.style_slots.radio_tile.as_ref())
343                        .map(|s| s.vertical_row_height())
344                        .unwrap_or(RADIO_TILE_VERTICAL_ROW_HEIGHT)
345                });
346                let mut rects = Vec::with_capacity(n);
347                let mut y = 0.0;
348                for _ in 0..n {
349                    rects.push(Rect::new(0.0, y, avail, h));
350                    y += h + sp;
351                }
352                let total_h = (h * nf + (nf - 1.0) * sp).max(0.0);
353                (rects, Size::new(avail, total_h))
354            }
355            TileLayout::Grid { min_tile_width } => {
356                let cols = (((avail + sp) / (min_tile_width + sp)).floor() as usize).clamp(1, n);
357                self.col_count.set(cols);
358                let colsf = cols as f32;
359                let cell_w = ((avail - (colsf - 1.0) * sp) / colsf).max(0.0);
360                let cell_h = self
361                    .tile_ids
362                    .iter()
363                    .map(|&id| measure_h(id, cell_w))
364                    .fold(0.0_f32, f32::max);
365                let rows = n.div_ceil(cols);
366                let mut rects = Vec::with_capacity(n);
367                for i in 0..n {
368                    let r = (i / cols) as f32;
369                    let c = (i % cols) as f32;
370                    rects.push(Rect::new(
371                        c * (cell_w + sp),
372                        r * (cell_h + lsp),
373                        cell_w,
374                        cell_h,
375                    ));
376                }
377                let total_h = rows as f32 * cell_h + (rows.saturating_sub(1)) as f32 * lsp;
378                (rects, Size::new(avail, total_h))
379            }
380        }
381    }
382}
383
384impl std::fmt::Debug for RadioTileGroup {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        f.debug_struct("RadioTileGroup")
387            .field("layout", &self.layout)
388            .field("num_tiles", &self.pending.len().max(self.tile_ids.len()))
389            .field("label", &self.label)
390            .finish()
391    }
392}
393
394impl Widget for RadioTileGroup {
395    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
396        let self_id = ctx.self_id();
397        ctx.enabled_when(self_id, self.enabled.clone());
398
399        // Whole-group keyboard focus ring: visible only when the group holds
400        // focus AND the last input was keyboard (`:focus-visible`).
401        let focus_visible = ctx.focus_visible();
402        let ring_visible = self.group_focused.and(&focus_visible);
403        self.ring_visible = ring_visible.clone();
404
405        // Re-walk AT on selection change so `active_descendant` stays current;
406        // repaint the ring when focus/modality flips.
407        {
408            let registry = ctx.binding_registry();
409            self.selected
410                .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
411            ring_visible.bind_to(self_id, registry, BindingLevel::RepaintOnly);
412        }
413
414        let pending = std::mem::take(&mut self.pending);
415        let n = pending.len();
416        self.group_ids.borrow_mut().clear();
417        self.tile_ids.clear();
418        let mut disabled: Vec<bool> = Vec::with_capacity(n);
419
420        // Two-pass: inject selection + group wiring before adding each tile,
421        // then record its id in the shared sibling buffer (the RadioGroup
422        // pattern).
423        for (i, mut tile) in pending.into_iter().enumerate() {
424            tile.set_selection(i, self.selected.clone());
425            tile.set_grouped(self.group_focused.clone(), self.group_ids.clone(), i + 1);
426            if self.layout == TileLayout::Vertical {
427                tile.set_vertical_arrangement();
428            }
429            // Vertical layouts (`Column` / `Vertical`) stack tiles top-to-
430            // bottom, so a tile's tooltip opens to the trailing side; `Row`
431            // (horizontal) and `Grid` (2-D) keep the default `Below`.
432            let tip_placement = match self.layout {
433                TileLayout::Column | TileLayout::Vertical => crate::tooltip::TooltipPlacement::Side,
434                TileLayout::Row | TileLayout::Grid { .. } => {
435                    crate::tooltip::TooltipPlacement::Below
436                }
437            };
438            tile.set_tooltip_placement(tip_placement);
439            if let Some(style) = &self.style_override {
440                tile.set_style_if_unset(style.clone());
441            }
442            disabled.push(!tile.is_enabled());
443            let id = ctx.add(tile);
444            self.group_ids.borrow_mut().push(id);
445            self.tile_ids.push(id);
446        }
447
448        let disabled: Rc<Vec<bool>> = Rc::new(disabled);
449        let layout = self.layout;
450        let col_count = self.col_count.clone();
451
452        let mut handlers = HandlerSet::new().focusable(true);
453
454        // Roving keyboard: selection follows focus (WAI-ARIA radiogroup).
455        {
456            let selected = self.selected.clone();
457            let disabled = disabled.clone();
458            let col_count = col_count.clone();
459            // Tile widget ids (already populated above), so the roving handler
460            // can reveal the newly-selected tile in an enclosing scroll area.
461            // The group holds focus (tiles aren't focusable when grouped), so
462            // the framework's focus-driven follow never reveals the tile — it
463            // only ever chases the group's own bounds.
464            let tile_ids = self.tile_ids.clone();
465            handlers = handlers.on_key(move |event, ctx: &mut EventContext| {
466                if n == 0 {
467                    return EventResponse::Ignored;
468                }
469                let cur = selected.get().min(n - 1);
470                let WidgetEvent::KeyDown { key, .. } = event else {
471                    return EventResponse::Ignored;
472                };
473                let next = match (layout, key) {
474                    // Grid: 2-D navigation.
475                    (TileLayout::Grid { .. }, Key::ArrowRight) => Self::step(cur, true, &disabled),
476                    (TileLayout::Grid { .. }, Key::ArrowLeft) => Self::step(cur, false, &disabled),
477                    (TileLayout::Grid { .. }, Key::ArrowDown) => {
478                        Self::step_vertical(cur, true, col_count.get(), &disabled)
479                    }
480                    (TileLayout::Grid { .. }, Key::ArrowUp) => {
481                        Self::step_vertical(cur, false, col_count.get(), &disabled)
482                    }
483                    // Row / Column: any arrow moves linearly.
484                    (_, Key::ArrowRight | Key::ArrowDown) => Self::step(cur, true, &disabled),
485                    (_, Key::ArrowLeft | Key::ArrowUp) => Self::step(cur, false, &disabled),
486                    (_, Key::Home) => Self::first_enabled(&disabled),
487                    (_, Key::End) => Self::last_enabled(&disabled),
488                    _ => return EventResponse::Ignored,
489                };
490                if next != cur {
491                    selected.set(next);
492                    // Reveal the newly-selected tile in any enclosing scroll
493                    // area — the vertical-column group inside a scrolling form
494                    // is the case this exists for.
495                    if let Some(&id) = tile_ids.get(next) {
496                        ctx.ensure_widget_visible(id);
497                    }
498                }
499                EventResponse::Handled
500            });
501        }
502
503        // Track group focus (drives tile focus rings + selection surface).
504        {
505            let group_focused = self.group_focused.clone();
506            handlers = handlers.on_focus(move |gained, _ctx: &mut EventContext| {
507                group_focused.set(gained);
508            });
509        }
510
511        // Increment / Decrement AT actions mirror the arrow keys — including
512        // revealing the newly-selected tile in an enclosing scroll area, since
513        // an AT action moves selection without moving focus (the focus-driven
514        // follow can't compensate), exactly like the on_key path above.
515        {
516            let selected = self.selected.clone();
517            let disabled = disabled.clone();
518            let tile_ids = self.tile_ids.clone();
519            handlers = handlers.on_access_action(move |action, ctx: &mut EventContext| {
520                if n == 0 {
521                    return EventResponse::Ignored;
522                }
523                let cur = selected.get().min(n - 1);
524                let next = if action == teksilo_core::accesskit::Action::Increment {
525                    Self::step(cur, true, &disabled)
526                } else if action == teksilo_core::accesskit::Action::Decrement {
527                    Self::step(cur, false, &disabled)
528                } else {
529                    return EventResponse::Ignored;
530                };
531                if next != cur {
532                    selected.set(next);
533                    if let Some(&id) = tile_ids.get(next) {
534                        ctx.ensure_widget_visible(id);
535                    }
536                }
537                EventResponse::Handled
538            });
539        }
540
541        ctx.apply_self_handlers(handlers);
542
543        self.tile_ids.clone()
544    }
545
546    fn layout_response(
547        &self,
548        proposal: SizeProposal,
549        ctx: &LayoutContext,
550    ) -> teksilo_core::widget::LayoutResponse {
551        // Reserve a focus-ring envelope around the tiles (the SegmentedControl
552        // model) so the whole-group ring has room outside the tile bounds.
553        let env = focus_ring_envelope(ctx.theme);
554        let inner_w = proposal.width.map(|w| (w - env * 2.0).max(0.0));
555        let (_rects, size) = self.compute_layout(inner_w, ctx);
556        Size::new(size.width + env * 2.0, size.height + env * 2.0).into()
557    }
558
559    fn place_children(
560        &self,
561        bounds: Rect,
562        _proposal: SizeProposal,
563        children: &mut [WidgetPlacement],
564        ctx: &LayoutContext,
565    ) {
566        let env = focus_ring_envelope(ctx.theme);
567        let inner_w = (bounds.width - env * 2.0).max(0.0);
568        let (rects, _size) = self.compute_layout(Some(inner_w), ctx);
569        for (child, rect) in children.iter_mut().zip(rects.iter()) {
570            child.origin = Point::new(bounds.x + env + rect.x, bounds.y + env + rect.y);
571            child.size = Size::new(rect.width, rect.height);
572        }
573    }
574
575    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
576        // One keyboard focus ring around the whole group, drawn in the
577        // reserved envelope outside the tiles. `focus_ring` desaturates itself
578        // in an inactive window (theme-side).
579        if !self.ring_visible.get() {
580            return;
581        }
582        let shape = &ctx.theme.shape;
583        let half = shape.focus_ring_width * 0.5;
584        let ring_rect = Rect::new(
585            bounds.x + half,
586            bounds.y + half,
587            (bounds.width - half * 2.0).max(0.0),
588            (bounds.height - half * 2.0).max(0.0),
589        );
590        let ring_radius = RADIO_TILE_CORNER_RADIUS + shape.focus_ring_offset + half;
591        canvas.stroke_rounded_rect(
592            ring_rect,
593            CornerRadius::uniform(ring_radius),
594            ctx.theme.colors.focus_ring,
595            shape.focus_ring_width,
596        );
597    }
598
599    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
600        builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
601        // The set size belongs on this container, not on each tile:
602        // AccessKit's `size_of_set` differs from ARIA's per-item
603        // `aria-setsize`, and `size_of_set_from_container` resolves an item's
604        // set size by walking *up* from it, so a count written on a
605        // `Role::RadioButton` is read by no adapter on any platform.
606        if !self.tile_ids.is_empty() {
607            builder.set_size_of_set(self.tile_ids.len());
608        }
609        if let Some(ref name) = self.label {
610            builder.set_name(name.resolve_now());
611        }
612        // Roving focus: focus stays on the group; point at the selected tile.
613        let idx = self.selected.get();
614        if let Some(&id) = self.tile_ids.get(idx) {
615            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(id));
616        }
617        builder.add_action(teksilo_core::accesskit::Action::Focus);
618        builder.add_action(teksilo_core::accesskit::Action::Increment);
619        builder.add_action(teksilo_core::accesskit::Action::Decrement);
620    }
621
622    fn children(&self) -> Vec<WidgetId> {
623        self.tile_ids.clone()
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use teksilo_core::event::Modifiers;
631    use teksilo_core::widget_tree::WidgetTree;
632    use teksilo_i18n::lit;
633
634    fn group_with(
635        selected: Signal<usize>,
636        layout: TileLayout,
637        descriptions: &[&'static str],
638    ) -> RadioTileGroup {
639        let labels = ["A", "B", "C", "D", "E", "F"];
640        let mut g = RadioTileGroup::new(selected).layout(layout);
641        for (i, desc) in descriptions.iter().enumerate() {
642            g = g.tile(
643                RadioTile::new()
644                    .title(lit!(labels[i]))
645                    .description(lit!(*desc)),
646            );
647        }
648        g
649    }
650
651    fn tile_ids(tree: &WidgetTree, n: usize) -> Vec<WidgetId> {
652        ["A", "B", "C", "D", "E", "F"][..n]
653            .iter()
654            .map(|l| {
655                tree.find_by_label(l)
656                    .unwrap_or_else(|| panic!("tile {l} not found"))
657            })
658            .collect()
659    }
660
661    #[test]
662    fn click_selects_tile_and_deselects_siblings() {
663        let selected = Signal::new(0_usize);
664        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
665        tree.add(group_with(
666            selected.clone(),
667            TileLayout::Row,
668            &["one", "two", "three"],
669        ));
670        tree.layout(SizeProposal::exact(600.0, 300.0));
671        let ids = tile_ids(&tree, 3);
672
673        assert_eq!(selected.get(), 0);
674        tree.click(ids[1]);
675        assert_eq!(selected.get(), 1);
676        tree.click(ids[2]);
677        assert_eq!(selected.get(), 2);
678    }
679
680    #[test]
681    fn roving_arrows_move_selection_and_wrap() {
682        let selected = Signal::new(0_usize);
683        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
684        let g = tree.add(group_with(
685            selected.clone(),
686            TileLayout::Row,
687            &["one", "two", "three"],
688        ));
689        tree.layout(SizeProposal::exact(600.0, 300.0));
690
691        tree.focus(g);
692        tree.press_key(Key::ArrowRight, Modifiers::NONE);
693        assert_eq!(selected.get(), 1);
694        tree.press_key(Key::ArrowRight, Modifiers::NONE);
695        assert_eq!(selected.get(), 2);
696        tree.press_key(Key::ArrowRight, Modifiers::NONE);
697        assert_eq!(selected.get(), 0, "wraps around");
698        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
699        assert_eq!(selected.get(), 2, "wraps backwards");
700        tree.press_key(Key::End, Modifiers::NONE);
701        assert_eq!(selected.get(), 2);
702        tree.press_key(Key::Home, Modifiers::NONE);
703        assert_eq!(selected.get(), 0);
704    }
705
706    #[test]
707    fn roving_skips_disabled_tile() {
708        let selected = Signal::new(0_usize);
709        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
710        let g = tree.add(
711            RadioTileGroup::new(selected.clone())
712                .layout(TileLayout::Row)
713                .tile(RadioTile::new().title(lit!("A")))
714                .tile(RadioTile::new().title(lit!("B")).enabled(false))
715                .tile(RadioTile::new().title(lit!("C"))),
716        );
717        tree.layout(SizeProposal::exact(600.0, 300.0));
718        tree.focus(g);
719        tree.press_key(Key::ArrowRight, Modifiers::NONE);
720        assert_eq!(
721            selected.get(),
722            2,
723            "ArrowRight skips the disabled middle tile"
724        );
725    }
726
727    #[test]
728    fn row_layout_equalizes_width_and_height() {
729        // Tiles carry very different description lengths → different natural
730        // heights. A Row must give them equal width AND equal height.
731        let selected = Signal::new(0_usize);
732        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
733        tree.add(group_with(
734            selected,
735            TileLayout::Row,
736            &[
737                "short",
738                "a considerably longer description that will wrap across several lines in the tile",
739                "medium length text here",
740            ],
741        ));
742        tree.layout(SizeProposal::exact(600.0, 400.0));
743        let ids = tile_ids(&tree, 3);
744        let b0 = tree.bounds(ids[0]);
745        let b1 = tree.bounds(ids[1]);
746        let b2 = tree.bounds(ids[2]);
747
748        assert!((b0.width - b1.width).abs() < 0.5, "equal widths");
749        assert!((b1.width - b2.width).abs() < 0.5, "equal widths");
750        assert!(
751            (b0.height - b1.height).abs() < 0.5,
752            "equal heights despite different content"
753        );
754        assert!(
755            (b1.height - b2.height).abs() < 0.5,
756            "equal heights despite different content"
757        );
758        assert!(b1.height > 0.0);
759    }
760
761    #[test]
762    fn column_layout_gives_full_width_tiles() {
763        let selected = Signal::new(0_usize);
764        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
765        tree.add(group_with(selected, TileLayout::Column, &["one", "two"]));
766        tree.layout(SizeProposal::exact(500.0, 400.0));
767        let ids = tile_ids(&tree, 2);
768        // Full width (minus the focus-ring envelope), equal, and stacked.
769        assert!((tree.bounds(ids[0]).width - tree.bounds(ids[1]).width).abs() < 0.5);
770        assert!(tree.bounds(ids[0]).width > 480.0);
771        assert!(tree.bounds(ids[1]).y > tree.bounds(ids[0]).y);
772    }
773
774    #[test]
775    fn grid_layout_wraps_into_expected_columns() {
776        let selected = Signal::new(0_usize);
777        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
778        // 4 tiles, min 200 wide, 640 available, 12 gap → 3 columns (3*200+2*12=624<=640),
779        // so the 4th tile wraps to a second row below tile 0.
780        tree.add(group_with(
781            selected,
782            TileLayout::Grid {
783                min_tile_width: 200.0,
784            },
785            &["one", "two", "three", "four"],
786        ));
787        tree.layout(SizeProposal::exact(640.0, 600.0));
788        let ids = tile_ids(&tree, 4);
789        let b0 = tree.bounds(ids[0]);
790        let b3 = tree.bounds(ids[3]);
791        // Tile 3 wraps under tile 0 (same column, lower row).
792        assert!(b3.y > b0.y, "4th tile is on a second row");
793        assert!(
794            (b3.x - b0.x).abs() < 0.5,
795            "4th tile aligns under the first column"
796        );
797    }
798
799    #[test]
800    fn vertical_layout_is_compact_full_width_list() {
801        let selected = Signal::new(0_usize);
802        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
803        let g = tree.add(
804            RadioTileGroup::new(selected.clone())
805                .layout(TileLayout::Vertical)
806                .tile(
807                    RadioTile::new()
808                        .title(lit!("None"))
809                        .trailing(lit!("empty binder")),
810                )
811                .tile(
812                    RadioTile::new()
813                        .title(lit!("Novel"))
814                        .trailing(lit!("20 chapters")),
815                )
816                .tile(
817                    RadioTile::new()
818                        .title(lit!("Notebook"))
819                        .trailing(lit!("free-form notes")),
820                ),
821        );
822        tree.layout(SizeProposal::exact(500.0, 400.0));
823        let none = tree.find_by_label("None").unwrap();
824        let novel = tree.find_by_label("Novel").unwrap();
825        // Full-width rows (minus the envelope), equal, stacked, each the
826        // theme's fixed compact height.
827        assert!((tree.bounds(none).width - tree.bounds(novel).width).abs() < 0.5);
828        assert!(tree.bounds(none).width > 480.0);
829        assert!((tree.bounds(none).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
830        assert!((tree.bounds(novel).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
831        assert!(tree.bounds(novel).y > tree.bounds(none).y);
832        // Roving works vertically.
833        tree.focus(g);
834        tree.press_key(Key::ArrowDown, Modifiers::NONE);
835        assert_eq!(selected.get(), 1);
836        // Each row is still a RadioButton.
837        assert_eq!(
838            tree.accessibility_node(none).role(),
839            teksilo_core::accesskit::Role::RadioButton
840        );
841    }
842
843    #[test]
844    fn vertical_row_height_override_wins() {
845        let selected = Signal::new(0_usize);
846        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
847        tree.add(
848            RadioTileGroup::new(selected)
849                .layout(TileLayout::Vertical)
850                .row_height(40.0)
851                .tile(RadioTile::new().title(lit!("A")))
852                .tile(RadioTile::new().title(lit!("B"))),
853        );
854        tree.layout(SizeProposal::exact(400.0, 400.0));
855        let a = tree.find_by_label("A").unwrap();
856        assert!((tree.bounds(a).height - 40.0).abs() < 0.5);
857    }
858
859    #[test]
860    fn keyboard_focus_adds_one_group_ring() {
861        let selected = Signal::new(0_usize);
862        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
863        let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
864        tree.layout(SizeProposal::exact(600.0, 300.0));
865
866        // Not focused (mouse modality) → no group ring, only the two tile
867        // borders.
868        let base = tree
869            .render()
870            .shapes
871            .iter()
872            .filter(|s| s.stroke_width > 0.0)
873            .count();
874
875        // Keyboard focus (Tab / arrow) → exactly one extra stroke: the
876        // whole-group focus ring.
877        tree.focus(g);
878        tree.press_key(Key::ArrowRight, Modifiers::NONE);
879        let with_ring = tree
880            .render()
881            .shapes
882            .iter()
883            .filter(|s| s.stroke_width > 0.0)
884            .count();
885        assert_eq!(
886            with_ring,
887            base + 1,
888            "keyboard focus draws exactly one whole-group ring"
889        );
890    }
891
892    #[test]
893    fn accessibility_group_and_tiles() {
894        let selected = Signal::new(1_usize);
895        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
896        let g = tree.add(
897            group_with(selected, TileLayout::Row, &["one", "two", "three"]).label(lit!("Format")),
898        );
899        tree.layout(SizeProposal::exact(600.0, 300.0));
900
901        let ginfo = tree.accessibility_node(g);
902        assert_eq!(ginfo.role(), teksilo_core::accesskit::Role::RadioGroup);
903        assert_eq!(ginfo.name(), Some("Format"));
904
905        let ids = tile_ids(&tree, 3);
906        assert_eq!(
907            tree.accessibility_node(ids[0]).role(),
908            teksilo_core::accesskit::Role::RadioButton
909        );
910        assert!(!tree.accessibility_node(ids[0]).is_toggled());
911        assert!(
912            tree.accessibility_node(ids[1]).is_toggled(),
913            "selected tile is toggled"
914        );
915        assert!(!tree.accessibility_node(ids[2]).is_toggled());
916    }
917
918    #[test]
919    fn toggled_updates_after_keyboard_selection() {
920        let selected = Signal::new(0_usize);
921        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
922        let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
923        tree.layout(SizeProposal::exact(600.0, 300.0));
924        let ids = tile_ids(&tree, 2);
925        assert!(tree.accessibility_node(ids[0]).is_toggled());
926
927        tree.focus(g);
928        tree.press_key(Key::ArrowRight, Modifiers::NONE);
929        // AccessibilityOnly binding must have re-walked the AT tree.
930        assert!(!tree.accessibility_node(ids[0]).is_toggled());
931        assert!(tree.accessibility_node(ids[1]).is_toggled());
932    }
933
934    #[test]
935    fn roving_selection_chases_outer_scroll_area() {
936        // A tall Vertical group inside a short outer ScrollArea. Selection roves
937        // on the group (individual tiles are NOT focusable when grouped), so the
938        // framework's focus-driven follow never reveals the selected tile — the
939        // group's `ctx.ensure_widget_visible(tile)` must scroll the enclosing
940        // area to keep the moving selection on screen.
941        use crate::ScrollArea;
942
943        let selected = Signal::new(0_usize);
944        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
945        let g = tree.add(group_with(
946            selected.clone(),
947            TileLayout::Vertical,
948            &["a", "b", "c", "d", "e", "f"],
949        ));
950        let outer = ScrollArea::from_id(g).smooth_scrolling(false);
951        let outer_y = outer.scroll_y_signal().clone();
952        let _outer = tree.add(outer);
953        // Outer viewport far shorter than the 6-tile column.
954        tree.layout(SizeProposal::exact(320.0, 90.0));
955
956        // Focus reveals the group; reset so any further scroll is attributable
957        // to the roving selection.
958        tree.focus(g);
959        tree.layout(SizeProposal::exact(320.0, 90.0));
960        outer_y.set(0.0);
961        tree.layout(SizeProposal::exact(320.0, 90.0));
962        assert!(outer_y.get().abs() < 0.01, "reset outer to top");
963
964        // Rove to the last tile (below the fold).
965        for _ in 0..5 {
966            tree.press_key(Key::ArrowDown, Modifiers::NONE);
967        }
968        tree.layout(SizeProposal::exact(320.0, 90.0));
969
970        assert_eq!(
971            selected.get(),
972            5,
973            "arrows moved the selection to the last tile"
974        );
975        assert!(
976            outer_y.get() > 0.01,
977            "selecting a tile below the fold must scroll the enclosing ScrollArea \
978             (got {})",
979            outer_y.get()
980        );
981    }
982
983    #[test]
984    fn at_increment_chases_outer_scroll_area() {
985        // Same as above, but driven by an assistive-technology Increment action
986        // instead of a physical arrow key. The AT path moves selection without
987        // moving focus, so it must reveal the tile itself.
988        use crate::ScrollArea;
989        use teksilo_core::accessibility::widget_id_to_node_id;
990        use teksilo_core::accesskit::Action;
991
992        let selected = Signal::new(0_usize);
993        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
994        let g = tree.add(group_with(
995            selected.clone(),
996            TileLayout::Vertical,
997            &["a", "b", "c", "d", "e", "f"],
998        ));
999        let outer = ScrollArea::from_id(g).smooth_scrolling(false);
1000        let outer_y = outer.scroll_y_signal().clone();
1001        let _outer = tree.add(outer);
1002        tree.layout(SizeProposal::exact(320.0, 90.0));
1003        tree.focus(g);
1004        tree.layout(SizeProposal::exact(320.0, 90.0));
1005        outer_y.set(0.0);
1006        tree.layout(SizeProposal::exact(320.0, 90.0));
1007        assert!(outer_y.get().abs() < 0.01, "reset outer to top");
1008
1009        let node = widget_id_to_node_id(g);
1010        let mut ops = teksilo_core::window::NoopWindowOps;
1011        for _ in 0..5 {
1012            tree.dispatch_access_action(node, Action::Increment, None, &mut ops);
1013        }
1014        tree.layout(SizeProposal::exact(320.0, 90.0));
1015
1016        assert_eq!(
1017            selected.get(),
1018            5,
1019            "AT Increment moved selection to the last tile"
1020        );
1021        assert!(
1022            outer_y.get() > 0.01,
1023            "AT-driven selection below the fold must scroll the enclosing \
1024             ScrollArea (got {})",
1025            outer_y.get()
1026        );
1027    }
1028}