use std::cell::Cell;
use ratatui::{Frame, layout::Rect, text::Line, widgets::Paragraph};
use super::{chrome, nav, scroll, style};
use crate::theme::Skin;
pub struct ListView<'a> {
pub rows: Vec<Line<'static>>,
pub selected: usize,
pub offset: &'a Cell<usize>,
}
pub fn render(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: ListView,
) -> usize {
render_core(frame, area, skin, view)
}
pub fn render_counted(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: ListView,
) -> usize {
if area.height <= 1 {
return render_core(frame, area, skin, view);
}
let badge = chrome::position_badge(view.selected, view.rows.len());
let rows = Rect {
height: area.height - 1,
..area
};
let viewport = render_core(frame, rows, skin, view);
chrome::render_corner_badge(frame, area, skin, &badge);
viewport
}
pub fn render_boxed(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: ListView,
decor: &chrome::BoxDecor,
force: bool,
) -> usize {
if force {
let badge = chrome::position_badge(view.selected, view.rows.len());
let inner = chrome::framed_decor(frame, area, skin, decor, &badge);
render_core(frame, inner, skin, view)
} else {
render(frame, area, skin, view)
}
}
fn render_core(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: ListView,
) -> usize {
let viewport = area.height as usize;
let total = view.rows.len();
let selected = view.selected;
let scroll = nav::ScrollView {
total,
offset: view.offset.get(),
viewport,
};
let visible_offset = nav::keep_visible(scroll, selected);
view.offset.set(visible_offset);
let highlight = style::bg(skin.palette.selection);
let visible: Vec<Line> = view
.rows
.into_iter()
.enumerate()
.skip(visible_offset)
.take(viewport)
.map(|(index, line)| {
if index == selected {
line.style(highlight)
} else {
line
}
})
.collect();
frame.render_widget(Paragraph::new(visible), area);
scroll::render_scrollbar(
frame,
area,
skin,
nav::ScrollView {
total,
offset: visible_offset,
viewport,
},
);
viewport
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::theme::{
ColorOverrides, GlyphVariant, Glyphs, Palette, ThemeRegistry,
};
fn skin() -> Skin {
let base = ThemeRegistry::builtin().resolve("default");
Skin::new(
Palette::resolve(base, &ColorOverrides::default()),
Glyphs::new(GlyphVariant::Unicode),
)
}
fn rows(count: usize) -> Vec<Line<'static>> {
(0..count).map(|i| Line::from(format!("row {i}"))).collect()
}
fn viewport_of(
width: u16,
height: u16,
body: impl FnOnce(&mut Frame, Rect, &Skin) -> usize,
) -> usize {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("a test terminal");
let mut reported = 0;
terminal
.draw(|frame| {
reported = body(frame, frame.area(), &skin());
})
.expect("the frame draws");
reported
}
#[test]
fn a_plain_list_uses_every_row_of_its_area() {
let offset = Cell::new(0);
let viewport = viewport_of(20, 6, |frame, area, skin| {
render(
frame,
area,
skin,
ListView {
rows: rows(10),
selected: 0,
offset: &offset,
},
)
});
assert_eq!(viewport, 6);
}
#[test]
fn a_counted_list_gives_up_one_row_to_the_badge() {
let offset = Cell::new(0);
let viewport = viewport_of(20, 6, |frame, area, skin| {
render_counted(
frame,
area,
skin,
ListView {
rows: rows(10),
selected: 0,
offset: &offset,
},
)
});
assert_eq!(viewport, 5);
}
#[test]
fn a_one_row_area_keeps_its_content_and_drops_the_badge() {
let offset = Cell::new(0);
let viewport = viewport_of(20, 1, |frame, area, skin| {
render_counted(
frame,
area,
skin,
ListView {
rows: rows(10),
selected: 0,
offset: &offset,
},
)
});
assert_eq!(viewport, 1);
}
#[test]
fn the_offset_follows_a_selection_past_the_viewport() {
let offset = Cell::new(0);
viewport_of(20, 4, |frame, area, skin| {
render(
frame,
area,
skin,
ListView {
rows: rows(20),
selected: 12,
offset: &offset,
},
)
});
assert!(offset.get() > 0, "the list did not scroll to the selection");
assert!(offset.get() <= 12, "scrolled past the selection");
}
#[test]
fn an_empty_list_renders_without_panicking() {
let offset = Cell::new(0);
let viewport = viewport_of(20, 4, |frame, area, skin| {
render_counted(
frame,
area,
skin,
ListView {
rows: Vec::new(),
selected: 0,
offset: &offset,
},
)
});
assert_eq!(viewport, 3);
}
}