use ratatui::layout::Rect;
use crate::widget::{RenderCtx, Renderable, RetainedChild, hash_combine, hash_str};
#[derive(Debug)]
pub struct List<T: Renderable> {
items: Vec<RetainedChild<T>>,
scroll: usize,
}
impl<T: Renderable> Default for List<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Renderable> List<T> {
#[must_use]
pub fn new() -> Self {
Self {
items: Vec::new(),
scroll: 0,
}
}
pub fn push(&mut self, item: T) {
self.items.push(RetainedChild::new(item));
}
pub fn scroll_to(&mut self, pos: usize) {
self.scroll = pos.min(self.items.len());
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use]
pub fn scroll(&self) -> usize {
self.scroll
}
}
impl<T: Renderable> Renderable for List<T> {
fn content_hash(&self) -> u64 {
let mut h = hash_combine(hash_str(&self.scroll.to_string()), self.items.len() as u64);
for item in self.items.iter().skip(self.scroll) {
h = hash_combine(h, item.inner().content_hash());
}
h
}
fn height_for(&self, width: u16, ctx: &RenderCtx) -> u16 {
let mut total: u32 = 0;
for item in self.items.iter().skip(self.scroll) {
total = total.saturating_add(u32::from(item.inner().height_for(width, ctx)));
}
u16::try_from(total).unwrap_or(u16::MAX)
}
fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
for item in self.items.iter_mut().skip(self.scroll) {
let h = item.inner().height_for(area.width, ctx);
if bottom.saturating_sub(y) == 0 {
break;
}
let item_area = Rect {
x: area.x,
y,
width: area.width,
height: h.min(bottom.saturating_sub(y)),
};
item.render_if_changed(item_area, ctx);
y = y.saturating_add(h);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{TerminalCaps, Theme};
use crate::widget::Text;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn render_list(list: &mut List<Text>, width: u16, height: u16) -> Buffer {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = RenderCtx::new(frame, &theme, &caps);
let area = ctx.area();
list.render(area, &mut ctx);
})
.unwrap();
term.backend().buffer().clone()
}
fn count_renders(list: &mut List<Text>, width: u16, height: u16) -> usize {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).unwrap();
let mut count = 0;
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = RenderCtx::new(frame, &theme, &caps);
let area = ctx.area();
for item in &mut list.items {
if item.render_if_changed(
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
&mut ctx,
) {
count += 1;
}
}
})
.unwrap();
count
}
#[test]
fn renders_only_visible_items() {
let mut list: List<Text> = List::new();
for i in 0..5 {
list.push(Text::new(format!("item{i}")));
}
let buf = render_list(&mut list, 10, 3);
let line0: String = (0..10).map(|x| buf[(x, 0)].symbol()).collect();
let line2: String = (0..10).map(|x| buf[(x, 2)].symbol()).collect();
assert!(
line0.starts_with("item0"),
"row 0 should start with item0: {line0:?}"
);
assert!(
line2.starts_with("item2"),
"row 2 should start with item2: {line2:?}"
);
}
#[test]
fn scroll_changes_hash() {
let mut list: List<Text> = List::new();
for i in 0..5 {
list.push(Text::new(format!("item{i}")));
}
let h_top = list.content_hash();
list.scroll_to(2);
let h_scrolled = list.content_hash();
assert_ne!(h_top, h_scrolled);
}
#[test]
fn unchanged_items_skip_render() {
let mut list: List<Text> = List::new();
list.push(Text::new("a"));
list.push(Text::new("b"));
list.push(Text::new("c"));
let count_first = count_renders(&mut list, 10, 3);
let count_second = count_renders(&mut list, 10, 3);
assert_eq!(count_first, 3, "first render should render all 3 items");
assert_eq!(count_second, 0, "unchanged items must skip second render");
}
#[test]
fn push_changes_hash() {
let mut list: List<Text> = List::new();
list.push(Text::new("a"));
let h1 = list.content_hash();
list.push(Text::new("b"));
let h2 = list.content_hash();
assert_ne!(h1, h2);
}
#[test]
fn scroll_to_saturates_at_len() {
let mut list: List<Text> = List::new();
list.push(Text::new("a"));
list.push(Text::new("b"));
list.scroll_to(99);
assert_eq!(list.scroll(), 2);
}
}