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>,
100 enabled: Prop<bool>,
103 style_override: Option<SharedRadioTileStyle>,
104 group_focused: Signal<bool>,
106 ring_visible: Signal<bool>,
110 group_ids: Rc<RefCell<Vec<WidgetId>>>,
113 tile_ids: Vec<WidgetId>,
114 col_count: Rc<Cell<usize>>,
117}
118
119impl RadioTileGroup {
120 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 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
143 self.label = Some(label.into());
144 self
145 }
146
147 pub fn tile(mut self, tile: RadioTile) -> Self {
150 self.pending.push(tile);
151 self
152 }
153
154 pub fn tiles(mut self, tiles: impl IntoIterator<Item = RadioTile>) -> Self {
156 self.pending.extend(tiles);
157 self
158 }
159
160 pub fn layout(mut self, layout: TileLayout) -> Self {
162 self.layout = layout;
163 self
164 }
165
166 pub fn spacing(mut self, spacing: f32) -> Self {
169 self.spacing = Some(spacing);
170 self
171 }
172
173 pub fn line_spacing(mut self, spacing: f32) -> Self {
175 self.line_spacing = spacing;
176 self
177 }
178
179 pub fn row_height(mut self, height: f32) -> Self {
184 self.row_height = Some(height);
185 self
186 }
187
188 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
191 self.enabled = enabled.into();
192 self
193 }
194
195 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 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 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 Self::step(target, true, disabled)
256 }
257
258 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 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 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 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 {
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 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 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 {
455 let selected = self.selected.clone();
456 let disabled = disabled.clone();
457 let col_count = col_count.clone();
458 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 (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 (_, 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 if let Some(&id) = tile_ids.get(next) {
495 ctx.ensure_widget_visible(id);
496 }
497 }
498 EventResponse::Handled
499 });
500 }
501
502 {
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 {
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 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 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 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 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 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 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 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 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 tree.focus(g);
825 tree.press_key(Key::ArrowDown, Modifiers::NONE);
826 assert_eq!(selected.get(), 1);
827 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 let base = tree
860 .render()
861 .shapes
862 .iter()
863 .filter(|s| s.stroke_width > 0.0)
864 .count();
865
866 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 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 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 tree.layout(SizeProposal::exact(320.0, 90.0));
946
947 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 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 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}