use std::cell::RefCell;
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{Element, RenderCtx, View};
use super::scroll::{ScrollState, draw_scrollbar};
pub struct ItemScroll {
items: Vec<Element>,
gap: u16,
window_start_row: usize,
content_height: Option<usize>,
offset: usize,
scrollbar: bool,
heights: RefCell<Option<(u16, Vec<u16>)>>,
}
impl ItemScroll {
pub fn new(items: Vec<Element>, state: &ScrollState) -> Self {
Self {
items,
gap: 0,
window_start_row: 0,
content_height: None,
offset: state.offset(),
scrollbar: true,
heights: RefCell::new(None),
}
}
pub fn windowed(
window: Vec<Element>,
window_start_row: usize,
content_height: usize,
state: &ScrollState,
) -> Self {
Self {
items: window,
gap: 0,
window_start_row,
content_height: Some(content_height),
offset: state.offset(),
scrollbar: true,
heights: RefCell::new(None),
}
}
pub fn gap(mut self, rows: u16) -> Self {
self.gap = rows;
self
}
pub fn scrollbar(mut self, show: bool) -> Self {
self.scrollbar = show;
self
}
pub fn measure_height(items: &[Element], width: u16, gap: u16, scrollbar: bool) -> usize {
let heights = measure_items(items, content_width(width, scrollbar));
total_height(&heights, gap)
}
fn with_heights<R>(&self, width: u16, f: impl FnOnce(&[u16]) -> R) -> R {
let mut cache = self.heights.borrow_mut();
let fresh = !matches!(cache.as_ref(), Some((w, _)) if *w == width);
if fresh {
*cache = Some((width, measure_items(&self.items, width)));
}
let (_, heights) = cache.as_ref().expect("heights measured above");
f(heights)
}
fn content_height(&self, width: u16) -> usize {
match self.content_height {
Some(h) => h,
None => self.with_heights(width, |h| total_height(h, self.gap)),
}
}
}
fn content_width(width: u16, scrollbar: bool) -> u16 {
if scrollbar {
width.saturating_sub(1)
} else {
width
}
}
fn measure_items(items: &[Element], width: u16) -> Vec<u16> {
items
.iter()
.map(|item| item.measure(Size::new(width, u16::MAX)).height)
.collect()
}
fn total_height(heights: &[u16], gap: u16) -> usize {
let rows: usize = heights.iter().map(|h| *h as usize).sum();
let gaps = heights.len().saturating_sub(1) * gap as usize;
rows + gaps
}
impl View for ItemScroll {
fn measure(&self, available: Size) -> Size {
let height = self
.content_height(content_width(available.width, self.scrollbar))
.min(u16::MAX as usize) as u16;
Size::new(available.width, height)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
let text_width = content_width(area.width, self.scrollbar);
if text_width == 0 {
return;
}
let content_h = self.content_height(text_width);
let overflow = content_h > area.height as usize;
let viewport_top = self.offset;
let viewport_bottom = self.offset + area.height as usize;
self.with_heights(text_width, |heights| {
let mut top = self.window_start_row;
for (item, height) in self.items.iter().zip(heights) {
let height = *height;
let bottom = top + height as usize;
if bottom > viewport_top && top < viewport_bottom {
let hidden = viewport_top.saturating_sub(top) as u16;
let y = area.y + (top.saturating_sub(viewport_top)) as u16;
let rect = Rect::new(area.x, y, text_width, height);
if hidden == 0 {
item.render(rect, surface, ctx);
} else {
let visible = height.saturating_sub(hidden).min(area.height);
render_scrolled(
item, area.x, area.y, text_width, height, hidden, visible, surface, ctx,
);
}
}
top = bottom + self.gap as usize;
if top >= viewport_bottom {
break;
}
}
});
if overflow && self.scrollbar {
draw_scrollbar(area, self.offset, content_h, surface, ctx);
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_scrolled(
item: &Element,
x: u16,
y: u16,
width: u16,
height: u16,
hidden: u16,
visible: u16,
surface: &mut Surface,
ctx: &RenderCtx,
) {
if visible == 0 || width == 0 || height == 0 {
return;
}
let destination = Rect::new(x, y, width, visible);
surface.render_ratatui(destination, |dst, buffer| {
let window = Rect::new(0, hidden, width, dst.height);
let mut scratch = Buffer::empty(window);
for row in 0..dst.height {
for col in 0..dst.width {
if let Some(cell) = buffer.cell((dst.x + col, dst.y + row)) {
scratch[(col, hidden + row)] = cell.clone();
}
}
}
let full = Rect::new(0, 0, width, height);
item.render(full, &mut Surface::new(&mut scratch, full), ctx);
for row in 0..dst.height {
for col in 0..dst.width {
if let Some(cell) = scratch.cell((col, hidden + row)) {
buffer[(dst.x + col, dst.y + row)] = cell.clone();
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{Boxed, Text};
use crate::style::Theme;
use crate::tests::support::{buffer, row};
use crate::view::element;
use ratatui_core::text::Line;
fn blocks(n: usize, rows: usize) -> Vec<Element> {
(0..n)
.map(|i| {
let lines: Vec<Line<'static>> = (0..rows)
.map(|r| Line::from(format!("item{i}-{r}")))
.collect();
element(Text::new(lines))
})
.collect()
}
fn paint(view: &ItemScroll, w: u16, h: u16) -> Vec<String> {
let theme = Theme::default();
let mut buf = buffer(w, h);
let area = Rect::new(0, 0, w, h);
let ctx = RenderCtx::new(&theme);
view.render(area, &mut Surface::new(&mut buf, area), &ctx);
(0..h)
.map(|y| row(&buf, y).trim_end().to_string())
.collect()
}
#[test]
fn measure_height_sums_items_and_gaps() {
let items = blocks(3, 2);
assert_eq!(ItemScroll::measure_height(&items, 20, 0, false), 6);
assert_eq!(ItemScroll::measure_height(&items, 20, 1, false), 8);
}
#[test]
fn renders_items_stacked_with_a_gap() {
let state = ScrollState::new();
let view = ItemScroll::new(blocks(2, 1), &state).gap(1);
assert_eq!(paint(&view, 20, 3), ["item0-0", "", "item1-0"]);
}
#[test]
fn scrolls_by_row_through_a_tall_item() {
let mut state = ScrollState::new();
state.set_offset(4);
let view = ItemScroll::new(blocks(3, 3), &state).scrollbar(false);
assert_eq!(paint(&view, 20, 3), ["item1-1", "item1-2", "item2-0"]);
}
#[test]
fn a_partially_scrolled_item_keeps_its_own_layout() {
let mut state = ScrollState::new();
state.set_offset(1);
let items: Vec<Element> = vec![element(
Boxed::new(element(Text::raw("body"))).title(Line::from("t")),
)];
let view = ItemScroll::new(items, &state).scrollbar(false);
assert_eq!(paint(&view, 8, 2), ["│ body │", "╰──────╯"]);
}
#[test]
fn windowed_matches_owned_for_the_same_offset() {
let mut state = ScrollState::new();
state.set_offset(3);
let owned = ItemScroll::new(blocks(4, 2), &state).scrollbar(false);
let windowed =
ItemScroll::windowed(blocks(4, 2).into_iter().skip(1).collect(), 2, 8, &state)
.scrollbar(false);
assert_eq!(paint(&owned, 20, 3), paint(&windowed, 20, 3));
}
#[test]
fn scrollbar_appears_only_on_overflow() {
let state = ScrollState::new();
let overflowing = ItemScroll::new(blocks(4, 2), &state);
let rows = paint(&overflowing, 10, 3);
assert!(rows.iter().any(|r| r.contains('█') || r.contains('│')));
let fits = ItemScroll::new(blocks(1, 1), &state);
assert_eq!(paint(&fits, 10, 3)[0], "item0-0");
}
#[test]
fn a_greedy_item_scrolls_without_allocating_its_measured_height() {
struct Greedy;
impl View for Greedy {
fn measure(&self, available: Size) -> Size {
available
}
fn render(&self, area: Rect, surface: &mut Surface, _ctx: &RenderCtx) {
for y in area.y..area.bottom().min(area.y.saturating_add(4)) {
surface.set_string(area.x, y, "greedy", ratatui_core::style::Style::default());
}
}
}
let mut state = ScrollState::new();
state.set_offset(2);
let items: Vec<Element> = vec![element(Greedy)];
let view = ItemScroll::new(items, &state).scrollbar(false);
assert_eq!(paint(&view, 8, 2), ["greedy", "greedy"]);
}
#[test]
fn degenerate_sizes_do_not_panic() {
let state = ScrollState::new();
for w in 0..4u16 {
for h in 0..4u16 {
let view = ItemScroll::new(blocks(3, 2), &state);
let theme = Theme::default();
let mut buf = buffer(w.max(1), h.max(1));
let area = Rect::new(0, 0, w, h);
let ctx = RenderCtx::new(&theme);
view.render(area, &mut Surface::new(&mut buf, area), &ctx);
}
}
}
}