use kui_core::{Color, Core, InputEvent, NodeSpec, Size, Value, Vec2};
const VIEW_H: f32 = 200.0;
const ROWS: usize = 20;
const ROW_H: f32 = 30.0;
fn frame(core: &mut Core) {
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..ROWS {
ui.leaf_keyed(
&format!("row{i}"),
NodeSpec::row()
.grow_width()
.height(ROW_H)
.bg(Color::rgb8(40, 40, 60))
.on_click(Value::Int(i as i64)),
);
}
});
ui.finish();
}
fn row_quad_ys(core: &mut Core) -> (f32, f32) {
let (dl, _) = core.output();
dl.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w > 100.0)
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), q| {
(lo.min(q.rect.y), hi.max(q.rect.y))
})
}
fn wheel(core: &mut Core, dy: f32) {
core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, 100.0)));
core.handle_input(InputEvent::Scroll(Vec2::new(0.0, dy)));
}
#[test]
fn wheel_scrolls_content_and_clamps() {
let mut core = Core::new();
frame(&mut core); assert_eq!(row_quad_ys(&mut core).0, 0.0);
wheel(&mut core, -50.0); frame(&mut core);
assert_eq!(row_quad_ys(&mut core).0, -20.0);
wheel(&mut core, -10_000.0); frame(&mut core);
assert_eq!(row_quad_ys(&mut core).1, VIEW_H - ROW_H);
wheel(&mut core, 20_000.0); frame(&mut core);
assert_eq!(row_quad_ys(&mut core).0, 0.0);
}
#[test]
fn clipped_away_rows_are_culled_and_unclickable() {
let mut core = Core::new();
frame(&mut core);
let (dl, _) = core.output();
let solid_rows = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w > 100.0)
.count();
assert!(solid_rows < ROWS, "expected culling, got {solid_rows} rows");
core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, 100.0)));
core.handle_input(InputEvent::mouse_down(1));
let evs = core.handle_input(InputEvent::mouse_up());
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].payload.as_int(), Some(3));
wheel(&mut core, -60.0);
frame(&mut core);
core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, 100.0)));
core.handle_input(InputEvent::mouse_down(1));
let evs = core.handle_input(InputEvent::mouse_up());
assert_eq!(evs[0].payload.as_int(), Some(5));
}
#[test]
fn scrollbar_appears_only_when_overflowing() {
let mut core = Core::new();
frame(&mut core);
let (dl, _) = core.output();
let bars = dl.quads.iter().filter(|q| q.rect.w == 4.0).count();
assert_eq!(bars, 1, "expected one vertical scrollbar");
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
ui.leaf(NodeSpec::row().height(50.0).bg(Color::WHITE));
});
ui.finish();
let (dl, _) = core.output();
assert_eq!(dl.quads.iter().filter(|q| q.rect.w == 4.0).count(), 0);
}
#[test]
fn scroll_offset_survives_and_reclamps_on_content_shrink() {
let mut core = Core::new();
frame(&mut core);
wheel(&mut core, -10_000.0);
frame(&mut core);
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..10 {
ui.leaf_keyed(
&format!("row{i}"),
NodeSpec::row()
.grow_width()
.height(ROW_H)
.bg(Color::rgb8(40, 40, 60)),
);
}
});
ui.finish();
assert_eq!(row_quad_ys(&mut core).0, -10.0);
}
fn list_key() -> kui_core::Key {
kui_core::Key::ROOT.str("list")
}
fn row_key(i: usize) -> kui_core::Key {
list_key().str(&format!("row{i}"))
}
fn row_y(core: &mut Core, i: usize) -> Option<f32> {
let (dl, _) = core.output();
let ys: Vec<f32> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w > 100.0)
.map(|q| q.rect.y)
.collect();
let want = i as f32 * ROW_H - core.scroll_offset(list_key()).y;
ys.into_iter().find(|y| (*y - want).abs() < 0.01)
}
#[test]
fn reveal_scrolls_a_row_into_view_and_is_idempotent() {
let mut core = Core::new();
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), Vec2::ZERO);
core.reveal(row_key(15));
frame(&mut core);
let y = row_y(&mut core, 15).expect("row 15 should draw");
assert!(
y >= 0.0 && y + ROW_H <= VIEW_H,
"row 15 not inside the viewport: y={y}"
);
let after = core.scroll_offset(list_key());
assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), after);
core.reveal(row_key(15));
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), after);
}
#[test]
fn reveal_scrolls_back_up() {
let mut core = Core::new();
frame(&mut core);
core.set_scroll(list_key(), Vec2::new(0.0, 400.0));
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()).y, 400.0);
core.reveal(row_key(0));
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), Vec2::ZERO);
assert_eq!(row_y(&mut core, 0), Some(0.0));
}
#[test]
fn reveal_of_a_key_the_frame_does_not_declare_is_a_no_op() {
let mut core = Core::new();
frame(&mut core);
let before = core.scroll_offset(list_key());
core.reveal(kui_core::Key::ROOT.str("no-such-node"));
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), before);
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), before);
}
#[test]
fn reveal_reaches_a_row_the_frame_declares_for_the_first_time() {
let mut core = Core::new();
frame(&mut core);
core.reveal(row_key(39));
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..40 {
ui.leaf_keyed(
&format!("row{i}"),
NodeSpec::row()
.grow_width()
.height(ROW_H)
.bg(Color::rgb8(40, 40, 60)),
);
}
});
ui.finish();
assert_eq!(core.scroll_offset(list_key()).y, 1000.0);
}
fn two_lists(core: &mut Core) {
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::row().fill());
for list in ["left", "right"] {
ui.with_keyed(list, NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..ROWS {
ui.leaf_keyed(
&format!("row{i}"),
NodeSpec::row().grow_width().height(ROW_H),
);
}
});
}
ui.finish();
}
#[test]
fn reveals_into_two_containers_in_one_frame_both_land() {
let left = kui_core::Key::ROOT.str("left");
let right = kui_core::Key::ROOT.str("right");
let mut core = Core::new();
two_lists(&mut core);
core.reveal(left.str("row15"));
core.reveal(right.str("row18"));
two_lists(&mut core);
assert_eq!(core.scroll_offset(left).y, 284.0);
assert_eq!(core.scroll_offset(right).y, 374.0);
core.reveal(left.str("row19"));
core.reveal(left.str("row0"));
two_lists(&mut core);
assert_eq!(core.scroll_offset(left).y, 0.0);
}
#[test]
fn set_scroll_and_scroll_offset_round_trip_through_a_model() {
let mut core = Core::new();
frame(&mut core);
wheel(&mut core, -150.0);
frame(&mut core);
let saved = core.scroll_offset(list_key());
assert_eq!(saved, Vec2::new(0.0, 150.0));
let mut restored = Core::new();
restored.set_scroll(list_key(), saved);
frame(&mut restored);
assert_eq!(restored.scroll_offset(list_key()), saved);
assert_eq!(row_y(&mut restored, 5), Some(0.0));
}
#[test]
fn set_scroll_is_clamped_by_the_next_layout() {
let mut core = Core::new();
frame(&mut core);
core.set_scroll(list_key(), Vec2::new(0.0, f32::MAX));
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()).y, 600.0 - VIEW_H);
core.set_scroll(list_key(), Vec2::new(0.0, -500.0));
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()), Vec2::ZERO);
}
#[test]
fn scroll_offset_of_a_node_that_never_scrolled_is_zero() {
let mut core = Core::new();
frame(&mut core);
assert_eq!(core.scroll_offset(row_key(0)), Vec2::ZERO);
assert_eq!(
core.scroll_offset(kui_core::Key::ROOT.str("gone")),
Vec2::ZERO
);
}
fn virtual_frame(core: &mut Core, rows: usize) -> usize {
let mut built = 0usize;
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
kui_core::widgets::uniform_list(
&mut ui,
"list",
NodeSpec::column().fill(),
rows,
ROW_H,
|ui, i| {
built += 1;
ui.leaf(
NodeSpec::row()
.fill()
.bg(Color::rgb8(40, 40, 60))
.on_click(Value::Int(i as i64)),
);
},
);
ui.finish();
built
}
#[test]
fn geometry_reports_the_container_box_and_its_content() {
let mut core = Core::new();
assert_eq!(core.scroll_geometry(list_key()), None);
frame(&mut core);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.rect, kui_core::Rect::new(0.0, 0.0, 400.0, VIEW_H));
assert_eq!(g.content.h, ROWS as f32 * ROW_H);
assert_eq!(g.offset, Vec2::ZERO);
assert_eq!(g.max_offset.y, ROWS as f32 * ROW_H - VIEW_H);
wheel(&mut core, -10_000.0);
frame(&mut core);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.offset, g.max_offset);
}
#[test]
fn geometry_is_none_for_a_node_that_is_not_a_scroll_container() {
let mut core = Core::new();
frame(&mut core);
assert_eq!(core.scroll_geometry(row_key(3)), None);
core.set_scroll(row_key(3), Vec2::new(0.0, 50.0));
frame(&mut core);
assert_eq!(core.scroll_geometry(row_key(3)), None);
assert_eq!(core.scroll_geometry(kui_core::Key::ROOT.str("gone")), None);
}
#[test]
fn geometry_offset_is_clamped_where_the_raw_one_is_not() {
let mut core = Core::new();
frame(&mut core);
core.set_scroll(list_key(), Vec2::new(0.0, 1e9));
assert_eq!(core.scroll_offset(list_key()).y, 1e9);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.offset.y, ROWS as f32 * ROW_H - VIEW_H);
assert_eq!(g.offset, g.max_offset);
frame(&mut core);
assert_eq!(core.scroll_offset(list_key()).y, g.offset.y);
}
#[test]
fn max_offset_is_zero_on_an_axis_that_does_not_scroll() {
let mut core = Core::new();
frame(&mut core);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.max_offset.x, 0.0);
assert_eq!(g.max_offset.y, ROWS as f32 * ROW_H - VIEW_H);
}
#[test]
fn a_uniform_list_builds_only_what_shows() {
let mut core = Core::new();
let first = virtual_frame(&mut core, 10_000);
assert!(first < 20, "first frame built {first} rows");
let n = virtual_frame(&mut core, 10_000);
assert_eq!(n, (VIEW_H / ROW_H).ceil() as usize + 2);
}
#[test]
fn the_built_range_follows_the_offset() {
let mut core = Core::new();
virtual_frame(&mut core, 10_000);
let steady = virtual_frame(&mut core, 10_000);
core.set_scroll(list_key(), Vec2::new(0.0, 300.0 * ROW_H));
virtual_frame(&mut core, 10_000);
let n = virtual_frame(&mut core, 10_000);
assert_eq!(n, steady + 2);
let (dl, _) = core.output();
let rows = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w > 100.0)
.count();
assert_eq!(rows, n - 4);
}
#[test]
fn a_uniform_list_scrolls_like_a_full_one() {
let mut core = Core::new();
virtual_frame(&mut core, 1_000);
virtual_frame(&mut core, 1_000);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.content.h, 1_000.0 * ROW_H);
assert_eq!(g.max_offset.y, 1_000.0 * ROW_H - VIEW_H);
core.set_scroll(list_key(), Vec2::new(0.0, f32::MAX));
virtual_frame(&mut core, 1_000);
virtual_frame(&mut core, 1_000);
assert_eq!(core.scroll_offset(list_key()).y, 1_000.0 * ROW_H - VIEW_H);
}
fn virtual_frame_in(core: &mut Core, rows: usize, h: f32) -> usize {
let mut built = 0usize;
let mut ui = core.frame(Size::new(400.0, h), 1.0);
ui.configure_root(NodeSpec::column().fill());
kui_core::widgets::uniform_list(
&mut ui,
"list",
NodeSpec::column().fill(),
rows,
ROW_H,
|ui, _| {
built += 1;
ui.leaf(NodeSpec::row().fill());
},
);
ui.finish();
built
}
#[test]
fn a_uniform_list_owes_a_frame_when_its_box_moved() {
let mut core = Core::new();
virtual_frame_in(&mut core, 1_000, VIEW_H);
assert!(core.owed().requested, "the first frame sliced blind");
let fits = |h: f32| kui_core::widgets::visible_rows(0.0, h, 0.0, ROW_H, 1_000, 2).len();
let built = virtual_frame_in(&mut core, 1_000, VIEW_H);
assert_eq!(built, fits(VIEW_H), "a screenful and the overscan");
assert!(!core.owed().requested, "settled");
let built = virtual_frame_in(&mut core, 1_000, VIEW_H * 3.0);
assert_eq!(built, fits(VIEW_H), "still the old box's rows");
assert!(core.owed().requested, "the box moved: one more frame");
let built = virtual_frame_in(&mut core, 1_000, VIEW_H * 3.0);
assert_eq!(built, fits(VIEW_H * 3.0), "the new box's rows");
assert!(!core.owed().requested, "settled again");
core.set_scroll(list_key(), Vec2::new(0.0, 10.0 * ROW_H));
virtual_frame_in(&mut core, 1_000, VIEW_H * 3.0);
assert!(
!core.owed().requested,
"a scroll slices right the first time"
);
core.set_scroll(list_key(), Vec2::new(0.0, 900.0 * ROW_H));
virtual_frame_in(&mut core, 1_000, VIEW_H * 3.0);
assert!(!core.owed().requested);
virtual_frame_in(&mut core, 100, VIEW_H * 3.0);
assert!(core.owed().requested, "clamped short: one more frame");
virtual_frame_in(&mut core, 100, VIEW_H * 3.0);
assert!(!core.owed().requested);
assert_eq!(
core.scroll_offset(list_key()).y,
100.0 * ROW_H - VIEW_H * 3.0
);
}
#[test]
fn list_rows_keep_their_key_as_the_range_slides() {
let mut core = Core::new();
virtual_frame(&mut core, 10_000);
virtual_frame(&mut core, 10_000);
let key_at_top = list_key().index(0);
core.set_scroll(list_key(), Vec2::new(0.0, 500.0 * ROW_H));
virtual_frame(&mut core, 10_000);
virtual_frame(&mut core, 10_000);
assert_eq!(key_at_top, list_key().index(0));
assert_ne!(key_at_top, list_key().index(500));
}
#[test]
fn spacers_do_not_collide_with_row_keys() {
let mut core = Core::new();
virtual_frame(&mut core, 10_000);
core.set_scroll(list_key(), Vec2::new(0.0, 300.0 * ROW_H));
virtual_frame(&mut core, 10_000);
virtual_frame(&mut core, 10_000);
let dup = core
.take_warnings()
.iter()
.any(|w| w.code == "duplicate-key");
assert!(!dup, "virtual rows and spacers collided");
}
#[test]
fn visible_rows_covers_the_band_and_no_more() {
use kui_core::widgets::visible_rows;
assert_eq!(visible_rows(0.0, 200.0, 0.0, 30.0, 10_000, 0), 0..7);
assert_eq!(visible_rows(3000.0, 200.0, 0.0, 30.0, 10_000, 0), 100..107);
assert_eq!(visible_rows(3000.0, 200.0, 0.0, 30.0, 10_000, 2), 98..109);
assert_eq!(visible_rows(0.0, 200.0, 0.0, 30.0, 10_000, 2), 0..9);
assert_eq!(visible_rows(3000.0, 200.0, 60.0, 30.0, 10_000, 0), 98..105);
assert_eq!(visible_rows(0.0, 200.0, 0.0, 30.0, 0, 2), 0..0);
assert_eq!(visible_rows(0.0, 200.0, 0.0, 0.0, 10, 2), 0..0);
assert_eq!(visible_rows(0.0, 200.0, 0.0, 30.0, 3, 2), 0..3);
}
use kui_core::Key;
use kui_core::testing::{click, hover, kinds};
use kui_core::widgets::RowHeights;
const VAR_ROWS: usize = 1_000;
fn var_h(i: usize) -> f32 {
if i < 100 { 20.0 } else { 60.0 }
}
fn var_frame(core: &mut Core, heights: &mut RowHeights) -> std::ops::Range<usize> {
let (mut first, mut last) = (usize::MAX, 0usize);
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
kui_core::widgets::list(
&mut ui,
"list",
NodeSpec::column().fill(),
heights,
|_ui, i, _w| var_h(i),
|ui, i| {
first = first.min(i);
last = i + 1;
ui.leaf(
NodeSpec::row()
.fill()
.bg(Color::rgb8(40, 40, 60))
.on_click(Value::Int(i as i64)),
);
},
);
ui.finish();
if last == 0 { 0..0 } else { first..last }
}
fn row_under(core: &mut Core, y: f32) -> Option<i64> {
hit_under(core, y).map(|(i, _)| i)
}
fn hit_under(core: &mut Core, y: f32) -> Option<(i64, kui_core::Key)> {
core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
core.handle_input(InputEvent::mouse_down(1));
let evs = core.handle_input(InputEvent::mouse_up());
let e = evs.first()?;
Some((e.payload.as_int()?, e.key))
}
#[test]
fn row_heights_answer_the_three_questions_a_stride_answered() {
let mut h = RowHeights::new(10, 20.0);
assert_eq!(h.estimate(), 20.0);
assert_eq!(h.total(), 200.0);
assert_eq!(h.offset_of(3), 60.0);
assert_eq!(h.row_at(0.0), 0);
assert_eq!(h.row_at(65.0), 3);
h.set(0, 50.0);
h.set(1, 50.0);
h.set(2, 50.0);
assert_eq!(h.measured(0), Some(50.0));
assert_eq!(h.measured(9), None);
assert_eq!(h.estimate(), 50.0);
assert_eq!(h.get(9), 50.0);
assert_eq!(h.total(), 500.0);
assert_eq!(h.offset_of(3), 150.0);
assert_eq!(h.row_at(149.0), 2);
assert_eq!(h.row_at(150.0), 3);
assert_eq!(h.row_at(1e9), 9);
h.set_len(12);
assert_eq!(h.measured(0), Some(50.0));
assert_eq!(h.total(), 600.0);
h.clear();
assert_eq!(h.measured(0), None);
assert_eq!(h.estimate(), 20.0, "back to the seed with nothing measured");
assert_eq!(h.len(), 12);
}
#[test]
fn a_new_width_drops_every_measurement() {
let mut h = RowHeights::new(10, 20.0);
assert!(h.set_width(300.0));
h.set(0, 55.0);
assert!(!h.set_width(300.0), "the same width changes nothing");
assert_eq!(h.measured(0), Some(55.0));
assert!(h.set_width(200.0));
assert_eq!(h.measured(0), None);
assert_eq!(h.width(), 200.0);
}
#[test]
fn a_variable_list_builds_only_what_shows() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
let built = var_frame(&mut core, &mut h);
assert!(built.len() <= 14, "built {built:?}");
assert_eq!(built.start, 0);
assert_eq!(h.measured(built.end - 1), Some(20.0));
assert_eq!(h.measured(built.end + 50), None);
}
#[test]
fn a_row_is_as_tall_as_it_measured() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
let (dl, _) = core.output();
let mut rows: Vec<f32> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w > 100.0)
.map(|q| q.rect.h)
.collect();
rows.dedup();
assert_eq!(rows, vec![20.0], "every visible row is its measured height");
}
#[test]
fn the_content_height_is_the_measured_and_the_estimated_together() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.content.h, VAR_ROWS as f32 * 20.0);
assert_eq!(h.total(), VAR_ROWS as f32 * 20.0);
core.set_scroll(list_key(), Vec2::new(0.0, 5_000.0));
for _ in 0..4 {
var_frame(&mut core, &mut h);
}
assert!(
h.total() > VAR_ROWS as f32 * 20.0 * 1.5,
"total {} did not grow with what was measured",
h.total()
);
}
#[test]
fn the_row_under_the_pointer_stays_there_while_the_estimate_moves() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
core.set_scroll(list_key(), Vec2::new(0.0, 5_000.0));
var_frame(&mut core, &mut h);
let settled = row_under(&mut core, 4.0);
assert!(settled.is_some(), "nothing under the top edge");
for n in 0..4 {
var_frame(&mut core, &mut h);
assert_eq!(
row_under(&mut core, 4.0),
settled,
"the content slid on frame {n} as the estimate moved",
);
}
}
#[test]
fn set_scroll_to_a_rows_offset_lands_it_at_the_top() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
let target = 400usize;
core.set_scroll(list_key(), Vec2::new(0.0, h.offset_of(target)));
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
assert_eq!(row_under(&mut core, 4.0), Some(target as i64));
}
#[test]
fn a_list_that_asks_for_the_end_every_frame_converges_on_it() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
for _ in 0..8 {
var_frame(&mut core, &mut h);
core.set_scroll(list_key(), Vec2::new(0.0, f32::MAX));
}
let built = var_frame(&mut core, &mut h);
assert_eq!(built.end, VAR_ROWS, "the last row is built at the end");
let g = core.scroll_geometry(list_key()).expect("laid out");
assert_eq!(g.offset, g.max_offset);
}
#[test]
fn a_list_left_alone_keeps_its_rows_not_its_place_in_the_travel() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
core.set_scroll(list_key(), Vec2::new(0.0, f32::MAX));
var_frame(&mut core, &mut h);
let at = row_under(&mut core, 4.0);
for _ in 0..4 {
var_frame(&mut core, &mut h);
}
assert_eq!(row_under(&mut core, 4.0), at);
let g = core.scroll_geometry(list_key()).expect("laid out");
assert!(g.offset.y < g.max_offset.y, "the travel grew under it");
}
#[test]
fn variable_rows_keep_their_key_as_the_range_slides() {
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
var_frame(&mut core, &mut h);
var_frame(&mut core, &mut h);
core.set_scroll(list_key(), Vec2::new(0.0, 3_000.0));
var_frame(&mut core, &mut h);
let built = var_frame(&mut core, &mut h);
assert!(built.start > 0, "scrolled past the top: {built:?}");
let (row, key) = hit_under(&mut core, 4.0).expect("a row under the edge");
assert_eq!(key, list_key().index(row as u64).index(0));
assert!(row as usize > built.start, "overscan sits above the window");
}
fn bars(core: &mut Core) -> Vec<(f32, Color)> {
let (dl, _) = core.output();
dl.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w < 100.0)
.map(|q| (q.rect.w, q.color))
.collect()
}
fn styled(core: &mut Core, style: impl Fn(NodeSpec) -> NodeSpec) {
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
ui.with_keyed("list", style(NodeSpec::column().fill().scroll_y()), |ui| {
for i in 0..ROWS {
ui.leaf_keyed(
&format!("row{i}"),
NodeSpec::row()
.grow_width()
.height(ROW_H)
.bg(Color::rgb8(40, 40, 60)),
);
}
});
ui.finish();
}
#[test]
fn a_hidden_scrollbar_draws_nothing_and_takes_no_press() {
use kui_core::ScrollbarMode;
let mut core = Core::new();
styled(&mut core, |s| s.scrollbar(ScrollbarMode::Hidden));
assert!(bars(&mut core).is_empty(), "no thumb");
let list = core.key_of("list").unwrap();
click(&mut core, Vec2::new(396.0, 190.0));
styled(&mut core, |s| s.scrollbar(ScrollbarMode::Hidden));
assert_eq!(core.scroll_offset(list).y, 0.0, "nothing to jump");
wheel(&mut core, -50.0);
styled(&mut core, |s| s.scrollbar(ScrollbarMode::Hidden));
assert_eq!(core.scroll_offset(list).y, 50.0);
assert!(bars(&mut core).is_empty());
}
#[test]
fn a_scrollbar_takes_its_width_and_colours_from_the_node() {
const REST: Color = Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
const ACTIVE: Color = Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
};
let style = |s: NodeSpec| {
s.scrollbar_width(8.0)
.scrollbar_color(REST)
.scrollbar_active_color(ACTIVE)
};
let mut core = Core::new();
styled(&mut core, style);
assert_eq!(bars(&mut core), vec![(8.0, REST)]);
core.handle_input(InputEvent::CursorMoved(Vec2::new(389.0, 100.0)));
styled(&mut core, style);
assert_eq!(bars(&mut core), vec![(10.0, ACTIVE)]);
let list = core.key_of("list").unwrap();
click(&mut core, Vec2::new(389.0, 190.0));
styled(&mut core, style);
assert!(core.scroll_offset(list).y > 0.0);
}
#[test]
fn an_auto_scrollbar_fades_out_when_the_scroll_state_is_quiet() {
use kui_core::ScrollbarMode;
let auto = |s: NodeSpec| s.scrollbar(ScrollbarMode::Auto);
let mut core = Core::new();
core.set_time(0.0);
styled(&mut core, auto);
let first = bars(&mut core);
assert_eq!(first.len(), 1, "first seen: shown");
assert!(core.animating(), "the hold is running");
core.set_time(0.9);
styled(&mut core, auto);
assert_eq!(bars(&mut core)[0].1.a, first[0].1.a);
core.set_time(1.125);
styled(&mut core, auto);
let mid = bars(&mut core);
assert_eq!(mid.len(), 1);
assert!((mid[0].1.a - first[0].1.a * 0.5).abs() < 1e-3, "{:?}", mid);
assert!(core.animating());
core.set_time(1.3);
styled(&mut core, auto);
assert!(bars(&mut core).is_empty());
assert!(!core.animating());
let list = core.key_of("list").unwrap();
click(&mut core, Vec2::new(396.0, 190.0));
core.set_time(1.31);
styled(&mut core, auto);
assert_eq!(core.scroll_offset(list).y, 0.0);
wheel(&mut core, -30.0);
core.set_time(1.4);
styled(&mut core, auto);
assert_eq!(bars(&mut core).len(), 1);
core.set_time(2.3);
styled(&mut core, auto);
assert_eq!(bars(&mut core).len(), 1, "quiet since 1.4: still held");
core.set_time(2.7);
styled(&mut core, auto);
assert!(bars(&mut core).is_empty());
core.handle_input(InputEvent::CursorMoved(Vec2::new(396.0, 100.0)));
core.set_time(2.8);
styled(&mut core, auto);
assert_eq!(bars(&mut core).len(), 1);
assert!(!core.animating(), "held by the pointer: input ends that");
core.set_time(9.0);
styled(&mut core, auto);
assert_eq!(bars(&mut core).len(), 1);
}
#[test]
fn an_auto_scrollbar_without_a_clock_is_visible() {
use kui_core::ScrollbarMode;
let mut core = Core::new();
styled(&mut core, |s| s.scrollbar(ScrollbarMode::Auto));
assert_eq!(bars(&mut core).len(), 1);
assert!(!core.animating());
}
#[test]
fn a_short_padded_list_settles() {
let mut core = Core::new();
let mut heights = kui_core::widgets::RowHeights::new(3, 20.0);
let frame = |core: &mut Core, heights: &mut kui_core::widgets::RowHeights| {
let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
ui.configure_root(NodeSpec::column().fill());
kui_core::widgets::list(
&mut ui,
"list",
NodeSpec::column().fill().pad(6.0),
heights,
|_ui, _i, _w| 20.0,
|ui, _i| {
ui.leaf(NodeSpec::row().fill());
},
);
ui.finish();
};
frame(&mut core, &mut heights);
frame(&mut core, &mut heights);
let mut asked = 0;
for _ in 0..5 {
frame(&mut core, &mut heights);
asked += usize::from(core.animating());
}
assert_eq!(
asked, 0,
"a laid-out list with nothing to measure asks for no frame"
);
}
#[test]
fn hover_over_the_scrollbar_is_not_hover_over_the_row_beneath() {
let mut core = Core::new();
let hovered_row = |core: &mut Core| -> Option<Key> {
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..ROWS {
ui.leaf_keyed(
&format!("row{i}"),
NodeSpec::row()
.grow_width()
.height(ROW_H)
.bg(Color::rgb8(40, 40, 60))
.hover_bg(Color::WHITE)
.on_hover(Value::Int(i as i64)),
);
}
});
ui.finish();
let keys: Vec<Key> = (0..ROWS)
.map(|i| core.key_of(&format!("row{i}")).unwrap())
.collect();
keys.into_iter().find(|k| core.is_hovered(*k))
};
hovered_row(&mut core);
let evs = hover(&mut core, Vec2::new(200.0, 45.0));
assert_eq!(kinds(&evs), ["hover"]);
assert_eq!(hovered_row(&mut core), core.key_of("row1"));
let evs = hover(&mut core, Vec2::new(398.0, 45.0));
assert_eq!(kinds(&evs), ["hover"], "the leave");
assert_eq!(evs[0].payload.get_str("phase"), Some("leave"));
assert_eq!(
hovered_row(&mut core),
None,
"over the bar, no row is hovered"
);
let evs = hover(&mut core, Vec2::new(200.0, 45.0));
assert_eq!(evs[0].payload.get_str("phase"), Some("enter"));
assert_eq!(hovered_row(&mut core), core.key_of("row1"));
}
#[test]
fn strokes_in_a_scrolled_row_are_clipped_by_the_container() {
let mut core = Core::new();
let build = |core: &mut Core| {
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
ui.leaf_keyed("strip", NodeSpec::row().grow_width().height(40.0));
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..ROWS {
ui.with_keyed(
&format!("row{i}"),
NodeSpec::row().grow_width().height(ROW_H),
|ui| {
ui.line(
Vec2::new(10.0, 0.0),
Vec2::new(10.0, ROW_H),
kui_core::Stroke::new(2.0, Color::rgb8(200, 200, 200)),
NodeSpec::row(),
);
ui.polygon(
&[
Vec2::new(4.0, ROW_H / 2.0 - 4.0),
Vec2::new(16.0, ROW_H / 2.0 - 4.0),
Vec2::new(16.0, ROW_H / 2.0 + 4.0),
Vec2::new(4.0, ROW_H / 2.0 + 4.0),
],
NodeSpec::row().bg(Color::rgb8(255, 0, 0)),
);
},
);
}
});
ui.finish();
};
build(&mut core);
core.set_scroll(list_key(), Vec2::new(0.0, 3.0 * ROW_H));
build(&mut core);
let (dl, _) = core.output();
let spill: Vec<_> = dl
.quads
.iter()
.filter(|q| q.rect.y < 40.0 - 0.5 && q.rect.h > 0.0)
.filter(|q| dl.clips[q.clip as usize].rect.y < 40.0 - 0.5)
.map(|q| (q.rect, q.kind, dl.clips[q.clip as usize].rect))
.collect();
assert!(
spill.is_empty(),
"drawn over the strip unclipped: {spill:?}"
);
}
#[test]
fn a_programmatic_scroll_eases_where_the_container_asks_and_the_wheel_never_does() {
use kui_core::{Easing, Transition};
let build = |core: &mut Core, smooth: bool, reveal: Option<usize>| {
let mut ui = core.frame(Size::new(100.0, 50.0), 1.0);
ui.configure_root(NodeSpec::column().fill());
let mut spec = NodeSpec::row().fill().scroll_x();
if smooth {
spec = spec.transition_with(Transition::ms(100.0).easing(Easing::Linear));
}
let mut boxes = Vec::new();
let row = ui.with_keyed("row", spec, |ui| {
for i in 0..4 {
boxes.push(ui.leaf_keyed(
&format!("b{i}"),
NodeSpec::column().width(100.0).grow_height(),
));
}
});
if let Some(i) = reveal {
ui.reveal(boxes[i]);
}
ui.finish();
(row, boxes)
};
let drawn = |core: &Core, row| {
core.scroll_geometry(row)
.expect("a resolved container")
.offset
.x
};
let mut core = Core::new();
core.set_time(0.0);
let (row, _) = build(&mut core, true, None);
assert_eq!(drawn(&core, row), 0.0, "the ribbon starts at the left");
core.set_time(0.0);
build(&mut core, true, Some(3));
assert_eq!(core.scroll_offset(row).x, 300.0, "the offset is the target");
assert_eq!(drawn(&core, row), 0.0, "the content has not moved yet");
assert!(core.owed().scroll, "a frame is owed for the leg");
core.set_time(0.05);
build(&mut core, true, None);
let mid = drawn(&core, row);
assert!((mid - 150.0).abs() < 2.0, "halfway along: {mid}");
assert!(core.owed().scroll);
core.set_time(0.1);
build(&mut core, true, None);
assert_eq!(drawn(&core, row), 300.0, "landed");
assert!(!core.owed().scroll, "nothing owed once it lands");
core.set_time(0.2);
build(&mut core, true, None);
core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 25.0)));
core.handle_input(InputEvent::Scroll(Vec2::new(40.0, 0.0)));
core.set_time(0.2);
build(&mut core, true, None);
let after = drawn(&core, row);
assert!(
(after - (300.0 - 40.0)).abs() < 1.0,
"the wheel landed whole: {after}"
);
let mut core = Core::new();
core.set_time(0.0);
let (row, _) = build(&mut core, false, None);
core.set_time(0.0);
build(&mut core, false, Some(3));
assert_eq!(drawn(&core, row), 300.0, "no transition, no easing");
assert!(!core.owed().scroll);
}
fn eased_ribbon(
core: &mut Core,
n: usize,
reveal: Option<usize>,
) -> (kui_core::Key, Vec<kui_core::Key>) {
use kui_core::{Easing, Transition};
let mut ui = core.frame(Size::new(100.0, 50.0), 1.0);
ui.configure_root(NodeSpec::column().fill());
let spec = NodeSpec::row()
.fill()
.scroll_x()
.transition_with(Transition::ms(100.0).easing(Easing::Linear));
let mut boxes = Vec::new();
let row = ui.with_keyed("row", spec, |ui| {
for i in 0..n {
boxes.push(ui.leaf_keyed(
&format!("b{i}"),
NodeSpec::column().width(100.0).grow_height(),
));
}
});
if let Some(i) = reveal {
ui.reveal(boxes[i]);
}
ui.finish();
(row, boxes)
}
fn drawn_x(core: &mut Core, row: kui_core::Key) -> f32 {
core.access_tree()
.nodes
.iter()
.find(|n| n.key == row)
.and_then(|n| n.scroll)
.expect("the ribbon is a scroll node")
.x
}
fn ribbon_mid_leg() -> (Core, kui_core::Key) {
let mut core = Core::new();
core.set_time(0.0);
let (row, _) = eased_ribbon(&mut core, 4, None);
eased_ribbon(&mut core, 4, Some(3));
core.set_time(0.05);
eased_ribbon(&mut core, 4, None);
assert!((drawn_x(&mut core, row) - 150.0).abs() < 1.0);
(core, row)
}
#[test]
fn the_wheel_mid_glide_moves_from_the_drawn_place() {
let (mut core, row) = ribbon_mid_leg();
core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 25.0)));
core.handle_input(InputEvent::Scroll(Vec2::new(40.0, 0.0)));
eased_ribbon(&mut core, 4, None);
let at = drawn_x(&mut core, row);
assert!((at - 110.0).abs() < 1.0, "150 back by the notch's 40: {at}");
assert!(!core.owed().scroll, "the hand ended the leg");
}
#[test]
fn a_reveal_mid_glide_lands_the_node_it_asked_for() {
let (mut core, row) = ribbon_mid_leg();
eased_ribbon(&mut core, 4, Some(2));
let target = core.scroll_offset(row).x;
assert!(
(target - 204.0).abs() < 1.0,
"b2's right edge in view: {target}"
);
core.set_time(0.2);
eased_ribbon(&mut core, 4, None);
assert!((drawn_x(&mut core, row) - 204.0).abs() < 1.0, "and landed");
}
#[test]
fn geometry_mid_glide_is_where_the_coming_frame_draws() {
let mut core = Core::new();
core.set_time(0.0);
let (row, _) = eased_ribbon(&mut core, 4, None);
eased_ribbon(&mut core, 4, Some(3));
core.set_time(0.05);
let read = core.scroll_geometry(row).expect("resolved").offset.x;
eased_ribbon(&mut core, 4, None);
let drawn = drawn_x(&mut core, row);
assert!((read - drawn).abs() < 0.5, "read {read}, drawn {drawn}");
assert!((drawn - 150.0).abs() < 1.0);
assert!(
!core.owed().requested,
"the slice was right, nothing re-owed"
);
}
#[test]
fn a_leg_over_content_that_shrank_stays_inside_it() {
let mut core = Core::new();
core.set_time(0.0);
let (row, _) = eased_ribbon(&mut core, 4, None);
eased_ribbon(&mut core, 4, Some(3));
core.set_time(0.2);
eased_ribbon(&mut core, 4, None);
assert_eq!(drawn_x(&mut core, row), 300.0);
eased_ribbon(&mut core, 4, Some(0));
core.set_time(0.25);
eased_ribbon(&mut core, 2, None);
let at = drawn_x(&mut core, row);
assert!(at <= 100.0 + 0.5, "drawn past the end: {at}");
let read = core.scroll_geometry(row).expect("resolved").offset.x;
assert!(read <= 100.0 + 0.5, "sliced past the end: {read}");
}
#[test]
fn the_access_tree_mid_glide_reports_the_drawn_offset() {
let (mut core, row) = ribbon_mid_leg();
assert_eq!(core.scroll_offset(row).x, 300.0, "the target");
let at = drawn_x(&mut core, row);
assert!((at - 150.0).abs() < 1.0, "reported {at}, drawn at 150");
}
#[test]
fn a_geometry_read_before_the_frame_begins_is_that_frames_read() {
let build = |core: &mut Core, h: f32| {
let mut ui = core.frame(Size::new(100.0, h), 1.0);
ui.configure_root(NodeSpec::column().fill());
let k = ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
ui.leaf_keyed("tall", NodeSpec::column().height(1000.0));
});
ui.finish();
k
};
let mut core = Core::new();
build(&mut core, 100.0);
build(&mut core, 100.0);
build(&mut core, 300.0);
assert!(!core.owed().requested, "no read, nothing owed");
let mut core = Core::new();
let k = build(&mut core, 100.0);
build(&mut core, 100.0);
core.scroll_geometry(k).expect("resolved");
build(&mut core, 300.0);
assert!(core.owed().requested, "the read was sliced by a 100 box");
}
#[test]
fn a_read_of_a_container_not_laid_out_owes_no_frame() {
let build = |core: &mut Core, show: bool| {
let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
ui.configure_root(NodeSpec::column().fill());
ui.scroll_geometry(list_key());
if show {
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
ui.leaf_keyed("tall", NodeSpec::column().height(1000.0));
});
}
ui.finish();
};
let mut core = Core::new();
build(&mut core, true);
core.set_scroll(list_key(), Vec2::new(0.0, 200.0));
build(&mut core, false);
for _ in 0..3 {
build(&mut core, false);
assert!(!core.owed().requested, "a hidden list asked for a frame");
}
}
#[test]
fn a_long_glide_over_variable_rows_reaches_the_row_asked_for() {
use kui_core::{Easing, Transition};
let frame = |core: &mut Core, h: &mut RowHeights| {
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
kui_core::widgets::list(
&mut ui,
"list",
NodeSpec::column()
.fill()
.transition_with(Transition::ms(100.0).easing(Easing::Linear)),
h,
|_ui, i, _w| var_h(i),
|ui, i| {
ui.leaf(
NodeSpec::row()
.fill()
.bg(Color::rgb8(40, 40, 60))
.on_click(Value::Int(i as i64)),
);
},
);
ui.finish();
};
let mut core = Core::new();
let mut h = RowHeights::new(VAR_ROWS, 20.0);
let mut t = 0.0;
core.set_time(t);
frame(&mut core, &mut h);
frame(&mut core, &mut h);
let target = 400usize;
core.set_scroll(list_key(), Vec2::new(0.0, h.offset_of(target)));
for _ in 0..30 {
t += 1.0 / 60.0;
core.set_time(t);
frame(&mut core, &mut h);
}
assert_eq!(row_under(&mut core, 4.0), Some(target as i64));
}
#[test]
fn strokes_in_a_departing_scrolled_row_are_clipped_by_the_container() {
let mut core = Core::new();
let build = |core: &mut Core, now: f64, show: bool| {
core.set_time(now);
let mut ui = core.frame(Size::new(400.0, VIEW_H), 1.0);
ui.configure_root(NodeSpec::column().fill());
if show {
let panel = NodeSpec::column()
.fill()
.transition(100.0)
.exit(kui_core::Enter::default().opacity(0.0));
ui.with_keyed("panel", panel, |ui| {
ui.leaf_keyed("strip", NodeSpec::row().grow_width().height(40.0));
ui.with_keyed("list", NodeSpec::column().fill().scroll_y(), |ui| {
for i in 0..ROWS {
ui.with_keyed(
&format!("row{i}"),
NodeSpec::row().grow_width().height(ROW_H),
|ui| {
ui.line(
Vec2::new(10.0, 0.0),
Vec2::new(10.0, ROW_H),
kui_core::Stroke::new(2.0, Color::rgb8(200, 200, 200)),
NodeSpec::row(),
);
},
);
}
});
});
}
ui.finish();
};
build(&mut core, 0.0, true);
core.set_scroll(
Key::ROOT.str("panel").str("list"),
Vec2::new(0.0, 3.0 * ROW_H),
);
build(&mut core, 0.0, true);
build(&mut core, 0.01, false);
assert!(core.animating(), "the exit is playing");
let (dl, _) = core.output();
let segments: Vec<_> = dl
.quads
.iter()
.filter(|q| q.rect.h > 0.0 && q.kind == kui_core::QuadKind::Segment)
.collect();
assert!(!segments.is_empty(), "the ghost draws its strokes");
let spill: Vec<_> = segments
.iter()
.filter(|q| q.rect.y < 40.0 - 0.5)
.filter(|q| dl.clips[q.clip as usize].rect.y < 40.0 - 0.5)
.map(|q| (q.rect, dl.clips[q.clip as usize].rect))
.collect();
assert!(
spill.is_empty(),
"a ghost's stroke over the strip: {spill:?}"
);
}