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::{FocusRingStroke, SharedRadioTileStyle};
43use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
44use teksilo_core::widget_builder::HandlerSet;
45use teksilo_core::widget_id::WidgetId;
46use teksilo_tokens::{CornerRadius, ShapeTokens};
47
48use crate::radio_tile::RadioTile;
49use crate::styles::{RADIO_TILE_CORNER_RADIUS, RADIO_TILE_VERTICAL_ROW_HEIGHT};
50use teksilo_i18n::LocalizedString;
51
52#[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_envelope()
83}
84
85fn group_focus_ring(bounds: Rect, shape: &ShapeTokens) -> FocusRingStroke {
89 let env = shape.focus_ring_envelope();
90 let tiles = bounds.inset(env, env, env, env);
91 FocusRingStroke::around(tiles, RADIO_TILE_CORNER_RADIUS, shape)
92}
93
94pub struct RadioTileGroup {
97 pending: Vec<RadioTile>,
98 selected: Signal<usize>,
99 label: Option<LocalizedString>,
100 layout: TileLayout,
101 spacing: Option<f32>,
105 line_spacing: f32,
106 row_height: Option<f32>,
110 enabled: Prop<bool>,
113 style_override: Option<SharedRadioTileStyle>,
114 group_focused: Signal<bool>,
116 ring_visible: Signal<bool>,
120 group_ids: Rc<RefCell<Vec<WidgetId>>>,
123 tile_ids: Vec<WidgetId>,
124 col_count: Rc<Cell<usize>>,
127}
128
129impl RadioTileGroup {
130 pub fn new(selected: Signal<usize>) -> Self {
133 Self {
134 pending: Vec::new(),
135 selected,
136 label: None,
137 layout: TileLayout::default(),
138 spacing: None,
139 line_spacing: 12.0,
140 row_height: None,
141 enabled: Prop::Static(true),
142 style_override: None,
143 group_focused: Signal::new(false),
144 ring_visible: Signal::new(false),
145 group_ids: Rc::new(RefCell::new(Vec::new())),
146 tile_ids: Vec::new(),
147 col_count: Rc::new(Cell::new(1)),
148 }
149 }
150
151 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
153 self.label = Some(label.into());
154 self
155 }
156
157 pub fn tile(mut self, tile: RadioTile) -> Self {
160 self.pending.push(tile);
161 self
162 }
163
164 pub fn tiles(mut self, tiles: impl IntoIterator<Item = RadioTile>) -> Self {
166 self.pending.extend(tiles);
167 self
168 }
169
170 pub fn layout(mut self, layout: TileLayout) -> Self {
172 self.layout = layout;
173 self
174 }
175
176 pub fn spacing(mut self, spacing: f32) -> Self {
179 self.spacing = Some(spacing);
180 self
181 }
182
183 pub fn line_spacing(mut self, spacing: f32) -> Self {
185 self.line_spacing = spacing;
186 self
187 }
188
189 pub fn row_height(mut self, height: f32) -> Self {
194 self.row_height = Some(height);
195 self
196 }
197
198 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
201 self.enabled = enabled.into();
202 self
203 }
204
205 pub fn style(mut self, style: impl teksilo_core::styles::RadioTileStyle) -> Self {
208 self.style_override = Some(Rc::new(style));
209 self
210 }
211
212 fn step(current: usize, forward: bool, disabled: &[bool]) -> usize {
215 let n = disabled.len();
216 if n == 0 {
217 return current;
218 }
219 let mut i = current;
220 for _ in 0..n {
221 i = if forward {
222 (i + 1) % n
223 } else {
224 (i + n - 1) % n
225 };
226 if !disabled[i] {
227 return i;
228 }
229 }
230 current
231 }
232
233 fn first_enabled(disabled: &[bool]) -> usize {
234 (0..disabled.len()).find(|&i| !disabled[i]).unwrap_or(0)
235 }
236
237 fn last_enabled(disabled: &[bool]) -> usize {
238 (0..disabled.len())
239 .rev()
240 .find(|&i| !disabled[i])
241 .unwrap_or(disabled.len().saturating_sub(1))
242 }
243
244 fn step_vertical(current: usize, down: bool, cols: usize, disabled: &[bool]) -> usize {
247 let n = disabled.len();
248 if n == 0 || cols == 0 {
249 return current;
250 }
251 let target = if down {
252 current + cols
253 } else if current >= cols {
254 current - cols
255 } else {
256 return current;
257 };
258 if target >= n {
259 return current;
260 }
261 if !disabled[target] {
262 return target;
263 }
264 Self::step(target, true, disabled)
266 }
267
268 fn compute_layout(&self, avail_w: Option<f32>, ctx: &LayoutContext) -> (Vec<Rect>, Size) {
271 let n = self.tile_ids.len();
272 if n == 0 {
273 self.col_count.set(1);
274 return (Vec::new(), Size::new(0.0, 0.0));
275 }
276 let nf = n as f32;
277 let sp = self.spacing.unwrap_or(match self.layout {
278 TileLayout::Vertical => 6.0,
279 _ => 12.0,
280 });
281 let lsp = self.line_spacing;
282
283 let measure_h = |id: WidgetId, w: f32| -> f32 {
284 ctx.measure_intrinsic(
285 id,
286 SizeProposal {
287 width: Some(w),
288 height: None,
289 },
290 )
291 .map(|s| s.height)
292 .unwrap_or(0.0)
293 };
294
295 let avail = avail_w.unwrap_or_else(|| {
298 let maxw = self
299 .tile_ids
300 .iter()
301 .map(|&id| {
302 ctx.measure_intrinsic(id, SizeProposal::unspecified())
303 .map(|s| s.width)
304 .unwrap_or(0.0)
305 })
306 .fold(0.0_f32, f32::max);
307 match self.layout {
308 TileLayout::Column | TileLayout::Vertical => maxw,
309 _ => maxw * nf + (nf - 1.0) * sp,
310 }
311 });
312
313 match self.layout {
314 TileLayout::Row => {
315 self.col_count.set(n);
316 let tile_w = ((avail - (nf - 1.0) * sp) / nf).max(0.0);
317 let row_h = self
318 .tile_ids
319 .iter()
320 .map(|&id| measure_h(id, tile_w))
321 .fold(0.0_f32, f32::max);
322 let mut rects = Vec::with_capacity(n);
323 let mut x = 0.0;
324 for _ in 0..n {
325 rects.push(Rect::new(x, 0.0, tile_w, row_h));
326 x += tile_w + sp;
327 }
328 (rects, Size::new(avail, row_h))
329 }
330 TileLayout::Column => {
331 self.col_count.set(1);
332 let mut rects = Vec::with_capacity(n);
333 let mut y = 0.0;
334 for &id in &self.tile_ids {
335 let h = measure_h(id, avail);
336 rects.push(Rect::new(0.0, y, avail, h));
337 y += h + sp;
338 }
339 let total_h = (y - sp).max(0.0);
340 (rects, Size::new(avail, total_h))
341 }
342 TileLayout::Vertical => {
343 self.col_count.set(1);
348 let h = self.row_height.unwrap_or_else(|| {
349 self.style_override
350 .as_ref()
351 .or(ctx.theme.style_slots.radio_tile.as_ref())
352 .map(|s| s.vertical_row_height())
353 .unwrap_or(RADIO_TILE_VERTICAL_ROW_HEIGHT)
354 });
355 let mut rects = Vec::with_capacity(n);
356 let mut y = 0.0;
357 for _ in 0..n {
358 rects.push(Rect::new(0.0, y, avail, h));
359 y += h + sp;
360 }
361 let total_h = (h * nf + (nf - 1.0) * sp).max(0.0);
362 (rects, Size::new(avail, total_h))
363 }
364 TileLayout::Grid { min_tile_width } => {
365 let cols = (((avail + sp) / (min_tile_width + sp)).floor() as usize).clamp(1, n);
366 self.col_count.set(cols);
367 let colsf = cols as f32;
368 let cell_w = ((avail - (colsf - 1.0) * sp) / colsf).max(0.0);
369 let cell_h = self
370 .tile_ids
371 .iter()
372 .map(|&id| measure_h(id, cell_w))
373 .fold(0.0_f32, f32::max);
374 let rows = n.div_ceil(cols);
375 let mut rects = Vec::with_capacity(n);
376 for i in 0..n {
377 let r = (i / cols) as f32;
378 let c = (i % cols) as f32;
379 rects.push(Rect::new(
380 c * (cell_w + sp),
381 r * (cell_h + lsp),
382 cell_w,
383 cell_h,
384 ));
385 }
386 let total_h = rows as f32 * cell_h + (rows.saturating_sub(1)) as f32 * lsp;
387 (rects, Size::new(avail, total_h))
388 }
389 }
390 }
391}
392
393impl std::fmt::Debug for RadioTileGroup {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("RadioTileGroup")
396 .field("layout", &self.layout)
397 .field("num_tiles", &self.pending.len().max(self.tile_ids.len()))
398 .field("label", &self.label)
399 .finish()
400 }
401}
402
403impl Widget for RadioTileGroup {
404 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
405 let self_id = ctx.self_id();
406 ctx.enabled_when(self_id, self.enabled.clone());
407
408 let focus_visible = ctx.focus_visible();
411 let ring_visible = self.group_focused.and(&focus_visible);
412 self.ring_visible = ring_visible.clone();
413
414 {
417 let registry = ctx.binding_registry();
418 self.selected
419 .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
420 ring_visible.bind_to(self_id, registry, BindingLevel::RepaintOnly);
421 }
422
423 let pending = std::mem::take(&mut self.pending);
424 let n = pending.len();
425 self.group_ids.borrow_mut().clear();
426 self.tile_ids.clear();
427 let mut disabled: Vec<bool> = Vec::with_capacity(n);
428
429 for (i, mut tile) in pending.into_iter().enumerate() {
433 tile.set_selection(i, self.selected.clone());
434 tile.set_grouped(self.group_focused.clone(), self.group_ids.clone(), i + 1);
435 if self.layout == TileLayout::Vertical {
436 tile.set_vertical_arrangement();
437 }
438 let tip_placement = match self.layout {
442 TileLayout::Column | TileLayout::Vertical => crate::tooltip::TooltipPlacement::Side,
443 TileLayout::Row | TileLayout::Grid { .. } => {
444 crate::tooltip::TooltipPlacement::Below
445 }
446 };
447 tile.set_tooltip_placement(tip_placement);
448 if let Some(style) = &self.style_override {
449 tile.set_style_if_unset(style.clone());
450 }
451 disabled.push(!tile.is_enabled());
452 let id = ctx.add(tile);
453 self.group_ids.borrow_mut().push(id);
454 self.tile_ids.push(id);
455 }
456
457 let disabled: Rc<Vec<bool>> = Rc::new(disabled);
458 let layout = self.layout;
459 let col_count = self.col_count.clone();
460
461 let mut handlers = HandlerSet::new().focusable(true);
462
463 {
465 let selected = self.selected.clone();
466 let disabled = disabled.clone();
467 let col_count = col_count.clone();
468 let tile_ids = self.tile_ids.clone();
474 handlers = handlers.on_key(move |event, ctx: &mut EventContext| {
475 if n == 0 {
476 return EventResponse::Ignored;
477 }
478 let cur = selected.get().min(n - 1);
479 let WidgetEvent::KeyDown { key, .. } = event else {
480 return EventResponse::Ignored;
481 };
482 let next = match (layout, key) {
483 (TileLayout::Grid { .. }, Key::ArrowRight) => Self::step(cur, true, &disabled),
485 (TileLayout::Grid { .. }, Key::ArrowLeft) => Self::step(cur, false, &disabled),
486 (TileLayout::Grid { .. }, Key::ArrowDown) => {
487 Self::step_vertical(cur, true, col_count.get(), &disabled)
488 }
489 (TileLayout::Grid { .. }, Key::ArrowUp) => {
490 Self::step_vertical(cur, false, col_count.get(), &disabled)
491 }
492 (_, Key::ArrowRight | Key::ArrowDown) => Self::step(cur, true, &disabled),
494 (_, Key::ArrowLeft | Key::ArrowUp) => Self::step(cur, false, &disabled),
495 (_, Key::Home) => Self::first_enabled(&disabled),
496 (_, Key::End) => Self::last_enabled(&disabled),
497 _ => return EventResponse::Ignored,
498 };
499 if next != cur {
500 selected.set(next);
501 if let Some(&id) = tile_ids.get(next) {
505 ctx.ensure_widget_visible(id);
506 }
507 }
508 EventResponse::Handled
509 });
510 }
511
512 {
514 let group_focused = self.group_focused.clone();
515 handlers = handlers.on_focus(move |gained, _ctx: &mut EventContext| {
516 group_focused.set(gained);
517 });
518 }
519
520 {
525 let selected = self.selected.clone();
526 let disabled = disabled.clone();
527 let tile_ids = self.tile_ids.clone();
528 handlers = handlers.on_access_action(move |action, ctx: &mut EventContext| {
529 if n == 0 {
530 return EventResponse::Ignored;
531 }
532 let cur = selected.get().min(n - 1);
533 let next = if action == teksilo_core::accesskit::Action::Increment {
534 Self::step(cur, true, &disabled)
535 } else if action == teksilo_core::accesskit::Action::Decrement {
536 Self::step(cur, false, &disabled)
537 } else {
538 return EventResponse::Ignored;
539 };
540 if next != cur {
541 selected.set(next);
542 if let Some(&id) = tile_ids.get(next) {
543 ctx.ensure_widget_visible(id);
544 }
545 }
546 EventResponse::Handled
547 });
548 }
549
550 ctx.apply_self_handlers(handlers);
551
552 self.tile_ids.clone()
553 }
554
555 fn layout_response(
556 &self,
557 proposal: SizeProposal,
558 ctx: &LayoutContext,
559 ) -> teksilo_core::widget::LayoutResponse {
560 let env = focus_ring_envelope(ctx.theme);
563 let inner_w = proposal.width.map(|w| (w - env * 2.0).max(0.0));
564 let (_rects, size) = self.compute_layout(inner_w, ctx);
565 Size::new(size.width + env * 2.0, size.height + env * 2.0).into()
566 }
567
568 fn place_children(
569 &self,
570 bounds: Rect,
571 _proposal: SizeProposal,
572 children: &mut [WidgetPlacement],
573 ctx: &LayoutContext,
574 ) {
575 let env = focus_ring_envelope(ctx.theme);
576 let inner_w = (bounds.width - env * 2.0).max(0.0);
577 let (rects, _size) = self.compute_layout(Some(inner_w), ctx);
578 for (child, rect) in children.iter_mut().zip(rects.iter()) {
579 child.origin = Point::new(bounds.x + env + rect.x, bounds.y + env + rect.y);
580 child.size = Size::new(rect.width, rect.height);
581 }
582 }
583
584 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
585 if !self.ring_visible.get() {
589 return;
590 }
591 let ring = group_focus_ring(bounds, &ctx.theme.shape);
592 canvas.stroke_rounded_rect(
593 ring.rect,
594 CornerRadius::uniform(ring.corner_radius),
595 ctx.theme.colors.focus_ring,
596 ctx.theme.shape.focus_ring_width,
597 );
598 }
599
600 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
601 builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
602 if !self.tile_ids.is_empty() {
608 builder.set_size_of_set(self.tile_ids.len());
609 }
610 if let Some(ref name) = self.label {
611 builder.set_name(name.resolve_now());
612 }
613 let idx = self.selected.get();
615 if let Some(&id) = self.tile_ids.get(idx) {
616 builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(id));
617 }
618 builder.add_action(teksilo_core::accesskit::Action::Focus);
619 builder.add_action(teksilo_core::accesskit::Action::Increment);
620 builder.add_action(teksilo_core::accesskit::Action::Decrement);
621 }
622
623 fn children(&self) -> Vec<WidgetId> {
624 self.tile_ids.clone()
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use teksilo_core::event::Modifiers;
632 use teksilo_core::widget_tree::WidgetTree;
633 use teksilo_i18n::lit;
634
635 fn group_with(
636 selected: Signal<usize>,
637 layout: TileLayout,
638 descriptions: &[&'static str],
639 ) -> RadioTileGroup {
640 let labels = ["A", "B", "C", "D", "E", "F"];
641 let mut g = RadioTileGroup::new(selected).layout(layout);
642 for (i, desc) in descriptions.iter().enumerate() {
643 g = g.tile(
644 RadioTile::new()
645 .title(lit!(labels[i]))
646 .description(lit!(*desc)),
647 );
648 }
649 g
650 }
651
652 fn tile_ids(tree: &WidgetTree, n: usize) -> Vec<WidgetId> {
653 ["A", "B", "C", "D", "E", "F"][..n]
654 .iter()
655 .map(|l| {
656 tree.find_by_label(l)
657 .unwrap_or_else(|| panic!("tile {l} not found"))
658 })
659 .collect()
660 }
661
662 #[test]
663 fn click_selects_tile_and_deselects_siblings() {
664 let selected = Signal::new(0_usize);
665 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
666 tree.add(group_with(
667 selected.clone(),
668 TileLayout::Row,
669 &["one", "two", "three"],
670 ));
671 tree.layout(SizeProposal::exact(600.0, 300.0));
672 let ids = tile_ids(&tree, 3);
673
674 assert_eq!(selected.get(), 0);
675 tree.click(ids[1]);
676 assert_eq!(selected.get(), 1);
677 tree.click(ids[2]);
678 assert_eq!(selected.get(), 2);
679 }
680
681 #[test]
682 fn roving_arrows_move_selection_and_wrap() {
683 let selected = Signal::new(0_usize);
684 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
685 let g = tree.add(group_with(
686 selected.clone(),
687 TileLayout::Row,
688 &["one", "two", "three"],
689 ));
690 tree.layout(SizeProposal::exact(600.0, 300.0));
691
692 tree.focus(g);
693 tree.press_key(Key::ArrowRight, Modifiers::NONE);
694 assert_eq!(selected.get(), 1);
695 tree.press_key(Key::ArrowRight, Modifiers::NONE);
696 assert_eq!(selected.get(), 2);
697 tree.press_key(Key::ArrowRight, Modifiers::NONE);
698 assert_eq!(selected.get(), 0, "wraps around");
699 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
700 assert_eq!(selected.get(), 2, "wraps backwards");
701 tree.press_key(Key::End, Modifiers::NONE);
702 assert_eq!(selected.get(), 2);
703 tree.press_key(Key::Home, Modifiers::NONE);
704 assert_eq!(selected.get(), 0);
705 }
706
707 #[test]
708 fn roving_skips_disabled_tile() {
709 let selected = Signal::new(0_usize);
710 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
711 let g = tree.add(
712 RadioTileGroup::new(selected.clone())
713 .layout(TileLayout::Row)
714 .tile(RadioTile::new().title(lit!("A")))
715 .tile(RadioTile::new().title(lit!("B")).enabled(false))
716 .tile(RadioTile::new().title(lit!("C"))),
717 );
718 tree.layout(SizeProposal::exact(600.0, 300.0));
719 tree.focus(g);
720 tree.press_key(Key::ArrowRight, Modifiers::NONE);
721 assert_eq!(
722 selected.get(),
723 2,
724 "ArrowRight skips the disabled middle tile"
725 );
726 }
727
728 #[test]
729 fn row_layout_equalizes_width_and_height() {
730 let selected = Signal::new(0_usize);
733 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
734 tree.add(group_with(
735 selected,
736 TileLayout::Row,
737 &[
738 "short",
739 "a considerably longer description that will wrap across several lines in the tile",
740 "medium length text here",
741 ],
742 ));
743 tree.layout(SizeProposal::exact(600.0, 400.0));
744 let ids = tile_ids(&tree, 3);
745 let b0 = tree.bounds(ids[0]);
746 let b1 = tree.bounds(ids[1]);
747 let b2 = tree.bounds(ids[2]);
748
749 assert!((b0.width - b1.width).abs() < 0.5, "equal widths");
750 assert!((b1.width - b2.width).abs() < 0.5, "equal widths");
751 assert!(
752 (b0.height - b1.height).abs() < 0.5,
753 "equal heights despite different content"
754 );
755 assert!(
756 (b1.height - b2.height).abs() < 0.5,
757 "equal heights despite different content"
758 );
759 assert!(b1.height > 0.0);
760 }
761
762 #[test]
763 fn column_layout_gives_full_width_tiles() {
764 let selected = Signal::new(0_usize);
765 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
766 tree.add(group_with(selected, TileLayout::Column, &["one", "two"]));
767 tree.layout(SizeProposal::exact(500.0, 400.0));
768 let ids = tile_ids(&tree, 2);
769 assert!((tree.bounds(ids[0]).width - tree.bounds(ids[1]).width).abs() < 0.5);
771 assert!(tree.bounds(ids[0]).width > 480.0);
772 assert!(tree.bounds(ids[1]).y > tree.bounds(ids[0]).y);
773 }
774
775 #[test]
776 fn grid_layout_wraps_into_expected_columns() {
777 let selected = Signal::new(0_usize);
778 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
779 tree.add(group_with(
782 selected,
783 TileLayout::Grid {
784 min_tile_width: 200.0,
785 },
786 &["one", "two", "three", "four"],
787 ));
788 tree.layout(SizeProposal::exact(640.0, 600.0));
789 let ids = tile_ids(&tree, 4);
790 let b0 = tree.bounds(ids[0]);
791 let b3 = tree.bounds(ids[3]);
792 assert!(b3.y > b0.y, "4th tile is on a second row");
794 assert!(
795 (b3.x - b0.x).abs() < 0.5,
796 "4th tile aligns under the first column"
797 );
798 }
799
800 #[test]
801 fn vertical_layout_is_compact_full_width_list() {
802 let selected = Signal::new(0_usize);
803 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
804 let g = tree.add(
805 RadioTileGroup::new(selected.clone())
806 .layout(TileLayout::Vertical)
807 .tile(
808 RadioTile::new()
809 .title(lit!("None"))
810 .trailing(lit!("empty binder")),
811 )
812 .tile(
813 RadioTile::new()
814 .title(lit!("Novel"))
815 .trailing(lit!("20 chapters")),
816 )
817 .tile(
818 RadioTile::new()
819 .title(lit!("Notebook"))
820 .trailing(lit!("free-form notes")),
821 ),
822 );
823 tree.layout(SizeProposal::exact(500.0, 400.0));
824 let none = tree.find_by_label("None").unwrap();
825 let novel = tree.find_by_label("Novel").unwrap();
826 assert!((tree.bounds(none).width - tree.bounds(novel).width).abs() < 0.5);
829 assert!(tree.bounds(none).width > 480.0);
830 assert!((tree.bounds(none).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
831 assert!((tree.bounds(novel).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
832 assert!(tree.bounds(novel).y > tree.bounds(none).y);
833 tree.focus(g);
835 tree.press_key(Key::ArrowDown, Modifiers::NONE);
836 assert_eq!(selected.get(), 1);
837 assert_eq!(
839 tree.accessibility_node(none).role(),
840 teksilo_core::accesskit::Role::RadioButton
841 );
842 }
843
844 #[test]
845 fn vertical_row_height_override_wins() {
846 let selected = Signal::new(0_usize);
847 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
848 tree.add(
849 RadioTileGroup::new(selected)
850 .layout(TileLayout::Vertical)
851 .row_height(40.0)
852 .tile(RadioTile::new().title(lit!("A")))
853 .tile(RadioTile::new().title(lit!("B"))),
854 );
855 tree.layout(SizeProposal::exact(400.0, 400.0));
856 let a = tree.find_by_label("A").unwrap();
857 assert!((tree.bounds(a).height - 40.0).abs() < 0.5);
858 }
859
860 #[test]
861 fn keyboard_focus_adds_one_group_ring() {
862 let selected = Signal::new(0_usize);
863 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
864 let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
865 tree.layout(SizeProposal::exact(600.0, 300.0));
866
867 let base = tree
870 .render()
871 .shapes
872 .iter()
873 .filter(|s| s.stroke_width > 0.0)
874 .count();
875
876 tree.focus(g);
879 tree.press_key(Key::ArrowRight, Modifiers::NONE);
880 let with_ring = tree
881 .render()
882 .shapes
883 .iter()
884 .filter(|s| s.stroke_width > 0.0)
885 .count();
886 assert_eq!(
887 with_ring,
888 base + 1,
889 "keyboard focus draws exactly one whole-group ring"
890 );
891 }
892
893 #[test]
894 fn accessibility_group_and_tiles() {
895 let selected = Signal::new(1_usize);
896 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
897 let g = tree.add(
898 group_with(selected, TileLayout::Row, &["one", "two", "three"]).label(lit!("Format")),
899 );
900 tree.layout(SizeProposal::exact(600.0, 300.0));
901
902 let ginfo = tree.accessibility_node(g);
903 assert_eq!(ginfo.role(), teksilo_core::accesskit::Role::RadioGroup);
904 assert_eq!(ginfo.name(), Some("Format"));
905
906 let ids = tile_ids(&tree, 3);
907 assert_eq!(
908 tree.accessibility_node(ids[0]).role(),
909 teksilo_core::accesskit::Role::RadioButton
910 );
911 assert!(!tree.accessibility_node(ids[0]).is_toggled());
912 assert!(
913 tree.accessibility_node(ids[1]).is_toggled(),
914 "selected tile is toggled"
915 );
916 assert!(!tree.accessibility_node(ids[2]).is_toggled());
917 }
918
919 #[test]
920 fn toggled_updates_after_keyboard_selection() {
921 let selected = Signal::new(0_usize);
922 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
923 let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
924 tree.layout(SizeProposal::exact(600.0, 300.0));
925 let ids = tile_ids(&tree, 2);
926 assert!(tree.accessibility_node(ids[0]).is_toggled());
927
928 tree.focus(g);
929 tree.press_key(Key::ArrowRight, Modifiers::NONE);
930 assert!(!tree.accessibility_node(ids[0]).is_toggled());
932 assert!(tree.accessibility_node(ids[1]).is_toggled());
933 }
934
935 #[test]
936 fn roving_selection_chases_outer_scroll_area() {
937 use crate::ScrollArea;
943
944 let selected = Signal::new(0_usize);
945 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
946 let g = tree.add(group_with(
947 selected.clone(),
948 TileLayout::Vertical,
949 &["a", "b", "c", "d", "e", "f"],
950 ));
951 let outer = ScrollArea::from_id(g).smooth_scrolling(false);
952 let outer_y = outer.scroll_y_signal().clone();
953 let _outer = tree.add(outer);
954 tree.layout(SizeProposal::exact(320.0, 90.0));
956
957 tree.focus(g);
960 tree.layout(SizeProposal::exact(320.0, 90.0));
961 outer_y.set(0.0);
962 tree.layout(SizeProposal::exact(320.0, 90.0));
963 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
964
965 for _ in 0..5 {
967 tree.press_key(Key::ArrowDown, Modifiers::NONE);
968 }
969 tree.layout(SizeProposal::exact(320.0, 90.0));
970
971 assert_eq!(
972 selected.get(),
973 5,
974 "arrows moved the selection to the last tile"
975 );
976 assert!(
977 outer_y.get() > 0.01,
978 "selecting a tile below the fold must scroll the enclosing ScrollArea \
979 (got {})",
980 outer_y.get()
981 );
982 }
983
984 #[test]
985 fn at_increment_chases_outer_scroll_area() {
986 use crate::ScrollArea;
990 use teksilo_core::accessibility::widget_id_to_node_id;
991 use teksilo_core::accesskit::Action;
992
993 let selected = Signal::new(0_usize);
994 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
995 let g = tree.add(group_with(
996 selected.clone(),
997 TileLayout::Vertical,
998 &["a", "b", "c", "d", "e", "f"],
999 ));
1000 let outer = ScrollArea::from_id(g).smooth_scrolling(false);
1001 let outer_y = outer.scroll_y_signal().clone();
1002 let _outer = tree.add(outer);
1003 tree.layout(SizeProposal::exact(320.0, 90.0));
1004 tree.focus(g);
1005 tree.layout(SizeProposal::exact(320.0, 90.0));
1006 outer_y.set(0.0);
1007 tree.layout(SizeProposal::exact(320.0, 90.0));
1008 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
1009
1010 let node = widget_id_to_node_id(g);
1011 let mut ops = teksilo_core::window::NoopWindowOps;
1012 for _ in 0..5 {
1013 tree.dispatch_access_action(node, Action::Increment, None, &mut ops);
1014 }
1015 tree.layout(SizeProposal::exact(320.0, 90.0));
1016
1017 assert_eq!(
1018 selected.get(),
1019 5,
1020 "AT Increment moved selection to the last tile"
1021 );
1022 assert!(
1023 outer_y.get() > 0.01,
1024 "AT-driven selection below the fold must scroll the enclosing \
1025 ScrollArea (got {})",
1026 outer_y.get()
1027 );
1028 }
1029
1030 #[test]
1031 fn a_negative_focus_ring_offset_lays_the_group_ring_over_the_tiles_edge() {
1032 let shape = |offset: f32| ShapeTokens {
1033 focus_ring_offset: offset,
1034 focus_ring_width: 2.0,
1035 ..ShapeTokens::default()
1036 };
1037 let bounds = Rect::new(0.0, 0.0, 300.0, 120.0);
1038 let outer = group_focus_ring(bounds, &shape(2.0));
1040 assert_eq!(outer.rect, Rect::new(1.0, 1.0, 298.0, 118.0));
1041 let inner = group_focus_ring(bounds, &shape(-1.0));
1043 assert_eq!(inner.rect, Rect::new(1.0, 1.0, 298.0, 118.0));
1044 assert_eq!(inner.corner_radius, RADIO_TILE_CORNER_RADIUS);
1045 assert!(inner.corner_radius < outer.corner_radius);
1046
1047 let tiles = Rect::new(4.0, 4.0, 292.0, 112.0);
1049 let around_outer = FocusRingStroke::around(tiles, RADIO_TILE_CORNER_RADIUS, &shape(2.0));
1050 let around_inner = FocusRingStroke::around(tiles, RADIO_TILE_CORNER_RADIUS, &shape(-1.0));
1051 assert!(around_inner.rect.width < around_outer.rect.width);
1052
1053 let deep = group_focus_ring(bounds, &shape(-500.0));
1054 assert!(deep.rect.width >= 0.0 && deep.rect.height >= 0.0);
1055 assert!(deep.corner_radius >= 0.0);
1056 }
1057}