use super::*;
use crate::base::{Point, Size};
use crate::theme::default_theme;
use crate::ui::{set_event_time, Key, MouseKind, UiTree};
use crate::widgets::itest_util::{click, key, mount_widget, mouse, render};
use std::time::{Duration, Instant};
fn sample(cx: Scope, on_sort: impl FnMut(usize) + 'static) -> Element {
let t = &default_theme().tokens;
Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..12)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.sorted(0, true)
.on_sort_requested(on_sort)
.element(cx, t)
}
fn activating(cx: Scope, on_activate: impl FnMut(usize) + 'static) -> Element {
let t = &default_theme().tokens;
Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..12)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.on_activate(on_activate)
.element(cx, t)
}
fn click_at(tree: &mut UiTree, x: i32, y: i32, t: Instant) {
set_event_time(Some(t));
click(tree, x, y);
}
#[test]
fn header_body_selection_and_indicator_render() {
let size = Size::new(20, 5);
let (_root, mut tree) = mount_widget(size, |cx| sample(cx, |_| {}).build());
let canvas = render(&mut tree, size);
assert!(
canvas.row_text(0).contains("name▲"),
"{:?}",
canvas.row_text(0)
);
assert!(canvas.row_text(0).contains("size"));
assert!(canvas.row_text(1).starts_with("file-0"));
assert!(
canvas.attrs_at(Point::new(0, 0)).contains(Attrs::BOLD),
"header renders bold"
);
let theme = default_theme();
assert_eq!(
canvas.cell(Point::new(0, 1)).unwrap().2,
theme.tokens.selection_bg
);
}
#[test]
fn keyboard_navigates_rows_with_ensure_visible() {
let size = Size::new(20, 5); let (_root, mut tree) = mount_widget(size, |cx| sample(cx, |_| {}).build());
key(&mut tree, Key::Tab);
key(&mut tree, Key::End);
let canvas = render(&mut tree, size);
assert!(
canvas.row_text(4).starts_with("file-11"),
"{:?}",
canvas.row_text(4)
);
}
#[test]
fn s_key_requests_sort_round_robin_from_the_sorted_column() {
let size = Size::new(20, 5);
let requested: std::rc::Rc<std::cell::RefCell<Vec<usize>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let r = requested.clone();
let (_root, mut tree) = mount_widget(size, |cx| {
sample(cx, move |c| r.borrow_mut().push(c)).build()
});
key(&mut tree, Key::Tab); key(&mut tree, Key::Char('s'));
key(&mut tree, Key::Char('s'));
assert_eq!(*requested.borrow(), vec![1, 1]);
}
#[test]
fn header_click_requests_sort_body_click_selects() {
let size = Size::new(20, 5);
let sorts: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let s2 = sorts.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
sample(cx, move |c| s2.borrow_mut().push(c)).build()
});
click(&mut tree, 2, 0); assert_eq!(*sorts.borrow(), vec![0]);
click(&mut tree, 15, 0); assert_eq!(*sorts.borrow(), vec![0, 1]);
click(&mut tree, 2, 3); let theme = default_theme();
let canvas = render(&mut tree, size);
assert_eq!(
canvas.cell(Point::new(0, 3)).unwrap().2,
theme.tokens.selection_bg
);
}
#[test]
fn on_sort_requested_may_dispose_the_tables_scope() {
let t = default_theme().tokens;
let mut tree = crate::ui::UiTree::new(Size::new(20, 5));
let (root, ()) = crate::reactive::create_root(|cx| {
let modal_cx = cx.child();
let view = Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..3)
.map(|i| vec![format!("f{i}"), format!("{i}")])
.collect(),
)
.on_sort_requested(move |_| modal_cx.dispose())
.element(modal_cx, &t)
.build();
tree.mount(modal_cx, view);
});
tree.layout();
key(&mut tree, Key::Tab); key(&mut tree, Key::Char('s')); assert_eq!(tree.instance_count(), 0, "subtree unmounted by dispose");
root.dispose();
}
#[test]
fn on_select_may_dispose_the_tables_scope() {
let t = default_theme().tokens;
let mut tree = crate::ui::UiTree::new(Size::new(20, 5));
let (root, ()) = crate::reactive::create_root(|cx| {
let picker_cx = cx.child();
let view = Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..12)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.on_select(move |_| picker_cx.dispose())
.element(picker_cx, &t)
.build();
tree.mount(picker_cx, view);
});
tree.layout();
key(&mut tree, Key::Tab);
key(&mut tree, Key::Down); assert_eq!(tree.instance_count(), 0, "subtree unmounted by dispose");
root.dispose();
}
#[test]
fn enter_and_space_activate_the_selected_row_when_bound() {
let size = Size::new(20, 5);
let activated: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let a2 = activated.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
activating(cx, move |i| a2.borrow_mut().push(i)).build()
});
key(&mut tree, Key::Tab); key(&mut tree, Key::Down); assert!(activated.borrow().is_empty(), "movement must not activate");
key(&mut tree, Key::Enter);
assert_eq!(*activated.borrow(), vec![1], "Enter activates");
key(&mut tree, Key::Char(' '));
assert_eq!(
*activated.borrow(),
vec![1, 1],
"Space aliases Enter (single-select table: no toggle meaning)"
);
}
#[test]
fn enter_passes_through_to_app_shortcuts_when_on_activate_unbound() {
let size = Size::new(20, 5);
let confirmed: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
let c2 = confirmed.clone();
let t = default_theme().tokens;
let (_root, mut tree) = mount_widget(size, move |cx| {
let c3 = c2.clone();
Element::new()
.style(LayoutStyle::column().grow(1.0))
.shortcut(crate::ui::KeyChord::plain(Key::Enter), move |_| {
*c3.borrow_mut() += 1;
})
.child(
Table::new(vec![Column::new("name", ColWidth::Flex(1.0))])
.rows((0..3).map(|i| vec![format!("f{i}")]).collect())
.element(cx, &t)
.build(),
)
.build()
});
key(&mut tree, Key::Tab); key(&mut tree, Key::Enter);
assert_eq!(
*confirmed.borrow(),
1,
"unbound Table must not consume Enter"
);
}
#[test]
fn s_passes_through_to_app_shortcuts_when_no_sort_handler() {
let size = Size::new(20, 5);
let steered: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
let s2 = steered.clone();
let t = default_theme().tokens;
let (_root, mut tree) = mount_widget(size, move |cx| {
let s3 = s2.clone();
Element::new()
.style(LayoutStyle::column().grow(1.0))
.shortcut(crate::ui::KeyChord::plain(Key::Char('s')), move |_| {
*s3.borrow_mut() += 1;
})
.child(
Table::new(vec![Column::new("name", ColWidth::Flex(1.0))])
.rows((0..3).map(|i| vec![format!("f{i}")]).collect())
.element(cx, &t)
.build(),
)
.build()
});
key(&mut tree, Key::Tab); key(&mut tree, Key::Char('s'));
assert_eq!(
*steered.borrow(),
1,
"a sort-less Table must not consume 's'"
);
}
#[test]
fn double_click_activates_once_and_slow_or_single_clicks_never() {
let size = Size::new(20, 5);
let activated: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let a2 = activated.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
activating(cx, move |i| a2.borrow_mut().push(i)).build()
});
let t0 = Instant::now();
click_at(&mut tree, 2, 3, t0);
assert!(activated.borrow().is_empty(), "single click only selects");
let canvas = render(&mut tree, size);
assert_eq!(
canvas.cell(Point::new(0, 3)).unwrap().2,
default_theme().tokens.selection_bg,
"click 1 moved the selection (never suppressed by the chain)"
);
click_at(&mut tree, 2, 3, t0 + Duration::from_millis(100));
assert_eq!(*activated.borrow(), vec![2], "double-click activates");
click_at(&mut tree, 2, 3, t0 + Duration::from_millis(2000));
assert_eq!(
*activated.borrow(),
vec![2],
"slow re-click on the selected row must not activate"
);
set_event_time(None);
}
#[test]
fn chained_press_on_an_adjacent_row_selects_but_never_activates() {
let size = Size::new(20, 5);
let activated: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let a2 = activated.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
activating(cx, move |i| a2.borrow_mut().push(i)).build()
});
let t0 = Instant::now();
click_at(&mut tree, 2, 2, t0); click_at(&mut tree, 2, 3, t0 + Duration::from_millis(80)); assert!(
activated.borrow().is_empty(),
"cross-row chain must not activate: {:?}",
activated.borrow()
);
let canvas = render(&mut tree, size);
assert_eq!(
canvas.cell(Point::new(0, 3)).unwrap().2,
default_theme().tokens.selection_bg,
"the second press still selected its row"
);
set_event_time(None);
}
#[test]
fn wheel_between_clicks_resets_the_chain() {
let size = Size::new(20, 5);
let activated: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let a2 = activated.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
activating(cx, move |i| a2.borrow_mut().push(i)).build()
});
let t0 = Instant::now();
click_at(&mut tree, 2, 1, t0); set_event_time(Some(t0 + Duration::from_millis(40)));
mouse(&mut tree, MouseKind::ScrollUp, 2, 1);
click_at(&mut tree, 2, 1, t0 + Duration::from_millis(80));
assert!(
activated.borrow().is_empty(),
"press after a wheel starts a fresh chain (count 1)"
);
click_at(&mut tree, 2, 1, t0 + Duration::from_millis(160));
assert_eq!(*activated.borrow(), vec![0]);
set_event_time(None);
}
#[test]
fn without_an_event_time_source_presses_stay_isolated() {
let size = Size::new(20, 5);
let activated: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let a2 = activated.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
activating(cx, move |i| a2.borrow_mut().push(i)).build()
});
set_event_time(None);
click(&mut tree, 2, 3);
click(&mut tree, 2, 3);
click(&mut tree, 2, 3);
assert!(
activated.borrow().is_empty(),
"no time source → every press counts 1: {:?}",
activated.borrow()
);
}
#[test]
fn on_activate_may_dispose_the_tables_scope() {
let t = default_theme().tokens;
let mut tree = crate::ui::UiTree::new(Size::new(20, 5));
let (root, ()) = crate::reactive::create_root(|cx| {
let modal_cx = cx.child();
let view = Table::new(vec![Column::new("name", ColWidth::Flex(1.0))])
.rows((0..3).map(|i| vec![format!("f{i}")]).collect())
.on_activate(move |_| modal_cx.dispose())
.element(modal_cx, &t)
.build();
tree.mount(modal_cx, view);
});
tree.layout();
let t0 = Instant::now();
click_at(&mut tree, 2, 1, t0);
click_at(&mut tree, 2, 1, t0 + Duration::from_millis(100));
assert_eq!(tree.instance_count(), 0, "subtree unmounted by dispose");
set_event_time(None);
root.dispose();
}
#[test]
fn column_solver_tiles_exactly() {
let cols = solve_columns(
&[
ColWidth::Cells(4),
ColWidth::Percent(0.25),
ColWidth::Flex(1.0),
ColWidth::Flex(1.0),
],
24,
);
let gaps = (cols.len() as i32) - 1;
assert_eq!(cols.iter().sum::<i32>() + gaps, 24, "{cols:?}");
assert_eq!(cols[0], 4);
}
#[test]
fn only_the_strip_column_owns_drags() {
let size = Size::new(20, 6);
let (_root, mut tree) = mount_widget(size, |cx| {
let t = &default_theme().tokens;
Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..60)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.element(cx, t)
.build()
});
render(&mut tree, size);
let bar_x = size.w - 1;
assert!(
tree.press_probe_at(Point::new(bar_x, 3)).drag_owner,
"the strip owns drags"
);
assert!(
!tree.press_probe_at(Point::new(2, 3)).drag_owner,
"cell text stays selectable"
);
assert!(
!tree.press_probe_at(Point::new(bar_x, 0)).drag_owner,
"the header row is above the bar's body"
);
}
#[test]
fn the_scrollbar_column_scrolls_instead_of_selecting_a_row() {
let size = Size::new(20, 6);
let sel_probe: Rc<RefCell<Option<Signal<usize>>>> = Rc::new(RefCell::new(None));
let out = sel_probe.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
let t = &default_theme().tokens;
let sel = cx.signal(0usize);
*out.borrow_mut() = Some(sel);
Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..60)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.selection(sel)
.element(cx, t)
.build()
});
let sel = sel_probe.borrow().unwrap();
let bar_x = size.w - 1;
let before = canvas_first_row(&mut tree, size);
mouse(&mut tree, MouseKind::Down(MouseButton::Left), bar_x, 4);
assert_eq!(sel.get_untracked(), 0, "the strip selects nothing");
assert_ne!(
canvas_first_row(&mut tree, size),
before,
"...it scrolls instead"
);
mouse(&mut tree, MouseKind::Down(MouseButton::Left), 2, 2);
assert!(
sel.get_untracked() > 0,
"rows still answer their own column"
);
}
fn canvas_first_row(tree: &mut UiTree, size: Size) -> String {
render(tree, size).row_text(1)
}
#[test]
fn the_scrollbar_thumb_lights_under_the_pointer() {
let size = Size::new(20, 8);
let (_root, mut tree) = mount_widget(size, move |cx| {
let t = &default_theme().tokens;
Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..60)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.element(cx, t)
.build()
});
let bar_x = size.w - 1;
let ink = |tree: &mut UiTree, y: i32| {
render(tree, size)
.cell(crate::base::Point::new(bar_x, y))
.map(|(_, fg, _)| fg)
};
let cold = ink(&mut tree, 1).expect("thumb cell");
mouse(&mut tree, MouseKind::Move, bar_x, 1);
let hot = ink(&mut tree, 1).expect("thumb cell");
assert_ne!(cold, hot, "the strip answers the pointer");
mouse(&mut tree, MouseKind::Move, 2, 1);
assert_eq!(
ink(&mut tree, 1),
Some(cold),
"cools when the pointer leaves"
);
mouse(&mut tree, MouseKind::Down(MouseButton::Left), bar_x, 3);
mouse(&mut tree, MouseKind::Drag(MouseButton::Left), 0, 5);
let dragging = ink(&mut tree, 5).expect("thumb cell");
assert_eq!(dragging, hot, "still lit mid-drag, off-strip");
}
#[test]
fn hover_ink_lights_the_row_under_the_pointer() {
let size = Size::new(20, 8);
let sel_probe: Rc<RefCell<Option<Signal<usize>>>> = Rc::new(RefCell::new(None));
let out = sel_probe.clone();
let (_root, mut tree) = mount_widget(size, move |cx| {
let t = &default_theme().tokens;
let sel = cx.signal(0usize);
*out.borrow_mut() = Some(sel);
Table::new(vec![
Column::new("name", ColWidth::Flex(1.0)),
Column::new("size", ColWidth::Cells(6)),
])
.rows(
(0..60)
.map(|i| vec![format!("file-{i}"), format!("{i} kB")])
.collect(),
)
.selection(sel)
.layout(LayoutStyle::default().height(Dimension::Cells(6)))
.element(cx, t)
.build()
});
let sel = sel_probe.borrow().unwrap();
let cell = |tree: &mut UiTree, y: i32| {
render(tree, size)
.cell(crate::base::Point::new(1, y))
.expect("row cell")
};
let (_, cold, cold_bg) = cell(&mut tree, 3);
mouse(&mut tree, MouseKind::Move, 2, 3);
let (_, hot, hot_bg) = cell(&mut tree, 3);
assert_ne!(cold, hot, "the hovered row takes accent ink");
assert_eq!(cold_bg, hot_bg, "hover shifts INK only — bg unchanged");
assert_eq!(sel.get_untracked(), 0, "hover never selects");
mouse(&mut tree, MouseKind::Move, 2, 0);
assert_eq!(cell(&mut tree, 3).1, cold, "header hover lights no row");
mouse(&mut tree, MouseKind::Move, size.w - 1, 3);
assert_eq!(cell(&mut tree, 3).1, cold, "strip hover lights no row");
mouse(&mut tree, MouseKind::Move, 2, 3);
assert_eq!(cell(&mut tree, 3).1, hot);
mouse(&mut tree, MouseKind::Move, 2, 7); assert_eq!(cell(&mut tree, 3).1, cold, "cools when the pointer leaves");
}