1use 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#[derive(Copy, Clone, Debug, PartialEq, Default)]
54pub enum TileLayout {
55 #[default]
58 Row,
59 Grid {
63 min_tile_width: f32,
65 },
66 Column,
69 Vertical,
77}
78
79fn focus_ring_envelope(theme: &teksilo_core::Theme) -> f32 {
82 theme.shape.focus_ring_offset + theme.shape.focus_ring_width
83}
84
85pub struct RadioTileGroup {
88 pending: Vec<RadioTile>,
89 selected: Signal<usize>,
90 label: Option<LocalizedString>,
91 layout: TileLayout,
92 spacing: Option<f32>,
96 line_spacing: f32,
97 row_height: Option<f32>,
101 enabled: Prop<bool>,
104 style_override: Option<SharedRadioTileStyle>,
105 group_focused: Signal<bool>,
107 ring_visible: Signal<bool>,
111 group_ids: Rc<RefCell<Vec<WidgetId>>>,
114 tile_ids: Vec<WidgetId>,
115 col_count: Rc<Cell<usize>>,
118}
119
120impl RadioTileGroup {
121 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 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
144 self.label = Some(label.into());
145 self
146 }
147
148 pub fn tile(mut self, tile: RadioTile) -> Self {
151 self.pending.push(tile);
152 self
153 }
154
155 pub fn tiles(mut self, tiles: impl IntoIterator<Item = RadioTile>) -> Self {
157 self.pending.extend(tiles);
158 self
159 }
160
161 pub fn layout(mut self, layout: TileLayout) -> Self {
163 self.layout = layout;
164 self
165 }
166
167 pub fn spacing(mut self, spacing: f32) -> Self {
170 self.spacing = Some(spacing);
171 self
172 }
173
174 pub fn line_spacing(mut self, spacing: f32) -> Self {
176 self.line_spacing = spacing;
177 self
178 }
179
180 pub fn row_height(mut self, height: f32) -> Self {
185 self.row_height = Some(height);
186 self
187 }
188
189 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
192 self.enabled = enabled.into();
193 self
194 }
195
196 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 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 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 Self::step(target, true, disabled)
257 }
258
259 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 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 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 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 {
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 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 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 {
456 let selected = self.selected.clone();
457 let disabled = disabled.clone();
458 let col_count = col_count.clone();
459 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 (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 (_, 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 if let Some(&id) = tile_ids.get(next) {
496 ctx.ensure_widget_visible(id);
497 }
498 }
499 EventResponse::Handled
500 });
501 }
502
503 {
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 {
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 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 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 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 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 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 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 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 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 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 tree.focus(g);
834 tree.press_key(Key::ArrowDown, Modifiers::NONE);
835 assert_eq!(selected.get(), 1);
836 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 let base = tree
869 .render()
870 .shapes
871 .iter()
872 .filter(|s| s.stroke_width > 0.0)
873 .count();
874
875 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 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 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 tree.layout(SizeProposal::exact(320.0, 90.0));
955
956 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 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 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}