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