use ratatui::layout::{Position, Rect};
use ratatui::widgets::ListState;
pub fn down(state: &mut ListState, n: usize, len: usize) {
let Some(last) = len.checked_sub(1) else {
return;
};
let current = state.selected().unwrap_or(0);
state.select(Some(current.saturating_add(n).min(last)));
}
pub fn up(state: &mut ListState, n: usize, len: usize) {
let Some(last) = len.checked_sub(1) else {
return;
};
let current = state.selected().unwrap_or(0).min(last);
state.select(Some(current.saturating_sub(n)));
}
pub fn row_at(state: &ListState, area: Rect, at: Position, len: usize) -> Option<usize> {
if !area.contains(at) {
return None;
}
let row = usize::from(at.y.saturating_sub(area.y));
let index = state.offset() + row;
(index < len).then_some(index)
}
pub fn window(
selected: Option<usize>,
offset: usize,
len: usize,
height: usize,
) -> (std::ops::Range<usize>, Option<usize>) {
let last = len.saturating_sub(1);
let selected = selected.map(|at| at.min(last));
let offset = offset.min(last);
let first = match selected {
Some(at) if at < offset => at,
Some(at) if at >= offset + height => at + 1 - height,
_ => offset,
};
(first..len.min(first + height), selected)
}
pub fn in_order<'a, T>(items: &'a [T], ids: &[u64], id: impl Fn(&T) -> u64) -> Vec<&'a T> {
let mut wanted: Vec<(u64, usize)> = ids.iter().copied().zip(0..).collect();
wanted.sort_unstable();
let mut found: Vec<Option<&T>> = vec![None; ids.len()];
for item in items {
if let Ok(at) = wanted.binary_search_by_key(&id(item), |&(id, _)| id) {
found[wanted[at].1] = Some(item);
}
}
found.into_iter().flatten().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn at(index: usize) -> ListState {
let mut state = ListState::default();
state.select(Some(index));
state
}
#[test]
fn movement_clamps_at_both_ends_rather_than_wrapping() {
let mut state = at(0);
up(&mut state, 1, 5);
assert_eq!(
state.selected(),
Some(0),
"the top does not wrap to the end"
);
let mut state = at(4);
down(&mut state, 1, 5);
assert_eq!(
state.selected(),
Some(4),
"the end does not wrap to the top"
);
}
#[test]
fn usize_max_is_how_a_panel_says_first_and_last() {
let mut state = at(2);
down(&mut state, usize::MAX, 5);
assert_eq!(state.selected(), Some(4));
up(&mut state, usize::MAX, 5);
assert_eq!(state.selected(), Some(0));
}
#[test]
fn an_empty_list_keeps_no_selection() {
let mut state = ListState::default();
down(&mut state, 1, 0);
up(&mut state, 1, 0);
assert_eq!(
state.selected(),
None,
"a highlight over an empty list has nothing under it"
);
}
#[test]
fn both_directions_pull_a_selection_left_past_the_end_back_into_range() {
let mut state = at(30);
down(&mut state, 1, 5);
assert_eq!(state.selected(), Some(4), "down clamps into the new list");
let mut state = at(30);
up(&mut state, 1, 5);
assert_eq!(
state.selected(),
Some(3),
"up must land one above the last row, not one below a row that is gone"
);
let mut state = at(30);
up(&mut state, usize::MAX, 5);
assert_eq!(
state.selected(),
Some(0),
"and `first` still goes to the top"
);
}
#[test]
fn a_click_on_a_scrolled_list_accounts_for_the_offset() {
let area = Rect::new(0, 5, 20, 4);
let mut state = ListState::default();
*state.offset_mut() = 10;
assert_eq!(row_at(&state, area, Position::new(3, 5), 40), Some(10));
assert_eq!(row_at(&state, area, Position::new(3, 7), 40), Some(12));
}
#[test]
fn a_click_outside_the_list_or_past_its_end_selects_nothing() {
let area = Rect::new(0, 5, 20, 10);
let state = ListState::default();
assert_eq!(row_at(&state, area, Position::new(3, 4), 40), None, "above");
assert_eq!(
row_at(&state, area, Position::new(30, 6), 40),
None,
"right"
);
assert_eq!(
row_at(&state, area, Position::new(3, 9), 3),
None,
"the blank space below the last item is not the last item"
);
}
#[test]
fn the_window_is_the_one_list_would_have_scrolled_to() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
for len in 1..=8usize {
for height in 1..=5u16 {
for offset in 0..=10 {
for selected in std::iter::once(None).chain((0..=9).map(Some)) {
let list = ratatui::widgets::List::new(
(0..len).map(|i| ratatui::widgets::ListItem::new(i.to_string())),
);
let mut state = ListState::default()
.with_offset(offset)
.with_selected(selected);
let mut terminal = Terminal::new(TestBackend::new(4, height)).unwrap();
terminal
.draw(|frame| {
frame.render_stateful_widget(list, frame.area(), &mut state);
})
.unwrap();
let buffer = terminal.backend().buffer();
let drawn: Vec<usize> = (0..height)
.filter_map(|y| buffer[(0, y)].symbol().parse().ok())
.collect();
let case =
format!("{len} long, {height} high, from {offset} with {selected:?}");
let (shown, chosen) = window(selected, offset, len, usize::from(height));
assert_eq!(shown.start, state.offset(), "the scroll: {case}");
assert_eq!(chosen, state.selected(), "the selection: {case}");
assert_eq!(drawn, shown.collect::<Vec<_>>(), "the rows: {case}");
}
}
}
}
}
}