use std::cell::{Cell, RefCell};
use std::rc::Rc;
use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key, WidgetEvent};
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::styles::SharedRadioTileStyle;
use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::CornerRadius;
use crate::radio_tile::RadioTile;
use crate::styles::{RADIO_TILE_CORNER_RADIUS, RADIO_TILE_VERTICAL_ROW_HEIGHT};
use teksilo_i18n::LocalizedString;
#[derive(Copy, Clone, Debug, PartialEq, Default)]
pub enum TileLayout {
#[default]
Row,
Grid {
min_tile_width: f32,
},
Column,
Vertical,
}
fn focus_ring_envelope(theme: &teksilo_core::Theme) -> f32 {
theme.shape.focus_ring_offset + theme.shape.focus_ring_width
}
pub struct RadioTileGroup {
pending: Vec<RadioTile>,
selected: Signal<usize>,
label: Option<LocalizedString>,
layout: TileLayout,
spacing: Option<f32>,
line_spacing: f32,
row_height: Option<f32>,
enabled: Prop<bool>,
style_override: Option<SharedRadioTileStyle>,
group_focused: Signal<bool>,
ring_visible: Signal<bool>,
group_ids: Rc<RefCell<Vec<WidgetId>>>,
tile_ids: Vec<WidgetId>,
col_count: Rc<Cell<usize>>,
}
impl RadioTileGroup {
pub fn new(selected: Signal<usize>) -> Self {
Self {
pending: Vec::new(),
selected,
label: None,
layout: TileLayout::default(),
spacing: None,
line_spacing: 12.0,
row_height: None,
enabled: Prop::Static(true),
style_override: None,
group_focused: Signal::new(false),
ring_visible: Signal::new(false),
group_ids: Rc::new(RefCell::new(Vec::new())),
tile_ids: Vec::new(),
col_count: Rc::new(Cell::new(1)),
}
}
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn tile(mut self, tile: RadioTile) -> Self {
self.pending.push(tile);
self
}
pub fn tiles(mut self, tiles: impl IntoIterator<Item = RadioTile>) -> Self {
self.pending.extend(tiles);
self
}
pub fn layout(mut self, layout: TileLayout) -> Self {
self.layout = layout;
self
}
pub fn spacing(mut self, spacing: f32) -> Self {
self.spacing = Some(spacing);
self
}
pub fn line_spacing(mut self, spacing: f32) -> Self {
self.line_spacing = spacing;
self
}
pub fn row_height(mut self, height: f32) -> Self {
self.row_height = Some(height);
self
}
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub fn style(mut self, style: impl teksilo_core::styles::RadioTileStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
fn step(current: usize, forward: bool, disabled: &[bool]) -> usize {
let n = disabled.len();
if n == 0 {
return current;
}
let mut i = current;
for _ in 0..n {
i = if forward {
(i + 1) % n
} else {
(i + n - 1) % n
};
if !disabled[i] {
return i;
}
}
current
}
fn first_enabled(disabled: &[bool]) -> usize {
(0..disabled.len()).find(|&i| !disabled[i]).unwrap_or(0)
}
fn last_enabled(disabled: &[bool]) -> usize {
(0..disabled.len())
.rev()
.find(|&i| !disabled[i])
.unwrap_or(disabled.len().saturating_sub(1))
}
fn step_vertical(current: usize, down: bool, cols: usize, disabled: &[bool]) -> usize {
let n = disabled.len();
if n == 0 || cols == 0 {
return current;
}
let target = if down {
current + cols
} else if current >= cols {
current - cols
} else {
return current;
};
if target >= n {
return current;
}
if !disabled[target] {
return target;
}
Self::step(target, true, disabled)
}
fn compute_layout(&self, avail_w: Option<f32>, ctx: &LayoutContext) -> (Vec<Rect>, Size) {
let n = self.tile_ids.len();
if n == 0 {
self.col_count.set(1);
return (Vec::new(), Size::new(0.0, 0.0));
}
let nf = n as f32;
let sp = self.spacing.unwrap_or(match self.layout {
TileLayout::Vertical => 6.0,
_ => 12.0,
});
let lsp = self.line_spacing;
let measure_h = |id: WidgetId, w: f32| -> f32 {
ctx.measure_intrinsic(
id,
SizeProposal {
width: Some(w),
height: None,
},
)
.map(|s| s.height)
.unwrap_or(0.0)
};
let avail = avail_w.unwrap_or_else(|| {
let maxw = self
.tile_ids
.iter()
.map(|&id| {
ctx.measure_intrinsic(id, SizeProposal::unspecified())
.map(|s| s.width)
.unwrap_or(0.0)
})
.fold(0.0_f32, f32::max);
match self.layout {
TileLayout::Column | TileLayout::Vertical => maxw,
_ => maxw * nf + (nf - 1.0) * sp,
}
});
match self.layout {
TileLayout::Row => {
self.col_count.set(n);
let tile_w = ((avail - (nf - 1.0) * sp) / nf).max(0.0);
let row_h = self
.tile_ids
.iter()
.map(|&id| measure_h(id, tile_w))
.fold(0.0_f32, f32::max);
let mut rects = Vec::with_capacity(n);
let mut x = 0.0;
for _ in 0..n {
rects.push(Rect::new(x, 0.0, tile_w, row_h));
x += tile_w + sp;
}
(rects, Size::new(avail, row_h))
}
TileLayout::Column => {
self.col_count.set(1);
let mut rects = Vec::with_capacity(n);
let mut y = 0.0;
for &id in &self.tile_ids {
let h = measure_h(id, avail);
rects.push(Rect::new(0.0, y, avail, h));
y += h + sp;
}
let total_h = (y - sp).max(0.0);
(rects, Size::new(avail, total_h))
}
TileLayout::Vertical => {
self.col_count.set(1);
let h = self.row_height.unwrap_or_else(|| {
self.style_override
.as_ref()
.or(ctx.theme.style_slots.radio_tile.as_ref())
.map(|s| s.vertical_row_height())
.unwrap_or(RADIO_TILE_VERTICAL_ROW_HEIGHT)
});
let mut rects = Vec::with_capacity(n);
let mut y = 0.0;
for _ in 0..n {
rects.push(Rect::new(0.0, y, avail, h));
y += h + sp;
}
let total_h = (h * nf + (nf - 1.0) * sp).max(0.0);
(rects, Size::new(avail, total_h))
}
TileLayout::Grid { min_tile_width } => {
let cols = (((avail + sp) / (min_tile_width + sp)).floor() as usize).clamp(1, n);
self.col_count.set(cols);
let colsf = cols as f32;
let cell_w = ((avail - (colsf - 1.0) * sp) / colsf).max(0.0);
let cell_h = self
.tile_ids
.iter()
.map(|&id| measure_h(id, cell_w))
.fold(0.0_f32, f32::max);
let rows = n.div_ceil(cols);
let mut rects = Vec::with_capacity(n);
for i in 0..n {
let r = (i / cols) as f32;
let c = (i % cols) as f32;
rects.push(Rect::new(
c * (cell_w + sp),
r * (cell_h + lsp),
cell_w,
cell_h,
));
}
let total_h = rows as f32 * cell_h + (rows.saturating_sub(1)) as f32 * lsp;
(rects, Size::new(avail, total_h))
}
}
}
}
impl std::fmt::Debug for RadioTileGroup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioTileGroup")
.field("layout", &self.layout)
.field("num_tiles", &self.pending.len().max(self.tile_ids.len()))
.field("label", &self.label)
.finish()
}
}
impl Widget for RadioTileGroup {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
ctx.enabled_when(self_id, self.enabled.clone());
let focus_visible = ctx.focus_visible();
let ring_visible = self.group_focused.and(&focus_visible);
self.ring_visible = ring_visible.clone();
{
let registry = ctx.binding_registry();
self.selected
.bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
ring_visible.bind_to(self_id, registry, BindingLevel::RepaintOnly);
}
let pending = std::mem::take(&mut self.pending);
let n = pending.len();
self.group_ids.borrow_mut().clear();
self.tile_ids.clear();
let mut disabled: Vec<bool> = Vec::with_capacity(n);
for (i, mut tile) in pending.into_iter().enumerate() {
tile.set_selection(i, self.selected.clone());
tile.set_grouped(self.group_focused.clone(), self.group_ids.clone(), i + 1, n);
if self.layout == TileLayout::Vertical {
tile.set_vertical_arrangement();
}
let tip_placement = match self.layout {
TileLayout::Column | TileLayout::Vertical => crate::tooltip::TooltipPlacement::Side,
TileLayout::Row | TileLayout::Grid { .. } => {
crate::tooltip::TooltipPlacement::Below
}
};
tile.set_tooltip_placement(tip_placement);
if let Some(style) = &self.style_override {
tile.set_style_if_unset(style.clone());
}
disabled.push(!tile.is_enabled());
let id = ctx.add(tile);
self.group_ids.borrow_mut().push(id);
self.tile_ids.push(id);
}
let disabled: Rc<Vec<bool>> = Rc::new(disabled);
let layout = self.layout;
let col_count = self.col_count.clone();
let mut handlers = HandlerSet::new().focusable(true);
{
let selected = self.selected.clone();
let disabled = disabled.clone();
let col_count = col_count.clone();
let tile_ids = self.tile_ids.clone();
handlers = handlers.on_key(move |event, ctx: &mut EventContext| {
if n == 0 {
return EventResponse::Ignored;
}
let cur = selected.get().min(n - 1);
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
let next = match (layout, key) {
(TileLayout::Grid { .. }, Key::ArrowRight) => Self::step(cur, true, &disabled),
(TileLayout::Grid { .. }, Key::ArrowLeft) => Self::step(cur, false, &disabled),
(TileLayout::Grid { .. }, Key::ArrowDown) => {
Self::step_vertical(cur, true, col_count.get(), &disabled)
}
(TileLayout::Grid { .. }, Key::ArrowUp) => {
Self::step_vertical(cur, false, col_count.get(), &disabled)
}
(_, Key::ArrowRight | Key::ArrowDown) => Self::step(cur, true, &disabled),
(_, Key::ArrowLeft | Key::ArrowUp) => Self::step(cur, false, &disabled),
(_, Key::Home) => Self::first_enabled(&disabled),
(_, Key::End) => Self::last_enabled(&disabled),
_ => return EventResponse::Ignored,
};
if next != cur {
selected.set(next);
if let Some(&id) = tile_ids.get(next) {
ctx.ensure_widget_visible(id);
}
}
EventResponse::Handled
});
}
{
let group_focused = self.group_focused.clone();
handlers = handlers.on_focus(move |gained, _ctx: &mut EventContext| {
group_focused.set(gained);
});
}
{
let selected = self.selected.clone();
let disabled = disabled.clone();
let tile_ids = self.tile_ids.clone();
handlers = handlers.on_access_action(move |action, ctx: &mut EventContext| {
if n == 0 {
return EventResponse::Ignored;
}
let cur = selected.get().min(n - 1);
let next = if action == teksilo_core::accesskit::Action::Increment {
Self::step(cur, true, &disabled)
} else if action == teksilo_core::accesskit::Action::Decrement {
Self::step(cur, false, &disabled)
} else {
return EventResponse::Ignored;
};
if next != cur {
selected.set(next);
if let Some(&id) = tile_ids.get(next) {
ctx.ensure_widget_visible(id);
}
}
EventResponse::Handled
});
}
ctx.apply_self_handlers(handlers);
self.tile_ids.clone()
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let env = focus_ring_envelope(ctx.theme);
let inner_w = proposal.width.map(|w| (w - env * 2.0).max(0.0));
let (_rects, size) = self.compute_layout(inner_w, ctx);
Size::new(size.width + env * 2.0, size.height + env * 2.0).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
let env = focus_ring_envelope(ctx.theme);
let inner_w = (bounds.width - env * 2.0).max(0.0);
let (rects, _size) = self.compute_layout(Some(inner_w), ctx);
for (child, rect) in children.iter_mut().zip(rects.iter()) {
child.origin = Point::new(bounds.x + env + rect.x, bounds.y + env + rect.y);
child.size = Size::new(rect.width, rect.height);
}
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
if !self.ring_visible.get() {
return;
}
let shape = &ctx.theme.shape;
let half = shape.focus_ring_width * 0.5;
let ring_rect = Rect::new(
bounds.x + half,
bounds.y + half,
(bounds.width - half * 2.0).max(0.0),
(bounds.height - half * 2.0).max(0.0),
);
let ring_radius = RADIO_TILE_CORNER_RADIUS + shape.focus_ring_offset + half;
canvas.stroke_rounded_rect(
ring_rect,
CornerRadius::uniform(ring_radius),
ctx.theme.colors.focus_ring,
shape.focus_ring_width,
);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
if let Some(ref name) = self.label {
builder.set_name(name.resolve_now());
}
let idx = self.selected.get();
if let Some(&id) = self.tile_ids.get(idx) {
builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(id));
}
builder.add_action(teksilo_core::accesskit::Action::Focus);
builder.add_action(teksilo_core::accesskit::Action::Increment);
builder.add_action(teksilo_core::accesskit::Action::Decrement);
}
fn children(&self) -> Vec<WidgetId> {
self.tile_ids.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::event::Modifiers;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
fn group_with(
selected: Signal<usize>,
layout: TileLayout,
descriptions: &[&'static str],
) -> RadioTileGroup {
let labels = ["A", "B", "C", "D", "E", "F"];
let mut g = RadioTileGroup::new(selected).layout(layout);
for (i, desc) in descriptions.iter().enumerate() {
g = g.tile(
RadioTile::new()
.title(lit!(labels[i]))
.description(lit!(*desc)),
);
}
g
}
fn tile_ids(tree: &WidgetTree, n: usize) -> Vec<WidgetId> {
["A", "B", "C", "D", "E", "F"][..n]
.iter()
.map(|l| {
tree.find_by_label(l)
.unwrap_or_else(|| panic!("tile {l} not found"))
})
.collect()
}
#[test]
fn click_selects_tile_and_deselects_siblings() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(group_with(
selected.clone(),
TileLayout::Row,
&["one", "two", "three"],
));
tree.layout(SizeProposal::exact(600.0, 300.0));
let ids = tile_ids(&tree, 3);
assert_eq!(selected.get(), 0);
tree.click(ids[1]);
assert_eq!(selected.get(), 1);
tree.click(ids[2]);
assert_eq!(selected.get(), 2);
}
#[test]
fn roving_arrows_move_selection_and_wrap() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(group_with(
selected.clone(),
TileLayout::Row,
&["one", "two", "three"],
));
tree.layout(SizeProposal::exact(600.0, 300.0));
tree.focus(g);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(selected.get(), 1);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(selected.get(), 2);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(selected.get(), 0, "wraps around");
tree.press_key(Key::ArrowLeft, Modifiers::NONE);
assert_eq!(selected.get(), 2, "wraps backwards");
tree.press_key(Key::End, Modifiers::NONE);
assert_eq!(selected.get(), 2);
tree.press_key(Key::Home, Modifiers::NONE);
assert_eq!(selected.get(), 0);
}
#[test]
fn roving_skips_disabled_tile() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(
RadioTileGroup::new(selected.clone())
.layout(TileLayout::Row)
.tile(RadioTile::new().title(lit!("A")))
.tile(RadioTile::new().title(lit!("B")).enabled(false))
.tile(RadioTile::new().title(lit!("C"))),
);
tree.layout(SizeProposal::exact(600.0, 300.0));
tree.focus(g);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(
selected.get(),
2,
"ArrowRight skips the disabled middle tile"
);
}
#[test]
fn row_layout_equalizes_width_and_height() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(group_with(
selected,
TileLayout::Row,
&[
"short",
"a considerably longer description that will wrap across several lines in the tile",
"medium length text here",
],
));
tree.layout(SizeProposal::exact(600.0, 400.0));
let ids = tile_ids(&tree, 3);
let b0 = tree.bounds(ids[0]);
let b1 = tree.bounds(ids[1]);
let b2 = tree.bounds(ids[2]);
assert!((b0.width - b1.width).abs() < 0.5, "equal widths");
assert!((b1.width - b2.width).abs() < 0.5, "equal widths");
assert!(
(b0.height - b1.height).abs() < 0.5,
"equal heights despite different content"
);
assert!(
(b1.height - b2.height).abs() < 0.5,
"equal heights despite different content"
);
assert!(b1.height > 0.0);
}
#[test]
fn column_layout_gives_full_width_tiles() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(group_with(selected, TileLayout::Column, &["one", "two"]));
tree.layout(SizeProposal::exact(500.0, 400.0));
let ids = tile_ids(&tree, 2);
assert!((tree.bounds(ids[0]).width - tree.bounds(ids[1]).width).abs() < 0.5);
assert!(tree.bounds(ids[0]).width > 480.0);
assert!(tree.bounds(ids[1]).y > tree.bounds(ids[0]).y);
}
#[test]
fn grid_layout_wraps_into_expected_columns() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(group_with(
selected,
TileLayout::Grid {
min_tile_width: 200.0,
},
&["one", "two", "three", "four"],
));
tree.layout(SizeProposal::exact(640.0, 600.0));
let ids = tile_ids(&tree, 4);
let b0 = tree.bounds(ids[0]);
let b3 = tree.bounds(ids[3]);
assert!(b3.y > b0.y, "4th tile is on a second row");
assert!(
(b3.x - b0.x).abs() < 0.5,
"4th tile aligns under the first column"
);
}
#[test]
fn vertical_layout_is_compact_full_width_list() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(
RadioTileGroup::new(selected.clone())
.layout(TileLayout::Vertical)
.tile(
RadioTile::new()
.title(lit!("None"))
.trailing(lit!("empty binder")),
)
.tile(
RadioTile::new()
.title(lit!("Novel"))
.trailing(lit!("20 chapters")),
)
.tile(
RadioTile::new()
.title(lit!("Notebook"))
.trailing(lit!("free-form notes")),
),
);
tree.layout(SizeProposal::exact(500.0, 400.0));
let none = tree.find_by_label("None").unwrap();
let novel = tree.find_by_label("Novel").unwrap();
assert!((tree.bounds(none).width - tree.bounds(novel).width).abs() < 0.5);
assert!(tree.bounds(none).width > 480.0);
assert!((tree.bounds(none).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
assert!((tree.bounds(novel).height - RADIO_TILE_VERTICAL_ROW_HEIGHT).abs() < 0.5);
assert!(tree.bounds(novel).y > tree.bounds(none).y);
tree.focus(g);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(selected.get(), 1);
assert_eq!(
tree.accessibility_node(none).role(),
teksilo_core::accesskit::Role::RadioButton
);
}
#[test]
fn vertical_row_height_override_wins() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
RadioTileGroup::new(selected)
.layout(TileLayout::Vertical)
.row_height(40.0)
.tile(RadioTile::new().title(lit!("A")))
.tile(RadioTile::new().title(lit!("B"))),
);
tree.layout(SizeProposal::exact(400.0, 400.0));
let a = tree.find_by_label("A").unwrap();
assert!((tree.bounds(a).height - 40.0).abs() < 0.5);
}
#[test]
fn keyboard_focus_adds_one_group_ring() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
tree.layout(SizeProposal::exact(600.0, 300.0));
let base = tree
.render()
.shapes
.iter()
.filter(|s| s.stroke_width > 0.0)
.count();
tree.focus(g);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
let with_ring = tree
.render()
.shapes
.iter()
.filter(|s| s.stroke_width > 0.0)
.count();
assert_eq!(
with_ring,
base + 1,
"keyboard focus draws exactly one whole-group ring"
);
}
#[test]
fn accessibility_group_and_tiles() {
let selected = Signal::new(1_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(
group_with(selected, TileLayout::Row, &["one", "two", "three"]).label(lit!("Format")),
);
tree.layout(SizeProposal::exact(600.0, 300.0));
let ginfo = tree.accessibility_node(g);
assert_eq!(ginfo.role(), teksilo_core::accesskit::Role::RadioGroup);
assert_eq!(ginfo.name(), Some("Format"));
let ids = tile_ids(&tree, 3);
assert_eq!(
tree.accessibility_node(ids[0]).role(),
teksilo_core::accesskit::Role::RadioButton
);
assert!(!tree.accessibility_node(ids[0]).is_toggled());
assert!(
tree.accessibility_node(ids[1]).is_toggled(),
"selected tile is toggled"
);
assert!(!tree.accessibility_node(ids[2]).is_toggled());
}
#[test]
fn toggled_updates_after_keyboard_selection() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(group_with(selected, TileLayout::Row, &["one", "two"]));
tree.layout(SizeProposal::exact(600.0, 300.0));
let ids = tile_ids(&tree, 2);
assert!(tree.accessibility_node(ids[0]).is_toggled());
tree.focus(g);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert!(!tree.accessibility_node(ids[0]).is_toggled());
assert!(tree.accessibility_node(ids[1]).is_toggled());
}
#[test]
fn roving_selection_chases_outer_scroll_area() {
use crate::ScrollArea;
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(group_with(
selected.clone(),
TileLayout::Vertical,
&["a", "b", "c", "d", "e", "f"],
));
let outer = ScrollArea::from_id(g).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
tree.layout(SizeProposal::exact(320.0, 90.0));
tree.focus(g);
tree.layout(SizeProposal::exact(320.0, 90.0));
outer_y.set(0.0);
tree.layout(SizeProposal::exact(320.0, 90.0));
assert!(outer_y.get().abs() < 0.01, "reset outer to top");
for _ in 0..5 {
tree.press_key(Key::ArrowDown, Modifiers::NONE);
}
tree.layout(SizeProposal::exact(320.0, 90.0));
assert_eq!(
selected.get(),
5,
"arrows moved the selection to the last tile"
);
assert!(
outer_y.get() > 0.01,
"selecting a tile below the fold must scroll the enclosing ScrollArea \
(got {})",
outer_y.get()
);
}
#[test]
fn at_increment_chases_outer_scroll_area() {
use crate::ScrollArea;
use teksilo_core::accessibility::widget_id_to_node_id;
use teksilo_core::accesskit::Action;
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let g = tree.add(group_with(
selected.clone(),
TileLayout::Vertical,
&["a", "b", "c", "d", "e", "f"],
));
let outer = ScrollArea::from_id(g).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
tree.layout(SizeProposal::exact(320.0, 90.0));
tree.focus(g);
tree.layout(SizeProposal::exact(320.0, 90.0));
outer_y.set(0.0);
tree.layout(SizeProposal::exact(320.0, 90.0));
assert!(outer_y.get().abs() < 0.01, "reset outer to top");
let node = widget_id_to_node_id(g);
let mut ops = teksilo_core::window::NoopWindowOps;
for _ in 0..5 {
tree.dispatch_access_action(node, Action::Increment, None, &mut ops);
}
tree.layout(SizeProposal::exact(320.0, 90.0));
assert_eq!(
selected.get(),
5,
"AT Increment moved selection to the last tile"
);
assert!(
outer_y.get() > 0.01,
"AT-driven selection below the fold must scroll the enclosing \
ScrollArea (got {})",
outer_y.get()
);
}
}