use std::collections::HashMap;
use ratatui_core::{
buffer::Buffer,
layout::{Position, Rect},
style::{Style, Styled},
widgets::{StatefulWidget, Widget},
};
use ratatui_widgets::block::Block;
use ratatui_widgets::block::BlockExt;
use ratatui_widgets::scrollbar::Scrollbar;
use crate::{utils::layout_on_viewport, ListState};
#[allow(clippy::module_name_repetitions)]
pub struct ListView<'a, T> {
pub item_count: usize,
pub builder: ListBuilder<'a, T>,
pub scroll_axis: ScrollAxis,
pub scroll_direction: ScrollDirection,
pub style: Style,
pub block: Option<Block<'a>>,
pub scrollbar: Option<Scrollbar<'a>>,
pub(crate) scroll_padding: u16,
pub(crate) infinite_scrolling: bool,
}
impl<'a, T> ListView<'a, T> {
#[must_use]
pub fn new(builder: ListBuilder<'a, T>, item_count: usize) -> Self {
Self {
builder,
item_count,
scroll_axis: ScrollAxis::Vertical,
scroll_direction: ScrollDirection::Forward,
style: Style::default(),
block: None,
scrollbar: None,
scroll_padding: 0,
infinite_scrolling: true,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.item_count == 0
}
#[must_use]
pub fn len(&self) -> usize {
self.item_count
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
#[must_use]
pub fn scrollbar(mut self, scrollbar: Scrollbar<'a>) -> Self {
self.scrollbar = Some(scrollbar);
self
}
#[must_use]
pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
self.style = style.into();
self
}
#[must_use]
pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self {
self.scroll_axis = scroll_axis;
self
}
#[must_use]
pub fn scroll_direction(mut self, scroll_direction: ScrollDirection) -> Self {
self.scroll_direction = scroll_direction;
self
}
#[must_use]
pub fn scroll_padding(mut self, scroll_padding: u16) -> Self {
self.scroll_padding = scroll_padding;
self
}
#[must_use]
pub fn infinite_scrolling(mut self, infinite_scrolling: bool) -> Self {
self.infinite_scrolling = infinite_scrolling;
self
}
}
impl<T> Styled for ListView<'_, T> {
type Item = Self;
fn style(&self) -> Style {
self.style
}
fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
self.style = style.into();
self
}
}
impl<'a, T: Copy + 'a> From<Vec<T>> for ListView<'a, T> {
fn from(value: Vec<T>) -> Self {
let item_count = value.len();
let builder = ListBuilder::new(move |context| (value[context.index], 1));
ListView::new(builder, item_count)
}
}
pub struct ListBuildContext {
pub index: usize,
pub is_selected: bool,
pub scroll_axis: ScrollAxis,
pub cross_axis_size: u16,
}
type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a;
pub struct ListBuilder<'a, T> {
closure: Box<ListBuilderClosure<'a, T>>,
}
impl<'a, T> ListBuilder<'a, T> {
pub fn new<F>(closure: F) -> Self
where
F: Fn(&ListBuildContext) -> (T, u16) + 'a,
{
ListBuilder {
closure: Box::new(closure),
}
}
pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
(self.closure)(context)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAxis {
#[default]
Vertical,
Horizontal,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
#[default]
Forward,
Backward,
}
impl<T: Widget> StatefulWidget for ListView<'_, T> {
type State = ListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
state.set_num_elements(self.item_count);
state.set_infinite_scrolling(self.infinite_scrolling);
buf.set_style(area, self.style);
if let Some(ref block) = self.block {
block.render(area, buf);
}
let inner_area = self.block.inner_if_some(area);
state.set_inner_area(inner_area);
state.set_scroll_axis(self.scroll_axis);
state.set_scroll_direction(self.scroll_direction);
if self.item_count == 0 {
return;
}
let (main_axis_size, cross_axis_size) = match self.scroll_axis {
ScrollAxis::Vertical => (inner_area.height, inner_area.width),
ScrollAxis::Horizontal => (inner_area.width, inner_area.height),
};
let (mut scroll_axis_pos, cross_axis_pos) = match self.scroll_axis {
ScrollAxis::Vertical => (inner_area.top(), inner_area.left()),
ScrollAxis::Horizontal => (inner_area.left(), inner_area.top()),
};
let mut viewport = layout_on_viewport(
state,
&self.builder,
self.item_count,
main_axis_size,
cross_axis_size,
self.scroll_axis,
self.scroll_padding,
);
state.update_scrollbar_state(
&self.builder,
self.item_count,
main_axis_size,
cross_axis_size,
self.scroll_axis,
);
let (start, end) = (
state.view_state.offset,
viewport.len() + state.view_state.offset,
);
if self.scroll_direction == ScrollDirection::Backward {
let total_visible: u16 = (start..end)
.filter_map(|i| viewport.get(&i))
.map(|e| e.main_axis_size.saturating_sub(e.truncation.value()))
.sum();
scroll_axis_pos += main_axis_size.saturating_sub(total_visible);
}
let mut cached_sizes: std::collections::HashMap<usize, u16> = HashMap::new();
for i in start..end {
let Some(element) = viewport.remove(&i) else {
break;
};
let visible_main_axis_size = element
.main_axis_size
.saturating_sub(element.truncation.value());
cached_sizes.insert(i, visible_main_axis_size);
let area = match self.scroll_axis {
ScrollAxis::Vertical => Rect::new(
cross_axis_pos,
scroll_axis_pos,
cross_axis_size,
visible_main_axis_size,
),
ScrollAxis::Horizontal => Rect::new(
scroll_axis_pos,
cross_axis_pos,
visible_main_axis_size,
cross_axis_size,
),
};
if element.truncation.value() > 0 {
render_truncated(
element.widget,
area,
buf,
element.main_axis_size,
&element.truncation,
self.style,
self.scroll_axis,
);
} else {
element.widget.render(area, buf);
}
scroll_axis_pos += visible_main_axis_size;
}
state.set_visible_main_axis_sizes(cached_sizes);
if let Some(scrollbar) = self.scrollbar {
scrollbar.render(area, buf, &mut state.scrollbar_state);
}
}
}
fn render_truncated<T: Widget>(
item: T,
available_area: Rect,
buf: &mut Buffer,
untruncated_size: u16,
truncation: &Truncation,
base_style: Style,
scroll_axis: ScrollAxis,
) {
let (width, height) = match scroll_axis {
ScrollAxis::Vertical => (available_area.width, untruncated_size),
ScrollAxis::Horizontal => (untruncated_size, available_area.height),
};
let mut hidden_buffer = Buffer::empty(Rect {
x: available_area.left(),
y: available_area.top(),
width,
height,
});
hidden_buffer.set_style(hidden_buffer.area, base_style);
item.render(hidden_buffer.area, &mut hidden_buffer);
match scroll_axis {
ScrollAxis::Vertical => {
let offset = match truncation {
Truncation::Top(value) => *value,
_ => 0,
};
for y in available_area.top()..available_area.bottom() {
let y_off = y + offset;
for x in available_area.left()..available_area.right() {
if let Some(to) = buf.cell_mut(Position::new(x, y)) {
if let Some(from) = hidden_buffer.cell(Position::new(x, y_off)) {
*to = from.clone();
}
}
}
}
}
ScrollAxis::Horizontal => {
let offset = match truncation {
Truncation::Top(value) => *value,
_ => 0,
};
for x in available_area.left()..available_area.right() {
let x_off = x + offset;
for y in available_area.top()..available_area.bottom() {
if let Some(to) = buf.cell_mut(Position::new(x, y)) {
if let Some(from) = hidden_buffer.cell(Position::new(x_off, y)) {
*to = from.clone();
}
}
}
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, PartialOrd, Eq, Ord)]
pub(crate) enum Truncation {
#[default]
None,
Top(u16),
Bot(u16),
}
impl Truncation {
pub(crate) fn value(&self) -> u16 {
match self {
Self::Top(value) | Self::Bot(value) => *value,
Self::None => 0,
}
}
}
#[cfg(test)]
mod test {
use crate::ListBuilder;
use ratatui::widgets::Block;
use super::*;
use ratatui::widgets::Borders;
struct TestItem {}
impl Widget for TestItem {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Block::default().borders(Borders::ALL).render(area, buf);
}
}
fn test_data(total_height: u16) -> (Rect, Buffer, ListView<'static, TestItem>, ListState) {
let area = Rect::new(0, 0, 5, total_height);
let list = ListView::new(ListBuilder::new(|_| (TestItem {}, 3)), 3);
(area, Buffer::empty(area), list, ListState::default())
}
#[test]
fn not_truncated() {
let (area, mut buf, list, mut state) = test_data(9);
list.render(area, &mut buf, &mut state);
assert_buffer_eq(
buf,
Buffer::with_lines(vec![
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
]),
)
}
#[test]
fn empty_list() {
let area = Rect::new(0, 0, 5, 2);
let mut buf = Buffer::empty(area);
let mut state = ListState::default();
let builder = ListBuilder::new(|_| (TestItem {}, 0));
let list = ListView::new(builder, 0);
list.render(area, &mut buf, &mut state);
assert_buffer_eq(buf, Buffer::with_lines(vec![" ", " "]))
}
#[test]
fn zero_size() {
let (area, mut buf, list, mut state) = test_data(0);
list.render(area, &mut buf, &mut state);
assert_buffer_eq(buf, Buffer::empty(area))
}
#[test]
fn truncated_bot() {
let (area, mut buf, list, mut state) = test_data(8);
list.render(area, &mut buf, &mut state);
assert_buffer_eq(
buf,
Buffer::with_lines(vec![
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
]),
)
}
#[test]
fn truncated_top() {
let (area, mut buf, list, mut state) = test_data(8);
state.select(Some(2));
list.render(area, &mut buf, &mut state);
assert_buffer_eq(
buf,
Buffer::with_lines(vec![
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
]),
)
}
#[test]
fn scroll_up() {
let (area, mut buf, list, mut state) = test_data(8);
state.select(Some(2));
list.render(area, &mut buf, &mut state);
assert_buffer_eq(
buf,
Buffer::with_lines(vec![
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
]),
);
let (_, mut buf, list, _) = test_data(8);
state.select(Some(1));
list.render(area, &mut buf, &mut state);
assert_buffer_eq(
buf,
Buffer::with_lines(vec![
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
"┌───┐",
"│ │",
"└───┘",
]),
)
}
fn assert_buffer_eq(actual: Buffer, expected: Buffer) {
if actual.area != expected.area {
panic!(
"buffer areas not equal expected: {:?} actual: {:?}",
expected, actual
);
}
let diff = expected.diff(&actual);
if !diff.is_empty() {
panic!(
"buffer contents not equal\nexpected: {:?}\nactual: {:?}",
expected, actual,
);
}
assert_eq!(actual, expected, "buffers not equal");
}
}