use super::*;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_data::{DragEligibility, RowState};
use teksilo_i18n::lit;
fn row_ids(tree: &WidgetTree, tv: WidgetId) -> Vec<WidgetId> {
let kids = tree.children(tv);
match kids.first() {
Some(&pane) => tree.children(pane),
None => Vec::new(),
}
}
fn scrollbar_of(tree: &WidgetTree, tv: WidgetId) -> WidgetId {
*tree.children(tv).last().expect("TreeView has children")
}
#[test]
fn smooth_scroll_survives_a_body_pane_rebuild() {
let model: TreeModel<&'static str> = TreeModel::new();
for i in 0..500 {
model.insert_root(i, "row");
}
let (mut wtree, tv_id) = make_tree_view(model.clone());
let scroll = {
wtree.layout(SizeProposal::exact(400.0, 200.0));
let any = wtree.widget_as_any(tv_id).unwrap();
any.downcast_ref::<TreeView<&'static str>>()
.unwrap()
.scroll_y
.clone()
};
crate::common::thumb_drag_test::assert_fling_survives_pane_rebuild(
&mut wtree,
400.0,
200.0,
&scroll,
"TreeView",
|| {
model.insert_root(0, "fresh");
},
);
}
#[test]
fn rows_materialize_during_scrollbar_thumb_drag() {
let model: TreeModel<&'static str> = TreeModel::new();
for i in 0..500 {
model.insert_root(i, "row");
}
let (mut wtree, tv_id) = make_tree_view(model);
crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
&mut wtree,
tv_id,
400.0,
200.0,
0.0,
"TreeView",
|t| {
row_ids(t, tv_id)
.into_iter()
.filter(|id| {
let b = t.bounds(*id);
b.height > 1.0 && b.y > -b.height && b.y < 200.0
})
.count()
},
);
}
#[derive(Debug)]
struct FixedLeaf(f32, f32);
impl Widget for FixedLeaf {
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(self.0, self.1).into()
}
}
fn sample_tree() -> TreeModel<&'static str> {
let tree = TreeModel::new();
let a = tree.insert_root(0, "A");
tree.insert_child(a, 0, "A1");
tree.insert_child(a, 1, "A2");
let b = tree.insert_root(1, "B");
tree.insert_child(b, 0, "B1");
tree.insert_root(2, "C");
tree
}
fn make_tree_view(tree: TreeModel<&'static str>) -> (WidgetTree, WidgetId) {
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |_item, entry, _selected| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0),
);
(wtree, tv_id)
}
#[test]
fn initial_shows_only_roots() {
let tree = sample_tree();
let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
}
#[test]
fn insert_child_into_root_updates_view() {
let tree = sample_tree();
let a = tree.root(0);
let (mut wtree, tv_id) = make_tree_view(tree.clone());
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
tree.insert_child(a, 2, "A3");
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
}
#[test]
fn model_mutation_triggers_rebuild() {
let tree = sample_tree();
let (mut wtree, tv_id) = make_tree_view(tree.clone());
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
tree.insert_root(3, "D");
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 4);
}
#[test]
fn remove_triggers_rebuild() {
let tree = sample_tree();
let c = tree.root(2);
let (mut wtree, tv_id) = make_tree_view(tree.clone());
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
tree.remove(c);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 2);
}
#[test]
fn items_positioned_vertically() {
let tree = sample_tree();
let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let children = row_ids(&wtree, tv_id);
let y0 = wtree.bounds(children[0]).y;
let y1 = wtree.bounds(children[1]).y;
let y2 = wtree.bounds(children[2]).y;
assert!((y0 - 0.0).abs() < 0.01);
assert!((y1 - 28.0).abs() < 0.01);
assert!((y2 - 56.0).abs() < 0.01);
}
#[test]
fn virtualization_with_large_tree() {
let tree = TreeModel::new();
for i in 0..500 {
tree.insert_root(i, format!("Node {}", i).leak() as &'static str);
}
let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let item_count = row_ids(&wtree, tv_id).len();
assert!(
item_count < 30,
"Expected fewer than 30 items, got {}",
item_count
);
assert!(
item_count >= 10,
"Expected at least 10 items, got {}",
item_count
);
}
#[test]
fn scrollbar_collapses_when_not_needed() {
let tree = sample_tree(); let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let sb_bounds = wtree.bounds(scrollbar_of(&wtree, tv_id));
assert!(
sb_bounds.width < 0.01 && sb_bounds.height < 0.01,
"Scrollbar should be collapsed"
);
}
#[test]
fn accessibility_role_is_tree() {
let tree = sample_tree();
let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let info = wtree.accessibility_node(tv_id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Tree);
}
#[test]
fn empty_tree() {
let tree: TreeModel<&str> = TreeModel::new();
let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(wtree.children(tv_id).len(), 2, "body pane + scrollbar");
assert!(
row_ids(&wtree, tv_id).is_empty(),
"no rows for an empty tree"
);
}
#[test]
fn tree_item_has_a11y_role_and_expanded() {
let tree = sample_tree(); let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let children = row_ids(&wtree, tv_id);
let info_a = wtree.accessibility_node(children[0]);
assert_eq!(info_a.role(), teksilo_core::accesskit::Role::TreeItem);
assert!(
!info_a.is_expanded(),
"Root A should report not expanded (collapsed)"
);
let info_c = wtree.accessibility_node(children[2]);
assert_eq!(info_c.role(), teksilo_core::accesskit::Role::TreeItem);
assert!(!info_c.is_expanded(), "Leaf C should not be expanded");
}
#[test]
fn keyboard_arrow_down_navigates() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::{SelectionMode, SelectionModel};
let tree = sample_tree(); let selection = SelectionModel::new(SelectionMode::Single);
let sel_clone = selection.clone();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |_item, entry, _selected| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.selection(sel_clone),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv_id);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
selection.selected_indices(),
vec![0],
"ArrowDown from initial state should select index 0 (first root)"
);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::NONE,
text: None,
});
assert_eq!(
selection.selected_indices(),
vec![1],
"Second ArrowDown should select index 1 (second root)"
);
}
#[test]
fn arrow_nav_resumes_from_the_clicked_row() {
use teksilo_core::event::{Key, Modifiers};
let (mut wtree, tv, selection) = flat_tree_view(10, teksilo_data::SelectionMode::Single);
wtree.layout(SizeProposal::exact(400.0, 300.0)); wtree.focus(tv);
press_at(&mut wtree, 100.0, 70.0);
assert_eq!(
selection.selected_indices(),
vec![3],
"precondition: body click selects row 3"
);
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(
selection.selected_indices(),
vec![4],
"ArrowDown after a click resumes from the clicked row (3 → 4)"
);
wtree.press_key(Key::ArrowUp, Modifiers::NONE);
assert_eq!(
selection.selected_indices(),
vec![3],
"ArrowUp resumes (4 → 3)"
);
}
fn flat_tree_view(
n: usize,
mode: teksilo_data::SelectionMode,
) -> (WidgetTree, WidgetId, teksilo_data::SelectionModel) {
use teksilo_data::SelectionModel;
let tree = TreeModel::new();
for i in 0..n {
tree.insert_root(i, format!("Node {i}"));
}
let selection = SelectionModel::new(mode);
let sel = selection.clone();
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new(tree, |_item, _entry, _sel| Box::new(FixedLeaf(120.0, 20.0)))
.item_height(20.0)
.selection(sel)
.type_ahead_label(|s: &String| s.clone())
.on_activate(|_, _| {}),
);
(wtree, tv, selection)
}
#[test]
fn a_caller_owned_scroll_signal_outlives_the_view() {
let scroll = Signal::new_animated(0.0_f32);
let build = |scroll: Signal<f32>| {
let tree = TreeModel::new();
for i in 0..100usize {
tree.insert_root(i, format!("Node {i}"));
}
let mut wtree = WidgetTree::new();
wtree.add(
TreeView::new(tree, |_i, _e, _s| Box::new(FixedLeaf(120.0, 20.0)))
.item_height(20.0)
.smooth_scrolling(false)
.scroll_signal(scroll),
);
wtree
};
let mut first = build(scroll.clone());
first.layout(SizeProposal::exact(200.0, 200.0));
let _ = first.render();
scroll.set(340.0);
first.layout(SizeProposal::exact(200.0, 200.0));
let _ = first.render();
assert!((scroll.get() - 340.0).abs() < 0.5);
drop(first);
let mut rebuilt = build(scroll.clone());
rebuilt.layout(SizeProposal::exact(200.0, 200.0));
let _ = rebuilt.render();
assert!(
(scroll.get() - 340.0).abs() < 0.5,
"a rebuilt tree came back at {} instead of where it was left",
scroll.get()
);
}
#[test]
fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
use teksilo_core::event::{Key, Modifiers};
for (key, want, what) in [
(
Key::ArrowDown,
0usize,
"first ArrowDown selects the first row",
),
(Key::ArrowUp, 9usize, "first ArrowUp selects the last row"),
] {
let (mut wtree, tv, selection) = flat_tree_view(10, teksilo_data::SelectionMode::Single);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
assert!(
selection.selected_indices().is_empty(),
"precondition: nothing selected, no cursor"
);
wtree.press_key(key, Modifiers::NONE);
assert_eq!(selection.selected_indices(), vec![want], "{what}");
}
}
#[test]
fn keyboard_cursor_starts_from_a_preset_selection() {
use teksilo_core::event::{Key, Modifiers};
let (mut wtree, tv, selection) = flat_tree_view(10, teksilo_data::SelectionMode::Single);
wtree.layout(SizeProposal::exact(400.0, 300.0));
selection.select(4);
wtree.focus(tv);
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(
selection.selected_indices(),
vec![5],
"Down from a preselected row 4 continues to 5"
);
}
#[test]
fn page_down_up_moves_selection_by_viewport() {
use teksilo_core::event::{Key, Modifiers};
let (mut wtree, tv, selection) = flat_tree_view(100, teksilo_data::SelectionMode::Single);
let p = SizeProposal::exact(400.0, 200.0); wtree.layout(p);
wtree.focus(tv);
selection.select(0);
wtree.press_key(Key::PageDown, Modifiers::NONE);
wtree.layout(p);
let after = selection.selected_indices()[0];
assert!(after >= 8, "PageDown advances ~one viewport, got {after}");
wtree.press_key(Key::PageUp, Modifiers::NONE);
wtree.layout(p);
assert!(
selection.selected_indices()[0] < after,
"PageUp moves selection back up"
);
}
#[test]
fn ctrl_a_selects_all_visible_in_multi_mode() {
use teksilo_core::event::{Key, Modifiers};
let (mut wtree, tv, selection) = flat_tree_view(7, teksilo_data::SelectionMode::Multi);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
wtree.press_key(Key::A, Modifiers::CTRL);
assert_eq!(selection.selected_indices().len(), 7, "Ctrl+A selects all");
}
#[test]
fn ctrl_arrow_moves_cursor_without_selecting_in_multi_mode() {
use teksilo_core::event::{Key, Modifiers};
let (mut wtree, tv, selection) = flat_tree_view(6, teksilo_data::SelectionMode::Multi);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
let focused_index = |wtree: &WidgetTree| -> Option<usize> {
wtree
.widget_as_any(tv)
.and_then(|any| any.downcast_ref::<TreeView<String>>())
.and_then(|v| v.focused_index.get())
};
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(selection.selected_indices(), vec![0]);
wtree.press_key(Key::ArrowDown, Modifiers::CTRL);
assert_eq!(
selection.selected_indices(),
vec![0],
"Ctrl+ArrowDown must leave the selection unchanged"
);
assert_eq!(
focused_index(&wtree),
Some(1),
"Ctrl+ArrowDown moves the cursor to row 1"
);
wtree.press_key(Key::ArrowDown, Modifiers::CTRL);
assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
assert_eq!(focused_index(&wtree), Some(2));
wtree.press_key(Key::Space, Modifiers::CTRL);
assert_eq!(selection.selected_indices(), vec![0, 2]);
wtree.press_key(Key::Space, Modifiers::CTRL);
assert_eq!(selection.selected_indices(), vec![0]);
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(selection.selected_indices(), vec![3]);
}
#[test]
fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
use teksilo_core::event::{Key, Modifiers};
let (mut wtree, tv, selection) = flat_tree_view(6, teksilo_data::SelectionMode::Single);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(selection.selected_indices(), vec![0]);
wtree.press_key(Key::ArrowDown, Modifiers::CTRL);
assert_eq!(
selection.selected_indices(),
vec![0],
"Ctrl+ArrowDown must not select in Single mode either"
);
let focused = wtree
.widget_as_any(tv)
.and_then(|any| any.downcast_ref::<TreeView<String>>())
.and_then(|v| v.focused_index.get());
assert_eq!(focused, Some(1));
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(selection.selected_indices(), vec![2]);
}
#[test]
fn space_toggles_enter_activates() {
use std::cell::Cell;
use teksilo_core::event::{Key, Modifiers};
let tree = TreeModel::new();
for i in 0..5 {
tree.insert_root(i, format!("Node {i}"));
}
let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
let sel = selection.clone();
let activated = Rc::new(Cell::new(None));
let act = activated.clone();
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new(tree, |_i, _e, _s| Box::new(FixedLeaf(120.0, 20.0)))
.item_height(20.0)
.selection(sel)
.on_activate(move |i, _| act.set(Some(i))),
);
wtree.layout(SizeProposal::exact(400.0, 200.0));
wtree.focus(tv);
wtree.press_key(Key::ArrowDown, Modifiers::NONE); wtree.press_key(Key::ArrowDown, Modifiers::NONE); assert_eq!(selection.selected_indices(), vec![1]);
assert_eq!(activated.get(), None);
wtree.press_key(Key::Space, Modifiers::NONE); assert!(selection.selected_indices().is_empty(), "Space toggles off");
assert_eq!(activated.get(), None, "Space must NOT activate");
wtree.press_key(Key::Enter, Modifiers::NONE);
assert_eq!(activated.get(), Some(1), "Enter activates");
}
#[test]
fn row_composite_tooltip_opens_for_the_hovered_row() {
use crate::primitives::TextWidget;
use std::time::Duration;
use teksilo_i18n::lit;
let tree = TreeModel::new();
for i in 0..5 {
tree.insert_root(i, format!("Node {i}"));
}
let mut wtree = WidgetTree::new().with_text_backend(Rc::new(std::cell::RefCell::new(
teksilo_canvas::MockTextBackend::new(),
)));
let tv = wtree.add(
TreeView::new(tree, |_i, _e, _s| Box::new(FixedLeaf(120.0, 20.0)))
.item_height(20.0)
.row_composite_tooltip(|_i, item: &String| {
Some(Box::new(TextWidget::new(lit!(format!("about {item}")))) as Box<dyn Widget>)
}),
);
wtree.layout(SizeProposal::exact(400.0, 200.0));
assert!(wtree.active_overlays().is_empty());
let bounds = wtree.bounds(tv);
wtree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 50.0));
assert!(
wtree.active_overlays().is_empty(),
"a composite row tip waits out the heavy delay, like any other"
);
wtree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
assert_eq!(
wtree.active_overlays().len(),
1,
"the hovered row's tooltip opens once the pointer has paused"
);
assert!(
wtree.find_by_label("about Node 2").is_some(),
"and it carries the hovered row's own content, not another row's"
);
}
#[test]
fn row_composite_tooltip_opens_on_a_real_standard_tree_item() {
use crate::primitives::TextWidget;
use crate::standard_item::StandardTreeItem;
use std::time::Duration;
use teksilo_i18n::lit;
let tree = TreeModel::new();
for i in 0..5 {
tree.insert_root(i, format!("Node {i}"));
}
let mut wtree = WidgetTree::new().with_text_backend(Rc::new(std::cell::RefCell::new(
teksilo_canvas::MockTextBackend::new(),
)));
let tv = wtree.add(
TreeView::new(tree, |item: &String, entry, selected| {
Box::new(
StandardTreeItem::new(lit!(item.clone()))
.depth(entry.depth)
.selected(selected),
)
})
.item_height(20.0)
.row_composite_tooltip(|_i, item: &String| {
Some(Box::new(TextWidget::new(lit!(format!("about {item}")))) as Box<dyn Widget>)
}),
);
wtree.layout(SizeProposal::exact(400.0, 200.0));
let bounds = wtree.bounds(tv);
let at = teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 50.0);
wtree.pointer_move(at);
wtree.pointer_move(teksilo_canvas::Point::new(at.x + 1.0, at.y));
wtree.advance_time(Duration::from_millis(750));
assert_eq!(
wtree.active_overlays().len(),
1,
"a real row's hover reaction must not keep restarting the tooltip delay"
);
}
#[test]
fn a_row_whose_resolver_returns_none_has_no_tooltip() {
use std::time::Duration;
let tree = TreeModel::new();
for i in 0..5 {
tree.insert_root(i, format!("Node {i}"));
}
let mut wtree = WidgetTree::new().with_text_backend(Rc::new(std::cell::RefCell::new(
teksilo_canvas::MockTextBackend::new(),
)));
let tv = wtree.add(
TreeView::new(tree, |_i, _e, _s| Box::new(FixedLeaf(120.0, 20.0)))
.item_height(20.0)
.row_composite_tooltip(|_i, _item: &String| None),
);
wtree.layout(SizeProposal::exact(400.0, 200.0));
let bounds = wtree.bounds(tv);
wtree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 50.0));
wtree.advance_time(Duration::from_millis(900));
assert!(
wtree.active_overlays().is_empty(),
"no resolver result means no tooltip on that row"
);
}
#[test]
fn type_ahead_jumps_to_matching_visible_row() {
use teksilo_core::event::{Key, Modifiers};
let tree = TreeModel::new();
tree.insert_root(0, "Apple".to_string());
tree.insert_root(1, "Banana".to_string());
tree.insert_root(2, "Cherry".to_string());
tree.insert_root(3, "Date".to_string());
let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
let sel = selection.clone();
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new(tree, |_i, _e, _s| Box::new(FixedLeaf(120.0, 20.0)))
.item_height(20.0)
.selection(sel)
.type_ahead_label(|s: &String| s.clone()),
);
wtree.layout(SizeProposal::exact(400.0, 200.0));
wtree.focus(tv);
selection.select(0);
wtree.press_key(Key::C, Modifiers::NONE);
assert_eq!(selection.selected_indices(), vec![2], "'c' → Cherry");
wtree.press_key(Key::B, Modifiers::NONE);
assert_eq!(
selection.selected_indices(),
vec![2],
"'cb' no match, stays"
);
}
fn make_standard_tree_view() -> (WidgetTree, WidgetId, teksilo_data::SelectionModel) {
use teksilo_data::{SelectionMode, SelectionModel};
let tree = sample_tree();
let selection = SelectionModel::new(SelectionMode::Single);
let sel_clone = selection.clone();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new_with_context(tree, |item: &&'static str, entry, selected, ctx| {
Box::new(
crate::StandardTreeItem::new(lit!((*item).to_string()))
.from_entry(entry)
.selected(selected)
.on_toggle_rc(ctx.toggle_callback()),
) as Box<dyn Widget>
})
.item_height(28.0)
.selection(sel_clone)
.row_click_expands(false),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
(wtree, tv_id, selection)
}
fn press_at(w: &mut WidgetTree, x: f32, y: f32) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
w.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(x, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
w.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(x, y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
#[test]
fn chevron_press_toggles_without_selecting_the_row() {
let (mut wtree, tv_id, selection) = make_standard_tree_view();
assert_eq!(
row_ids(&wtree, tv_id).len(),
3,
"precondition: 3 collapsed roots"
);
press_at(&mut wtree, 8.0, 14.0);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
5,
"chevron press should expand A, revealing A1 and A2"
);
assert!(
selection.selected_indices().is_empty(),
"chevron press must not select the row (got {:?})",
selection.selected_indices()
);
}
#[test]
fn body_press_selects_the_row() {
let (mut wtree, tv_id, selection) = make_standard_tree_view();
press_at(&mut wtree, 100.0, 14.0);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
selection.selected_indices(),
vec![0],
"body press should select row 0"
);
assert_eq!(
row_ids(&wtree, tv_id).len(),
3,
"body press must not expand when row_click_expands=false"
);
}
fn make_reorderable_standard_tree_view() -> (WidgetTree, WidgetId) {
let tree = sample_tree();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new_with_context(tree, |item: &&'static str, entry, selected, ctx| {
Box::new(
crate::StandardTreeItem::new(lit!((*item).to_string()))
.from_entry(entry)
.selected(selected)
.on_toggle_rc(ctx.toggle_callback()),
) as Box<dyn Widget>
})
.item_height(28.0)
.row_click_expands(false)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
(wtree, tv_id)
}
#[test]
fn chevron_tap_with_jitter_toggles_in_a_reorderable_tree() {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let (mut wtree, tv_id) = make_reorderable_standard_tree_view();
assert_eq!(
row_ids(&wtree, tv_id).len(),
3,
"precondition: 3 collapsed roots"
);
wtree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(8.0, 10.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(8.0, 15.0), });
wtree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(8.0, 13.0), button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
5,
"the chevron tap must expand A (revealing A1, A2), not start a row drag"
);
}
fn drag_item(tree: &mut WidgetTree, from: Point, to: Point) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
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: to });
tree.dispatch_event(WidgetEvent::PointerUp {
position: to,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
fn make_reorderable_tree_view() -> (
WidgetTree,
WidgetId,
TreeModel<&'static str>,
NodeId,
NodeId,
NodeId,
) {
let model = sample_tree();
let a = model.root(0);
let b = model.root(1);
let c = model.root(2);
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(model.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.reorderable(true),
);
(wtree, tv_id, model, a, b, c)
}
#[test]
fn drag_reorders_root_before() {
let (mut wtree, _tv_id, model, a, _b, c) = make_reorderable_tree_view();
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 70.0), Point::new(50.0, 2.0));
assert_eq!(model.root(0), c, "C should be first root");
assert_eq!(model.root(1), a, "A should be second root");
}
#[test]
fn drag_reorders_root_after() {
let (mut wtree, _tv_id, model, _a, b, c) = make_reorderable_tree_view();
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 42.0), Point::new(50.0, 80.0));
assert_eq!(model.root_count(), 3);
assert_eq!(model.root(1), c, "C should shift up to root 1");
assert_eq!(model.root(2), b, "B should land at root 2");
}
#[test]
fn drag_reparents_into_target() {
let (mut wtree, _tv_id, model, a, _b, c) = make_reorderable_tree_view();
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 70.0), Point::new(50.0, 14.0));
let a_children = model.children(a);
assert_eq!(a_children.len(), 3, "A should have three children");
assert_eq!(a_children[2], c, "C should be A's last child");
assert_eq!(model.root_count(), 2);
}
#[test]
fn drag_into_reparents_the_node() {
let model = sample_tree();
let a = model.root(0);
let c = model.root(2);
let mut wtree = WidgetTree::new();
let _tv = wtree.add(
TreeView::new(model.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 70.0), Point::new(50.0, 14.0));
assert_eq!(model.root_count(), 2, "C is no longer a root");
assert_eq!(model.parent(c), Some(a), "C is now a child of A");
}
#[test]
fn drag_into_own_descendant_is_refused_without_panicking() {
let model = sample_tree();
let a = model.root(0);
let a1 = model.children(a)[0];
let tv = TreeView::new(model.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.reorderable(true);
tv.expand(a);
let mut wtree = WidgetTree::new();
let _tv = wtree.add(tv);
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 14.0), Point::new(50.0, 42.0));
assert_eq!(model.root_count(), 3, "A unchanged (cycle refused)");
assert_eq!(model.parent(a1), Some(a), "A1 still under A");
}
#[test]
fn drag_emits_node_moved_change() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_data::TreeChange;
let (mut wtree, _tv_id, model, _a, b, _c) = make_reorderable_tree_view();
wtree.layout(SizeProposal::exact(400.0, 300.0));
let emitted = Rc::new(Cell::new(false));
let e = emitted.clone();
let moved_node = Rc::new(Cell::new(None::<NodeId>));
let mn = moved_node.clone();
let handle = model.observe_changes(move |change| {
if let TreeChange::NodeMoved { node, .. } = change {
e.set(true);
mn.set(Some(*node));
}
});
drag_item(&mut wtree, Point::new(50.0, 42.0), Point::new(50.0, 2.0));
assert!(emitted.get(), "TreeChange::NodeMoved should be emitted");
assert_eq!(moved_node.get(), Some(b));
drop(handle);
}
#[test]
fn click_on_branch_with_nested_delegate_expands() {
use crate::RectWidget;
use crate::primitives::{HStack, Padding, Spacer, TextWidget, ZStack};
let tree = sample_tree();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |name, entry, selected| {
let arrow: &'static str = if entry.has_children {
if entry.is_expanded { "v" } else { ">" }
} else {
" "
};
let bg = if selected {
teksilo_tokens::Color::from_rgba(0.25, 0.47, 0.85, 0.25)
} else {
teksilo_tokens::Color::TRANSPARENT
};
Box::new(
ZStack::new().child(RectWidget::new().background(bg)).child(
Padding::symmetric(4.0, 12.0).child(
HStack::new()
.spacing(8.0)
.child(TextWidget::new(lit!(arrow)))
.child(TextWidget::new(lit!(name.to_string())))
.child(Spacer::new()),
),
),
)
})
.item_height(28.0),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
let children = row_ids(&wtree, tv_id);
wtree.click(children[0]);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
5,
"Click on A (branch) should expand it even with a nested delegate"
);
}
#[test]
fn drag_with_nested_delegate_still_works() {
use crate::RectWidget;
use crate::primitives::{HStack, Padding, Spacer, TextWidget, ZStack};
let tree = sample_tree();
let a = tree.root(0);
let c = tree.root(2);
let mut wtree = WidgetTree::new();
let _tv_id = wtree.add(
TreeView::new(tree.clone(), |name, _entry, _sel| {
Box::new(
ZStack::new()
.child(RectWidget::new().background(teksilo_tokens::Color::TRANSPARENT))
.child(
Padding::symmetric(4.0, 12.0).child(
HStack::new()
.spacing(8.0)
.child(TextWidget::new(lit!(name.to_string())))
.child(Spacer::new()),
),
),
)
})
.item_height(28.0)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 70.0), Point::new(50.0, 2.0));
assert_eq!(tree.root(0), c, "C should be first root after drag");
assert_eq!(tree.root(1), a, "A should shift to second root");
}
#[test]
fn click_on_branch_expands_and_collapses() {
let tree = sample_tree();
let (mut wtree, tv_id) = make_tree_view(tree);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
let children = row_ids(&wtree, tv_id);
wtree.click(children[0]);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
5,
"After clicking A, its two children should become visible"
);
let children = row_ids(&wtree, tv_id);
wtree.click(children[0]);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
3,
"Second click should collapse A back to 3 visible roots"
);
}
#[test]
fn row_click_expands_false_disables_auto_toggle() {
let tree = sample_tree();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |_item, entry, _selected| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.row_click_expands(false),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 3);
let children = row_ids(&wtree, tv_id);
wtree.click(children[0]);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
3,
"row body click on a branch must not auto-expand when row_click_expands=false"
);
}
#[test]
fn spring_loaded_folder_expands_after_dwell() {
use std::thread::sleep;
use std::time::Duration;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let tree = sample_tree(); let a = tree.root(0);
let b = tree.root(1);
let mut wtree = WidgetTree::new();
let _tv_id = wtree.add(
TreeView::new(tree.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(50.0, 70.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(60.0, 70.0),
});
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(60.0, 42.0),
});
assert!(tree.with_item(b, |_| ()).is_some());
assert_eq!(
row_ids(&wtree, _tv_id).len(),
3,
"Precondition: 3 visible roots, nothing expanded"
);
sleep(Duration::from_millis(750));
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, _tv_id).len(),
4,
"B should have spring-opened after the dwell"
);
assert!(!row_ids(&wtree, _tv_id).is_empty());
let _ = a;
wtree.dispatch_event(WidgetEvent::PointerUp {
position: Point::new(60.0, 42.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
}
#[test]
fn spring_loaded_folder_expand_then_drop_moves_the_originally_dragged_node() {
use std::thread::sleep;
use std::time::Duration;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let tree = sample_tree(); let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(50.0, 70.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(60.0, 70.0),
});
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(60.0, 42.0),
});
assert_eq!(
row_ids(&wtree, tv_id).len(),
3,
"Precondition: 3 visible roots, nothing expanded"
);
sleep(Duration::from_millis(750));
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv_id).len(),
4,
"B should have spring-opened after the dwell"
);
let drop_at = Point::new(60.0, 2.0);
wtree.dispatch_event(WidgetEvent::PointerMove { position: drop_at });
wtree.dispatch_event(WidgetEvent::PointerUp {
position: drop_at,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(
tree.with_item(tree.root(0), |&v| v),
Some("C"),
"the ORIGINALLY dragged node (C) must land at the drop target, \
not whichever node the mid-drag spring-load reflow shifted into \
its old flat index"
);
}
#[test]
fn alt_arrow_reorders_flat_root_sibling() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::{SelectionMode, SelectionModel};
let model = sample_tree(); let _a = model.root(0);
let _b = model.root(1);
let _c = model.root(2);
let selection = SelectionModel::new(SelectionMode::Single);
let sel_clone = selection.clone();
let model_clone = model.clone();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(model_clone, move |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.selection(sel_clone)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv_id);
wtree.click(row_ids(&wtree, tv_id)[1]); assert_eq!(selection.selected_indices(), vec![1]);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowUp,
modifiers: Modifiers::ALT,
text: None,
});
let new_roots: Vec<NodeId> = (0..model.root_count()).map(|i| model.root(i)).collect();
assert_eq!(
model.with_item(new_roots[0], |&v| v),
Some("B"),
"B should now be first root"
);
assert_eq!(
model.with_item(new_roots[1], |&v| v),
Some("A"),
"A should now be second root"
);
assert_eq!(
selection.selected_indices(),
vec![0],
"Selection should now be at index 0 (B moved to top)"
);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::ALT,
text: None,
});
let new_roots: Vec<NodeId> = (0..model.root_count()).map(|i| model.root(i)).collect();
assert_eq!(
model.with_item(new_roots[0], |&v| v),
Some("A"),
"A should be back at first root"
);
assert_eq!(
model.with_item(new_roots[1], |&v| v),
Some("B"),
"B should be back at second root"
);
assert_eq!(
selection.selected_indices(),
vec![1],
"Selection should be back at index 1"
);
}
#[test]
fn alt_arrow_reorders_nested_sibling() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::{SelectionMode, SelectionModel};
let tree = TreeModel::new();
let a = tree.insert_root(0, "A");
let _a1 = tree.insert_child(a, 0, "A1");
let _a2 = tree.insert_child(a, 1, "A2");
let selection = SelectionModel::new(SelectionMode::Single);
let sel_clone = selection.clone();
let model = tree.clone();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(model, |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.selection(sel_clone)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv_id);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowRight,
modifiers: Modifiers::NONE,
text: None,
});
wtree.layout(SizeProposal::exact(400.0, 300.0));
let children = row_ids(&wtree, tv_id);
wtree.click(children[2]);
assert_eq!(selection.selected_indices(), vec![2]);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowUp,
modifiers: Modifiers::ALT,
text: None,
});
wtree.layout(SizeProposal::exact(400.0, 300.0));
let children_of_a = tree.children(a);
assert_eq!(children_of_a.len(), 2, "A should still have 2 children");
assert_eq!(
tree.with_item(children_of_a[0], |&v| v),
Some("A2"),
"A2 should now be first child of A"
);
assert_eq!(
tree.with_item(children_of_a[1], |&v| v),
Some("A1"),
"A1 should now be second child of A"
);
assert_eq!(
selection.selected_indices(),
vec![1],
"Selection should follow A2 to flat index 1"
);
}
#[test]
fn alt_arrow_cannot_move_past_boundaries() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::{SelectionMode, SelectionModel};
let model = sample_tree(); let selection = SelectionModel::new(SelectionMode::Single);
let sel_clone = selection.clone();
let model_clone = model.clone();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(model_clone, move |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.selection(sel_clone)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv_id);
wtree.click(row_ids(&wtree, tv_id)[0]);
let a = model.root(0);
let c = model.root(2);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowUp,
modifiers: Modifiers::ALT,
text: None,
});
assert_eq!(
model.with_item(a, |&v| v),
Some("A"),
"A should still be first after Alt+Up on first item"
);
wtree.click(row_ids(&wtree, tv_id)[2]);
wtree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
key: Key::ArrowDown,
modifiers: Modifiers::ALT,
text: None,
});
assert_eq!(
model.with_item(c, |&v| v),
Some("C"),
"C should still be last after Alt+Down on last item"
);
}
fn nested_tree_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>) {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
let model = TreeModel::new();
for i in 0..40 {
model.insert_root(i, i as i32);
}
let mut tree = WidgetTree::new();
let tv = TreeView::new(model, |_item: &i32, _entry, _sel| {
Box::new(FixedLeaf(180.0, 20.0))
})
.item_height(20.0)
.overscroll_behavior(inner);
let tv_id = tree.add(tv);
let viewport = tree.add(FixedSize::new().width(200.0).height(100.0).child_id(tv_id));
let filler = tree.add(FixedLeaf(200.0, 200.0));
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::exact(200.0, 150.0));
(tree, outer_y)
}
#[test]
fn nested_tree_chains_to_outer_at_boundary() {
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let (mut tree, outer_y) = nested_tree_fixture(OverscrollBehavior::Chain);
tree.pointer_move(Point::new(50.0, 40.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(200.0, 150.0));
assert!(
outer_y.get() < 0.01,
"outer must not move while the inner absorbs"
);
tree.pointer_move(Point::new(50.0, 40.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(200.0, 150.0));
assert!(
outer_y.get() > 0.01,
"outer scrolled because the clamped tree chained"
);
}
#[test]
fn nested_tree_contain_blocks_chaining() {
use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
let (mut tree, outer_y) = nested_tree_fixture(OverscrollBehavior::Contain);
tree.pointer_move(Point::new(50.0, 40.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(200.0, 150.0));
tree.pointer_move(Point::new(50.0, 40.0));
tree.dispatch_event(WidgetEvent::Scroll {
delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
modifiers: Modifiers::NONE,
});
tree.layout(SizeProposal::exact(200.0, 150.0));
assert!(
outer_y.get() < 0.01,
"Contain must prevent chaining: outer stays put"
);
}
#[test]
fn keyboard_selection_chases_outer_scroll_area() {
use crate::ScrollArea;
use crate::primitives::{FixedSize, VStack};
use teksilo_core::event::{Key, Modifiers};
let model = TreeModel::new();
for i in 0..20 {
model.insert_root(i, i as i32);
}
let mut tree = WidgetTree::new();
let tv = TreeView::new(model, |_item: &i32, _entry, _sel| {
Box::new(FixedLeaf(180.0, 20.0))
})
.item_height(20.0);
let tv_id = tree.add(tv);
let tv_box = tree.add(FixedSize::new().width(200.0).height(200.0).child_id(tv_id));
let filler = tree.add(FixedLeaf(200.0, 200.0));
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);
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.focus(tv_id);
tree.layout(SizeProposal::exact(200.0, 100.0));
outer_y.set(0.0);
tree.layout(SizeProposal::exact(200.0, 100.0));
assert!(outer_y.get().abs() < 0.01, "reset outer to top");
for _ in 0..20 {
tree.press_key(Key::ArrowDown, Modifiers::NONE);
}
tree.layout(SizeProposal::exact(200.0, 100.0));
assert!(
outer_y.get() > 0.01,
"arrow-navigating below the fold must scroll the enclosing ScrollArea (got {})",
outer_y.get()
);
}
fn row_spans(tree: &WidgetTree, tv_id: WidgetId) -> Vec<(f32, f32)> {
let children = row_ids(tree, tv_id);
let mut spans: Vec<(f32, f32)> = children[..]
.iter()
.map(|c| {
let b = tree.bounds(*c);
(b.y, b.height)
})
.collect();
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
spans
}
#[test]
fn exact_item_height_fn_positions_tree_rows() {
let tree = sample_tree();
let heights = [60.0_f32, 20.0, 40.0];
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |_item, _entry, _sel| Box::new(FixedLeaf(100.0, 28.0)))
.item_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let spans = row_spans(&wtree, tv_id);
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_measure_tree_rows_at_measured_heights() {
let tree = sample_tree();
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |_item, _entry, _sel| Box::new(FixedLeaf(100.0, 30.0)))
.auto_item_height(50.0),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.layout(SizeProposal::exact(400.0, 300.0));
let spans = row_spans(&wtree, tv_id);
assert!(
(spans[1].0 - 30.0).abs() < 0.01,
"row 1 should sit at measured 30, got {}",
spans[1].0
);
}
#[test]
fn expand_preserves_measured_heights_above_toggle() {
let tree = sample_tree();
let b = tree.root(1);
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(tree, |_item, _entry, _sel| Box::new(FixedLeaf(100.0, 30.0)))
.auto_item_height(50.0)
.row_click_expands(false),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree
.widget_as_any(tv_id)
.and_then(|any| any.downcast_ref::<TreeView<&'static str>>())
.expect("TreeView exposes itself via as_any")
.expand(b);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let spans = row_spans(&wtree, tv_id);
assert_eq!(spans.len(), 4); assert!(
(spans[1].0 - 30.0).abs() < 0.01,
"A's measured height must survive the expand below it, got {}",
spans[1].0
);
}
#[test]
fn drop_zone_thirds_with_variable_heights() {
let tree = TreeModel::new();
tree.insert_root(0, "A");
tree.insert_root(1, "B");
tree.insert_root(2, "C");
let heights = [60.0_f32, 20.0, 40.0];
let mut wtree = WidgetTree::new();
let _tv_id = wtree.add(
TreeView::new(tree.clone(), |_item, _entry, _sel| {
Box::new(FixedLeaf(100.0, 28.0))
})
.item_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0))
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
drag_item(&mut wtree, Point::new(50.0, 100.0), Point::new(50.0, 62.0));
let order: Vec<&str> = (0..tree.root_count())
.map(|i| tree.with_item(tree.root(i), |v| *v).unwrap())
.collect();
assert_eq!(order, vec!["A", "C", "B"]);
}
#[test]
fn keyed_selection_tracks_identity_and_prunes_deleted() {
use teksilo_data::{KeyedSelectionModel, SelectionMode};
let model = sample_tree();
let a = model.root(0);
let a1 = model.children(a)[0];
let b = model.root(1);
let b1 = model.children(b)[0];
let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
let mut wtree = WidgetTree::new();
wtree.add(
TreeView::new(model.clone(), |_item, _entry, _sel| {
Box::new(FixedLeaf(100.0, 28.0))
})
.item_height(28.0)
.keyed_selection(keyed.clone()),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
keyed.select(a1);
keyed.toggle(b1);
assert!(keyed.is_selected(&a1) && keyed.is_selected(&b1));
model.remove(a1);
assert!(
!keyed.is_selected(&a1),
"deleted node is pruned from selection"
);
assert!(keyed.is_selected(&b1), "surviving node stays selected");
}
struct MockNode {
id: i64,
parent: Option<i64>,
label: String,
}
struct MockI64Source {
nodes: RefCell<Vec<MockNode>>,
expanded: RefCell<std::collections::HashSet<i64>>,
version: Signal<u64>,
accept_log: RefCell<Vec<(i64, i64, DropPosition)>>,
}
impl MockI64Source {
fn new() -> Self {
let nodes = vec![
MockNode {
id: 1,
parent: None,
label: "root1".into(),
},
MockNode {
id: 2,
parent: Some(1),
label: "a".into(),
},
MockNode {
id: 3,
parent: Some(1),
label: "b".into(),
},
MockNode {
id: 4,
parent: None,
label: "root2".into(),
},
];
Self {
nodes: RefCell::new(nodes),
expanded: RefCell::new([1, 4].into_iter().collect()),
version: Signal::new(0),
accept_log: RefCell::new(Vec::new()),
}
}
fn parent_of(&self, id: i64) -> Option<i64> {
self.nodes
.borrow()
.iter()
.find(|n| n.id == id)
.and_then(|n| n.parent)
}
fn exists(&self, id: i64) -> bool {
self.nodes.borrow().iter().any(|n| n.id == id)
}
fn is_descendant(&self, node: i64, ancestor: i64) -> bool {
let mut cur = Some(node);
while let Some(c) = cur {
if c == ancestor {
return true;
}
cur = self.parent_of(c);
}
false
}
fn visible_ids(&self) -> Vec<i64> {
let nodes = self.nodes.borrow();
let expanded = self.expanded.borrow();
nodes
.iter()
.filter(|n| {
let mut cur = n.parent;
while let Some(p) = cur {
if !expanded.contains(&p) {
return false;
}
cur = nodes.iter().find(|m| m.id == p).and_then(|m| m.parent);
}
true
})
.map(|n| n.id)
.collect()
}
fn depth_of(&self, id: i64) -> usize {
let mut d = 0;
let mut cur = self.parent_of(id);
while let Some(p) = cur {
d += 1;
cur = self.parent_of(p);
}
d
}
fn remove(&self, id: i64) {
self.nodes
.borrow_mut()
.retain(|n| n.id != id && n.parent != Some(id));
self.bump();
}
fn bump(&self) {
let v = self.version.get() + 1;
self.version.set(v);
}
}
impl TreeDataSource for MockI64Source {
type Item = String;
type Key = i64;
fn visible_count(&self) -> usize {
self.visible_ids().len()
}
fn with_entry<R>(
&self,
flat_index: usize,
f: impl FnOnce(&String, &FlatEntry<i64>) -> R,
) -> Option<R> {
let id = *self.visible_ids().get(flat_index)?;
let entry = FlatEntry {
node_id: id,
depth: self.depth_of(id),
has_children: self.nodes.borrow().iter().any(|n| n.parent == Some(id)),
is_expanded: self.expanded.borrow().contains(&id),
};
let nodes = self.nodes.borrow();
let label = &nodes.iter().find(|n| n.id == id)?.label;
Some(f(label, &entry))
}
fn key_at(&self, flat_index: usize) -> Option<i64> {
self.visible_ids().get(flat_index).copied()
}
fn flat_index_of(&self, key: &i64) -> Option<usize> {
self.visible_ids().iter().position(|k| k == key)
}
fn parent(&self, key: &i64) -> Option<i64> {
self.parent_of(*key)
}
fn child_keys(&self, key: &i64) -> Vec<i64> {
self.nodes
.borrow()
.iter()
.filter(|n| n.parent == Some(*key))
.map(|n| n.id)
.collect()
}
fn version_signal(&self) -> Signal<u64> {
self.version.clone()
}
fn is_expanded(&self, key: &i64) -> bool {
self.expanded.borrow().contains(key)
}
fn set_expanded(&self, key: &i64, expanded: bool) {
if expanded {
self.expanded.borrow_mut().insert(*key);
} else {
self.expanded.borrow_mut().remove(key);
}
self.bump();
}
fn contains_key(&self, key: &i64) -> bool {
self.exists(*key)
}
fn drag(&self, _key: &i64) -> DragEligibility {
DragEligibility::CanDrag
}
fn can_accept(&self, query: &teksilo_data::DropQuery<'_, i64>) -> DropResponse {
match &query.source {
teksilo_data::DragSource::SameView { key: src } => {
if *src == query.target || self.is_descendant(query.target, *src) {
DropResponse::Reject
} else {
DropResponse::Accept
}
}
teksilo_data::DragSource::Foreign { .. } => DropResponse::Reject,
}
}
fn accept_drop(&self, commit: teksilo_data::DropCommit<'_, i64>) -> bool {
let teksilo_data::DragSource::SameView { key: src } = commit.source else {
return false;
};
if src == commit.target || self.is_descendant(commit.target, src) {
return false;
}
self.accept_log
.borrow_mut()
.push((src, commit.target, commit.position));
self.bump();
true
}
}
#[test]
fn from_source_row_scope_is_the_treeview_not_a_higher_ancestor() {
use crate::primitives::ZStack;
use teksilo_core::widget_builder::WidgetBuilder;
use teksilo_data::{KeyedSelectionModel, SelectionMode};
let source = Rc::new(MockI64Source::new());
let keyed = KeyedSelectionModel::<i64>::new(SelectionMode::Single);
let mut tree = WidgetTree::new();
let tv = tree.add(
TreeView::from_source_keyed(
MockI64Wrapper(source.clone()),
keyed.clone(),
|_l: &String, _r: &TreeRow, _s| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0),
);
let editor = tree.add(FixedLeaf(100.0, 24.0).focusable(true));
let _shell = tree.add(
ZStack::new()
.add_child(tv)
.add_child(editor)
.focusable(true),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let rows = row_ids(&tree, tv);
let scope = tree.view_focus_active_for(rows[0]);
tree.focus(tv);
assert!(scope.get(), "row scope active when the TreeView is focused");
tree.focus(editor);
assert!(
!scope.get(),
"focus moved to the sibling editor → the row's TreeView scope must go \
inactive (so selection mutes and the focus ring clears)"
);
}
#[test]
fn view_focus_active_tracks_view_focus_for_rows() {
use teksilo_data::{KeyedSelectionModel, SelectionMode};
let source = Rc::new(MockI64Source::new());
let keyed = KeyedSelectionModel::<i64>::new(SelectionMode::Single);
let mut tree = WidgetTree::new();
let tv = tree.add(
TreeView::from_source_keyed(
MockI64Wrapper(source.clone()),
keyed.clone(),
|_label: &String, _row: &TreeRow, _sel| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0)
.reorderable(true)
.activate_on(crate::data_views::ActivateOn::SingleClick)
.on_activate(|_, _| {}),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let rows = row_ids(&tree, tv);
assert_eq!(tree.focused(), None, "no focus yet");
tree.click(rows[1]);
let focused = tree.focused();
assert!(focused.is_some(), "clicking a row focuses something");
assert!(
focused == Some(tv) || tree.is_descendant_of(focused.unwrap(), tv),
"focus landed inside the TreeView (got {focused:?}, tv = {tv:?})"
);
}
#[test]
fn container_focus_ring_shows_when_tab_focused_without_selection() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::{KeyedSelectionModel, SelectionMode};
let source = Rc::new(MockI64Source::new());
let keyed = KeyedSelectionModel::<i64>::new(SelectionMode::Single);
let mut tree = WidgetTree::new();
let tv = tree.add(
TreeView::from_source_keyed(
MockI64Wrapper(source.clone()),
keyed.clone(),
|_label: &String, _row: &TreeRow, _sel| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let view_focused = tree.view_focus_active_for(tv);
let focus_visible = tree.focus_visible_signal();
assert!(
!view_focused.get() && !focus_visible.get(),
"before focus: no container ring (not focused, pointer modality)"
);
tree.focus(tv);
tree.press_key(Key::Tab, Modifiers::NONE);
assert!(view_focused.get(), "view holds keyboard focus");
assert!(focus_visible.get(), "keyboard input → focus-visible");
assert_eq!(
keyed.count(),
0,
"nothing selected → no row ring, container ring shows"
);
tree.click(tv);
assert!(
!focus_visible.get(),
"pointer input clears focus-visible → ring hides"
);
}
#[test]
fn container_focus_ring_hidden_when_a_sibling_holds_focus() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_core::widget_builder::WidgetBuilder;
use teksilo_data::{KeyedSelectionModel, SelectionMode};
let source = Rc::new(MockI64Source::new());
let keyed = KeyedSelectionModel::<i64>::new(SelectionMode::Single);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let root = tree.add(
crate::primitives::VStack::new()
.child(
TreeView::from_source_keyed(
MockI64Wrapper(source.clone()),
keyed.clone(),
|_label: &String, _row: &TreeRow, _sel| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0),
)
.child(FixedLeaf(40.0, 24.0).focusable(true)),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let children = tree.children(root);
let (tv, sibling) = (children[0], children[1]);
let ring = teksilo_tokens::BorderRole::Focused
.resolve(&teksilo_core::presets::intui::light().colors)
.to_array();
tree.focus(sibling);
tree.press_key(Key::ArrowDown, Modifiers::NONE); assert_eq!(tree.focused(), Some(sibling), "sibling holds focus");
assert_eq!(keyed.count(), 0, "nothing selected");
let frame = tree.render();
assert!(
!frame.decorations.iter().any(|d| d.color == ring)
&& !frame.shapes.iter().any(|s| s.color == ring)
&& !frame.cosmetic_lines.iter().any(|l| l.color == ring),
"container ring must NOT paint while a sibling holds focus",
);
tree.focus(tv);
assert_eq!(tree.focused(), Some(tv), "tree view holds focus");
assert_eq!(keyed.count(), 0, "still nothing selected");
let frame = tree.render();
assert!(
frame.decorations.iter().any(|d| d.color == ring)
|| frame.shapes.iter().any(|s| s.color == ring)
|| frame.cosmetic_lines.iter().any(|l| l.color == ring),
"container ring paints when the view holds keyboard focus",
);
}
#[test]
fn from_source_keyed_i64_survives_collapse_and_prunes() {
use teksilo_data::{KeyedSelectionModel, SelectionMode};
let source = Rc::new(MockI64Source::new());
let keyed = KeyedSelectionModel::<i64>::new(SelectionMode::Multi);
let mut tree = WidgetTree::new();
let tv = tree.add(
TreeView::from_source_keyed(
MockI64Wrapper(source.clone()),
keyed.clone(),
|_label: &String, _row: &TreeRow, _sel| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let rows = row_ids(&tree, tv);
tree.click(rows[1]);
assert!(
keyed.is_selected(&2),
"click stores the row's i64 key, not its index"
);
assert!(!keyed.is_selected(&1));
source.set_expanded(&1, false);
assert!(
keyed.is_selected(&2),
"a collapsed-but-present i64 node keeps its selection"
);
source.remove(2);
assert!(!keyed.is_selected(&2), "a deleted i64 node is pruned");
}
struct MockI64Wrapper(Rc<MockI64Source>);
impl TreeDataSource for MockI64Wrapper {
type Item = String;
type Key = i64;
fn visible_count(&self) -> usize {
self.0.visible_count()
}
fn with_entry<R>(&self, i: usize, f: impl FnOnce(&String, &FlatEntry<i64>) -> R) -> Option<R> {
self.0.with_entry(i, f)
}
fn key_at(&self, i: usize) -> Option<i64> {
self.0.key_at(i)
}
fn flat_index_of(&self, k: &i64) -> Option<usize> {
self.0.flat_index_of(k)
}
fn parent(&self, k: &i64) -> Option<i64> {
self.0.parent(k)
}
fn child_keys(&self, k: &i64) -> Vec<i64> {
self.0.child_keys(k)
}
fn version_signal(&self) -> Signal<u64> {
self.0.version_signal()
}
fn is_expanded(&self, k: &i64) -> bool {
self.0.is_expanded(k)
}
fn set_expanded(&self, k: &i64, e: bool) {
self.0.set_expanded(k, e)
}
fn contains_key(&self, k: &i64) -> bool {
self.0.contains_key(k)
}
fn drag(&self, k: &i64) -> DragEligibility {
self.0.drag(k)
}
fn can_accept(&self, q: &teksilo_data::DropQuery<'_, i64>) -> DropResponse {
self.0.can_accept(q)
}
fn accept_drop(&self, c: teksilo_data::DropCommit<'_, i64>) -> bool {
self.0.accept_drop(c)
}
}
#[test]
fn from_source_drop_routes_through_source_and_refuses_cycle() {
let valid = Rc::new(MockI64Source::new());
let mut t1 = WidgetTree::new();
let v1 = t1.add(
TreeView::from_source(
MockI64Wrapper(valid.clone()),
|_l: &String, _r: &TreeRow, _s| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0)
.reorderable(true),
);
t1.layout(SizeProposal::exact(400.0, 300.0));
let rows = row_ids(&t1, v1);
let from = t1.bounds(rows[2]).center();
let to = t1.bounds(rows[3]).center();
drag_item(&mut t1, from, to);
assert_eq!(
valid.accept_log.borrow().len(),
1,
"a valid drop is routed to the source's accept_drop"
);
assert_eq!(
valid.accept_log.borrow()[0].0,
3,
"dragged key recovered from RowDragData"
);
assert_eq!(
valid.accept_log.borrow()[0].1,
4,
"target key resolved from the hovered row"
);
let cyclic = Rc::new(MockI64Source::new());
let mut t2 = WidgetTree::new();
let v2 = t2.add(
TreeView::from_source(
MockI64Wrapper(cyclic.clone()),
|_l: &String, _r: &TreeRow, _s| Box::new(FixedLeaf(120.0, 24.0)),
)
.item_height(24.0)
.reorderable(true),
);
t2.layout(SizeProposal::exact(400.0, 300.0));
let rows2 = row_ids(&t2, v2);
let from2 = t2.bounds(rows2[0]).center();
let to2 = t2.bounds(rows2[1]).center();
drag_item(&mut t2, from2, to2);
assert!(
cyclic.accept_log.borrow().is_empty(),
"a cyclic drop is refused by the source — no mutation applied"
);
}
#[test]
fn from_source_reorder_bubbles_past_a_per_row_drop_target() {
use crate::drop_target::DropTarget;
use teksilo_core::event::EventResponse;
use teksilo_core::widget_builder::WidgetBuilder;
#[derive(Clone)]
struct Palette;
let src = Rc::new(MockI64Source::new());
let mut t = WidgetTree::new();
let v = t.add(
TreeView::from_source(
MockI64Wrapper(src.clone()),
|_l: &String, _r: &TreeRow, _s| {
Box::new(
DropTarget::new()
.on_drop_typed::<Palette>(|_p, _pos, _ctx| true)
.child(FixedLeaf(120.0, 24.0))
.on_pointer_event(|_ev, _ctx| EventResponse::Ignored)
.context_menu(|_pos, _ctx| None),
) as Box<dyn Widget>
},
)
.item_height(24.0)
.reorderable(true),
);
t.layout(SizeProposal::exact(400.0, 300.0));
let rows = row_ids(&t, v);
let from = t.bounds(rows[2]).center();
let to = t.bounds(rows[3]).center();
drag_item(&mut t, from, to);
assert_eq!(
src.accept_log.borrow().len(),
1,
"row reorder must bubble past the per-row DropTarget to the TreeView"
);
}
#[test]
fn from_source_row_with_pointer_event_selection_stays_draggable() {
use teksilo_core::event::{EventResponse, WidgetEvent};
use teksilo_core::widget_builder::WidgetBuilder;
let src = Rc::new(MockI64Source::new());
let picked = Rc::new(Cell::new(None::<i64>));
let mut t = WidgetTree::new();
let picked_for_rows = picked.clone();
let v = t.add(
TreeView::from_source(
MockI64Wrapper(src.clone()),
move |_l: &String, _r: &TreeRow, _s| {
let picked = picked_for_rows.clone();
Box::new(FixedLeaf(120.0, 24.0).on_pointer_event(move |ev, _c| {
if let WidgetEvent::PointerDown { .. } = ev {
picked.set(Some(7));
}
EventResponse::Ignored
})) as Box<dyn Widget>
},
)
.item_height(24.0)
.reorderable(true),
);
t.layout(SizeProposal::exact(400.0, 300.0));
let rows = row_ids(&t, v);
let from = t.bounds(rows[2]).center();
let to = t.bounds(rows[3]).center();
drag_item(&mut t, from, to);
assert!(
picked.get().is_some(),
"press still selects via on_pointer_event"
);
assert_eq!(
src.accept_log.borrow().len(),
1,
"on_pointer_event selection must not block the row drag"
);
}
#[test]
fn lazy_loading_tree_rows_render_placeholders_and_request_the_window() {
use std::ops::Range;
struct WindowedTree {
total: usize,
version: Signal<u64>,
requested: Rc<RefCell<Vec<Range<usize>>>>,
}
impl TreeDataSource for WindowedTree {
type Item = String;
type Key = usize;
fn visible_count(&self) -> usize {
self.total
}
fn with_entry<R>(
&self,
_flat_index: usize,
_f: impl FnOnce(&String, &FlatEntry<usize>) -> R,
) -> Option<R> {
None }
fn key_at(&self, i: usize) -> Option<usize> {
(i < self.total).then_some(i)
}
fn flat_index_of(&self, key: &usize) -> Option<usize> {
(*key < self.total).then_some(*key)
}
fn parent(&self, _key: &usize) -> Option<usize> {
None
}
fn child_keys(&self, _key: &usize) -> Vec<usize> {
Vec::new()
}
fn version_signal(&self) -> Signal<u64> {
self.version.clone()
}
fn is_expanded(&self, _key: &usize) -> bool {
false
}
fn set_expanded(&self, _key: &usize, _expanded: bool) {}
fn row_state(&self, _flat_index: usize) -> RowState {
RowState::Loading
}
fn request_window(&self, range: Range<usize>) {
self.requested.borrow_mut().push(range);
}
}
let requested = Rc::new(RefCell::new(Vec::new()));
let source = WindowedTree {
total: 1000,
version: Signal::new(0),
requested: requested.clone(),
};
let mut t = WidgetTree::new();
let v = t.add(
TreeView::from_source(source, |_l: &String, _r: &TreeRow, _s| {
Box::new(FixedLeaf(120.0, 28.0)) as Box<dyn Widget>
})
.item_height(28.0),
);
t.layout(SizeProposal::exact(400.0, 300.0));
let placeholder_rows = row_ids(&t, v).len();
assert!(
placeholder_rows >= 10,
"loading tree rows must render as placeholders, got {placeholder_rows}"
);
assert!(
!requested.borrow().is_empty(),
"request_window must be called for the visible range"
);
}
#[test]
fn treeview_exportable_row_drops_on_foreign_sink_with_items() {
use crate::primitives::{FixedSize, VStack};
use teksilo_core::widget_builder::WidgetBuilder as _;
let model = sample_tree();
#[allow(clippy::type_complexity)]
let cap: Rc<RefCell<Option<(Vec<usize>, Option<Vec<&'static str>>)>>> =
Rc::new(RefCell::new(None));
let cap2 = cap.clone();
let tv = TreeView::new(model.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.exportable(DragTransferMode::Copy);
let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
if let Some(rd) = payload.take_typed::<RowDragData<&'static str>>() {
*cap2.borrow_mut() = Some((rd.rows, rd.items));
true
} else {
false
}
});
let mut tree = WidgetTree::new();
tree.add(
VStack::new()
.spacing(0.0)
.child(FixedSize::new().height(84.0).child(tv))
.child(sink),
);
tree.layout(SizeProposal::exact(200.0, 300.0));
drag_item(&mut tree, Point::new(50.0, 14.0), Point::new(50.0, 120.0));
let (rows, items) = cap.borrow().clone().expect("sink received a RowDragData");
assert_eq!(rows, vec![0]);
assert_eq!(items, Some(vec!["A"]));
}
#[test]
fn treeview_exportable_move_removes_source_node_via_stable_key() {
use crate::primitives::{FixedSize, VStack};
use teksilo_core::widget_builder::WidgetBuilder as _;
let model = sample_tree(); let a = model.root(0);
let accepted: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let acc2 = accepted.clone();
let tv = TreeView::new(model.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.exportable(DragTransferMode::Move);
let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
if payload.take_typed::<RowDragData<&'static str>>().is_some() {
*acc2.borrow_mut() = true;
true
} else {
false
}
});
let mut tree = WidgetTree::new();
tree.add(
VStack::new()
.spacing(0.0)
.child(FixedSize::new().height(84.0).child(tv))
.child(sink),
);
tree.layout(SizeProposal::exact(200.0, 300.0));
drag_item(&mut tree, Point::new(50.0, 14.0), Point::new(50.0, 120.0));
assert!(*accepted.borrow(), "sink accepted the Move drop");
assert_eq!(
model.with_item(a, |v| *v),
None,
"node A was removed on Move"
);
}
#[test]
fn focused_index_follows_insert_above_it() {
use teksilo_core::event::{Key, Modifiers};
use teksilo_data::{SelectionMode, SelectionModel};
let tree = TreeModel::new();
for i in 0..10usize {
tree.insert_root(i, format!("Node {i}"));
}
let selection = SelectionModel::new(SelectionMode::Single);
let sel = selection.clone();
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new(tree.clone(), |_item, _entry, _sel| {
Box::new(FixedLeaf(120.0, 20.0))
})
.item_height(20.0)
.selection(sel),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
press_at(&mut wtree, 100.0, 70.0);
assert_eq!(selection.selected_indices(), vec![3], "precondition");
tree.insert_root(0, "New A".to_string());
tree.insert_root(0, "New B".to_string());
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
selection.selected_indices(),
vec![3],
"precondition: index selection does not follow the insert"
);
wtree.press_key(Key::ArrowDown, Modifiers::NONE);
assert_eq!(
selection.selected_indices(),
vec![6],
"ArrowDown after a leading insert resumes from the shifted row \
(5 → 6), not the stale pre-insert one (3 → 4)"
);
}
#[test]
fn focused_index_clears_when_its_row_is_removed() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_core::event::{Key, Modifiers};
let tree = TreeModel::new();
for i in 0..10usize {
tree.insert_root(i, format!("Node {i}"));
}
let activated: Rc<Cell<Option<usize>>> = Rc::new(Cell::new(None));
let act = activated.clone();
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new(tree.clone(), |_item, _entry, _sel| {
Box::new(FixedLeaf(120.0, 20.0))
})
.item_height(20.0)
.on_activate(move |i, _| act.set(Some(i))),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
press_at(&mut wtree, 100.0, 70.0);
wtree.press_key(Key::Enter, Modifiers::NONE);
assert_eq!(activated.get(), Some(3), "precondition: cursor is on row 3");
let node3 = tree.root(3);
tree.remove(node3);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.press_key(Key::Enter, Modifiers::NONE);
assert_eq!(
activated.get(),
Some(0),
"focused_index was cleared when its row was removed, so activation \
falls back to row 0 rather than the stale index or its replacement"
);
}
#[test]
fn collapsing_a_branch_above_keeps_the_cursor_on_the_same_logical_row() {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_core::event::{Key, Modifiers};
let tree = sample_tree(); let activated: Rc<Cell<Option<usize>>> = Rc::new(Cell::new(None));
let act = activated.clone();
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new_with_context(tree, |item: &&'static str, entry, selected, ctx| {
Box::new(
crate::StandardTreeItem::new(lit!((*item).to_string()))
.from_entry(entry)
.selected(selected)
.on_toggle_rc(ctx.toggle_callback()),
) as Box<dyn Widget>
})
.item_height(28.0)
.row_click_expands(false)
.on_activate(move |i, _| act.set(Some(i))),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
press_at(&mut wtree, 8.0, 14.0);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(row_ids(&wtree, tv).len(), 5, "precondition: A expanded");
press_at(&mut wtree, 100.0, 126.0);
wtree.press_key(Key::Enter, Modifiers::NONE);
assert_eq!(
activated.get(),
Some(4),
"precondition: cursor is on row C (index 4)"
);
press_at(&mut wtree, 8.0, 14.0);
wtree.layout(SizeProposal::exact(400.0, 300.0));
assert_eq!(
row_ids(&wtree, tv).len(),
3,
"precondition: A collapsed again"
);
wtree.press_key(Key::Enter, Modifiers::NONE);
assert_eq!(
activated.get(),
Some(2),
"the cursor follows row C to its new flat index (4 → 2) once the \
branch above it collapses, instead of staying on the stale index \
(now B)"
);
}
#[test]
fn alt_arrow_after_a_structural_change_reorders_the_row_the_cursor_is_on() {
use teksilo_core::event::{Key, Modifiers};
let tree = TreeModel::new();
for (i, label) in ["N0", "N1", "N2", "N3", "N4"].into_iter().enumerate() {
tree.insert_root(i, label);
}
let mut wtree = WidgetTree::new();
let tv = wtree.add(
TreeView::new(tree.clone(), |_item, entry, _sel| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0))
})
.item_height(28.0)
.reorderable(true),
);
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.focus(tv);
press_at(&mut wtree, 50.0, 70.0);
tree.insert_root(0, "New");
wtree.layout(SizeProposal::exact(400.0, 300.0));
wtree.press_key(Key::ArrowDown, Modifiers::ALT);
let order: Vec<&str> = (0..tree.root_count())
.map(|i| tree.with_item(tree.root(i), |&v| v).unwrap())
.collect();
assert_eq!(
order,
vec!["New", "N0", "N1", "N3", "N2", "N4"],
"Alt+ArrowDown moved N2 (the row the cursor is visibly on) past N3, \
not the stale pre-insert index 2 (now N0)"
);
}
#[test]
fn a_natural_height_query_does_not_strand_the_realization_window() {
let model = TreeModel::new();
for i in 0..56 {
model.insert_root(i, i as i32);
}
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(
TreeView::new(model.clone(), |_item: &i32, _entry, _sel| {
Box::new(FixedLeaf(280.0, 28.0))
})
.auto_item_height(28.0),
);
wtree.layout(SizeProposal::exact(300.0, 654.0));
assert_eq!(row_ids(&wtree, tv_id).len(), 27, "fixture is wrong");
wtree.measure_root_intrinsic(SizeProposal::with_width(300.0));
model.insert_root(56, 56);
wtree.layout(SizeProposal::exact(300.0, 654.0));
let rows = row_ids(&wtree, tv_id).len();
assert_eq!(
rows, 29,
"realized {rows} rows for a 654px viewport — the window was sized \
from the measurement's fallback, not from the allocation"
);
for id in row_ids(&wtree, tv_id).iter().take(rows) {
let b = wtree.bounds(*id);
assert!(
b.height > 0.0 && b.width > 0.0,
"row {id:?} was never placed: {b:?}"
);
}
let settled = row_ids(&wtree, tv_id);
wtree.layout(SizeProposal::exact(300.0, 654.0));
wtree.layout(SizeProposal::exact(300.0, 654.0));
assert_eq!(
settled,
row_ids(&wtree, tv_id),
"the tree rebuilt its rows on an idle layout pass — build and \
place_children disagree about the viewport"
);
}
fn drag_over(target_row: usize, frac: f32) -> (WidgetTree, WidgetId, f32) {
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let tree = TreeModel::new();
let a = tree.insert_root(0, "A");
let a1 = tree.insert_child(a, 0, "A1");
tree.insert_child(a1, 0, "A1a");
tree.insert_root(1, "B");
let tv = TreeView::new(tree, |_item, entry, _selected| {
Box::new(FixedLeaf(100.0 + entry.depth as f32 * 20.0, 28.0)) as Box<dyn Widget>
})
.item_height(28.0)
.reorderable(true);
tv.expand(a);
tv.expand(a1);
let mut wtree = WidgetTree::new();
let tv_id = wtree.add(tv);
wtree.layout(SizeProposal::exact(400.0, 300.0));
let src_y = 3.0 * 28.0 + 14.0;
wtree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(50.0, src_y),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(62.0, src_y),
});
let y = target_row as f32 * 28.0 + 28.0 * frac;
wtree.dispatch_event(WidgetEvent::PointerMove {
position: Point::new(62.0, y),
});
(wtree, tv_id, y)
}
fn viz(wtree: &WidgetTree, tv_id: WidgetId) -> Option<DropViz> {
wtree
.widget_as_any(tv_id)
.unwrap()
.downcast_ref::<TreeView<&'static str>>()
.unwrap()
.drop_feedback
.get()
}
#[test]
fn insertion_line_is_indented_to_the_level_the_row_lands_at() {
let (wtree, tv, _) = drag_over(2, 0.9);
assert!(
matches!(viz(&wtree, tv), Some(DropViz::Line { depth: 2, .. })),
"After a depth-2 row must report depth 2, got {:?}",
viz(&wtree, tv)
);
let (wtree, tv, _) = drag_over(0, 0.05);
assert!(
matches!(viz(&wtree, tv), Some(DropViz::Line { depth: 0, .. })),
"Before a root row must report depth 0, got {:?}",
viz(&wtree, tv)
);
}
#[test]
fn into_box_is_inset_so_it_cannot_be_read_as_an_insertion_line() {
use teksilo_canvas::{DrawCommand, ShapeKind};
let (mut wtree, tv, _) = drag_over(1, 0.5);
let Some(DropViz::Rect {
top, height, depth, ..
}) = viz(&wtree, tv)
else {
panic!("middle third must be an Into, got {:?}", viz(&wtree, tv));
};
assert_eq!(
depth, 1,
"the box frames the target row, at the target's depth"
);
assert_eq!((top, height), (28.0, 28.0), "the box frames row 1");
let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
assert!(recipe.inset > 0.0, "a zero inset re-creates the ambiguity");
let frame = wtree.render();
let rounded: Vec<_> = frame
.draw_order
.iter()
.filter_map(|c| match c {
DrawCommand::Shape(i) => frame.shapes.get(*i),
_ => None,
})
.filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
.collect();
assert!(
rounded.iter().any(|s| {
let [x, y, w, h] = s.screen;
(y - (top + recipe.inset)).abs() < 0.01
&& (h - (height - recipe.inset * 2.0)).abs() < 0.01
&& x > 0.0
&& w > 0.0
}),
"no inset rounded box for the Into hover; shapes = {:?}",
rounded.iter().map(|s| s.screen).collect::<Vec<_>>()
);
assert!(
rounded.iter().any(|s| s.stroke_width > 0.0)
&& rounded.iter().any(|s| s.stroke_width == 0.0),
"the Into box needs both a wash and an outline"
);
}