use super::*;
use teksilo_canvas::SizeProposal;
use teksilo_core::widget::LayoutContext;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_data::{ListModel, SelectionMode, SelectionModel};
#[derive(Debug)]
struct FixedLeaf(f32, f32);
impl Widget for FixedLeaf {
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(self.0, self.1).into()
}
}
fn make_grid(count: usize) -> (WidgetTree, WidgetId, ListModel<usize>) {
let model = ListModel::from_vec((0..count).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model.clone(), |_tc| Box::new(FixedLeaf(100.0, 50.0))).tile_size(100.0, 50.0),
);
(tree, id, model)
}
fn tiles(tree: &WidgetTree, grid_id: WidgetId) -> Vec<WidgetId> {
let children = tree.children(grid_id);
let body = children[0];
tree.children(body)
}
#[test]
fn tiles_materialize_during_scrollbar_thumb_drag() {
let (mut tree, id, _model) = make_grid(3000);
crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
&mut tree,
id,
400.0,
300.0,
0.0,
"GridView",
|t| {
tiles(t, id)
.into_iter()
.filter(|tile| {
let b = t.bounds(*tile);
b.height > 1.0 && b.y > -b.height && b.y < 300.0
})
.count()
},
);
}
#[test]
fn virtualization_realizes_only_visible_tiles() {
let (mut tree, id, _model) = make_grid(300);
tree.layout(SizeProposal::exact(400.0, 300.0));
let n = tiles(&tree, id).len();
assert!(n < 60, "expected far fewer than 300 tiles, got {n}");
assert!(n >= 18, "expected at least 18 tiles realized, got {n}");
}
#[test]
fn fixed_tile_size_derives_three_columns() {
let (mut tree, id, _model) = make_grid(30);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
let b0 = tree.bounds(t[0]);
let b1 = tree.bounds(t[1]);
let b3 = tree.bounds(t[3]);
assert!((b0.x - 0.0).abs() < 0.01, "tile 0 x = {}", b0.x);
assert!((b1.x - 108.0).abs() < 0.01, "tile 1 x = {}", b1.x);
assert!((b0.y - 0.0).abs() < 0.01, "tile 0 y = {}", b0.y);
assert!((b3.y - 58.0).abs() < 0.01, "tile 3 y = {}", b3.y);
assert!((b3.x - 0.0).abs() < 0.01, "tile 3 x = {}", b3.x);
}
#[test]
fn fixed_column_count_uses_exact_columns() {
let model = ListModel::from_vec((0..40).collect());
let mut tree = WidgetTree::new();
let id =
tree.add(GridView::new(model, |_tc| Box::new(FixedLeaf(10.0, 40.0))).column_count(4, 40.0));
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
let b0 = tree.bounds(t[0]);
let b4 = tree.bounds(t[4]);
assert!((b0.y - 0.0).abs() < 0.01);
assert!((b4.y - 48.0).abs() < 0.01, "tile 4 y = {} (row 1)", b4.y);
}
#[test]
fn empty_model_realizes_no_tiles() {
let (mut tree, id, _model) = make_grid(0);
tree.layout(SizeProposal::exact(400.0, 300.0));
let children = tree.children(id);
assert!(
children.len() <= 1,
"empty grid should have at most a scrollbar child, got {}",
children.len()
);
}
#[test]
fn data_change_triggers_rebuild() {
let (mut tree, id, model) = make_grid(6);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(tiles(&tree, id).len(), 6);
model.push(99);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(tiles(&tree, id).len(), 7);
model.remove(0);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(tiles(&tree, id).len(), 6);
}
#[test]
fn focused_index_follows_insert_before_it() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model.clone(), |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
let t = tiles(&tree, id);
tree.click(t[1]);
assert_eq!(
selection.selected_indices(),
vec![1],
"precondition: click selects tile 1"
);
model.insert(0, 100);
model.insert(0, 200);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
selection.selected_indices(),
vec![3],
"precondition: selection shifts with the inserted tiles"
);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
assert_eq!(
selection.selected_indices(),
vec![4],
"ArrowRight after a leading insert resumes from the shifted tile (3 → 4), \
not the stale pre-insert one (1 → 2)"
);
}
#[test]
fn focused_index_dropped_when_its_tile_is_removed() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model.clone(), |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
let t = tiles(&tree, id);
tree.click(t[3]);
assert_eq!(selection.selected_indices(), vec![3], "precondition");
model.remove(3);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
selection.selected_indices().is_empty(),
"precondition: selection drops the removed tile"
);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
assert_eq!(
selection.selected_indices(),
vec![0],
"focused_index must be dropped when its tile is removed, not silently repointed"
);
}
#[test]
fn click_selects_tile() {
let model = ListModel::from_vec((0..12).collect());
let selection = SelectionModel::new(SelectionMode::Multi);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
tree.click(t[2]);
assert!(selection.is_selected(2), "tile 2 should be selected");
assert!(!selection.is_selected(0));
}
#[test]
fn access_click_selects_tile() {
let model = ListModel::from_vec((0..12).collect());
let selection = SelectionModel::new(SelectionMode::Multi);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(t[2]),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
assert!(selection.is_selected(2), "AT click should select tile 2");
assert!(!selection.is_selected(0));
}
#[test]
fn access_click_activates_only_on_single_click_mode() {
use std::cell::Cell;
use std::rc::Rc;
let at_click = |tree: &mut WidgetTree, id: WidgetId| {
tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(id),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
};
let fired: Rc<Cell<usize>> = Rc::new(Cell::new(0));
let f = fired.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(ListModel::from_vec((0..12).collect()), |_tc| {
Box::new(FixedLeaf(100.0, 50.0))
})
.tile_size(100.0, 50.0)
.selection(SelectionModel::new(SelectionMode::Single))
.activate_on(crate::data_views::ActivateOn::DoubleClick)
.on_tile_activate(move |_idx, _ctx| f.set(f.get() + 1)),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
at_click(&mut tree, t[2]);
assert_eq!(
fired.get(),
0,
"AT click must not activate under DoubleClick mode"
);
let fired: Rc<Cell<usize>> = Rc::new(Cell::new(0));
let f = fired.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(ListModel::from_vec((0..12).collect()), |_tc| {
Box::new(FixedLeaf(100.0, 50.0))
})
.tile_size(100.0, 50.0)
.selection(SelectionModel::new(SelectionMode::Single))
.activate_on(crate::data_views::ActivateOn::SingleClick)
.on_tile_activate(move |idx, _ctx| {
assert_eq!(idx, 2);
f.set(f.get() + 1)
}),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
at_click(&mut tree, t[2]);
assert_eq!(
fired.get(),
1,
"AT click must activate under SingleClick mode"
);
}
#[test]
fn first_arrow_lands_on_an_end_tile_instead_of_skipping_it() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let press = |tree: &mut WidgetTree, key: Key| {
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: Modifiers::default(),
text: None,
});
};
for (key, want, what) in [
(Key::ArrowRight, 0usize, "first ArrowRight selects tile 0"),
(Key::ArrowDown, 0usize, "first ArrowDown selects tile 0"),
(
Key::ArrowLeft,
29usize,
"first ArrowLeft selects the last tile",
),
(Key::ArrowUp, 29usize, "first ArrowUp selects the last tile"),
] {
let model = ListModel::from_vec((0..30).collect());
let selection = SelectionModel::new(SelectionMode::Single);
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(selection.clone()),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
assert!(
selection.selected_indices().is_empty(),
"precondition: nothing selected, no cursor"
);
press(&mut tree, key);
assert!(selection.is_selected(want), "{what}");
}
let model = ListModel::from_vec((0..30).collect());
let selection = SelectionModel::new(SelectionMode::Single);
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(selection.clone()),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
selection.select(4);
tree.focus(id);
press(&mut tree, Key::ArrowRight);
assert!(
selection.is_selected(5),
"ArrowRight from a preselected tile 4 continues to 5"
);
}
#[test]
fn arrow_keys_move_focus_and_selection() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..30).collect());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
assert!(
selection.is_selected(0),
"the first arrow key selects index 0, it does not skip it"
);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
assert!(selection.is_selected(1), "ArrowRight selects index 1");
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::default(),
text: None,
});
assert!(
selection.is_selected(4),
"ArrowDown moves by one row (cols)"
);
}
#[test]
fn keyboard_selection_chases_outer_scroll_area() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..20).collect());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let grid = GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel);
let grid_id = tree.add(grid);
let grid_box = tree.add(
FixedSize::new()
.width(120.0)
.height(200.0)
.child_id(grid_id),
);
let filler = tree.add(FixedLeaf(120.0, 200.0));
let outer_content = tree.add(VStack::new().add_child(grid_box).add_child(filler));
let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
let outer_y = outer.scroll_y_signal().clone();
let _outer = tree.add(outer);
tree.layout(SizeProposal::exact(120.0, 100.0));
tree.focus(grid_id);
tree.layout(SizeProposal::exact(120.0, 100.0));
outer_y.set(0.0);
tree.layout(SizeProposal::exact(120.0, 100.0));
assert!(outer_y.get().abs() < 0.01, "reset outer to top");
for _ in 0..20 {
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::default(),
text: None,
});
}
tree.layout(SizeProposal::exact(120.0, 100.0));
assert!(
outer_y.get() > 0.01,
"navigating to a tile below the fold must scroll the enclosing ScrollArea (got {})",
outer_y.get()
);
}
#[test]
fn ctrl_a_selects_all() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..12).collect());
let selection = SelectionModel::new(SelectionMode::Multi);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::A,
modifiers: Modifiers::CTRL,
text: None,
});
assert_eq!(selection.count(), 12, "Ctrl+A selects every item");
}
#[test]
fn ctrl_arrow_moves_cursor_without_selecting() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..30).collect::<Vec<usize>>());
let selection = SelectionModel::new(SelectionMode::Multi);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
let focused_index = |tree: &WidgetTree| -> Option<usize> {
tree.widget_as_any(id)
.and_then(|any| any.downcast_ref::<GridView<usize>>())
.and_then(|g| g.focused_index.get())
};
let press = |tree: &mut WidgetTree, key: Key, modifiers: Modifiers| {
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers,
text: None,
});
};
press(&mut tree, Key::ArrowRight, Modifiers::default());
assert_eq!(selection.selected_indices(), vec![0]);
press(&mut tree, Key::ArrowRight, Modifiers::CTRL);
assert_eq!(
selection.selected_indices(),
vec![0],
"Ctrl+ArrowRight must leave the selection unchanged"
);
assert_eq!(focused_index(&tree), Some(1));
press(&mut tree, Key::ArrowDown, Modifiers::CTRL);
assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
assert_eq!(
focused_index(&tree),
Some(4),
"Ctrl+ArrowDown moves the cursor by one row (col count 3)"
);
press(&mut tree, Key::Space, Modifiers::CTRL);
assert_eq!(selection.selected_indices(), vec![0, 4]);
press(&mut tree, Key::Space, Modifiers::CTRL);
assert_eq!(selection.selected_indices(), vec![0]);
press(&mut tree, Key::ArrowRight, Modifiers::default());
assert_eq!(selection.selected_indices(), vec![5]);
}
#[test]
fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..30).collect::<Vec<usize>>());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
assert_eq!(selection.selected_indices(), vec![0]);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::CTRL,
text: None,
});
assert_eq!(
selection.selected_indices(),
vec![0],
"Ctrl+ArrowRight must not select in Single mode either"
);
let focused = tree
.widget_as_any(id)
.and_then(|any| any.downcast_ref::<GridView<usize>>())
.and_then(|g| g.focused_index.get());
assert_eq!(focused, Some(1));
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
assert_eq!(selection.selected_indices(), vec![2]);
}
#[test]
fn container_has_grid_role_and_counts() {
let (mut tree, id, _model) = make_grid(30);
tree.layout(SizeProposal::exact(400.0, 300.0));
let info = tree.accessibility_node(id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Grid);
}
#[test]
fn tiles_have_gridcell_role() {
let (mut tree, id, _model) = make_grid(12);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
let info = tree.accessibility_node(t[0]);
assert_eq!(info.role(), teksilo_core::accesskit::Role::GridCell);
}
#[test]
fn tile_a11y_label_names_each_gridcell() {
let model = ListModel::from_vec((0..12).collect::<Vec<usize>>());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.tile_a11y_label(|i| format!("Item {i}")),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
assert_eq!(tree.accessibility_node(t[0]).name(), Some("Item 0"));
assert_eq!(tree.accessibility_node(t[1]).name(), Some("Item 1"));
assert_eq!(
tree.accessibility_node(t[0]).role(),
teksilo_core::accesskit::Role::GridCell
);
}
#[test]
fn exact_item_height_positions_rows_by_height() {
let model = ListModel::from_vec((0..20).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |tc| {
let h = if tc.index == 0 { 100.0 } else { 50.0 };
Box::new(FixedLeaf(50.0, h))
})
.column_count(1, 50.0)
.item_height(|i| if i == 0 { 100.0 } else { 50.0 }),
);
tree.layout(SizeProposal::exact(200.0, 400.0));
let t = tiles(&tree, id);
assert!((tree.bounds(t[0]).y - 0.0).abs() < 0.01);
assert!((tree.bounds(t[0]).height - 100.0).abs() < 0.01);
assert!(
(tree.bounds(t[1]).y - 108.0).abs() < 0.01,
"row 1 y = {}",
tree.bounds(t[1]).y
);
assert!((tree.bounds(t[2]).y - 166.0).abs() < 0.01); }
#[test]
fn auto_measure_places_rows_at_measured_heights() {
let model = ListModel::from_vec((0..40).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(50.0, 30.0)))
.column_count(1, 50.0)
.variable_row_heights(50.0),
);
tree.layout(SizeProposal::exact(200.0, 300.0));
tree.layout(SizeProposal::exact(200.0, 300.0));
let t = tiles(&tree, id);
assert!(
(tree.bounds(t[1]).y - 38.0).abs() < 0.5,
"row 1 y = {} (expected ~38 from measured height)",
tree.bounds(t[1]).y
);
assert!((tree.bounds(t[1]).height - 30.0).abs() < 0.5);
}
#[test]
fn auto_measure_under_realization_converges_without_scroll() {
let model = ListModel::from_vec((0..200).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(50.0, 20.0)))
.column_count(1, 50.0)
.row_spacing(0.0)
.variable_row_heights(50.0),
);
for _ in 0..6 {
tree.layout(SizeProposal::exact(200.0, 300.0));
}
let t = tiles(&tree, id);
let last_bottom = t
.iter()
.map(|id| {
let b = tree.bounds(*id);
b.y + b.height
})
.fold(0.0_f32, f32::max);
assert!(
last_bottom >= 300.0,
"realized tiles must cover the viewport bottom without scrolling, got {last_bottom}"
);
}
#[test]
fn variable_grid_virtualizes() {
let model = ListModel::from_vec((0..500).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(50.0, 40.0)))
.column_count(2, 40.0)
.variable_row_heights(40.0),
);
tree.layout(SizeProposal::exact(200.0, 300.0));
let n = tiles(&tree, id).len();
assert!(n < 80, "variable grid should virtualize, got {n} tiles");
assert!(n >= 10);
}
struct TwoSections;
impl super::sections::SectionProvider for TwoSections {
fn section_count(&self) -> usize {
2
}
fn items_in_section(&self, _s: usize) -> usize {
3
}
fn section_title(&self, s: usize) -> String {
format!("Section {s}")
}
}
#[test]
fn sections_offset_tiles_below_headers() {
let model = ListModel::from_vec((0..6).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(50.0, 50.0)))
.column_count(2, 50.0)
.section_header_height(28.0)
.sections(TwoSections),
);
tree.layout(SizeProposal::exact(300.0, 600.0));
let kids = tree.children(id);
let body = kids[0];
let body_kids = tree.children(body);
let tile0 = tree.bounds(body_kids[0]);
let tile3 = tree.bounds(body_kids[3]);
assert!((tile0.y - 28.0).abs() < 0.5, "tile 0 y = {}", tile0.y);
assert!((tile3.y - 172.0).abs() < 0.5, "tile 3 y = {}", tile3.y);
let header0 = tree.accessibility_node(body_kids[6]);
assert_eq!(
header0.role(),
teksilo_core::accesskit::Role::RowHeader,
"section header should be RowHeader"
);
}
#[test]
fn sections_report_section_local_aria_row_col() {
let model = ListModel::from_vec((0..6).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(50.0, 50.0)))
.column_count(2, 50.0)
.section_header_height(28.0)
.sections(TwoSections),
);
tree.layout(SizeProposal::exact(300.0, 600.0));
let kids = tree.children(id);
let body = kids[0];
let body_kids = tree.children(body);
let item3_id = body_kids[3];
let update = tree.sync_accessibility();
let node_id = widget_id_to_node_id(item3_id);
let node = update
.nodes
.iter()
.find(|(id, _)| *id == node_id)
.map(|(_, n)| n)
.expect("item 3's a11y node must be present in the sync");
assert_eq!(
node.row_index(),
Some(1),
"item 3 is the first row of its OWN section"
);
assert_eq!(
node.column_index(),
Some(1),
"item 3 is the first column of its row"
);
}
#[test]
fn pinned_header_is_not_built_for_a_zero_section_provider() {
struct ZeroSections;
impl super::sections::SectionProvider for ZeroSections {
fn section_count(&self) -> usize {
0
}
fn items_in_section(&self, _s: usize) -> usize {
panic!("must not be called for a zero-section provider")
}
fn section_title(&self, _s: usize) -> String {
panic!("must not be called for a zero-section provider")
}
}
let model = ListModel::from_vec((0..3).collect());
let mut tree = WidgetTree::new();
let _id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(50.0, 50.0)))
.column_count(2, 50.0)
.sections(ZeroSections)
.pinned_section_headers(true),
);
tree.layout(SizeProposal::exact(300.0, 300.0));
}
#[test]
fn waterfall_places_into_shortest_column() {
let heights = [60.0_f32, 100.0, 40.0, 80.0, 50.0];
let model = ListModel::from_vec((0..heights.len()).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, move |tc| {
Box::new(FixedLeaf(80.0, heights[tc.index]))
})
.column_count(2, 60.0)
.waterfall(60.0)
.item_height(move |i| heights[i]),
);
tree.layout(SizeProposal::exact(200.0, 400.0));
let t = tiles(&tree, id);
let b0 = tree.bounds(t[0]);
let b1 = tree.bounds(t[1]);
let b2 = tree.bounds(t[2]);
assert!((b0.y - 0.0).abs() < 0.5);
assert!((b1.y - 0.0).abs() < 0.5);
assert!(b1.x > b0.x, "item 1 should be in a column to the right");
assert!((b2.x - b0.x).abs() < 0.5, "item 2 shares column 0");
assert!((b2.y - 68.0).abs() < 0.5, "item 2 y = {}", b2.y);
}
#[test]
fn alt_arrow_reorders_tile() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec(vec![10usize, 20, 30, 40]);
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model.clone(), |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.reorderable(true),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::ALT,
text: None,
});
assert_eq!(model.with_item(0, |v| *v), Some(20));
assert_eq!(model.with_item(1, |v| *v), Some(10));
}
#[test]
fn pointer_drag_reorders_tile_through_source_accept_drop() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let model = ListModel::from_vec(vec![10usize, 20, 30, 40]);
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model.clone(), |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.reorderable(true),
);
tree.layout(SizeProposal::exact(440.0, 300.0));
let from = Point::new(50.0, 25.0); tree.dispatch_event(WidgetEvent::PointerDown {
position: from,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(72.0, 25.0),
});
let to = Point::new(430.0, 25.0);
tree.dispatch_event(WidgetEvent::PointerMove { position: to });
tree.dispatch_event(WidgetEvent::PointerUp {
position: to,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(
model.with_item(3, |v| *v),
Some(10),
"tile 0 moved to the end via the source's accept_drop"
);
assert_eq!(model.with_item(0, |v| *v), Some(20));
let _ = id;
}
#[test]
fn pointer_drag_drop_in_a_row_gap_does_not_append_at_the_end() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let model = ListModel::from_vec(vec![10usize, 20, 30, 40, 50, 60, 70, 80]);
let mut tree = WidgetTree::new();
let _id = tree.add(
GridView::new(model.clone(), |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.reorderable(true),
);
tree.layout(SizeProposal::exact(220.0, 300.0));
let from = Point::new(50.0, 25.0); tree.dispatch_event(WidgetEvent::PointerDown {
position: from,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(72.0, 25.0),
});
let to = Point::new(70.0, 53.0);
tree.dispatch_event(WidgetEvent::PointerMove { position: to });
tree.dispatch_event(WidgetEvent::PointerUp {
position: to,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_ne!(
model.with_item(7, |v| *v),
Some(10),
"a row-gap drop must not silently append the dragged tile at the end"
);
}
#[test]
fn marquee_edge_auto_scroll_selects_tiles_revealed_by_scrolling() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let model = ListModel::from_vec((0..40).collect::<Vec<usize>>());
let selection = SelectionModel::new(SelectionMode::Multi);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let _id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel),
);
tree.layout(SizeProposal::exact(150.0, 150.0));
let press = Point::new(120.0, 10.0);
tree.dispatch_event(WidgetEvent::PointerDown {
position: press,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(120.0, 16.0),
});
let hold = Point::new(50.0, 200.0);
tree.dispatch_event(WidgetEvent::PointerMove { position: hold });
for _ in 0..80 {
tree.layout(SizeProposal::exact(150.0, 150.0));
}
tree.dispatch_event(WidgetEvent::PointerUp {
position: hold,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert!(
selection.is_selected(0),
"row 0, under the original press point, should stay selected"
);
assert!(
selection.is_selected(15),
"row 15 was off-screen at press time; auto-scroll must have \
revealed it and grown the marquee to cover it"
);
}
#[test]
fn insertion_bar_geometry_uses_target_row_at_row_boundary() {
let g = UniformGrid::new(
GridSizing::Fixed {
width: 100.0,
height: 50.0,
},
10.0,
10.0,
EdgeInsets::ZERO,
);
let (bar_x, r) = insertion_bar_geometry(&g, 4, 12, 430.0).unwrap();
assert!(
(r.y - 60.0).abs() < 0.01,
"row-boundary bar must sit on the TARGET row (y=60), got y={}",
r.y
);
assert!(
(bar_x - 0.0).abs() < 0.01,
"bar should sit at row 1's leading edge x=0, got {bar_x}"
);
}
#[test]
fn insertion_bar_geometry_appends_at_trailing_edge_of_last_tile() {
let g = UniformGrid::new(
GridSizing::Fixed {
width: 100.0,
height: 50.0,
},
10.0,
10.0,
EdgeInsets::ZERO,
);
let (bar_x, _) = insertion_bar_geometry(&g, 12, 12, 430.0).unwrap();
assert!((bar_x - 430.0).abs() < 0.01, "append bar_x = {bar_x}");
}
#[test]
fn insertion_bar_geometry_is_none_for_an_empty_grid() {
let g = UniformGrid::new(
GridSizing::Fixed {
width: 100.0,
height: 50.0,
},
10.0,
10.0,
EdgeInsets::ZERO,
);
assert!(insertion_bar_geometry(&g, 0, 0, 430.0).is_none());
}
#[test]
fn enter_activates_focused_tile() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let model = ListModel::from_vec((0..12).collect());
let activated = Rc::new(Cell::new(usize::MAX));
let a = activated.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.on_tile_activate(move |idx, _ctx| a.set(idx)),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::default(),
text: None,
});
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::Enter,
modifiers: Modifiers::default(),
text: None,
});
assert_eq!(activated.get(), 1);
}
#[test]
fn type_ahead_jumps_to_match() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let names = vec![
"apple".to_string(),
"banana".to_string(),
"cherry".to_string(),
"date".to_string(),
];
let model = ListModel::from_vec(names.clone());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel)
.type_ahead_label(move |i| names[i].clone()),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.dispatch_event(WidgetEvent::KeyDown {
key: Key::Character('c'),
modifiers: Modifiers::default(),
text: Some("c".to_string()),
});
assert!(selection.is_selected(2), "type-ahead 'c' selects cherry");
}
#[test]
fn type_ahead_fires_on_letter_key_variant() {
use teksilo_core::event::{Key, Modifiers};
let names = vec![
"apple".to_string(),
"banana".to_string(),
"cherry".to_string(),
];
let model = ListModel::from_vec(names.clone());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel)
.type_ahead_label(move |i| names[i].clone()),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.press_key(Key::B, Modifiers::NONE);
assert!(
selection.is_selected(1),
"letter key 'B' must trigger type-ahead → banana"
);
}
#[test]
fn type_ahead_skips_unloaded_rows() {
use teksilo_core::ObserverHandle;
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::ListDataSource;
struct PartiallyLoaded;
impl ListDataSource for PartiallyLoaded {
type Item = String;
type Key = usize;
fn len(&self) -> usize {
4
}
fn with_item<R>(&self, index: usize, f: impl FnOnce(&String) -> R) -> Option<R> {
if index == 1 || index >= 4 {
return None;
}
let names = ["apple", "banana", "cherry", "date"];
Some(f(&names[index].to_string()))
}
fn key_at(&self, index: usize) -> Option<usize> {
(index < 4).then_some(index)
}
fn observe_changes(
&self,
_f: impl Fn(&teksilo_data::DataChange) + 'static,
) -> ObserverHandle {
let inner: std::rc::Rc<dyn std::any::Any> = std::rc::Rc::new(());
ObserverHandle::new(inner, 0, std::rc::Rc::new(|_| {}))
}
}
let names = ["apple", "banana", "cherry", "date"];
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::from_source(PartiallyLoaded, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel)
.type_ahead_label(move |i| names[i].to_string()),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.focus(id);
tree.press_key(Key::B, Modifiers::NONE);
assert!(
selection.selected_indices().is_empty(),
"type-ahead must skip an unloaded row ('banana') rather than jump to it"
);
}
#[test]
fn fetch_more_fires_when_scrolled_near_the_end() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_core::ObserverHandle;
use teksilo_data::ListDataSource;
struct Growing {
total: usize,
fetched: Rc<Cell<bool>>,
}
impl ListDataSource for Growing {
type Item = usize;
type Key = usize;
fn len(&self) -> usize {
self.total
}
fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
(i < self.total).then(|| f(&i))
}
fn key_at(&self, i: usize) -> Option<usize> {
(i < self.total).then_some(i)
}
fn can_fetch_more(&self) -> bool {
true
}
fn fetch_more(&self) {
self.fetched.set(true);
}
fn observe_changes(
&self,
_f: impl Fn(&teksilo_data::DataChange) + 'static,
) -> ObserverHandle {
let inner: Rc<dyn std::any::Any> = Rc::new(());
ObserverHandle::new(inner, 0, Rc::new(|_| {}))
}
}
let fetched = Rc::new(Cell::new(false));
let source = Growing {
total: 300,
fetched: fetched.clone(),
};
let mut tree = WidgetTree::new();
let gv = GridView::from_source(source, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0);
let scroll = gv.scroll_y_signal().clone();
let max_sig = gv.max_scroll_y_signal().clone();
let _id = tree.add(gv);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
!fetched.get(),
"fetch_more must not fire while far from the end"
);
scroll.set(max_sig.get());
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
fetched.get(),
"fetch_more should fire as the window nears the end"
);
}
#[test]
fn custom_grid_view_style_is_accepted() {
struct LoudStyle;
impl teksilo_core::styles::GridViewStyle for LoudStyle {
fn focus_ring(&self) -> teksilo_core::styles::GridFocusRingRecipe {
teksilo_core::styles::GridFocusRingRecipe {
role: teksilo_tokens::BorderRole::Accent,
thickness: 3.0,
inset: 0.0,
}
}
}
let model = ListModel::from_vec((0..12).collect());
let mut tree = WidgetTree::new();
let id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.style(LoudStyle),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(!tiles(&tree, id).is_empty());
}
#[test]
fn on_selection_changed_fires() {
use std::cell::Cell;
use std::rc::Rc;
let model = ListModel::from_vec((0..6).collect());
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let fired = Rc::new(Cell::new(0u32));
let f = fired.clone();
let mut tree = WidgetTree::new();
let _id = tree.add(
GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
.tile_size(100.0, 50.0)
.selection(sel)
.on_selection_changed(move |_set| f.set(f.get() + 1)),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
selection.select(2);
assert!(fired.get() >= 1, "on_selection_changed should fire");
}
#[test]
fn reactive_sizing_signal_reflows_and_preserves_scroll() {
let model = ListModel::from_vec((0..30).collect::<Vec<usize>>());
let sizing = Signal::new(GridSizing::Fixed {
width: 100.0,
height: 50.0,
});
let mut tree = WidgetTree::new();
let gv = GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0))).sizing(sizing.clone());
let scroll = gv.scroll_y_signal().clone();
let id = tree.add(gv);
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
let row_delta = tree.bounds(t[2]).y - tree.bounds(t[0]).y;
assert!(
row_delta.abs() < 0.01,
"3 columns expected — tile 2 on row 0 with tile 0, Δy = {row_delta}"
);
sizing.set(GridSizing::Fixed {
width: 190.0,
height: 50.0,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
let t = tiles(&tree, id);
let row_delta = tree.bounds(t[2]).y - tree.bounds(t[0]).y;
assert!(
(row_delta - 58.0).abs() < 0.01,
"after resize to 2 columns, tile 2 should wrap to row 1 (Δy ≈ 58), got {row_delta}"
);
scroll.set(60.0);
tree.layout(SizeProposal::exact(400.0, 300.0));
sizing.set(GridSizing::Fixed {
width: 100.0,
height: 50.0,
});
tree.layout(SizeProposal::exact(400.0, 300.0));
assert!(
(scroll.get() - 60.0).abs() < 0.01,
"scroll_y should survive the sizing change, got {}",
scroll.get()
);
}