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