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);
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 !self.tile_ids.is_empty() {
606 builder.set_size_of_set(self.tile_ids.len());
607 }
608 if let Some(ref name) = self.label {
609 builder.set_name(name.resolve_now());
610 }
611 let idx = self.selected.get();
613 if let Some(&id) = self.tile_ids.get(idx) {
614 builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(id));
615 }
616 builder.add_action(teksilo_core::accesskit::Action::Focus);
617 builder.add_action(teksilo_core::accesskit::Action::Increment);
618 builder.add_action(teksilo_core::accesskit::Action::Decrement);
619 }
620
621 fn children(&self) -> Vec<WidgetId> {
622 self.tile_ids.clone()
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use teksilo_core::event::Modifiers;
630 use teksilo_core::widget_tree::WidgetTree;
631 use teksilo_i18n::lit;
632
633 fn group_with(
634 selected: Signal<usize>,
635 layout: TileLayout,
636 descriptions: &[&'static str],
637 ) -> RadioTileGroup {
638 let labels = ["A", "B", "C", "D", "E", "F"];
639 let mut g = RadioTileGroup::new(selected).layout(layout);
640 for (i, desc) in descriptions.iter().enumerate() {
641 g = g.tile(
642 RadioTile::new()
643 .title(lit!(labels[i]))
644 .description(lit!(*desc)),
645 );
646 }
647 g
648 }
649
650 fn tile_ids(tree: &WidgetTree, n: usize) -> Vec<WidgetId> {
651 ["A", "B", "C", "D", "E", "F"][..n]
652 .iter()
653 .map(|l| {
654 tree.find_by_label(l)
655 .unwrap_or_else(|| panic!("tile {l} not found"))
656 })
657 .collect()
658 }
659
660 #[test]
661 fn click_selects_tile_and_deselects_siblings() {
662 let selected = Signal::new(0_usize);
663 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
664 tree.add(group_with(
665 selected.clone(),
666 TileLayout::Row,
667 &["one", "two", "three"],
668 ));
669 tree.layout(SizeProposal::exact(600.0, 300.0));
670 let ids = tile_ids(&tree, 3);
671
672 assert_eq!(selected.get(), 0);
673 tree.click(ids[1]);
674 assert_eq!(selected.get(), 1);
675 tree.click(ids[2]);
676 assert_eq!(selected.get(), 2);
677 }
678
679 #[test]
680 fn roving_arrows_move_selection_and_wrap() {
681 let selected = Signal::new(0_usize);
682 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
683 let g = tree.add(group_with(
684 selected.clone(),
685 TileLayout::Row,
686 &["one", "two", "three"],
687 ));
688 tree.layout(SizeProposal::exact(600.0, 300.0));
689
690 tree.focus(g);
691 tree.press_key(Key::ArrowRight, Modifiers::NONE);
692 assert_eq!(selected.get(), 1);
693 tree.press_key(Key::ArrowRight, Modifiers::NONE);
694 assert_eq!(selected.get(), 2);
695 tree.press_key(Key::ArrowRight, Modifiers::NONE);
696 assert_eq!(selected.get(), 0, "wraps around");
697 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
698 assert_eq!(selected.get(), 2, "wraps backwards");
699 tree.press_key(Key::End, Modifiers::NONE);
700 assert_eq!(selected.get(), 2);
701 tree.press_key(Key::Home, Modifiers::NONE);
702 assert_eq!(selected.get(), 0);
703 }
704
705 #[test]
706 fn roving_skips_disabled_tile() {
707 let selected = Signal::new(0_usize);
708 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
709 let g = tree.add(
710 RadioTileGroup::new(selected.clone())
711 .layout(TileLayout::Row)
712 .tile(RadioTile::new().title(lit!("A")))
713 .tile(RadioTile::new().title(lit!("B")).enabled(false))
714 .tile(RadioTile::new().title(lit!("C"))),
715 );
716 tree.layout(SizeProposal::exact(600.0, 300.0));
717 tree.focus(g);
718 tree.press_key(Key::ArrowRight, Modifiers::NONE);
719 assert_eq!(
720 selected.get(),
721 2,
722 "ArrowRight skips the disabled middle tile"
723 );
724 }
725
726 #[test]
727 fn row_layout_equalizes_width_and_height() {
728 let selected = Signal::new(0_usize);
731 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
732 tree.add(group_with(
733 selected,
734 TileLayout::Row,
735 &[
736 "short",
737 "a considerably longer description that will wrap across several lines in the tile",
738 "medium length text here",
739 ],
740 ));
741 tree.layout(SizeProposal::exact(600.0, 400.0));
742 let ids = tile_ids(&tree, 3);
743 let b0 = tree.bounds(ids[0]);
744 let b1 = tree.bounds(ids[1]);
745 let b2 = tree.bounds(ids[2]);
746
747 assert!((b0.width - b1.width).abs() < 0.5, "equal widths");
748 assert!((b1.width - b2.width).abs() < 0.5, "equal widths");
749 assert!(
750 (b0.height - b1.height).abs() < 0.5,
751 "equal heights despite different content"
752 );
753 assert!(
754 (b1.height - b2.height).abs() < 0.5,
755 "equal heights despite different content"
756 );
757 assert!(b1.height > 0.0);
758 }
759
760 #[test]
761 fn column_layout_gives_full_width_tiles() {
762 let selected = Signal::new(0_usize);
763 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
764 tree.add(group_with(selected, TileLayout::Column, &["one", "two"]));
765 tree.layout(SizeProposal::exact(500.0, 400.0));
766 let ids = tile_ids(&tree, 2);
767 assert!((tree.bounds(ids[0]).width - tree.bounds(ids[1]).width).abs() < 0.5);
769 assert!(tree.bounds(ids[0]).width > 480.0);
770 assert!(tree.bounds(ids[1]).y > tree.bounds(ids[0]).y);
771 }
772
773 #[test]
774 fn grid_layout_wraps_into_expected_columns() {
775 let selected = Signal::new(0_usize);
776 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
777 tree.add(group_with(
780 selected,
781 TileLayout::Grid {
782 min_tile_width: 200.0,
783 },
784 &["one", "two", "three", "four"],
785 ));
786 tree.layout(SizeProposal::exact(640.0, 600.0));
787 let ids = tile_ids(&tree, 4);
788 let b0 = tree.bounds(ids[0]);
789 let b3 = tree.bounds(ids[3]);
790 assert!(b3.y > b0.y, "4th tile is on a second row");
792 assert!(
793 (b3.x - b0.x).abs() < 0.5,
794 "4th tile aligns under the first column"
795 );
796 }
797
798 #[test]
799 fn vertical_layout_is_compact_full_width_list() {
800 let selected = Signal::new(0_usize);
801 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
802 let g = tree.add(
803 RadioTileGroup::new(selected.clone())
804 .layout(TileLayout::Vertical)
805 .tile(
806 RadioTile::new()
807 .title(lit!("None"))
808 .trailing(lit!("empty binder")),
809 )
810 .tile(
811 RadioTile::new()
812 .title(lit!("Novel"))
813 .trailing(lit!("20 chapters")),
814 )
815 .tile(
816 RadioTile::new()
817 .title(lit!("Notebook"))
818 .trailing(lit!("free-form notes")),
819 ),
820 );
821 tree.layout(SizeProposal::exact(500.0, 400.0));
822 let none = tree.find_by_label("None").unwrap();
823 let novel = tree.find_by_label("Novel").unwrap();
824 assert!((tree.bounds(none).width - tree.bounds(novel).width).abs() < 0.5);
827 assert!(tree.bounds(none).width > 480.0);
828 assert!((tree.bounds(none).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
829 assert!((tree.bounds(novel).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
830 assert!(tree.bounds(novel).y > tree.bounds(none).y);
831 tree.focus(g);
833 tree.press_key(Key::ArrowDown, Modifiers::NONE);
834 assert_eq!(selected.get(), 1);
835 assert_eq!(
837 tree.accessibility_node(none).role(),
838 teksilo_core::accesskit::Role::RadioButton
839 );
840 }
841
842 #[test]
843 fn vertical_row_height_override_wins() {
844 let selected = Signal::new(0_usize);
845 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
846 tree.add(
847 RadioTileGroup::new(selected)
848 .layout(TileLayout::Vertical)
849 .row_height(40.0)
850 .tile(RadioTile::new().title(lit!("A")))
851 .tile(RadioTile::new().title(lit!("B"))),
852 );
853 tree.layout(SizeProposal::exact(400.0, 400.0));
854 let a = tree.find_by_label("A").unwrap();
855 assert!((tree.bounds(a).height - 40.0).abs() < 0.5);
856 }
857
858 #[test]
859 fn keyboard_focus_adds_one_group_ring() {
860 let selected = Signal::new(0_usize);
861 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
862 let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
863 tree.layout(SizeProposal::exact(600.0, 300.0));
864
865 let base = tree
868 .render()
869 .shapes
870 .iter()
871 .filter(|s| s.stroke_width > 0.0)
872 .count();
873
874 tree.focus(g);
877 tree.press_key(Key::ArrowRight, Modifiers::NONE);
878 let with_ring = tree
879 .render()
880 .shapes
881 .iter()
882 .filter(|s| s.stroke_width > 0.0)
883 .count();
884 assert_eq!(
885 with_ring,
886 base + 1,
887 "keyboard focus draws exactly one whole-group ring"
888 );
889 }
890
891 #[test]
892 fn accessibility_group_and_tiles() {
893 let selected = Signal::new(1_usize);
894 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
895 let g = tree.add(
896 group_with(selected, TileLayout::Row, &["one", "two", "three"]).label(lit!("Format")),
897 );
898 tree.layout(SizeProposal::exact(600.0, 300.0));
899
900 let ginfo = tree.accessibility_node(g);
901 assert_eq!(ginfo.role(), teksilo_core::accesskit::Role::RadioGroup);
902 assert_eq!(ginfo.name(), Some("Format"));
903
904 let ids = tile_ids(&tree, 3);
905 assert_eq!(
906 tree.accessibility_node(ids[0]).role(),
907 teksilo_core::accesskit::Role::RadioButton
908 );
909 assert!(!tree.accessibility_node(ids[0]).is_toggled());
910 assert!(
911 tree.accessibility_node(ids[1]).is_toggled(),
912 "selected tile is toggled"
913 );
914 assert!(!tree.accessibility_node(ids[2]).is_toggled());
915 }
916
917 #[test]
918 fn toggled_updates_after_keyboard_selection() {
919 let selected = Signal::new(0_usize);
920 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
921 let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
922 tree.layout(SizeProposal::exact(600.0, 300.0));
923 let ids = tile_ids(&tree, 2);
924 assert!(tree.accessibility_node(ids[0]).is_toggled());
925
926 tree.focus(g);
927 tree.press_key(Key::ArrowRight, Modifiers::NONE);
928 assert!(!tree.accessibility_node(ids[0]).is_toggled());
930 assert!(tree.accessibility_node(ids[1]).is_toggled());
931 }
932
933 #[test]
934 fn roving_selection_chases_outer_scroll_area() {
935 use crate::ScrollArea;
941
942 let selected = Signal::new(0_usize);
943 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
944 let g = tree.add(group_with(
945 selected.clone(),
946 TileLayout::Vertical,
947 &["a", "b", "c", "d", "e", "f"],
948 ));
949 let outer = ScrollArea::from_id(g).smooth_scrolling(false);
950 let outer_y = outer.scroll_y_signal().clone();
951 let _outer = tree.add(outer);
952 tree.layout(SizeProposal::exact(320.0, 90.0));
954
955 tree.focus(g);
958 tree.layout(SizeProposal::exact(320.0, 90.0));
959 outer_y.set(0.0);
960 tree.layout(SizeProposal::exact(320.0, 90.0));
961 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
962
963 for _ in 0..5 {
965 tree.press_key(Key::ArrowDown, Modifiers::NONE);
966 }
967 tree.layout(SizeProposal::exact(320.0, 90.0));
968
969 assert_eq!(
970 selected.get(),
971 5,
972 "arrows moved the selection to the last tile"
973 );
974 assert!(
975 outer_y.get() > 0.01,
976 "selecting a tile below the fold must scroll the enclosing ScrollArea \
977 (got {})",
978 outer_y.get()
979 );
980 }
981
982 #[test]
983 fn at_increment_chases_outer_scroll_area() {
984 use crate::ScrollArea;
988 use teksilo_core::accessibility::widget_id_to_node_id;
989 use teksilo_core::accesskit::Action;
990
991 let selected = Signal::new(0_usize);
992 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
993 let g = tree.add(group_with(
994 selected.clone(),
995 TileLayout::Vertical,
996 &["a", "b", "c", "d", "e", "f"],
997 ));
998 let outer = ScrollArea::from_id(g).smooth_scrolling(false);
999 let outer_y = outer.scroll_y_signal().clone();
1000 let _outer = tree.add(outer);
1001 tree.layout(SizeProposal::exact(320.0, 90.0));
1002 tree.focus(g);
1003 tree.layout(SizeProposal::exact(320.0, 90.0));
1004 outer_y.set(0.0);
1005 tree.layout(SizeProposal::exact(320.0, 90.0));
1006 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
1007
1008 let node = widget_id_to_node_id(g);
1009 let mut ops = teksilo_core::window::NoopWindowOps;
1010 for _ in 0..5 {
1011 tree.dispatch_access_action(node, Action::Increment, None, &mut ops);
1012 }
1013 tree.layout(SizeProposal::exact(320.0, 90.0));
1014
1015 assert_eq!(
1016 selected.get(),
1017 5,
1018 "AT Increment moved selection to the last tile"
1019 );
1020 assert!(
1021 outer_y.get() > 0.01,
1022 "AT-driven selection below the fold must scroll the enclosing \
1023 ScrollArea (got {})",
1024 outer_y.get()
1025 );
1026 }
1027}