use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::widgets::{List, ListState};
#[derive(Debug, Default)]
pub(crate) struct ListScroll {
at: std::cell::Cell<(u64, usize)>,
}
impl ListScroll {
pub(crate) fn render(
&self,
f: &mut Frame,
area: Rect,
list: List<'_>,
sel: Option<usize>,
len: usize,
) -> usize {
self.render_ctx(f, area, list, sel, len, 0)
}
pub(crate) fn render_ctx(
&self,
f: &mut Frame,
area: Rect,
list: List<'_>,
sel: Option<usize>,
len: usize,
ctx: u64,
) -> usize {
let (last_ctx, carried) = self.at.get();
let carried = if last_ctx == ctx {
carried.min(len.saturating_sub(1))
} else {
0
};
let mut st = ListState::default().with_offset(carried);
st.select(sel);
f.render_stateful_widget(list, area, &mut st);
let offset = st.offset();
self.at.set((ctx, offset));
offset
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn offset(&self) -> usize {
self.at.get().1
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::widgets::ListItem;
fn scroll_through(rows: usize, height: u16, selections: &[usize]) -> Vec<usize> {
let mut term =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(20, height)).unwrap();
let scroll = ListScroll::default();
let mut seen = Vec::new();
for &sel in selections {
term.draw(|f| {
let items: Vec<ListItem> =
(0..rows).map(|i| ListItem::new(format!("{i}"))).collect();
let area = Rect::new(0, 0, 20, height);
seen.push(scroll.render(f, area, List::new(items), Some(sel), rows));
})
.unwrap();
}
seen
}
#[test]
fn the_list_holds_still_until_the_cursor_reaches_an_edge() {
let seen = scroll_through(10, 4, &[0, 6, 4]);
assert_eq!(seen[0], 0, "starts at the top");
assert_eq!(seen[1], 3, "walking past the bottom edge scrolls");
assert_eq!(
seen[2], 3,
"coming back to a row that is already visible must not scroll"
);
}
#[test]
fn a_shrinking_list_pulls_the_viewport_back() {
let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap();
let scroll = ListScroll::default();
term.draw(|f| {
let items: Vec<ListItem> = (0..40).map(|i| ListItem::new(format!("{i}"))).collect();
scroll.render(f, Rect::new(0, 0, 20, 4), List::new(items), Some(30), 40);
})
.unwrap();
assert!(scroll.offset() > 0, "scrolled a long way down");
term.draw(|f| {
let items: Vec<ListItem> = (0..2).map(|i| ListItem::new(format!("{i}"))).collect();
scroll.render(f, Rect::new(0, 0, 20, 4), List::new(items), Some(0), 2);
})
.unwrap();
assert_eq!(scroll.offset(), 0, "the short list is shown from the top");
}
#[test]
fn another_lists_scroll_position_does_not_carry_over() {
let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap();
let scroll = ListScroll::default();
let draw = |term: &mut ratatui::Terminal<_>, sel: usize, ctx: u64| {
term.draw(|f| {
let items: Vec<ListItem> = (0..40).map(|i| ListItem::new(format!("{i}"))).collect();
scroll.render_ctx(
f,
Rect::new(0, 0, 20, 4),
List::new(items),
Some(sel),
40,
ctx,
);
})
.unwrap();
};
draw(&mut term, 30, 1);
assert!(scroll.offset() > 0);
draw(&mut term, 0, 2);
assert_eq!(scroll.offset(), 0, "a different list starts at the top");
}
}