use teksilo_canvas::SizeProposal;
use teksilo_core::accesskit::Role;
use teksilo_core::signal::Signal;
use teksilo_core::widget_id::WidgetId;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_data::{ListModel, SelectionMode, SelectionModel};
use teksilo_i18n::lit;
use super::{CellContext, Column, ColumnWidth, SortDirection, TableSelectionMode, TableView};
use crate::OverscrollBehavior;
use crate::primitives::TextWidget;
#[derive(Clone, Debug, PartialEq)]
struct Row {
id: u32,
name: String,
}
fn rows(n: u32) -> ListModel<Row> {
let mut v = Vec::with_capacity(n as usize);
for i in 0..n {
v.push(Row {
id: i,
name: format!("row {i}"),
});
}
ListModel::from_vec(v)
}
fn id_col() -> Column<Row> {
Column::<Row>::new("id", lit!("ID"), |row, _: &CellContext| {
Box::new(TextWidget::new(lit!(row.id.to_string())))
})
.width(ColumnWidth::Fixed(60.0))
}
fn name_col() -> Column<Row> {
Column::<Row>::new("name", lit!("Name"), |row, _: &CellContext| {
Box::new(TextWidget::new(lit!(row.name.clone())))
})
.width(ColumnWidth::Flex(1.0))
}
fn build_table(n: u32) -> (WidgetTree, WidgetId, ListModel<Row>) {
let model = rows(n);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model.clone())
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
(tree, table, model)
}
#[test]
fn rows_materialize_during_scrollbar_thumb_drag() {
use crate::styles::recipe_table_style as cp;
let (mut tree, table, _) = build_table(500);
crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
&mut tree,
table,
400.0,
200.0,
cp::HEADER_HEIGHT,
"TableView",
|t| {
let mut n = 0;
let mut walker = vec![table];
while let Some(id) = walker.pop() {
if t.accessibility_node(id).role() == Role::Row {
let b = t.bounds(id);
if b.y >= 0.0 && b.y < 200.0 {
n += 1;
}
}
for c in t.children(id) {
walker.push(c);
}
}
n
},
);
}
#[test]
fn builds_with_two_columns() {
let (tree, table, _) = build_table(10);
let b = tree.bounds(table);
assert!((b.width - 400.0).abs() < 0.01, "got width {}", b.width);
assert!((b.height - 200.0).abs() < 0.01, "got height {}", b.height);
}
#[test]
fn fixed_plus_flex_split_pane_minus_scrollbar() {
let (tree, table, _) = build_table(50);
let info = tree.accessibility_node(table);
assert_eq!(info.role(), Role::Table);
let row_ids = first_visible_row_cells(&tree, table);
assert_eq!(row_ids.len(), 2, "expected 2 cells in row");
let id_cell = tree.bounds(row_ids[0]);
let name_cell = tree.bounds(row_ids[1]);
assert!((id_cell.width - 60.0).abs() < 0.5);
assert!(
(name_cell.width - 328.0).abs() < 0.5,
"got {}",
name_cell.width
);
assert!((id_cell.x).abs() < 0.5);
assert!((name_cell.x - 60.0).abs() < 0.5);
}
#[test]
fn no_scrollbar_when_content_fits() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let model = rows(5);
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let row_ids = first_visible_row_cells(&tree, table);
let name_cell = tree.bounds(row_ids[1]);
assert!(
(name_cell.width - 340.0).abs() < 0.5,
"got {}",
name_cell.width
);
}
#[test]
fn virtualizes_visible_window() {
let (tree, table, _) = build_table(1000);
let row_count = count_role(&tree, table, Role::Row);
assert!(row_count >= 10, "got {row_count} rows");
assert!(
row_count <= 50,
"got {row_count} rows (expected virtualized)"
);
assert!(
row_count < 1000,
"TableView must virtualize, not materialise all rows"
);
}
#[test]
fn rebuild_on_scroll_past_buffer() {
let (mut tree, table, _model) = build_table(1000);
let initial_rows = count_role(&tree, table, Role::Row);
let signal = {
let any = tree
.widget_as_any(table)
.expect("TableView exposes itself via as_any");
let tv = any
.downcast_ref::<TableView<Row>>()
.expect("downcast to TableView<Row>");
tv.scroll_y_signal().clone()
};
signal.set(2000.0);
tree.request_frame();
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let after_rows = count_role(&tree, table, Role::Row);
assert!(after_rows >= 10);
let direct_rows: Vec<_> = tree
.children(table)
.into_iter()
.filter(|c| tree.accessibility_node(*c).role() == Role::Row)
.collect();
let any_in_viewport = direct_rows.iter().any(|id| {
let b = tree.bounds(*id);
b.y >= 0.0 && b.y < 200.0
});
assert!(
any_in_viewport,
"expected at least one materialised row to land inside the viewport after scroll"
);
let _ = initial_rows;
}
#[test]
fn selection_signal_reflects_select() {
let model = rows(10);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
sel.select(2);
sel.toggle(4);
let s = sel.selected_indices();
assert_eq!(s, vec![2, 4]);
}
#[test]
fn selection_auto_adjusts_on_insert() {
let model = rows(10);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(model.clone())
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
sel.select(3);
sel.toggle(7);
model.insert(
4,
Row {
id: 99,
name: "inserted".into(),
},
);
assert_eq!(sel.selected_indices(), vec![3, 8]);
}
#[test]
fn selection_auto_adjusts_on_remove() {
let model = rows(10);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(model.clone())
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
sel.select(3);
sel.toggle(7);
model.remove(3);
assert_eq!(sel.selected_indices(), vec![6]);
}
#[test]
fn root_role_is_table_with_row_and_col_count() {
let (tree, table, _) = build_table(50);
let info = tree.accessibility_node(table);
assert_eq!(info.role(), Role::Table);
let row_count = count_role(&tree, table, Role::Row);
let header_count = count_role(&tree, table, Role::ColumnHeader);
let cell_count = count_role(&tree, table, Role::Cell);
assert!(row_count >= 2);
assert_eq!(header_count, 2, "two column headers");
assert_eq!(
cell_count,
(row_count - 1) * 2,
"{cell_count} body cells across {} body rows",
row_count - 1
);
}
#[test]
fn rows_under_table_form_a_rowgroup_with_header_alongside() {
let (tree, table, _) = build_table(5);
let direct_rows: Vec<_> = tree
.children(table)
.into_iter()
.filter(|c| tree.accessibility_node(*c).role() == Role::Row)
.collect();
assert_eq!(direct_rows.len(), 1, "exactly one header Role::Row");
let rowgroups: Vec<_> = tree
.children(table)
.into_iter()
.filter(|c| tree.accessibility_node(*c).role() == Role::RowGroup)
.collect();
assert_eq!(rowgroups.len(), 1, "exactly one Role::RowGroup body pane");
let body_rows: Vec<_> = tree
.children(rowgroups[0])
.into_iter()
.filter(|c| tree.accessibility_node(*c).role() == Role::Row)
.collect();
assert_eq!(body_rows.len(), 5, "five body rows inside the rowgroup");
}
#[test]
fn cells_carry_role_cell_under_row() {
let (tree, table, _) = build_table(3);
let mut walker = vec![table];
let mut saw_body_row = false;
while let Some(id) = walker.pop() {
let info = tree.accessibility_node(id);
if info.role() == Role::Row {
let kids: Vec<_> = tree
.children(id)
.into_iter()
.map(|c| tree.accessibility_node(c).role())
.collect();
if kids.iter().all(|&r| r == Role::ColumnHeader) {
} else {
for r in &kids {
assert_eq!(*r, Role::Cell, "body row child should be Role::Cell");
}
saw_body_row = true;
}
}
for c in tree.children(id) {
walker.push(c);
}
}
assert!(
saw_body_row,
"expected at least one body Role::Row in the tree"
);
}
#[test]
fn header_strip_renders_one_column_header_per_column() {
let (tree, table, _) = build_table(10);
let header_count = count_role(&tree, table, Role::ColumnHeader);
assert_eq!(header_count, 2, "one ColumnHeader per declared column");
}
#[test]
fn header_can_be_hidden() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.show_header(false),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
assert_eq!(count_role(&tree, _table, Role::ColumnHeader), 0);
}
#[test]
fn header_label_appears_in_a11y_tree() {
let (tree, table, _) = build_table(5);
let mut q = vec![table];
let mut found_id = false;
let mut found_name = false;
while let Some(id) = q.pop() {
let info = tree.accessibility_node(id);
if info.role() == Role::ColumnHeader {
match info.name() {
Some("ID") => found_id = true,
Some("Name") => found_name = true,
_ => {}
}
}
for c in tree.children(id) {
q.push(c);
}
}
assert!(found_id && found_name, "headers should expose their labels");
}
#[test]
fn sort_cycle_none_asc_desc_none() {
let model = rows(5);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col().sortable(true))
.add_column(name_col().sortable(true))
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let signal = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.sort_signal().clone()
};
assert_eq!(signal.get(), None);
let cycle = |s: &Signal<Option<(String, SortDirection)>>, col_id: &str| {
let next = match s.get() {
None => Some((col_id.to_string(), SortDirection::Ascending)),
Some((id, SortDirection::Ascending)) if id == col_id => {
Some((col_id.to_string(), SortDirection::Descending))
}
Some((id, SortDirection::Descending)) if id == col_id => None,
Some(_) => Some((col_id.to_string(), SortDirection::Ascending)),
};
s.set(next);
};
cycle(&signal, "id");
assert_eq!(
signal.get(),
Some(("id".to_string(), SortDirection::Ascending))
);
cycle(&signal, "id");
assert_eq!(
signal.get(),
Some(("id".to_string(), SortDirection::Descending))
);
cycle(&signal, "id");
assert_eq!(signal.get(), None);
}
#[test]
fn switching_sort_columns_resets_to_ascending() {
let (tree, table, _) = build_table(5);
let signal = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.sort_signal().clone()
};
signal.set(Some(("id".to_string(), SortDirection::Descending)));
let next = match signal.get() {
Some((id, _)) if id != "name" => Some(("name".to_string(), SortDirection::Ascending)),
_ => unreachable!(),
};
signal.set(next);
assert_eq!(
signal.get(),
Some(("name".to_string(), SortDirection::Ascending))
);
}
#[test]
fn set_column_width_pins_column() {
let (tree, table, _) = build_table(10);
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_width("id", 120.0);
}
let mut tree = tree;
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let row_cells = first_visible_row_cells(&tree, table);
let id_cell = tree.bounds(row_cells[0]);
assert!(
(id_cell.width - 120.0).abs() < 0.5,
"got width {}",
id_cell.width
);
}
#[test]
fn clear_sort_returns_to_unsorted() {
let (tree, table, _) = build_table(5);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_sort(Some("id"), SortDirection::Ascending);
assert!(tv.sort_signal().get().is_some());
tv.clear_sort();
assert!(tv.sort_signal().get().is_none());
}
#[test]
fn header_row_carries_role_row_with_index_one() {
let (tree, table, _) = build_table(3);
let mut q = vec![table];
let mut found = false;
while let Some(id) = q.pop() {
let info = tree.accessibility_node(id);
if info.role() == Role::Row {
let kids: Vec<Role> = tree
.children(id)
.into_iter()
.map(|c| tree.accessibility_node(c).role())
.collect();
if kids.contains(&Role::ColumnHeader) {
found = true;
break;
}
}
for c in tree.children(id) {
q.push(c);
}
}
assert!(
found,
"expected one Role::Row whose children are ColumnHeaders"
);
}
#[test]
fn declared_order_is_default_display_order() {
let (tree, table, _) = build_table(3);
let row_cells = first_visible_row_cells(&tree, table);
let id_cell = tree.bounds(row_cells[0]);
let name_cell = tree.bounds(row_cells[1]);
assert!(id_cell.x < name_cell.x);
}
#[test]
fn pinned_leading_moves_to_front() {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(
Column::<Row>::new("id", lit!("ID"), |row, _: &CellContext| {
Box::new(crate::primitives::TextWidget::new(lit!(row.id.to_string())))
})
.width(ColumnWidth::Fixed(60.0))
.pinned(super::PinnedSide::Trailing),
)
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let row_cells = first_visible_row_cells(&tree, table);
let first = tree.bounds(row_cells[0]); let second = tree.bounds(row_cells[1]); assert!(first.x < second.x);
assert!(
(second.width - 60.0).abs() < 0.5,
"id width pinned at 60 — got {}",
second.width
);
}
#[test]
fn set_column_order_reorders_display() {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col()) .add_column(name_col())
.add_column(
Column::<Row>::new("extra", lit!("Extra"), |_row, _: &CellContext| {
Box::new(crate::primitives::TextWidget::new(lit!("…")))
})
.width(ColumnWidth::Fixed(40.0)),
)
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_order(vec![
"extra".to_string(),
"id".to_string(),
"name".to_string(),
]);
}
let mut tree = tree;
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let row_cells = first_visible_row_cells(&tree, table);
let first = tree.bounds(row_cells[0]); let second = tree.bounds(row_cells[1]); let third = tree.bounds(row_cells[2]); assert!(
(first.width - 40.0).abs() < 0.5,
"extra width 40 — got {}",
first.width
);
assert!(
(second.width - 60.0).abs() < 0.5,
"id width 60 — got {}",
second.width
);
assert!(third.width > 100.0, "name fills the rest");
assert!(first.x < second.x);
assert!(second.x < third.x);
}
#[test]
fn set_column_pinning_relocates_column() {
let (tree, table, _) = build_table(3);
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_pinning("name", super::PinnedSide::Trailing);
}
let mut tree = tree;
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let row_cells = first_visible_row_cells(&tree, table);
let id_cell = tree.bounds(row_cells[0]);
let name_cell = tree.bounds(row_cells[1]);
assert!(id_cell.x < name_cell.x);
}
#[test]
fn cycle_pinning_back_to_none_via_signal_clear() {
let (tree, table, _) = build_table(3);
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_pinning("id", super::PinnedSide::Leading);
assert_eq!(
tv.column_pinning_signal().get().get("id").copied(),
Some(super::PinnedSide::Leading)
);
tv.set_column_pinning("id", super::PinnedSide::None);
assert_eq!(tv.column_pinning_signal().get().get("id").copied(), None);
}
}
#[test]
fn column_order_signal_persists_after_data_change() {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model.clone())
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_order(vec!["name".to_string(), "id".to_string()]);
}
model.push(Row {
id: 999,
name: "z".into(),
});
let mut tree = tree;
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(
tv.column_order_signal().get(),
vec!["name".to_string(), "id".to_string()]
);
}
#[test]
fn unknown_column_id_in_order_signal_is_ignored() {
let (tree, table, _) = build_table(3);
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_order(vec![
"nope".to_string(),
"name".to_string(),
"id".to_string(),
]);
}
let mut tree = tree;
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let row_cells = first_visible_row_cells(&tree, table);
assert_eq!(row_cells.len(), 2, "phantom id was skipped");
}
use teksilo_core::event::{Key, Modifiers};
fn focus_at(tree: &mut WidgetTree, table: WidgetId, row: usize, col: usize) {
tree.focus(table);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_focused_cell(row, col);
}
fn read_focused_cell(tree: &WidgetTree, table: WidgetId) -> Option<(usize, usize)> {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.focused_cell_signal().get()
}
#[test]
fn first_arrow_lands_on_an_end_cell_instead_of_skipping_it() {
use teksilo_core::event::{Key, Modifiers};
for (key, want, what) in [
(
Key::ArrowDown,
(0, 0),
"first ArrowDown enters at the first row",
),
(Key::ArrowUp, (9, 0), "first ArrowUp enters at the last row"),
(
Key::ArrowRight,
(0, 0),
"first ArrowRight enters at the leading column",
),
(
Key::ArrowLeft,
(0, 1),
"first ArrowLeft enters at the trailing column",
),
] {
let (mut tree, table, _) = build_table(10);
tree.focus(table);
assert_eq!(
read_focused_cell(&tree, table),
None,
"precondition: no cell cursor yet"
);
tree.press_key(key, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some(want), "{what}");
}
}
#[test]
fn arrow_keys_move_focused_cell() {
let (mut tree, table, _) = build_table(10);
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((1, 0)));
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((1, 1)));
tree.press_key(Key::ArrowUp, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 1)));
tree.press_key(Key::ArrowLeft, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
}
#[test]
fn arrow_keys_clamp_at_edges() {
let (mut tree, table, _) = build_table(3);
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::ArrowUp, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
tree.press_key(Key::ArrowLeft, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
focus_at(&mut tree, table, 2, 1);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((2, 1)));
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((2, 1)));
}
#[test]
fn ctrl_arrow_moves_cursor_without_touching_selection() {
let model = rows(5);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
sel.select(0);
assert_eq!(sel.selected_indices(), vec![0]);
tree.press_key(Key::ArrowDown, Modifiers::CTRL);
assert_eq!(
read_focused_cell(&tree, table),
Some((1, 0)),
"cursor advances"
);
assert_eq!(
sel.selected_indices(),
vec![0],
"Ctrl+Arrow must not touch selection"
);
tree.press_key(Key::ArrowDown, Modifiers::CTRL);
assert_eq!(read_focused_cell(&tree, table), Some((2, 0)));
assert_eq!(
sel.selected_indices(),
vec![0],
"still untouched after a second Ctrl+Arrow"
);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((3, 0)));
assert_eq!(
sel.selected_indices(),
vec![3],
"plain Arrow selects the row it lands on"
);
}
#[test]
fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
let model = rows(5);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::ArrowDown, Modifiers::CTRL);
tree.press_key(Key::ArrowDown, Modifiers::CTRL);
assert_eq!(
read_focused_cell(&tree, table),
Some((2, 0)),
"cursor at row 2, nothing selected yet"
);
assert!(sel.selected_indices().is_empty());
tree.press_key(Key::Space, Modifiers::CTRL);
assert_eq!(
sel.selected_indices(),
vec![2],
"Ctrl+Space toggles the focused row on"
);
tree.press_key(Key::Space, Modifiers::CTRL);
assert!(
sel.selected_indices().is_empty(),
"Ctrl+Space toggles it back off"
);
}
#[test]
fn row_click_moves_focus_so_arrow_nav_resumes_there() {
use crate::styles::recipe_table_style as cp;
use teksilo_canvas::Point;
use teksilo_core::event::{PointerButton, WidgetEvent};
let model = rows(10);
let sel = SelectionModel::new(SelectionMode::Single);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::SingleRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
tree.focus(table);
let click_y = cp::HEADER_HEIGHT + 3.0 * 20.0 + 10.0;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(100.0, click_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(100.0, click_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(sel.selected_indices(), vec![3], "click selects row 3");
assert_eq!(
read_focused_cell(&tree, table).map(|(r, _)| r),
Some(3),
"click must move the nav cursor to the clicked row"
);
tree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(
sel.selected_indices(),
vec![4],
"ArrowDown after a click resumes from the clicked row (3 → 4)"
);
}
#[test]
fn home_end_jump_within_row() {
let (mut tree, table, _) = build_table(5);
focus_at(&mut tree, table, 1, 0);
tree.press_key(Key::End, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((1, 1)));
tree.press_key(Key::Home, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((1, 0)));
}
#[test]
fn ctrl_home_end_jump_to_corners() {
let (mut tree, table, _) = build_table(5);
focus_at(&mut tree, table, 2, 1);
tree.press_key(Key::End, Modifiers::CTRL);
assert_eq!(read_focused_cell(&tree, table), Some((4, 1)));
tree.press_key(Key::Home, Modifiers::CTRL);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
}
fn read_scroll(tree: &WidgetTree, table: WidgetId) -> f32 {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.scroll_y_signal().get()
}
#[test]
fn arrow_nav_scroll_follows_focused_row() {
let model = rows(100);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
let proposal = SizeProposal {
width: Some(400.0),
height: Some(200.0),
};
tree.layout(proposal);
focus_at(&mut tree, table, 0, 0);
assert_eq!(read_scroll(&tree, table), 0.0, "starts at top");
for _ in 0..20 {
tree.press_key(Key::ArrowDown, Modifiers::NONE);
tree.layout(proposal);
}
assert_eq!(read_focused_cell(&tree, table), Some((20, 0)));
let scroll = read_scroll(&tree, table);
assert!(
scroll > 200.0,
"arrow-down nav must scroll to reveal row 20, got {scroll}"
);
tree.press_key(Key::Home, Modifiers::CTRL);
tree.layout(proposal);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
assert_eq!(
read_scroll(&tree, table),
0.0,
"Ctrl+Home must scroll back to the top"
);
tree.press_key(Key::End, Modifiers::CTRL);
tree.layout(proposal);
assert_eq!(read_focused_cell(&tree, table), Some((99, 1)));
assert!(
read_scroll(&tree, table) > 0.0,
"Ctrl+End must scroll to reveal the last row"
);
}
#[test]
fn type_ahead_jumps_focus_to_matching_row() {
let model = ListModel::from_vec(vec![
Row {
id: 0,
name: "Apple".into(),
},
Row {
id: 1,
name: "Banana".into(),
},
Row {
id: 2,
name: "Cherry".into(),
},
Row {
id: 3,
name: "Cranberry".into(),
},
]);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(name_col())
.row_height(20.0)
.type_ahead_label(|r: &Row| r.name.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::C, Modifiers::NONE);
assert_eq!(
read_focused_cell(&tree, table),
Some((2, 0)),
"'c' → Cherry"
);
tree.press_key(Key::R, Modifiers::NONE);
assert_eq!(
read_focused_cell(&tree, table),
Some((3, 0)),
"'cr' → Cranberry"
);
}
#[test]
fn page_down_advances_focus_and_scroll() {
let model = rows(100);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
let scroll_before = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.scroll_y_signal().get()
};
tree.press_key(Key::PageDown, Modifiers::NONE);
let after_pos = read_focused_cell(&tree, table).unwrap();
let scroll_after = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.scroll_y_signal().get()
};
assert!(
after_pos.0 > 0,
"PageDown must advance focused row (got {after_pos:?})"
);
assert!(
scroll_after > scroll_before,
"PageDown must scroll forward (was {scroll_before}, now {scroll_after})"
);
}
#[test]
fn space_toggles_row_selection_at_focus() {
let model = rows(5);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 2, 0);
tree.press_key(Key::Space, Modifiers::NONE);
assert!(sel.is_selected(2));
tree.press_key(Key::Space, Modifiers::NONE);
assert!(!sel.is_selected(2));
}
#[test]
fn keyed_selection_stores_row_key_not_index() {
use std::rc::Rc;
use teksilo_core::ObserverHandle;
use teksilo_data::{KeyedSelectionModel, ListDataSource, SelectionMode};
struct KeyedRowSource {
rows: Vec<(u64, Row)>, }
impl ListDataSource for KeyedRowSource {
type Item = Row;
type Key = u64;
fn len(&self) -> usize {
self.rows.len()
}
fn with_item<R>(&self, i: usize, f: impl FnOnce(&Row) -> R) -> Option<R> {
self.rows.get(i).map(|(_, r)| f(r))
}
fn key_at(&self, i: usize) -> Option<u64> {
self.rows.get(i).map(|(k, _)| *k)
}
fn index_of(&self, key: &u64) -> Option<usize> {
self.rows.iter().position(|(k, _)| k == key)
}
fn observe_changes(
&self,
_f: impl Fn(&teksilo_data::DataChange) + 'static,
) -> ObserverHandle {
ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
}
}
let keyed = KeyedSelectionModel::<u64>::new(SelectionMode::Multi);
let source = KeyedRowSource {
rows: (0..5)
.map(|i| {
(
500 + i as u64 * 100,
Row {
id: i,
name: format!("row {i}"),
},
)
})
.collect(),
};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::from_source_keyed(source, keyed.clone())
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 2, 0);
tree.press_key(Key::Space, Modifiers::NONE);
assert!(
keyed.is_selected(&700),
"the row KEY is selected, not the index"
);
assert!(!keyed.is_selected(&2), "the index is not used as a key");
}
#[test]
fn shift_arrow_extends_multi_row_selection() {
let model = rows(10);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 2, 0);
sel.select(2);
tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
let s = sel.selected_indices();
assert!(s.contains(&2));
assert!(s.contains(&4));
assert!(s.len() >= 3, "expected 2..4 selected, got {s:?}");
}
#[test]
fn ctrl_a_selects_all_rows_in_multi_mode() {
let model = rows(5);
let sel = SelectionModel::new(SelectionMode::Multi);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::A, Modifiers::CTRL);
assert_eq!(sel.count(), 5);
}
#[test]
fn escape_clears_focus() {
let (mut tree, table, _) = build_table(3);
focus_at(&mut tree, table, 1, 1);
tree.press_key(Key::Escape, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), None);
}
#[test]
fn tab_moves_to_next_cell_with_row_wrap() {
let (mut tree, table, _) = build_table(3);
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::Tab, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 1)));
tree.press_key(Key::Tab, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((1, 0)));
}
#[test]
fn ctrl_tab_escapes_the_cell_grid() {
use crate::primitives::VStack;
use teksilo_core::widget_builder::WidgetBuilder;
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
let _root = tree.add(VStack::new().add_child(table).add_child(sink));
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::Tab, Modifiers::NONE);
assert_eq!(
read_focused_cell(&tree, table),
Some((0, 1)),
"plain Tab navigates cells"
);
let before = read_focused_cell(&tree, table);
tree.press_key(Key::Tab, Modifiers::CTRL);
assert_eq!(
read_focused_cell(&tree, table),
before,
"Ctrl+Tab must not navigate cells"
);
assert_eq!(
tree.focused(),
Some(sink),
"Ctrl+Tab moves focus out of the table to the next focusable"
);
}
#[test]
fn shift_tab_moves_to_prev_cell_with_row_wrap() {
let (mut tree, table, _) = build_table(3);
focus_at(&mut tree, table, 1, 0);
tree.press_key(Key::Tab, Modifiers::SHIFT);
assert_eq!(read_focused_cell(&tree, table), Some((0, 1)));
tree.press_key(Key::Tab, Modifiers::SHIFT);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
}
#[test]
fn cell_selection_mode_tracks_pairs() {
let model = rows(5);
let cs = super::CellSelectionModel::new(TableSelectionMode::MultiCell);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiCell)
.cell_selection(cs.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 1, 1);
tree.press_key(Key::Space, Modifiers::NONE);
assert!(cs.is_selected(1, 1));
tree.press_key(Key::ArrowRight, Modifiers::SHIFT); tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
assert!(cs.is_selected(2, 1));
}
#[test]
fn focused_cell_sets_active_descendant_to_the_cell_node() {
use teksilo_core::accessibility::widget_id_to_node_id;
let (mut tree, table, _) = build_table(5);
focus_at(&mut tree, table, 1, 1);
let update = tree.sync_accessibility();
let table_node_id = widget_id_to_node_id(table);
let table_node = update
.nodes
.iter()
.find(|(id, _)| *id == table_node_id)
.map(|(_, n)| n)
.expect("table node present in the AT tree");
let active = table_node
.active_descendant()
.expect("a focused cell must set active_descendant");
let cell_node = update
.nodes
.iter()
.find(|(id, _)| *id == active)
.map(|(_, n)| n)
.expect("active_descendant must reference a node present in the TreeUpdate");
assert_eq!(cell_node.role(), Role::Cell);
}
#[test]
fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
use teksilo_core::accessibility::widget_id_to_node_id;
let (mut tree, table, _model) = build_table(1000);
focus_at(&mut tree, table, 1, 1);
let table_node_id = widget_id_to_node_id(table);
let update = tree.sync_accessibility();
let active_before = update
.nodes
.iter()
.find(|(id, _)| *id == table_node_id)
.and_then(|(_, n)| n.active_descendant());
assert!(active_before.is_some(), "row 1 is realized initially");
let signal = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.scroll_y_signal().clone()
};
signal.set(2000.0);
tree.request_frame();
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let update = tree.sync_accessibility();
let active_after = update
.nodes
.iter()
.find(|(id, _)| *id == table_node_id)
.and_then(|(_, n)| n.active_descendant());
assert_eq!(
active_after, None,
"a focused cell that scrolled out of realization must not leave a \
stale active_descendant pointing at a destroyed node"
);
}
#[test]
fn column_pinning_remaps_focused_cell_to_follow_its_column() {
let (mut tree, table, _) = build_table(3);
focus_at(&mut tree, table, 1, 1); {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_pinning("name", super::PinnedSide::Leading);
}
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
assert_eq!(
read_focused_cell(&tree, table),
Some((1, 0)),
"focus must follow `name` to its new display position"
);
}
#[test]
fn column_pinning_remaps_editing_cell_to_follow_its_column() {
let (mut tree, table, _) = build_table(3);
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.begin_edit(1, "name"); assert_eq!(tv.editing_cell_signal().get(), Some((1, 1)));
tv.set_column_pinning("name", super::PinnedSide::Leading);
}
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(
tv.editing_cell_signal().get(),
Some((1, 0)),
"the open editor must follow `name` to its new display position, not \
relabel onto whatever column now sits at position 1"
);
}
#[test]
fn column_pinning_remaps_cell_selection_to_follow_its_column() {
let model = rows(3);
let cs = super::CellSelectionModel::new(TableSelectionMode::MultiCell);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::MultiCell)
.cell_selection(cs.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
cs.select(1, 1); {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_pinning("name", super::PinnedSide::Leading);
}
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
assert!(
cs.is_selected(1, 0),
"selection must follow `name` to its new display position"
);
assert!(!cs.is_selected(1, 1));
}
#[test]
fn set_column_order_remaps_focused_cell_across_a_full_permutation() {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.add_column(
Column::<Row>::new("extra", lit!("Extra"), |_row, _: &CellContext| {
Box::new(crate::primitives::TextWidget::new(lit!("…")))
})
.width(ColumnWidth::Fixed(40.0)),
)
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 2); {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_column_order(vec![
"extra".to_string(),
"id".to_string(),
"name".to_string(),
]);
}
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
assert_eq!(
read_focused_cell(&tree, table),
Some((0, 0)),
"focus must follow `extra` to its new display position"
);
}
#[test]
fn editing_cell_signal_round_trips() {
let (tree, table, _) = build_table(5);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(tv.editing_cell_signal().get(), None);
tv.begin_edit(2, "name");
let editing = tv.editing_cell_signal().get();
assert!(matches!(editing, Some((2, _))));
tv.end_edit();
assert_eq!(tv.editing_cell_signal().get(), None);
}
#[test]
fn f2_triggers_edit_request() {
use std::cell::RefCell;
use std::rc::Rc;
let fired_for: Rc<RefCell<Option<(usize, String)>>> = Rc::new(RefCell::new(None));
let f = fired_for.clone();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().editable(true))
.row_height(20.0)
.edit_triggers(super::EditTriggers::F2)
.on_cell_edit_request(move |row, col_id, _ctx| {
*f.borrow_mut() = Some((row, col_id.to_string()));
}),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 1, 1);
tree.press_key(Key::F2, Modifiers::NONE);
assert_eq!(*fired_for.borrow(), Some((1, "name".to_string())));
}
#[test]
fn typing_triggers_edit_request_in_f2_or_type_mode() {
use std::cell::Cell;
use std::rc::Rc;
let fired = Rc::new(Cell::new(0));
let f = fired.clone();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col().editable(true))
.add_column(name_col())
.row_height(20.0)
.edit_triggers(super::EditTriggers::F2 | super::EditTriggers::ANY_KEY)
.on_cell_edit_request(move |_, _, _| f.set(f.get() + 1)),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::Character('x'), Modifiers::NONE);
assert_eq!(fired.get(), 1);
}
#[test]
fn typing_does_not_trigger_edit_when_only_f2() {
use std::cell::Cell;
use std::rc::Rc;
let fired = Rc::new(Cell::new(0));
let f = fired.clone();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.edit_triggers(super::EditTriggers::F2)
.on_cell_edit_request(move |_, _, _| f.set(f.get() + 1)),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::Character('x'), Modifiers::NONE);
assert_eq!(fired.get(), 0);
}
#[test]
fn escape_ends_edit_before_clearing_focus() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 1, 0);
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.begin_edit(1, "id");
}
tree.press_key(Key::Escape, Modifiers::NONE);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(tv.editing_cell_signal().get(), None);
assert_eq!(tv.focused_cell_signal().get(), Some((1, 0)));
let _ = tv;
let _ = any;
tree.press_key(Key::Escape, Modifiers::NONE);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(tv.focused_cell_signal().get(), None);
}
#[test]
fn begin_edit_resolves_before_the_view_is_mounted() {
let view = TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().pinned(super::PinnedSide::Leading))
.row_height(20.0);
view.begin_edit(1, "id");
assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
view.end_edit();
view.begin_edit(0, "no-such-column");
assert_eq!(view.editing_cell_signal().get(), None);
view.begin_edit(9999, "id");
assert_eq!(view.editing_cell_signal().get(), None);
view.begin_edit(1, "id");
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(view);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(tv.editing_cell_signal().get(), Some((1, 1)));
}
#[test]
fn filters_signal_is_writable() {
let (tree, table, _) = build_table(5);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.set_filter("name", "abc");
assert_eq!(
tv.filters_signal().get().get("name").cloned(),
Some("abc".to_string())
);
tv.set_filter("name", "");
assert_eq!(tv.filters_signal().get().get("name"), None);
tv.set_filter("name", "x");
tv.set_filter("id", "1");
tv.clear_filters();
assert!(tv.filters_signal().get().is_empty());
}
#[test]
fn enter_invokes_on_row_activate() {
use std::cell::Cell;
use std::rc::Rc;
let activated = Rc::new(Cell::new(None::<usize>));
let a = activated.clone();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.on_row_activate(move |row, _ctx| a.set(Some(row))),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 3, 0);
tree.press_key(Key::Enter, Modifiers::NONE);
assert_eq!(activated.get(), Some(3));
}
#[test]
fn reorderable_on_list_model_lays_out_cleanly() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.reorderable(true),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let info = tree.accessibility_node(table);
assert_eq!(info.role(), Role::Table);
}
#[test]
fn empty_view_renders_when_no_rows() {
let model: ListModel<Row> = ListModel::new();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.empty_view(|| Box::new(TextWidget::new(lit!("nothing here")))),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let mut found = false;
let mut walker = vec![_table];
while let Some(id) = walker.pop() {
let info = tree.accessibility_node(id);
if let Some(name) = info.name()
&& name == "nothing here"
{
found = true;
break;
}
for c in tree.children(id) {
walker.push(c);
}
}
assert!(found, "expected the empty_view widget to be rendered");
}
#[test]
fn filterable_column_exposes_filter_trigger_in_a11y_tree() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().filterable(true))
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let trigger = tree.find_by_label("Filter");
assert!(
trigger.is_some(),
"filterable column must expose a 'Filter' trigger via the Popover"
);
}
#[test]
fn non_filterable_column_does_not_expose_filter_trigger() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
assert!(
tree.find_by_label("Filter").is_none(),
"columns without `.filterable(true)` must not advertise a filter trigger"
);
}
#[test]
fn filter_popover_opens_via_trigger_click() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().filterable(true))
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let trigger = tree.find_by_label("Filter").expect("trigger present");
tree.click(trigger);
assert_eq!(
tree.active_overlays().len(),
1,
"clicking the filter trigger should open exactly one overlay"
);
}
#[test]
fn filter_popover_content_hosts_a_text_input() {
use crate::table_view::filter::FilterPopoverContent;
let content = FilterPopoverContent::new("seed");
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(content);
tree.layout(SizeProposal {
width: Some(280.0),
height: Some(60.0),
});
let mut walker = vec![id];
let mut found = None;
while let Some(n) = walker.pop() {
if tree.accessibility_node(n).role() == Role::TextInput {
found = Some(n);
break;
}
for c in tree.children(n) {
walker.push(c);
}
}
assert!(
found.is_some(),
"FilterPopoverContent must contain a TextInput accessible node"
);
}
#[test]
fn resize_drag_right_grows_column_for_non_first_column() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col()) .add_column(name_col()) .row_height(20.0)
.show_internal_scrollbars(false),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
use crate::styles::recipe_table_style as cp;
let resize_handle = cp::RESIZE_HANDLE_WIDTH;
let down_x = 400.0 - resize_handle * 0.5;
let drag_to_x = down_x + 30.0;
let down_y = cp::HEADER_HEIGHT * 0.5;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(down_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(drag_to_x, down_y),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(drag_to_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let widths = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.column_widths_signal().get()
};
let name_w = widths.get("name").copied().unwrap_or(0.0);
assert!(
name_w > 350.0,
"drag right should enlarge column past its baseline ~340; got {}",
name_w
);
}
#[test]
fn resize_pointer_down_in_filter_zone_does_not_start_resize() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().filterable(true))
.row_height(20.0)
.show_internal_scrollbars(false),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
use crate::styles::recipe_table_style as cp;
let click_x = 400.0
- cp::RESIZE_HANDLE_WIDTH
- (cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL) * 0.5;
let click_y = cp::HEADER_HEIGHT * 0.5;
let mods = Modifiers::NONE;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(click_x, click_y),
button: PointerButton::Primary,
modifiers: mods,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(click_x + 20.0, click_y),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(click_x + 20.0, click_y),
button: PointerButton::Primary,
modifiers: mods,
});
let widths = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.column_widths_signal().get()
};
assert!(
!widths.contains_key("name"),
"PointerDown in the filter zone must not commit a resize override"
);
}
#[test]
fn filter_popover_editor_gains_focus_on_mouse_click() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().filterable(true))
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let trigger = tree.find_by_label("Filter").expect("trigger present");
tree.click(trigger);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let editor = {
let mut walker: Vec<WidgetId> = tree.overlay_manager().active_content_ids();
let mut found = None;
while let Some(n) = walker.pop() {
if tree.accessibility_node(n).role() == Role::TextInput {
found = Some(n);
break;
}
for c in tree.children(n) {
walker.push(c);
}
}
found.expect("TextInput must exist inside the filter popover overlay")
};
tree.click(editor);
assert_eq!(
tree.focused(),
Some(editor),
"clicking the popover's filter editor should focus it"
);
}
#[test]
fn header_resize_works_when_table_is_nested_in_panel() {
use crate::{Panel, VStack};
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false);
let table_id = tree.add(table);
let layout = VStack::new()
.spacing(6.0)
.child(Panel::new().child_id(table_id));
tree.add(layout);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let table_bounds = tree.bounds(table_id);
use crate::styles::recipe_table_style as cp;
let resize_handle = cp::RESIZE_HANDLE_WIDTH;
let down_x = table_bounds.right() - resize_handle * 0.5;
let drag_to_x = down_x + 30.0;
let down_y = table_bounds.y + cp::HEADER_HEIGHT * 0.5;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(down_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(drag_to_x, down_y),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(drag_to_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let widths = {
let any = tree.widget_as_any(table_id).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.column_widths_signal().get()
};
let name_w = widths.get("name").copied().unwrap_or(0.0);
assert!(
name_w > 100.0,
"drag-resize on a nested table must commit a width override (got name={})",
name_w
);
}
#[test]
fn cursor_resets_to_default_when_pointer_leaves_resize_zone() {
use teksilo_canvas::Point;
use teksilo_core::event::WidgetEvent;
use teksilo_core::widget::CursorIcon;
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
use crate::styles::recipe_table_style as cp;
let header_y = cp::HEADER_HEIGHT * 0.5;
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(398.0, header_y),
});
assert_eq!(
tree.current_cursor(),
CursorIcon::ColResize,
"expected ColResize on the trailing edge"
);
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(200.0, header_y),
});
assert_eq!(
tree.current_cursor(),
CursorIcon::Default,
"cursor must reset to Default once the pointer leaves the trailing-edge zone"
);
}
#[test]
fn header_resizing_works_in_full_data_grid_layout() {
use crate::{Panel, VStack};
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
fn id_c() -> Column<Row> {
Column::<Row>::new("id", lit!("ID"), |r, _: &CellContext| {
Box::new(TextWidget::new(lit!(r.id.to_string())))
})
.width(ColumnWidth::Fixed(60.0))
.pinned(crate::table_view::column::PinnedSide::Leading)
.sortable(true)
}
fn name_c() -> Column<Row> {
Column::<Row>::new("name", lit!("Name"), |r, _: &CellContext| {
Box::new(TextWidget::new(lit!(r.name.clone())))
})
.width(ColumnWidth::Flex(2.0))
.sortable(true)
.filterable(true)
}
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = TableView::new(rows(50))
.add_column(id_c())
.add_column(name_c())
.row_height(28.0)
.selection_mode(TableSelectionMode::MultiRow)
.selection(SelectionModel::new(SelectionMode::Multi));
let table_id = tree.add(table);
let layout = VStack::new()
.spacing(6.0)
.child(Panel::new().child_id(table_id));
tree.add(layout);
tree.layout(SizeProposal {
width: Some(600.0),
height: Some(400.0),
});
use crate::styles::recipe_table_style as cp;
let (name_w_at_layout, table_bounds) = {
let any = tree.widget_as_any(table_id).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
let w = tv.column_widths_signal().get();
(w.get("name").copied(), tree.bounds(table_id))
};
assert!(name_w_at_layout.is_none());
let scrollbar_thickness = 12.0_f32;
let trailing_x = table_bounds.right() - scrollbar_thickness;
let down_x = trailing_x - cp::RESIZE_HANDLE_WIDTH * 0.5;
let down_y = table_bounds.y + cp::HEADER_HEIGHT * 0.5;
let drop_x = down_x + 60.0;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(down_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(drop_x, down_y),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(drop_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let widths = {
let any = tree.widget_as_any(table_id).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.column_widths_signal().get()
};
assert!(
widths.contains_key("name"),
"drag-resize on a filterable column inside a Panel must commit a width override"
);
}
#[test]
fn body_row_does_not_paint_over_header_after_scroll() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(50))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
{
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.scroll_y_signal().set(80.0);
}
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let children = tree.children(table);
let mut header_pos: Option<usize> = None;
let mut rowgroup_pos: Option<usize> = None;
for (i, child) in children.iter().copied().enumerate() {
let role = tree.accessibility_node(child).role();
if role == Role::Row {
header_pos = Some(i);
} else if role == Role::RowGroup {
rowgroup_pos = Some(i);
}
}
let h = header_pos.expect("header row must be a direct child");
let g = rowgroup_pos.expect("row-group must be a direct child");
assert!(
h > g,
"header (idx {}) must paint after the row-group (idx {})",
h,
g
);
}
fn build_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(
Column::<Row>::new("id", lit!("ID"), |row, _: &CellContext| {
Box::new(TextWidget::new(lit!(row.id.to_string())))
})
.width(ColumnWidth::Fixed(60.0))
.pinned(super::PinnedSide::Leading),
)
.add_column(
Column::<Row>::new("name", lit!("Name"), |row, _: &CellContext| {
Box::new(TextWidget::new(lit!(row.name.clone())))
})
.width(ColumnWidth::Fixed(middle_w)),
)
.add_column(
Column::<Row>::new("extra", lit!("Extra"), |_row, _: &CellContext| {
Box::new(TextWidget::new(lit!("x")))
})
.width(ColumnWidth::Fixed(60.0))
.pinned(super::PinnedSide::Trailing),
)
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(table_w),
height: Some(200.0),
});
(tree, table)
}
fn build_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let mut tv = TableView::new(model);
for i in 0..n {
let id = format!("c{i}");
tv = tv.add_column(
Column::<Row>::new(id.clone(), lit!(id.clone()), |row, _: &CellContext| {
Box::new(TextWidget::new(lit!(row.name.clone())))
})
.width(ColumnWidth::Fixed(col_w)),
);
}
let table = tree.add(tv.row_height(20.0));
tree.layout(SizeProposal {
width: Some(table_w),
height: Some(200.0),
});
(tree, table)
}
fn scroll_x_of(tree: &WidgetTree, table: WidgetId) -> f32 {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.scroll_x_signal()
.get()
}
fn max_scroll_x_of(tree: &WidgetTree, table: WidgetId) -> f32 {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.max_scroll_x_signal()
.get()
}
fn set_scroll_x(tree: &WidgetTree, table: WidgetId, x: f32) {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.scroll_x_signal()
.set(x);
}
#[test]
fn scroll_x_clamps_after_the_pane_widens() {
let (mut tree, table) = build_wide_unpinned_table(200.0, 3, 300.0);
let max = max_scroll_x_of(&tree, table);
assert!(max > 0.0, "columns must overflow the narrow table");
set_scroll_x(&tree, table, max);
assert_eq!(scroll_x_of(&tree, table), max);
tree.layout(SizeProposal {
width: Some(700.0),
height: Some(200.0),
});
assert_eq!(max_scroll_x_of(&tree, table), 0.0, "content now fits");
assert_eq!(
scroll_x_of(&tree, table),
0.0,
"scroll_x must clamp down with the new (smaller) max_scroll_x"
);
}
#[test]
fn pinned_columns_keep_their_bands_under_scroll() {
let (mut tree, table) = build_pinned_scroll_table(400.0, 200.0);
let cells0 = body_row_cells(&tree, table);
assert_eq!(cells0.len(), 3, "id, name, extra");
let id_x0 = tree.bounds(cells0[0]).x;
let name_x0 = tree.bounds(cells0[1]).x;
let extra_x0 = tree.bounds(cells0[2]).x;
let raw_bands = tree.children(first_body_row_id(&tree, table));
assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
assert!(
!tree.widget_clips_children(raw_bands[0]),
"the Leading band never needs to clip"
);
assert!(
tree.widget_clips_children(raw_bands[1]),
"the Middle band must clip"
);
assert!(
!tree.widget_clips_children(raw_bands[2]),
"the Trailing band never needs to clip"
);
let max = max_scroll_x_of(&tree, table);
assert!(max > 0.0);
set_scroll_x(&tree, table, 50.0_f32.min(max));
tree.layout(SizeProposal {
width: Some(200.0),
height: Some(200.0),
});
let cells1 = body_row_cells(&tree, table);
assert_eq!(
tree.bounds(cells1[0]).x,
id_x0,
"Leading column never moves"
);
assert_eq!(
tree.bounds(cells1[2]).x,
extra_x0,
"Trailing column never moves"
);
let name_x1 = tree.bounds(cells1[1]).x;
assert!(
(name_x1 - (name_x0 - 50.0)).abs() < 0.5,
"the Middle column shifts left by exactly scroll_x: got {name_x1}, want ~{}",
name_x0 - 50.0
);
}
#[test]
fn header_and_body_x_offsets_agree_under_scroll() {
let (mut tree, table) = build_pinned_scroll_table(400.0, 200.0);
set_scroll_x(&tree, table, 37.0);
tree.layout(SizeProposal {
width: Some(200.0),
height: Some(200.0),
});
let header_cells = header_row_cells(&tree, table);
let body_cells = body_row_cells(&tree, table);
assert_eq!(header_cells.len(), body_cells.len());
for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
let hx = tree.bounds(h).x;
let bx = tree.bounds(b).x;
assert!(
(hx - bx).abs() < 0.01,
"column {i}: header x {hx} must equal body x {bx}"
);
}
}
#[test]
fn shift_wheel_scrolls_horizontally() {
use teksilo_canvas::Point;
use teksilo_core::event::{ScrollDelta, WidgetEvent};
let (mut tree, table) = build_wide_unpinned_table(200.0, 4, 300.0);
tree.pointer_move(Point::new(50.0, 60.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
modifiers: Modifiers::SHIFT,
});
tree.layout(SizeProposal {
width: Some(300.0),
height: Some(200.0),
});
assert!(
scroll_x_of(&tree, table) > 0.0,
"Shift+wheel must remap a vertical-only wheel to horizontal scroll"
);
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
assert_eq!(
tv.scroll_y_signal().get(),
0.0,
"Shift+wheel must not also scroll vertically"
);
}
#[test]
fn ensure_col_visible_follows_focus_in_both_directions() {
let (mut tree, table) = build_wide_unpinned_table(150.0, 5, 300.0);
focus_at(&mut tree, table, 0, 0);
assert_eq!(scroll_x_of(&tree, table), 0.0);
tree.press_key(Key::End, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 4)));
let scrolled_right = scroll_x_of(&tree, table);
assert!(
scrolled_right > 0.0,
"ensure-column-visible must scroll right to reveal column 4"
);
tree.press_key(Key::Home, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
assert_eq!(
scroll_x_of(&tree, table),
0.0,
"ensure-column-visible must scroll left back to 0 for column 0"
);
}
#[test]
fn ensure_col_visible_never_scrolls_for_a_pinned_column() {
let (mut tree, table) = build_pinned_scroll_table(400.0, 200.0);
let max = max_scroll_x_of(&tree, table);
set_scroll_x(&tree, table, max);
focus_at(&mut tree, table, 0, 1); tree.press_key(Key::Home, Modifiers::NONE); assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
assert_eq!(
scroll_x_of(&tree, table),
max,
"a Leading-pinned column is always visible; scroll_x must not move"
);
}
#[test]
fn resize_handle_hit_tests_correctly_under_scroll() {
use crate::styles::recipe_table_style as cp;
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let (mut tree, table) = build_wide_unpinned_table(300.0, 2, 200.0);
let max = max_scroll_x_of(&tree, table);
set_scroll_x(&tree, table, 150.0_f32.min(max));
tree.layout(SizeProposal {
width: Some(200.0),
height: Some(200.0),
});
let table_bounds = tree.bounds(table);
let resize_x = table_bounds.x + 150.0 - cp::RESIZE_HANDLE_WIDTH * 0.5;
let resize_y = table_bounds.y + cp::HEADER_HEIGHT * 0.5;
let drop_x = resize_x + 20.0;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(resize_x, resize_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(drop_x, resize_y),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(drop_x, resize_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
let widths = tv.column_widths_signal().get();
assert!(
widths.contains_key("c0"),
"dragging the first column's resize handle under scroll must commit its width override, got {widths:?}"
);
let got = widths["c0"];
assert!(
(got - 320.0).abs() < 1.0,
"expected c0 to grow to ~320px, got {got}"
);
}
fn first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
let mut walker = vec![root];
while let Some(id) = walker.pop() {
if tree.accessibility_node(id).role() == Role::Row {
let flat = flatten_through_bands(tree, tree.children(id));
if flat
.iter()
.any(|&c| tree.accessibility_node(c).role() == Role::Cell)
{
return id;
}
}
for c in tree.children(id) {
walker.push(c);
}
}
panic!("no body Role::Row found");
}
fn header_row_widget_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
let mut walker = vec![root];
while let Some(id) = walker.pop() {
if tree.accessibility_node(id).role() == Role::Row {
let flat = flatten_through_bands(tree, tree.children(id));
if !flat.is_empty()
&& flat
.iter()
.all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
{
return id;
}
}
for c in tree.children(id) {
walker.push(c);
}
}
panic!("no header Role::Row found");
}
fn body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
flatten_through_bands(tree, tree.children(first_body_row_id(tree, root)))
}
fn header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
flatten_through_bands(tree, tree.children(header_row_widget_id(tree, root)))
}
fn first_visible_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
let mut walker = vec![root];
let mut best: Option<WidgetId> = None;
while let Some(id) = walker.pop() {
let info = tree.accessibility_node(id);
if info.role() == Role::Row && best.is_none() {
best = Some(id);
}
for c in tree.children(id) {
walker.push(c);
}
}
let row = best.expect("no Role::Row found");
flatten_through_bands(tree, tree.children(row))
}
fn flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
let mut out = Vec::new();
for id in ids {
if matches!(
tree.accessibility_node(id).role(),
Role::GenericContainer | Role::Unknown
) {
out.extend(flatten_through_bands(tree, tree.children(id)));
} else {
out.push(id);
}
}
out
}
fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
let mut walker = vec![root];
let mut n = 0;
while let Some(id) = walker.pop() {
let info = tree.accessibility_node(id);
if info.role() == role {
n += 1;
}
for c in tree.children(id) {
walker.push(c);
}
}
n
}
#[test]
fn header_cells_route_through_table_style_make_header_cell() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_core::build_context::BuildContext;
use teksilo_core::styles::{
TableGridRecipe, TableHeaderCellConfig, TableRowConfig, TableStyle,
};
use teksilo_core::widget_id::WidgetId;
struct CountingStyle {
calls: Rc<Cell<u32>>,
}
impl TableStyle for CountingStyle {
fn make_header_cell(
&self,
cfg: &TableHeaderCellConfig,
ctx: &mut BuildContext,
) -> WidgetId {
self.calls.set(self.calls.get() + 1);
let _ = ctx;
cfg.label
}
fn make_sort_indicator(
&self,
_direction: teksilo_core::styles::SortDirection,
ctx: &mut BuildContext,
) -> WidgetId {
ctx.add(crate::primitives::Spacer::new())
}
fn make_row_background(&self, _cfg: &TableRowConfig, ctx: &mut BuildContext) -> WidgetId {
ctx.add(crate::primitives::Spacer::new())
}
fn grid(&self) -> TableGridRecipe {
TableGridRecipe::default()
}
}
let calls = Rc::new(Cell::new(0_u32));
let style: Rc<dyn TableStyle> = Rc::new(CountingStyle {
calls: calls.clone(),
});
let mut theme = teksilo_core::presets::intui::light();
theme.style_slots.table = Some(style);
let mut tree = WidgetTree::new().with_theme(theme);
tree.add(
TableView::new(rows(2))
.add_column(id_col())
.add_column(name_col()),
);
tree.layout(SizeProposal::exact(400.0, 240.0));
let _ = tree.render();
assert_eq!(
calls.get(),
2,
"TableStyle::make_header_cell must be called once per header cell",
);
}
fn nested_table_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>, Signal<f32>) {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let tv = TableView::new(rows(40))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.overscroll_behavior(inner);
let inner_y = tv.scroll_y_signal().clone();
let tv_id = tree.add(tv);
let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tv_id));
let filler = tree.add(
FixedSize::new()
.width(220.0)
.height(300.0)
.child(TextWidget::new(lit!(""))),
);
let outer_content = tree.add(VStack::new().add_child(viewport).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 {
width: Some(220.0),
height: Some(150.0),
});
(tree, inner_y, outer_y)
}
#[test]
fn nested_table_chains_to_outer_at_boundary() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let (mut tree, inner_y, outer_y) = nested_table_fixture(OverscrollBehavior::Chain);
tree.pointer_move(Point::new(110.0, 90.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal {
width: Some(220.0),
height: Some(150.0),
});
let inner_bottom = inner_y.get();
assert!(
inner_bottom > 0.0,
"inner table should scroll down; got {inner_bottom}"
);
assert!(
outer_y.get() < 0.01,
"outer must not move while the inner absorbs"
);
tree.pointer_move(Point::new(110.0, 90.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal {
width: Some(220.0),
height: Some(150.0),
});
assert!(
(inner_y.get() - inner_bottom).abs() < 0.01,
"inner stays clamped at bottom"
);
assert!(
outer_y.get() > 0.01,
"outer scrolled because the inner chained the boundary"
);
}
#[test]
fn keyboard_selection_chases_outer_scroll_area() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
use teksilo_canvas::Point;
use teksilo_core::event::{Key, Modifiers};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let tv = TableView::new(rows(20))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0);
let tv_id = tree.add(tv);
let tv_box = tree.add(FixedSize::new().width(220.0).height(200.0).child_id(tv_id));
let filler = tree.add(
FixedSize::new()
.width(220.0)
.height(200.0)
.child(TextWidget::new(lit!(""))),
);
let outer_content = tree.add(VStack::new().add_child(tv_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);
let sz = SizeProposal {
width: Some(220.0),
height: Some(100.0),
};
tree.layout(sz);
tree.focus(tv_id);
tree.layout(sz);
outer_y.set(0.0);
tree.layout(sz);
assert!(outer_y.get().abs() < 0.01, "reset outer to top");
tree.pointer_move(Point::new(110.0, 50.0));
for _ in 0..20 {
tree.press_key(Key::ArrowDown, Modifiers::NONE);
}
tree.layout(sz);
assert!(
outer_y.get() > 0.01,
"row nav below the fold must scroll the enclosing ScrollArea (got {})",
outer_y.get()
);
}
#[test]
fn nested_table_contain_blocks_chaining() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let (mut tree, _inner_y, outer_y) = nested_table_fixture(OverscrollBehavior::Contain);
tree.pointer_move(Point::new(110.0, 90.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal {
width: Some(220.0),
height: Some(150.0),
});
tree.pointer_move(Point::new(110.0, 90.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal {
width: Some(220.0),
height: Some(150.0),
});
assert!(
outer_y.get() < 0.01,
"Contain must prevent chaining: outer stays put"
);
}
use teksilo_core::environment::LayoutDirection;
fn relayout_rtl(tree: &mut WidgetTree) {
tree.set_layout_direction(LayoutDirection::RightToLeft);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
}
#[test]
fn rtl_reverses_column_x_order_with_scrollbar() {
let (mut tree, table, _) = build_table(50);
relayout_rtl(&mut tree);
let cells = first_visible_row_cells(&tree, table);
assert_eq!(cells.len(), 2);
let id_cell = tree.bounds(cells[0]);
let name_cell = tree.bounds(cells[1]);
assert!((id_cell.width - 60.0).abs() < 0.5, "id w {}", id_cell.width);
assert!(
(name_cell.width - 328.0).abs() < 0.5,
"name w {}",
name_cell.width
);
assert!(
id_cell.x > name_cell.x,
"RTL: id.x={} should be right of name.x={}",
id_cell.x,
name_cell.x
);
assert!((id_cell.x - 340.0).abs() < 0.5, "id.x {}", id_cell.x);
assert!(
(name_cell.x - 12.0).abs() < 0.5,
"name.x {} (band should start at SCROLLBAR_THICKNESS)",
name_cell.x
);
}
#[test]
fn rtl_right_anchors_content_without_scrollbar() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let model = rows(5);
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
relayout_rtl(&mut tree);
let cells = first_visible_row_cells(&tree, table);
let id_cell = tree.bounds(cells[0]);
let name_cell = tree.bounds(cells[1]);
assert!((id_cell.x - 340.0).abs() < 0.5, "id.x {}", id_cell.x);
assert!(
name_cell.x.abs() < 0.5,
"name.x {} (content right-anchored, no scrollbar gap)",
name_cell.x
);
}
#[test]
fn ltr_keeps_columns_left_to_right_control() {
let (tree, table, _) = build_table(50);
let cells = first_visible_row_cells(&tree, table);
let id_cell = tree.bounds(cells[0]);
let name_cell = tree.bounds(cells[1]);
assert!(
id_cell.x < name_cell.x,
"LTR: id.x={} should be left of name.x={}",
id_cell.x,
name_cell.x
);
assert!(id_cell.x.abs() < 0.5, "id.x {}", id_cell.x);
}
#[test]
fn rtl_swaps_arrow_key_column_navigation() {
let (mut tree, table, _) = build_table(10);
tree.set_layout_direction(LayoutDirection::RightToLeft);
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::ArrowLeft, Modifiers::NONE);
assert_eq!(
read_focused_cell(&tree, table),
Some((0, 1)),
"RTL ArrowLeft should advance to the next display column"
);
tree.press_key(Key::ArrowRight, Modifiers::NONE);
assert_eq!(
read_focused_cell(&tree, table),
Some((0, 0)),
"RTL ArrowRight should step back toward column 0"
);
tree.press_key(Key::End, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 1)));
tree.press_key(Key::Home, Modifiers::NONE);
assert_eq!(read_focused_cell(&tree, table), Some((0, 0)));
}
#[test]
fn rtl_live_resize_tracks_without_drift() {
use crate::styles::recipe_table_style as cp;
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col()) .add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
relayout_rtl(&mut tree);
let resize_handle = cp::RESIZE_HANDLE_WIDTH;
let down_x = 340.0 + resize_handle * 0.5;
let down_y = cp::HEADER_HEIGHT * 0.5;
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(down_x, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(down_x - 20.0, down_y),
});
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(down_x - 40.0, down_y),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(down_x - 40.0, down_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let id_w = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.column_widths_signal()
.get()
.get("id")
.copied()
.unwrap_or(0.0)
};
assert!(
(id_w - 100.0).abs() < 6.0,
"RTL drag-left by 40px should widen id from 60 to ~100 (no drift); got {}",
id_w
);
}
#[derive(Debug)]
struct FixedLeaf(f32, f32);
impl teksilo_core::widget::Widget for FixedLeaf {
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &teksilo_core::widget::LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
teksilo_canvas::Size::new(self.0, self.1).into()
}
}
fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
let mut walker = vec![root];
let mut spans = Vec::new();
while let Some(id) = walker.pop() {
if tree.accessibility_node(id).role() == Role::Row {
let b = tree.bounds(id);
spans.push((b.y, b.height));
}
for c in tree.children(id) {
walker.push(c);
}
}
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
spans
}
#[test]
fn exact_row_height_fn_positions_rows() {
let heights = [60.0_f32, 20.0, 40.0];
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.show_header(false)
.row_height_fn(move |i| heights[i]),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let spans = row_spans(&tree, table);
assert_eq!(spans.len(), 3);
assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
assert!((spans[2].0 - 80.0).abs() < 0.01 && (spans[2].1 - 40.0).abs() < 0.01);
}
#[test]
fn auto_row_height_measures_tallest_cell() {
let model = rows(3);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let col_a = Column::<Row>::new("a", lit!("A"), |_row, _: &CellContext| {
Box::new(FixedLeaf(50.0, 30.0))
})
.width(ColumnWidth::Fixed(60.0));
let col_b = Column::<Row>::new("b", lit!("B"), |_row, _: &CellContext| {
Box::new(FixedLeaf(50.0, 44.0))
})
.width(ColumnWidth::Flex(1.0));
let table = tree.add(
TableView::new(model)
.add_column(col_a)
.add_column(col_b)
.show_header(false)
.auto_row_height(20.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let spans = row_spans(&tree, table);
assert!(
(spans[0].1 - 44.0).abs() < 0.01,
"row height must be the tallest cell, got {}",
spans[0].1
);
assert!(
(spans[1].0 - 44.0).abs() < 0.01,
"row 1 must sit below the measured row 0, got {}",
spans[1].0
);
}
#[test]
fn page_down_with_variable_heights_lands_on_offset_row() {
use teksilo_core::event::{Key, Modifiers};
let model = rows(100);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.show_header(false)
.row_height_fn(|i| if i % 2 == 0 { 20.0 } else { 60.0 }),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
focus_at(&mut tree, table, 0, 0);
tree.press_key(Key::PageDown, Modifiers::NONE);
let after = read_focused_cell(&tree, table).unwrap();
assert_eq!(
after.0, 5,
"PageDown must land on the row one viewport below (offset-driven)"
);
}
#[test]
fn append_through_sort_filter_keeps_measured_prefix() {
use teksilo_data::SortFilterListModel;
let model = rows(4);
let proxy = SortFilterListModel::new(model.clone());
let col = Column::<Row>::new("a", lit!("A"), |_row, _: &CellContext| {
Box::new(FixedLeaf(50.0, 30.0))
})
.width(ColumnWidth::Flex(1.0));
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::from_source(proxy)
.add_column(col)
.show_header(false)
.auto_row_height(50.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
model.push(Row {
id: 99,
name: "new".into(),
});
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let spans = row_spans(&tree, table);
assert_eq!(spans.len(), 5);
assert!(
(spans[1].0 - 30.0).abs() < 0.01,
"measured prefix must survive an append through SortFilterListModel, got y {}",
spans[1].0
);
}
#[test]
fn row_drop_insertion_with_variable_heights() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
let model = rows(5);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(model.clone())
.add_column(id_col())
.add_column(name_col())
.show_header(false)
.row_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
.reorderable(true),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let from = Point::new(150.0, 150.0);
tree.dispatch_event(WidgetEvent::PointerDown {
position: from,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(from.x + 10.0, from.y),
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(from.x, 35.0),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(from.x, 35.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let ids: Vec<u32> = (0..model.len())
.map(|i| model.with_item(i, |r| r.id).unwrap())
.collect();
assert_eq!(ids, vec![0, 4, 1, 2, 3]);
}
#[test]
fn reorder_drag_routes_to_source_accept_drop_without_mutating() {
use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::Point;
use teksilo_core::ObserverHandle;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
use teksilo_data::{
DataChange, DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
ListDataSource,
};
struct CapturingSource {
items: Vec<Row>,
captured: Rc<RefCell<Vec<(usize, usize)>>>,
}
impl ListDataSource for CapturingSource {
type Item = Row;
type Key = usize;
fn len(&self) -> usize {
self.items.len()
}
fn with_item<R>(&self, i: usize, f: impl FnOnce(&Row) -> R) -> Option<R> {
self.items.get(i).map(f)
}
fn key_at(&self, i: usize) -> Option<usize> {
(i < self.items.len()).then_some(i)
}
fn observe_changes(&self, _f: impl Fn(&DataChange) + 'static) -> ObserverHandle {
ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
}
fn drag(&self, _k: &usize) -> DragEligibility {
DragEligibility::CanDrag
}
fn can_accept(&self, q: &DropQuery<'_, usize>) -> DropResponse {
match &q.source {
DragSource::SameView { .. } if q.position != DropPosition::Into => {
DropResponse::Accept
}
_ => DropResponse::Reject,
}
}
fn accept_drop(&self, c: DropCommit<'_, usize>) -> bool {
let DragSource::SameView { key: from } = c.source else {
return false;
};
let target = c.target;
let shift = if from < target { 1 } else { 0 };
let to = match c.position {
DropPosition::Before => target.saturating_sub(shift),
DropPosition::After => (target + 1).saturating_sub(shift),
DropPosition::Into => return false,
};
self.captured.borrow_mut().push((from, to));
true
}
}
let captured: Rc<RefCell<Vec<(usize, usize)>>> = Rc::new(RefCell::new(Vec::new()));
let items: Vec<Row> = (0..5)
.map(|i| Row {
id: i,
name: format!("row {i}"),
})
.collect();
let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::from_source(CapturingSource {
items,
captured: captured.clone(),
})
.add_column(id_col())
.add_column(name_col())
.show_header(false)
.row_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
.reorderable(true),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let from = Point::new(150.0, 150.0);
tree.dispatch_event(WidgetEvent::PointerDown {
position: from,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(from.x + 10.0, from.y),
});
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(from.x, 35.0),
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(from.x, 35.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(
*captured.borrow(),
vec![(4, 1)],
"the drop is routed to the source's accept_drop with the resolved move"
);
}
#[test]
fn auto_row_height_totals_settle_after_measurement() {
let model = rows(50);
let col = Column::<Row>::new("a", lit!("A"), |_row, _: &CellContext| {
Box::new(FixedLeaf(50.0, 44.0))
})
.width(ColumnWidth::Flex(1.0));
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(col)
.show_header(false)
.auto_row_height(30.0),
);
for _ in 0..5 {
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
}
let max_scroll = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.max_scroll_y_signal().get()
};
assert!(
max_scroll > 1300.0 + 0.5,
"max_scroll must pick up measured heights, got {max_scroll} (stale = 1300)"
);
let scroll = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.scroll_y_signal().clone()
};
let read_max = |tree: &WidgetTree| {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.max_scroll_y_signal().get()
};
let mut settled = false;
for _ in 0..60 {
let max_before = read_max(&tree);
scroll.set(max_before);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let max_after = read_max(&tree);
if (max_after - max_before).abs() < 0.01 && (scroll.get() - max_after).abs() < 0.01 {
settled = true;
break;
}
}
assert!(settled, "scroll range must converge");
let max_scroll = {
let any = tree.widget_as_any(table).unwrap();
let tv = any.downcast_ref::<TableView<Row>>().unwrap();
tv.max_scroll_y_signal().get()
};
assert!(
(scroll.get() - max_scroll).abs() < 0.01,
"scroll must settle at max"
);
let spans = row_spans(&tree, table);
let last_bottom = spans.last().map(|(y, h)| y + h).unwrap();
assert!(
(last_bottom - 200.0).abs() < 1.0,
"at max scroll the last row must end at the viewport bottom, got {last_bottom}"
);
}
#[test]
fn root_painted_row_decorations_clip_to_widget_bounds() {
use teksilo_canvas::DrawCommand;
let model = rows(50);
let sel = SelectionModel::new(SelectionMode::Multi);
sel.select(4); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.show_header(false)
.row_height_fn(|_| 44.0) .alternating_rows(true)
.grid_lines(super::GridLines::Horizontal)
.selection_mode(TableSelectionMode::MultiRow)
.selection(sel),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
let frame = tree.render();
let mut clips: Vec<[f32; 4]> = Vec::new();
let mut saw_raw_overflow = false;
for cmd in &frame.draw_order {
match cmd {
DrawCommand::SetClip(r) => clips.push([r.x, r.y, r.width, r.height]),
DrawCommand::ClearClip => {
clips.pop();
}
DrawCommand::Decoration(i) => {
let rect = frame.decorations[*i].rect;
let raw_bottom = rect[1] + rect[3];
let mut bottom = raw_bottom;
for c in &clips {
bottom = bottom.min(c[1] + c[3]);
}
if raw_bottom > 200.5 {
saw_raw_overflow = true;
}
assert!(
bottom <= 200.5,
"a painted rect's visible region extends below the table \
(raw rect {rect:?}, effective bottom {bottom})"
);
}
_ => {}
}
}
assert!(
saw_raw_overflow,
"expected a partial bottom-row decoration spanning past the bounds"
);
}
#[test]
fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;
use teksilo_core::ObserverHandle;
use teksilo_data::{DataChange, ListDataSource, RowState};
struct Windowed {
total: usize,
requested: Rc<RefCell<Vec<Range<usize>>>>,
}
impl ListDataSource for Windowed {
type Item = Row;
type Key = usize;
fn len(&self) -> usize {
self.total
}
fn with_item<R>(&self, _i: usize, _f: impl FnOnce(&Row) -> R) -> Option<R> {
None }
fn key_at(&self, i: usize) -> Option<usize> {
(i < self.total).then_some(i)
}
fn row_state(&self, _i: usize) -> RowState {
RowState::Loading
}
fn request_window(&self, range: Range<usize>) {
self.requested.borrow_mut().push(range);
}
fn observe_changes(&self, _f: impl Fn(&DataChange) + 'static) -> ObserverHandle {
ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
}
}
let requested = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::from_source(Windowed {
total: 1000,
requested: requested.clone(),
})
.add_column(id_col())
.add_column(name_col())
.show_header(false)
.row_height(30.0),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(300.0),
});
let body_pane = tree.children(table)[0];
let placeholder_rows = tree.children(body_pane).len();
assert!(
placeholder_rows >= 10,
"loading rows must render as placeholders, got {placeholder_rows}"
);
assert!(
!requested.borrow().is_empty(),
"request_window must be called for the visible range"
);
}
#[test]
fn selection_band_desaturates_when_window_inactive() {
use teksilo_canvas::DecorationKind;
use teksilo_tokens::SurfaceRole;
let model = rows(5);
let sel = SelectionModel::new(SelectionMode::Single);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(model)
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.selection_mode(TableSelectionMode::SingleRow)
.selection(sel.clone()),
);
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
sel.select(0); tree.focus(table);
let colors = teksilo_core::presets::intui::light().colors;
let active = SurfaceRole::Selected.resolve(&colors).to_array();
let inactive = SurfaceRole::SelectedInactive.resolve(&colors).to_array();
assert_ne!(active, inactive);
let band_colors = |tree: &mut WidgetTree| -> Vec<[f32; 4]> {
tree.render()
.decorations
.iter()
.filter(|d| matches!(d.kind, DecorationKind::WidgetBackground))
.map(|d| d.color)
.collect()
};
let bands = band_colors(&mut tree);
assert!(
bands.contains(&active),
"active window: vivid Selected band expected, got {bands:?}"
);
assert!(
!bands.contains(&inactive),
"active window: no muted band expected"
);
tree.set_window_active(false);
let bands = band_colors(&mut tree);
assert!(
bands.contains(&inactive),
"inactive window: muted SelectedInactive band expected, got {bands:?}"
);
assert!(
!bands.contains(&active),
"inactive window: no vivid band expected"
);
tree.set_window_active(true);
let bands = band_colors(&mut tree);
assert!(
bands.contains(&active),
"reactivated window: vivid band returns, got {bands:?}"
);
}
mod resize_grip {
use super::*;
use crate::styles::recipe_table_style as cp;
use crate::table_view::column::{ColumnResizePolicy, PinnedSide};
use teksilo_canvas::Point;
use teksilo_core::environment::LayoutDirection;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
fn down(tree: &mut WidgetTree, x: f32, y: f32) {
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(x, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
fn moved(tree: &mut WidgetTree, x: f32, y: f32) {
tree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(x, y),
});
}
fn up(tree: &mut WidgetTree, x: f32, y: f32) {
tree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(x, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
fn overrides(tree: &WidgetTree, table: WidgetId) -> std::collections::HashMap<String, f32> {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.column_widths_signal()
.get()
}
fn relayout(tree: &mut WidgetTree) {
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(200.0),
});
}
fn two_column_table() -> (WidgetTree, WidgetId) {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
relayout(&mut tree);
(tree, table)
}
#[test]
fn grip_reaches_into_the_next_column_so_a_one_pixel_overshoot_still_resizes() {
let (mut tree, table) = two_column_table();
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 61.0, y);
moved(&mut tree, 91.0, y);
up(&mut tree, 91.0, y);
let w = overrides(&tree, table);
assert!(
(w.get("id").copied().unwrap_or(0.0) - 90.0).abs() < 0.5,
"grabbing the divider from the trailing column must widen `id` \
from 60 to 90; got {w:?}"
);
assert!(
!w.contains_key("name"),
"the pressed cell's own column must not move; got {w:?}"
);
}
#[test]
fn grip_still_resizes_the_owning_column_from_its_own_trailing_edge() {
let (mut tree, table) = two_column_table();
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 59.0, y);
moved(&mut tree, 89.0, y);
up(&mut tree, 89.0, y);
let w = overrides(&tree, table);
assert!(
(w.get("id").copied().unwrap_or(0.0) - 90.0).abs() < 0.5,
"got {w:?}"
);
}
#[test]
fn cursor_turns_to_col_resize_on_the_far_side_of_the_divider_too() {
use teksilo_core::widget::CursorIcon;
let (mut tree, _table) = two_column_table();
let y = cp::HEADER_HEIGHT * 0.5;
moved(&mut tree, 61.0, y);
assert_eq!(
tree.current_cursor(),
CursorIcon::ColResize,
"the grip's far half must advertise itself like the near half"
);
}
#[test]
fn drag_clamps_to_max_width_so_the_stored_override_matches_what_renders() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col().max_width(200.0))
.add_column(
Column::<Row>::new("extra", lit!("Extra"), |row, _: &CellContext| {
Box::new(TextWidget::new(lit!(row.name.clone())))
})
.width(ColumnWidth::Flex(1.0)),
)
.row_height(20.0)
.show_internal_scrollbars(false),
);
relayout(&mut tree);
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 229.0, y);
moved(&mut tree, 429.0, y);
relayout(&mut tree);
let w = overrides(&tree, table);
assert!(
(w.get("name").copied().unwrap_or(0.0) - 200.0).abs() < 0.5,
"a drag 200 px past the 200 px cap must store the cap, not the raw \
overshoot; got {w:?}"
);
up(&mut tree, 429.0, y);
let w = overrides(&tree, table);
assert!(
(w.get("name").copied().unwrap_or(0.0) - 200.0).abs() < 0.5,
"and the committed value must agree; got {w:?}"
);
}
#[test]
fn a_column_narrower_than_the_grip_keeps_a_central_band_for_sorting() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(
Column::<Row>::new("flag", lit!("Flag"), |_row, _: &CellContext| {
Box::new(TextWidget::new(lit!("!")))
})
.width(ColumnWidth::Fixed(4.0))
.min_width(2.0)
.sortable(true),
)
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
relayout(&mut tree);
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 2.0, y);
up(&mut tree, 2.0, y);
let sort = {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.sort_signal()
.get()
};
assert_eq!(
sort,
Some(("flag".to_string(), SortDirection::Ascending)),
"a click in the middle of a very narrow column must still sort it"
);
assert!(
!overrides(&tree, table).contains_key("flag"),
"and must not have been swallowed as a resize"
);
}
#[test]
fn the_grip_does_not_reach_across_a_pane_seam() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col().pinned(PinnedSide::Leading))
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
relayout(&mut tree);
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 61.0, y);
moved(&mut tree, 91.0, y);
up(&mut tree, 91.0, y);
assert!(
!overrides(&tree, table).contains_key("id"),
"a press across the pane seam must not resize the pinned column"
);
down(&mut tree, 59.0, y);
moved(&mut tree, 89.0, y);
up(&mut tree, 89.0, y);
let w = overrides(&tree, table);
assert!(
(w.get("id").copied().unwrap_or(0.0) - 90.0).abs() < 0.5,
"the pinned column resizes from its own edge; got {w:?}"
);
}
#[test]
fn rtl_grip_straddles_the_divider_and_drag_direction_inverts() {
let (mut tree, table) = two_column_table();
tree.set_layout_direction(LayoutDirection::RightToLeft);
relayout(&mut tree);
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 339.0, y);
moved(&mut tree, 309.0, y);
up(&mut tree, 309.0, y);
let w = overrides(&tree, table);
assert!(
(w.get("id").copied().unwrap_or(0.0) - 90.0).abs() < 0.5,
"RTL: dragging the divider left from the trailing column must \
widen `id` from 60 to 90; got {w:?}"
);
}
#[test]
fn on_release_policy_shows_a_guide_line_and_commits_only_on_pointer_up() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col())
.add_column(name_col())
.row_height(20.0)
.column_resize_policy(ColumnResizePolicy::OnRelease)
.show_internal_scrollbars(false),
);
relayout(&mut tree);
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 398.0, y);
moved(&mut tree, 428.0, y);
let preview = {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.resize_preview_x
.get()
};
assert!(
preview.is_some_and(|x| (x - 430.0).abs() < 0.5),
"the guide must track the prospective divider (400 + 30); got {preview:?}"
);
assert!(
overrides(&tree, table).is_empty(),
"OnRelease must not commit mid-drag"
);
up(&mut tree, 428.0, y);
let w = overrides(&tree, table);
assert!(
(w.get("name").copied().unwrap_or(0.0) - 370.0).abs() < 0.5,
"commit on release; got {w:?}"
);
let preview = {
let any = tree.widget_as_any(table).unwrap();
any.downcast_ref::<TableView<Row>>()
.unwrap()
.resize_preview_x
.get()
};
assert!(preview.is_none(), "the guide must clear on release");
}
#[test]
fn losing_the_window_mid_drag_abandons_the_resize_instead_of_ghost_dragging() {
let (mut tree, table) = two_column_table();
let y = cp::HEADER_HEIGHT * 0.5;
down(&mut tree, 398.0, y);
moved(&mut tree, 428.0, y);
relayout(&mut tree);
let during = overrides(&tree, table)
.get("name")
.copied()
.unwrap_or_default();
assert!((during - 370.0).abs() < 0.5, "sanity: got {during}");
tree.set_window_active(false);
moved(&mut tree, 528.0, y);
relayout(&mut tree);
let after = overrides(&tree, table)
.get("name")
.copied()
.unwrap_or_default();
assert!(
(after - during).abs() < 0.001,
"a move after deactivation must not keep resizing; {during} → {after}"
);
}
#[test]
fn a_resizable_column_header_advertises_increment_and_decrement_to_at() {
use teksilo_core::accesskit::Action;
let (tree, table) = two_column_table();
let mut walker = vec![table];
let mut header = None;
while let Some(n) = walker.pop() {
if tree.accessibility_node(n).role() == Role::ColumnHeader {
header = Some(n);
break;
}
for c in tree.children(n) {
walker.push(c);
}
}
let header = header.expect("a column header must exist");
let node = tree.accessibility_node(header);
let actions = node.actions().to_vec();
assert!(
actions.contains(&Action::Increment) && actions.contains(&Action::Decrement),
"a resizable column header must advertise Increment/Decrement; got {actions:?}"
);
}
#[test]
fn at_increment_widens_the_column_and_respects_its_bounds() {
use teksilo_core::accesskit::Action;
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let table = tree.add(
TableView::new(rows(5))
.add_column(id_col().max_width(70.0))
.add_column(name_col())
.row_height(20.0)
.show_internal_scrollbars(false),
);
relayout(&mut tree);
let header = tree.find_by_label("ID").expect("the ID header cell");
assert!(
tree.dispatch_access_action(
teksilo_core::accessibility::widget_id_to_node_id(header),
Action::Increment,
None,
&mut teksilo_core::window::ops::NoopWindowOps,
),
"the header must handle the Increment it advertised"
);
relayout(&mut tree);
let w = overrides(&tree, table);
assert!(
(w.get("id").copied().unwrap_or(0.0) - 68.0).abs() < 0.5,
"one Increment must step the column by COLUMN_RESIZE_STEP; got {w:?}"
);
assert!(
tree.dispatch_access_action(
teksilo_core::accessibility::widget_id_to_node_id(header),
Action::Increment,
None,
&mut teksilo_core::window::ops::NoopWindowOps,
),
"the header must handle the Increment it advertised"
);
relayout(&mut tree);
let w = overrides(&tree, table);
assert!(
(w.get("id").copied().unwrap_or(0.0) - 70.0).abs() < 0.5,
"the AT path must clamp exactly like the drag path; got {w:?}"
);
}
#[test]
fn the_documented_settings_round_trip_terminates_instead_of_recursing() {
use crate::table_view::imperative;
use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc;
let table_widths: Signal<HashMap<String, f32>> = Signal::new(HashMap::new());
let store: Signal<HashMap<String, f32>> = Signal::new(HashMap::new());
let notifications = Rc::new(Cell::new(0_u32));
let _to_store = table_widths.observe({
let store = store.clone();
let seen = notifications.clone();
move |w| {
seen.set(seen.get() + 1);
store.set(w.clone());
}
});
let _to_table = store.observe({
let table_widths = table_widths.clone();
move |w| imperative::set_column_widths(&table_widths, w.clone())
});
let mut w = HashMap::new();
w.insert("name".to_string(), 370.0);
table_widths.set(w.clone());
assert_eq!(
notifications.get(),
1,
"the round trip must settle after exactly one pass"
);
assert_eq!(table_widths.get(), w);
assert_eq!(store.get(), w);
imperative::set_column_widths(&table_widths, w);
assert_eq!(notifications.get(), 1, "an unchanged map must not notify");
}
#[test]
fn header_strip_paints_a_separator_at_every_column_boundary() {
let (mut tree, _table) = two_column_table();
let frame = tree.render();
let found = frame.decorations.iter().any(|d| {
let [x, y, w, h] = d.rect;
(x - 59.0).abs() < 0.6
&& w <= 1.5
&& y.abs() < 0.6
&& (h - cp::HEADER_HEIGHT).abs() < 0.6
});
assert!(
found,
"expected a full-height header separator at the id/name divider \
(x≈59, h={}); decorations={:?}",
cp::HEADER_HEIGHT,
frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
);
}
}