From 40500245d411028cc10b6563bf864565287363cf Mon Sep 17 00:00:00 2001
From: Philipp Reiter
Date: Sun, 28 Jul 2024 20:55:23 +0200
Subject: [PATCH] Introduce listview builder
---
Cargo.toml | 12 +-
examples/demo.rs | 108 +++---
examples/demo_legacy.rs | 295 +++++++++++++++++
examples/simple.rs | 135 ++++----
examples/simple_deprecated.rs | 196 -----------
examples/{long.rs => simple_legacy.rs} | 23 +-
examples/var_sizes.rs | 92 +++---
src/legacy/mod.rs | 4 +
src/{ => legacy}/traits.rs | 0
src/{ => legacy}/traits_deprecated.rs | 0
src/legacy/utils.rs | 208 ++++++++++++
src/{ => legacy}/widget.rs | 13 +-
src/lib.rs | 17 +-
src/list_view.rs | 125 +++++++
src/render.rs | 429 ++++++++++++++++++++++++
src/scroll_axis.rs | 10 +
src/utils.rs | 435 +++++++++++++++++--------
17 files changed, 1559 insertions(+), 543 deletions(-)
create mode 100644 examples/demo_legacy.rs
delete mode 100644 examples/simple_deprecated.rs
rename examples/{long.rs => simple_legacy.rs} (82%)
create mode 100644 src/legacy/mod.rs
rename src/{ => legacy}/traits.rs (100%)
rename src/{ => legacy}/traits_deprecated.rs (100%)
create mode 100644 src/legacy/utils.rs
rename src/{ => legacy}/widget.rs (97%)
create mode 100644 src/list_view.rs
create mode 100644 src/render.rs
create mode 100644 src/scroll_axis.rs
diff --git a/Cargo.toml b/Cargo.toml
index b3aa7dd..269ddc5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,13 +19,17 @@ crossterm = "0.27"
name = "simple"
[[example]]
-name = "simple_deprecated"
+name = "var_sizes"
[[example]]
-name = "long"
+name = "demo"
[[example]]
-name = "var_sizes"
+name = "demo_legacy"
[[example]]
-name = "demo"
+name = "simple_legacy"
+
+[features]
+default = ["unstable-widget-ref"]
+unstable-widget-ref = ["ratatui/unstable-widget-ref"]
diff --git a/examples/demo.rs b/examples/demo.rs
index 27ed143..f79615f 100644
--- a/examples/demo.rs
+++ b/examples/demo.rs
@@ -13,14 +13,13 @@ use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Widget};
use ratatui::Terminal;
use std::error::Error;
use std::io::{stdout, Stdout};
-use tui_widget_list::{List, ListState, PreRender, PreRenderContext, ScrollAxis};
+use tui_widget_list::{ListBuilder, ListState, ListView, ScrollAxis};
#[derive(Debug, Clone)]
pub struct TextContainer {
title: String,
content: Vec,
style: Style,
- selected_color: Color,
expand: bool,
}
@@ -37,38 +36,16 @@ impl Styled for TextContainer {
}
impl TextContainer {
- pub fn new(title: &str, content: Vec, selected_color: Color) -> Self {
+ pub fn new(title: &str, content: Vec) -> Self {
Self {
title: title.to_string(),
content,
style: Style::default(),
- selected_color,
expand: false,
}
}
}
-impl PreRender for TextContainer {
- fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
- if context.index % 2 == 0 {
- self.style = Style::default().bg(Color::Rgb(28, 28, 32));
- } else {
- self.style = Style::default().bg(Color::Rgb(0, 0, 0));
- }
-
- let mut main_axis_size = 2;
- if context.is_selected {
- self.style = Style::default()
- .bg(self.selected_color)
- .fg(Color::Rgb(28, 28, 32));
- self.expand = true;
- main_axis_size = 3 + self.content.len() as u16;
- }
-
- main_axis_size
- }
-}
-
impl Widget for TextContainer {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![Line::styled(self.title, self.style)];
@@ -113,17 +90,6 @@ impl Widget for ColoredContainer {
.render(area, buf);
}
}
-impl PreRender for ColoredContainer {
- fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
- if context.is_selected {
- self.border_style = Style::default().fg(Color::Black);
- self.border_type = BorderType::Thick;
- }
-
- 15
- }
-}
-
type Result = std::result::Result>;
fn main() -> Result<()> {
@@ -220,18 +186,12 @@ impl Widget for &mut App {
let text_list = demo_text_list(selected_color);
text_list.render(top, buf, &mut self.text_list_state);
- let color_list = List::new(
- colors
- .into_iter()
- .map(|color| ColoredContainer::new(color))
- .collect(),
- )
- .scroll_direction(ScrollAxis::Horizontal);
+ let color_list = demo_colors_list();
color_list.render(bottom, buf, &mut self.color_list_state);
}
}
-fn demo_text_list(selected_color: Color) -> List<'static, TextContainer> {
+fn demo_text_list(selected_color: Color) -> ListView<'static, TextContainer> {
let monday: Vec = vec![
String::from("1. Exercise for 30 minutes"),
String::from("2. Work on the project for 2 hours"),
@@ -267,16 +227,56 @@ fn demo_text_list(selected_color: Color) -> List<'static, TextContainer> {
String::from("2. Read in the park"),
String::from("3. Go to dinner with friends"),
];
- List::new(vec![
- TextContainer::new("Monday", monday, selected_color),
- TextContainer::new("Tuesday", tuesday, selected_color),
- TextContainer::new("Wednesday", wednesday, selected_color),
- TextContainer::new("Thursday", thursday, selected_color),
- TextContainer::new("Friday", friday, selected_color),
- TextContainer::new("Saturday", saturday, selected_color),
- TextContainer::new("Sunday", sunday, selected_color),
- ])
- .set_style(Style::default())
+ let containers = vec![
+ TextContainer::new("Monday", monday),
+ TextContainer::new("Tuesday", tuesday),
+ TextContainer::new("Wednesday", wednesday),
+ TextContainer::new("Thursday", thursday),
+ TextContainer::new("Friday", friday),
+ TextContainer::new("Saturday", saturday),
+ TextContainer::new("Sunday", sunday),
+ ];
+
+ let builder = ListBuilder::new(move |context| {
+ let mut main_axis_size = 2;
+
+ let mut container = containers[context.index].clone();
+
+ if context.index % 2 == 0 {
+ container.style = Style::default().bg(Color::Rgb(28, 28, 32));
+ } else {
+ container.style = Style::default().bg(Color::Rgb(0, 0, 0));
+ }
+
+ if context.is_selected {
+ container.style = Style::default()
+ .bg(selected_color)
+ .fg(Color::Rgb(28, 28, 32));
+ container.expand = true;
+ main_axis_size = 3 + container.content.len() as u16;
+ }
+
+ (container, main_axis_size)
+ });
+
+ ListView::new(builder, 7)
+}
+
+fn demo_colors_list() -> ListView<'static, ColoredContainer> {
+ let colors = demo_colors();
+ let builder = ListBuilder::new(move |context| {
+ let color = demo_colors()[context.index];
+
+ let mut widget = ColoredContainer::new(color);
+ if context.is_selected {
+ widget.border_style = Style::default().fg(Color::Black);
+ widget.border_type = BorderType::Thick;
+ };
+
+ (widget, 15)
+ });
+
+ ListView::new(builder, colors.len()).scroll_axis(ScrollAxis::Horizontal)
}
fn demo_colors() -> Vec {
diff --git a/examples/demo_legacy.rs b/examples/demo_legacy.rs
new file mode 100644
index 0000000..27ed143
--- /dev/null
+++ b/examples/demo_legacy.rs
@@ -0,0 +1,295 @@
+use crossterm::event::{
+ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
+};
+use crossterm::terminal::{
+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
+};
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::prelude::*;
+use ratatui::style::Styled;
+use ratatui::style::{Color, Style};
+use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Widget};
+use ratatui::Terminal;
+use std::error::Error;
+use std::io::{stdout, Stdout};
+use tui_widget_list::{List, ListState, PreRender, PreRenderContext, ScrollAxis};
+
+#[derive(Debug, Clone)]
+pub struct TextContainer {
+ title: String,
+ content: Vec,
+ style: Style,
+ selected_color: Color,
+ expand: bool,
+}
+
+impl Styled for TextContainer {
+ type Item = Self;
+ fn style(&self) -> Style {
+ self.style
+ }
+
+ fn set_style>(mut self, style: S) -> Self::Item {
+ self.style = style.into();
+ self
+ }
+}
+
+impl TextContainer {
+ pub fn new(title: &str, content: Vec, selected_color: Color) -> Self {
+ Self {
+ title: title.to_string(),
+ content,
+ style: Style::default(),
+ selected_color,
+ expand: false,
+ }
+ }
+}
+
+impl PreRender for TextContainer {
+ fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
+ if context.index % 2 == 0 {
+ self.style = Style::default().bg(Color::Rgb(28, 28, 32));
+ } else {
+ self.style = Style::default().bg(Color::Rgb(0, 0, 0));
+ }
+
+ let mut main_axis_size = 2;
+ if context.is_selected {
+ self.style = Style::default()
+ .bg(self.selected_color)
+ .fg(Color::Rgb(28, 28, 32));
+ self.expand = true;
+ main_axis_size = 3 + self.content.len() as u16;
+ }
+
+ main_axis_size
+ }
+}
+
+impl Widget for TextContainer {
+ fn render(self, area: Rect, buf: &mut Buffer) {
+ let mut lines = vec![Line::styled(self.title, self.style)];
+ if self.expand {
+ lines.push(Line::from(String::new()));
+ lines.extend(self.content.into_iter().map(|x| Line::from(x)));
+ lines.push(Line::from(String::new()));
+ }
+ Paragraph::new(lines)
+ .alignment(Alignment::Center)
+ .style(self.style)
+ .render(area, buf);
+ }
+}
+
+struct ColoredContainer {
+ color: Color,
+ border_style: Style,
+ border_type: BorderType,
+}
+
+impl ColoredContainer {
+ fn new(color: Color) -> Self {
+ Self {
+ color,
+ border_style: Style::default(),
+ border_type: BorderType::Plain,
+ }
+ }
+}
+
+impl Widget for ColoredContainer {
+ fn render(self, area: Rect, buf: &mut Buffer)
+ where
+ Self: Sized,
+ {
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(self.border_style)
+ .border_type(self.border_type)
+ .bg(self.color)
+ .render(area, buf);
+ }
+}
+impl PreRender for ColoredContainer {
+ fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
+ if context.is_selected {
+ self.border_style = Style::default().fg(Color::Black);
+ self.border_type = BorderType::Thick;
+ }
+
+ 15
+ }
+}
+
+type Result = std::result::Result>;
+
+fn main() -> Result<()> {
+ let mut terminal = init_terminal()?;
+
+ App::default().run(&mut terminal).unwrap();
+
+ reset_terminal()?;
+ terminal.show_cursor()?;
+
+ Ok(())
+}
+
+/// Initializes the terminal.
+fn init_terminal() -> Result>> {
+ crossterm::execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
+ enable_raw_mode()?;
+
+ let backend = CrosstermBackend::new(stdout());
+
+ let mut terminal = Terminal::new(backend)?;
+ terminal.hide_cursor()?;
+
+ panic_hook();
+
+ Ok(terminal)
+}
+
+/// Resets the terminal.
+fn reset_terminal() -> Result<()> {
+ disable_raw_mode()?;
+ crossterm::execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
+
+ Ok(())
+}
+
+/// Shutdown gracefully
+fn panic_hook() {
+ let original_hook = std::panic::take_hook();
+
+ std::panic::set_hook(Box::new(move |panic| {
+ reset_terminal().unwrap();
+ original_hook(panic);
+ }));
+}
+
+#[derive(Default)]
+pub struct App {
+ pub text_list_state: ListState,
+ pub color_list_state: ListState,
+}
+
+impl App {
+ pub fn run(&mut self, terminal: &mut Terminal) -> Result<()> {
+ loop {
+ self.draw(terminal)?;
+
+ if let Event::Key(key) = event::read()? {
+ if key.kind == KeyEventKind::Press {
+ match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Up | KeyCode::Char('k') => self.text_list_state.previous(),
+ KeyCode::Down | KeyCode::Char('j') => self.text_list_state.next(),
+ KeyCode::Left | KeyCode::Char('h') => self.color_list_state.previous(),
+ KeyCode::Right | KeyCode::Char('l') => self.color_list_state.next(),
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+
+ fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
+ terminal.draw(|frame| {
+ frame.render_widget(self, frame.size());
+ })?;
+ Ok(())
+ }
+}
+
+impl Widget for &mut App {
+ fn render(self, area: Rect, buf: &mut Buffer)
+ where
+ Self: Sized,
+ {
+ use Constraint::{Min, Percentage};
+ let [top, bottom] = Layout::vertical([Percentage(75), Min(0)]).areas(area);
+ let colors = demo_colors();
+ let selected_color = match self.color_list_state.selected {
+ Some(index) => colors[index],
+ None => colors[1],
+ };
+
+ let text_list = demo_text_list(selected_color);
+ text_list.render(top, buf, &mut self.text_list_state);
+
+ let color_list = List::new(
+ colors
+ .into_iter()
+ .map(|color| ColoredContainer::new(color))
+ .collect(),
+ )
+ .scroll_direction(ScrollAxis::Horizontal);
+ color_list.render(bottom, buf, &mut self.color_list_state);
+ }
+}
+
+fn demo_text_list(selected_color: Color) -> List<'static, TextContainer> {
+ let monday: Vec = vec![
+ String::from("1. Exercise for 30 minutes"),
+ String::from("2. Work on the project for 2 hours"),
+ String::from("3. Read a book for 1 hour"),
+ String::from("4. Cook dinner"),
+ ];
+ let tuesday: Vec = vec![
+ String::from("1. Attend a team meeting at 10 AM"),
+ String::from("2. Reply to emails"),
+ String::from("3. Prepare lunch"),
+ ];
+ let wednesday: Vec = vec![
+ String::from("1. Update work tasks"),
+ String::from("2. Conduct code review"),
+ String::from("3. Attend a training"),
+ ];
+ let thursday: Vec = vec![
+ String::from("1. Brainstorm for an upcoming project"),
+ String::from("2. Document ideas and refine tasks"),
+ ];
+ let friday: Vec = vec![
+ String::from("1. Have a recap meeting"),
+ String::from("2. Attent conference talk"),
+ String::from("3. Go running for 1 hour"),
+ ];
+ let saturday: Vec = vec![
+ String::from("1. Work on coding project"),
+ String::from("2. Read a chapter from a book"),
+ String::from("3. Go for a short walk"),
+ ];
+ let sunday: Vec = vec![
+ String::from("1. Plan upcoming trip"),
+ String::from("2. Read in the park"),
+ String::from("3. Go to dinner with friends"),
+ ];
+ List::new(vec![
+ TextContainer::new("Monday", monday, selected_color),
+ TextContainer::new("Tuesday", tuesday, selected_color),
+ TextContainer::new("Wednesday", wednesday, selected_color),
+ TextContainer::new("Thursday", thursday, selected_color),
+ TextContainer::new("Friday", friday, selected_color),
+ TextContainer::new("Saturday", saturday, selected_color),
+ TextContainer::new("Sunday", sunday, selected_color),
+ ])
+ .set_style(Style::default())
+}
+
+fn demo_colors() -> Vec {
+ vec![
+ Color::Rgb(255, 102, 102), // Neon Red
+ Color::Rgb(255, 153, 0), // Neon Orange
+ Color::Rgb(255, 204, 0), // Neon Yellow
+ Color::Rgb(0, 204, 102), // Neon Green
+ Color::Rgb(0, 204, 255), // Neon Blue
+ Color::Rgb(102, 51, 255), // Neon Purple
+ Color::Rgb(255, 51, 204), // Neon Magenta
+ Color::Rgb(51, 255, 255), // Neon Cyan
+ Color::Rgb(255, 102, 255), // Neon Pink
+ Color::Rgb(102, 255, 255), // Neon Aqua
+ ]
+}
diff --git a/examples/simple.rs b/examples/simple.rs
index f40a53a..a53f6eb 100644
--- a/examples/simple.rs
+++ b/examples/simple.rs
@@ -4,33 +4,20 @@ use crossterm::{
};
use ratatui::prelude::*;
use std::{error::Error, io};
-use tui_widget_list::{List, ListState, PreRender, PreRenderContext};
+use tui_widget_list::{ListBuilder, ListState, ListView};
type Result = std::result::Result>;
-impl Widget for ListItem<'_> {
- fn render(self, area: Rect, buf: &mut Buffer) {
- self.clone().get_line().render(area, buf);
- }
-}
+fn main() -> Result<()> {
+ let mut terminal = init_terminal()?;
-impl PreRender for ListItem<'_> {
- fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
- if context.index % 2 == 0 {
- self.style = Style::default().bg(Color::Rgb(28, 28, 32));
- } else {
- self.style = Style::default().bg(Color::Rgb(0, 0, 0));
- }
+ let mut app = App::new();
+ app.run(&mut terminal).unwrap();
- if context.is_selected {
- self.prefix = Some(">>");
- self.style = Style::default()
- .bg(Color::Rgb(255, 153, 0))
- .fg(Color::Rgb(28, 28, 32));
- };
+ reset_terminal()?;
+ terminal.show_cursor()?;
- 1
- }
+ Ok(())
}
pub struct App {
@@ -45,42 +32,61 @@ impl App {
}
}
-fn main() -> Result<()> {
- let mut terminal = init_terminal()?;
-
- let app = App::new();
- run_app(&mut terminal, app).unwrap();
-
- reset_terminal()?;
- terminal.show_cursor()?;
-
- Ok(())
-}
-
-pub fn run_app(terminal: &mut Terminal, mut app: App) -> io::Result<()> {
- let items: Vec<_> = (0..30)
- .map(|index| ListItem::new(Line::from(format!("Item {index}"))))
- .collect();
- let list = List::from(items);
-
- loop {
- terminal.draw(|f| ui(f, &mut app, list.clone()))?;
-
- if let Event::Key(key) = event::read()? {
- if key.kind == KeyEventKind::Press {
- match key.code {
- KeyCode::Char('q') => return Ok(()),
- KeyCode::Up => app.state.previous(),
- KeyCode::Down => app.state.next(),
- _ => {}
+impl App {
+ pub fn run(&mut self, terminal: &mut Terminal) -> Result<()> {
+ loop {
+ self.draw(terminal)?;
+
+ if let Event::Key(key) = event::read()? {
+ if key.kind == KeyEventKind::Press {
+ match key.code {
+ KeyCode::Char('q') => return Ok(()),
+ KeyCode::Up | KeyCode::Char('k') => self.state.previous(),
+ KeyCode::Down | KeyCode::Char('j') => self.state.next(),
+ _ => {}
+ }
}
}
}
}
+
+ fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
+ terminal.draw(|frame| {
+ frame.render_widget(self, frame.size());
+ })?;
+ Ok(())
+ }
}
-pub fn ui(f: &mut Frame, app: &mut App, list: List) {
- f.render_stateful_widget(list, f.size(), &mut app.state);
+impl Widget for &mut App {
+ fn render(self, area: Rect, buf: &mut Buffer)
+ where
+ Self: Sized,
+ {
+ let builder = ListBuilder::new(|context| {
+ let text = format!("Item {0}", context.index);
+ let mut item = Line::from(text);
+
+ if context.index % 2 == 0 {
+ item.style = Style::default().bg(Color::Rgb(28, 28, 32))
+ } else {
+ item.style = Style::default().bg(Color::Rgb(0, 0, 0))
+ };
+
+ if context.is_selected {
+ item = prefix_text(item, ">>");
+ item.style = Style::default()
+ .bg(Color::Rgb(255, 153, 0))
+ .fg(Color::Rgb(28, 28, 32));
+ };
+
+ return (item, 1);
+ });
+ let list = ListView::new(builder, 20);
+ let state = &mut self.state;
+
+ list.render(area, buf, state);
+ }
}
fn prefix_text<'a>(line: Line<'a>, prefix: &'a str) -> Line<'a> {
@@ -103,34 +109,23 @@ pub struct ListItem<'a> {
}
impl<'a> ListItem<'a> {
- pub fn new(line: T) -> Self
- where
- T: Into>,
- {
+ pub fn new(text: String, style: Style, prefix: Option<&'a str>) -> Self {
Self {
- line: line.into(),
- style: Style::default(),
- prefix: None,
+ line: Line::from(text),
+ style,
+ prefix,
}
}
+}
- pub fn style(mut self, style: Style) -> Self {
- self.style = style;
- self
- }
-
- pub fn prefix(mut self, prefix: Option<&'a str>) -> Self {
- self.prefix = prefix;
- self
- }
-
- fn get_line(self) -> Line<'a> {
+impl Widget for ListItem<'_> {
+ fn render(self, area: Rect, buf: &mut Buffer) {
let text = if let Some(prefix) = self.prefix {
prefix_text(self.line, prefix)
} else {
self.line
};
- Line::from(text).style(self.style)
+ Line::from(text).style(self.style).render(area, buf);
}
}
diff --git a/examples/simple_deprecated.rs b/examples/simple_deprecated.rs
deleted file mode 100644
index 3fd6783..0000000
--- a/examples/simple_deprecated.rs
+++ /dev/null
@@ -1,196 +0,0 @@
-#![allow(deprecated)]
-use crossterm::{
- event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
- terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
-};
-use ratatui::{
- backend::{Backend, CrosstermBackend},
- prelude::*,
- widgets::Widget,
-};
-use std::{error::Error, io};
-use tui_widget_list::{widget::List, ListState, ListableWidget, ScrollAxis};
-
-/// A simple list text item.
-#[derive(Debug, Clone)]
-pub struct ListItem<'a> {
- /// The line
- line: Line<'a>,
-
- /// The style
- style: Style,
-
- /// The current prefix. Changes when the item is selected.
- prefix: Option<&'a str>,
-}
-
-impl<'a> ListItem<'a> {
- pub fn new(line: T) -> Self
- where
- T: Into>,
- {
- Self {
- line: line.into(),
- style: Style::default(),
- prefix: None,
- }
- }
-
- pub fn style(mut self, style: Style) -> Self {
- self.style = style;
- self
- }
-
- pub fn prefix(mut self, prefix: Option<&'a str>) -> Self {
- self.prefix = prefix;
- self
- }
-
- fn get_line(self) -> Line<'a> {
- let text = if let Some(prefix) = self.prefix {
- prefix_text(self.line, prefix)
- } else {
- self.line
- };
- Line::from(text).style(self.style)
- }
-}
-
-impl ListableWidget for ListItem<'_> {
- fn size(&self, _: &ScrollAxis) -> usize {
- 1
- }
-
- fn highlight(self) -> Self {
- self.prefix(Some(">>"))
- .style(Style::default().bg(Color::Cyan))
- }
-}
-
-impl Widget for ListItem<'_> {
- fn render(self, area: Rect, buf: &mut Buffer) {
- self.get_line().render(area, buf);
- }
-}
-
-type Result = std::result::Result>;
-
-fn main() -> Result<()> {
- let mut terminal = init_terminal()?;
-
- let app = App::new();
- run_app(&mut terminal, app).unwrap();
-
- reset_terminal()?;
- terminal.show_cursor()?;
-
- Ok(())
-}
-
-/// Initializes the terminal.
-fn init_terminal() -> Result>> {
- crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
- enable_raw_mode()?;
-
- let backend = CrosstermBackend::new(io::stdout());
-
- let mut terminal = Terminal::new(backend)?;
- terminal.hide_cursor()?;
-
- panic_hook();
-
- Ok(terminal)
-}
-
-/// Resets the terminal.
-fn reset_terminal() -> Result<()> {
- disable_raw_mode()?;
- crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
-
- Ok(())
-}
-
-/// Shutdown gracefully
-fn panic_hook() {
- let original_hook = std::panic::take_hook();
-
- std::panic::set_hook(Box::new(move |panic| {
- reset_terminal().unwrap();
- original_hook(panic);
- }));
-}
-
-pub struct App<'a> {
- list: List<'a, ListItem<'a>>,
- state: ListState,
-}
-
-impl<'a> App<'a> {
- pub fn new() -> App<'a> {
- let items = vec![
- ListItem::new(Line::from("Item 0")),
- ListItem::new(Line::from("Item 1")),
- ListItem::new(Line::from("Item 2")),
- ListItem::new(Line::from("Item 3")),
- ListItem::new(Line::from("Item 4")),
- ListItem::new(Line::from("Item 5")),
- ListItem::new(Line::from("Item 6")),
- ListItem::new(Line::from("Item 7")),
- ListItem::new(Line::from("Item 8")),
- ListItem::new(Line::from("Item 9")),
- ListItem::new(Line::from("Item 10")),
- ListItem::new(Line::from("Item 11")),
- ListItem::new(Line::from("Item 12")),
- ListItem::new(Line::from("Item 13")),
- ListItem::new(Line::from("Item 14")),
- ListItem::new(Line::from("Item 15")),
- ListItem::new(Line::from("Item 16")),
- ListItem::new(Line::from("Item 17")),
- ListItem::new(Line::from("Item 18")),
- ListItem::new(Line::from("Item 19")),
- ListItem::new(Line::from("Item 20")),
- ListItem::new(Line::from("Item 21")),
- ListItem::new(Line::from("Item 22")),
- ListItem::new(Line::from("Item 23")),
- ListItem::new(Line::from("Item 24")),
- ListItem::new(Line::from("Item 25")),
- ListItem::new(Line::from("Item 26")),
- ListItem::new(Line::from("Item 27")),
- ListItem::new(Line::from("Item 28")),
- ListItem::new(Line::from("Item 29")),
- ];
- let state = ListState::default();
- App {
- list: items.into(),
- state,
- }
- }
-}
-
-pub fn run_app(terminal: &mut Terminal, mut app: App) -> io::Result<()> {
- loop {
- terminal.draw(|f| ui(f, &mut app))?;
-
- if let Event::Key(key) = event::read()? {
- if key.kind == KeyEventKind::Press {
- match key.code {
- KeyCode::Char('q') => return Ok(()),
- KeyCode::Up => app.state.previous(),
- KeyCode::Down => app.state.next(),
- _ => {}
- }
- }
- }
- }
-}
-
-pub fn ui(f: &mut Frame, app: &mut App) {
- let list = app.list.clone();
- f.render_stateful_widget(list, f.size(), &mut app.state);
-}
-
-fn prefix_text<'a>(line: Line<'a>, prefix: &'a str) -> Line<'a> {
- let mut spans = line.spans;
- spans.insert(0, Span::from(prefix));
- ratatui::text::Line::from(spans)
-}
diff --git a/examples/long.rs b/examples/simple_legacy.rs
similarity index 82%
rename from examples/long.rs
rename to examples/simple_legacy.rs
index 4768bdf..f40a53a 100644
--- a/examples/long.rs
+++ b/examples/simple_legacy.rs
@@ -1,8 +1,3 @@
-//! This example showcases a list with many widgets.
-//!
-//! Note that we do not implement `Widget` and `PreRender` on the ListItem itself,
-//! but on its reference. This avoids copying the values in each frame and makes
-//! rendering more performant.
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
@@ -13,15 +8,13 @@ use tui_widget_list::{List, ListState, PreRender, PreRenderContext};
type Result = std::result::Result>;
-/// Implement `Widget` on the mutable reference to ListItem
-impl Widget for &mut ListItem<'_> {
+impl Widget for ListItem<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
self.clone().get_line().render(area, buf);
}
}
-/// Implement `PreRender` on the mutable reference to ListItem
-impl PreRender for &mut ListItem<'_> {
+impl PreRender for ListItem<'_> {
fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
if context.index % 2 == 0 {
self.style = Style::default().bg(Color::Rgb(28, 28, 32));
@@ -34,8 +27,6 @@ impl PreRender for &mut ListItem<'_> {
self.style = Style::default()
.bg(Color::Rgb(255, 153, 0))
.fg(Color::Rgb(28, 28, 32));
- } else {
- self.prefix = None;
};
1
@@ -67,15 +58,13 @@ fn main() -> Result<()> {
}
pub fn run_app(terminal: &mut Terminal, mut app: App) -> io::Result<()> {
- // Create the widgets only once
- let mut items: Vec<_> = (0..300000)
+ let items: Vec<_> = (0..30)
.map(|index| ListItem::new(Line::from(format!("Item {index}"))))
.collect();
+ let list = List::from(items);
loop {
- // Then pass them by reference
- let list = List::from(items.iter_mut().collect::>());
- terminal.draw(|f| ui(f, &mut app, list))?;
+ terminal.draw(|f| ui(f, &mut app, list.clone()))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
@@ -90,7 +79,7 @@ pub fn run_app(terminal: &mut Terminal, mut app: App) -> io::Resu
}
}
-pub fn ui(f: &mut Frame, app: &mut App, list: List<&mut ListItem>) {
+pub fn ui(f: &mut Frame, app: &mut App, list: List) {
f.render_stateful_widget(list, f.size(), &mut app.state);
}
diff --git a/examples/var_sizes.rs b/examples/var_sizes.rs
index b9181d6..7082655 100644
--- a/examples/var_sizes.rs
+++ b/examples/var_sizes.rs
@@ -1,3 +1,4 @@
+#![cfg(feature = "unstable-widget-ref")]
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
@@ -5,25 +6,21 @@ use crossterm::{
use ratatui::{
backend::{Backend, CrosstermBackend},
prelude::*,
- widgets::{Block, Borders},
+ widgets::{Block, Borders, StatefulWidgetRef, WidgetRef},
};
use std::{error::Error, io};
-use tui_widget_list::{List, ListState, PreRender, PreRenderContext};
+use tui_widget_list::{ListBuilder, ListState, ListView};
#[derive(Debug, Clone)]
pub struct LineItem<'a> {
line: Line<'a>,
- height: u16,
}
impl LineItem<'_> {
- pub fn new(text: &str, height: u16) -> Self {
- let line = Line::from(Span::styled(
- text.to_string(),
- Style::default().fg(Color::Cyan),
- ))
- .style(Style::default().bg(Color::Black));
- Self { line, height }
+ pub fn new(text: String) -> Self {
+ let span = Span::styled(text, Style::default().fg(Color::Cyan));
+ let line = Line::from(span).bg(Color::Black);
+ Self { line }
}
pub fn set_style(&mut self, style: Style) {
@@ -33,27 +30,15 @@ impl LineItem<'_> {
}
}
-impl PreRender for LineItem<'_> {
- fn pre_render(&mut self, context: &PreRenderContext) -> u16 {
- if context.is_selected {
- self.line.style = Style::default().bg(Color::White);
- }
-
- let main_axis_size = self.height;
-
- main_axis_size
- }
-}
-
-impl Widget for LineItem<'_> {
- fn render(self, area: Rect, buf: &mut Buffer) {
+impl WidgetRef for LineItem<'_> {
+ fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let inner = {
let block = Block::default().borders(Borders::ALL);
block.clone().render(area, buf);
block.inner(area)
};
- self.line.render(inner, buf);
+ self.line.render_ref(inner, buf);
}
}
@@ -105,31 +90,38 @@ fn panic_hook() {
}
pub struct App<'a> {
- pub list: List<'a, LineItem<'a>>,
+ pub list: ListView<'a, LineItem<'a>>,
pub state: ListState,
}
+impl Widget for &mut App<'_> {
+ fn render(self, area: Rect, buf: &mut Buffer)
+ where
+ Self: Sized,
+ {
+ self.list.render_ref(area, buf, &mut self.state);
+ }
+}
+
impl<'a> App<'a> {
pub fn new() -> App<'a> {
- let items = vec![
- LineItem::new("Height: 4", 4),
- LineItem::new("Height: 6", 6),
- LineItem::new("Height: 5", 5),
- LineItem::new("Height: 4", 4),
- LineItem::new("Height: 3", 3),
- LineItem::new("Height: 3", 3),
- LineItem::new("Height: 6", 6),
- LineItem::new("Height: 5", 5),
- LineItem::new("Height: 7", 7),
- LineItem::new("Height: 3", 3),
- LineItem::new("Height: 6", 6),
- LineItem::new("Height: 9", 9),
- LineItem::new("Height: 4", 4),
- LineItem::new("Height: 6", 6),
- ];
- let list = List::new(items)
+ let sizes = vec![4, 6, 5, 4, 3, 3, 6, 5, 7, 3, 6, 9, 4, 6];
+ let item_count = sizes.len();
+
+ let block = Block::default().borders(Borders::ALL).title("Outer block");
+ let builder = ListBuilder::new(move |context| {
+ let size = sizes[context.index];
+ let mut widget = LineItem::new(format!("Height: {:0}", size));
+
+ if context.is_selected {
+ widget.line.style = Style::default().bg(Color::White);
+ };
+
+ return (widget, size);
+ });
+ let list = ListView::new(builder, item_count)
.bg(Color::Black)
- .block(Block::default().borders(Borders::ALL).title("Outer block"));
+ .block(block);
let state = ListState::default();
App { list, state }
}
@@ -137,7 +129,9 @@ impl<'a> App<'a> {
pub fn run_app(terminal: &mut Terminal, mut app: App) -> io::Result<()> {
loop {
- terminal.draw(|f| ui(f, &mut app))?;
+ terminal.draw(|frame| {
+ frame.render_widget(&mut app, frame.size());
+ })?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
@@ -152,7 +146,7 @@ pub fn run_app(terminal: &mut Terminal, mut app: App) -> io::Resu
}
}
-pub fn ui(f: &mut Frame, app: &mut App) {
- let list = app.list.clone();
- f.render_stateful_widget(list, f.size(), &mut app.state);
-}
+// pub fn ui(f: &mut Frame, app: App) {
+// // let list = app.list;
+// f.render_stateful_widget_ref(app.list, f.size(), &mut app.state);
+// }
diff --git a/src/legacy/mod.rs b/src/legacy/mod.rs
new file mode 100644
index 0000000..163235c
--- /dev/null
+++ b/src/legacy/mod.rs
@@ -0,0 +1,4 @@
+pub mod traits;
+pub mod traits_deprecated;
+mod utils;
+pub mod widget;
diff --git a/src/traits.rs b/src/legacy/traits.rs
similarity index 100%
rename from src/traits.rs
rename to src/legacy/traits.rs
diff --git a/src/traits_deprecated.rs b/src/legacy/traits_deprecated.rs
similarity index 100%
rename from src/traits_deprecated.rs
rename to src/legacy/traits_deprecated.rs
diff --git a/src/legacy/utils.rs b/src/legacy/utils.rs
new file mode 100644
index 0000000..c9d4e92
--- /dev/null
+++ b/src/legacy/utils.rs
@@ -0,0 +1,208 @@
+use std::collections::HashMap;
+
+use crate::{ListState, PreRender, PreRenderContext, ScrollAxis};
+
+/// This method checks how to layout the items on the viewport and if necessary
+/// updates the offset of the first item on the screen.
+///
+/// For this we start with the first item on the screen and iterate until we have
+/// reached the maximum height. If the selected value is within the bounds we do
+/// nothing. If the selected value is out of bounds, the offset is adjusted.
+///
+/// # Returns
+/// - The sizes along the main axis of the elements on the viewport,
+/// and how much they are being truncated to fit on the viewport.
+pub(crate) fn layout_on_viewport(
+ state: &mut ListState,
+ widgets: &mut [T],
+ total_main_axis_size: u16,
+ cross_axis_size: u16,
+ scroll_axis: ScrollAxis,
+) -> Vec {
+ // The items heights on the viewport will be calculated on the fly.
+ let mut viewport_layouts: Vec = Vec::new();
+
+ // If none is selected, the first item should be show on top of the viewport.
+ let selected = state.selected.unwrap_or(0);
+
+ // If the selected value is smaller than the offset, we roll
+ // the offset so that the selected value is at the top
+ if selected < state.offset {
+ state.offset = selected;
+ }
+
+ let mut main_axis_size_cache: HashMap = HashMap::new();
+
+ // Check if the selected item is in the current view
+ let (mut y, mut index) = (0, state.offset);
+ let mut found = false;
+ for widget in widgets.iter_mut().skip(state.offset) {
+ // Get the main axis size of the widget.
+ let is_selected = state.selected.map_or(false, |j| index == j);
+ let context = PreRenderContext::new(is_selected, cross_axis_size, scroll_axis, index);
+
+ let main_axis_size = widget.pre_render(&context);
+ main_axis_size_cache.insert(index, main_axis_size);
+
+ // Out of bounds
+ if y + main_axis_size > total_main_axis_size {
+ // Truncate the last widget
+ let dy = total_main_axis_size - y;
+ if dy > 0 {
+ viewport_layouts.push(ViewportLayout {
+ main_axis_size: dy,
+ truncated_by: main_axis_size.saturating_sub(dy),
+ });
+ }
+ break;
+ }
+ // Selected value is within view/bounds, so we are good
+ // but we keep iterating to collect the view heights
+ if selected == index {
+ found = true;
+ }
+ y += main_axis_size;
+ index += 1;
+
+ viewport_layouts.push(ViewportLayout {
+ main_axis_size,
+ truncated_by: 0,
+ });
+ }
+ if found {
+ return viewport_layouts;
+ }
+
+ // The selected item is out of bounds. We iterate backwards from the selected
+ // item and determine the first widget that still fits on the screen.
+ viewport_layouts.clear();
+ let (mut y, mut index) = (0, selected);
+ let last = widgets.len().saturating_sub(1);
+ for widget in widgets.iter_mut().rev().skip(last.saturating_sub(selected)) {
+ // Get the main axis size of the widget. At this point we might have already
+ // calculated it, so check the cache first.
+ let main_axis_size = if let Some(main_axis_size) = main_axis_size_cache.remove(&index) {
+ main_axis_size
+ } else {
+ let is_selected = state.selected.map_or(false, |j| index == j);
+ let context = PreRenderContext::new(is_selected, cross_axis_size, scroll_axis, index);
+
+ widget.pre_render(&context)
+ };
+
+ // Truncate the first widget
+ if y + main_axis_size >= total_main_axis_size {
+ let dy = total_main_axis_size - y;
+ viewport_layouts.insert(
+ 0,
+ ViewportLayout {
+ main_axis_size: dy,
+ truncated_by: main_axis_size.saturating_sub(dy),
+ },
+ );
+ state.offset = index;
+ break;
+ }
+
+ viewport_layouts.insert(
+ 0,
+ ViewportLayout {
+ main_axis_size,
+ truncated_by: 0,
+ },
+ );
+
+ y += main_axis_size;
+ index -= 1;
+ }
+
+ viewport_layouts
+}
+
+#[derive(Debug, PartialEq, PartialOrd, Eq, Ord)]
+pub(crate) struct ViewportLayout {
+ pub(crate) main_axis_size: u16,
+ pub(crate) truncated_by: u16,
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::{
+ prelude::*,
+ widgets::{Block, Borders},
+ };
+
+ use super::*;
+
+ struct TestItem {
+ main_axis_size: u16,
+ }
+
+ impl Widget for TestItem {
+ fn render(self, area: Rect, buf: &mut Buffer)
+ where
+ Self: Sized,
+ {
+ Block::default().borders(Borders::ALL).render(area, buf);
+ }
+ }
+
+ impl PreRender for TestItem {
+ fn pre_render(&mut self, _context: &PreRenderContext) -> u16 {
+ self.main_axis_size
+ }
+ }
+
+ macro_rules! update_view_port_tests {
+ ($($name:ident:
+ [
+ $given_offset:expr,
+ $given_selected:expr,
+ $given_sizes:expr,
+ $given_max_size:expr
+ ],
+ [
+ $expected_offset:expr,
+ $expected_sizes:expr
+ ],)*) => {
+ $(
+ #[test]
+ fn $name() {
+ // given
+ let mut given_state = ListState {
+ offset: $given_offset,
+ selected: $given_selected,
+ num_elements: $given_sizes.len(),
+ circular: true,
+ };
+
+ //when
+ let mut widgets: Vec = $given_sizes
+ .into_iter()
+ .map(|main_axis_size| TestItem {
+ main_axis_size: main_axis_size as u16,
+ })
+ .collect();
+ let scroll_axis = ScrollAxis::default();
+ let layouts = layout_on_viewport(&mut given_state, &mut widgets, $given_max_size, 0, scroll_axis);
+ let offset = given_state.offset;
+
+ // then
+ let main_axis_sizes: Vec = layouts.iter().map(|x| x.main_axis_size).collect();
+ assert_eq!(offset, $expected_offset);
+ assert_eq!(main_axis_sizes, $expected_sizes);
+ }
+ )*
+ }
+ }
+
+ update_view_port_tests! {
+ happy_path: [0, Some(0), vec![2, 3], 6], [0, vec![2, 3]],
+ empty_list: [0, None, Vec::::new(), 4], [0, vec![]],
+ update_offset_down: [0, Some(2), vec![2, 3, 3], 6], [1, vec![3, 3]],
+ update_offset_up: [1, Some(0), vec![2, 3, 3], 6], [0, vec![2, 3, 1]],
+ truncate_bottom: [0, Some(0), vec![2, 3], 4], [0, vec![2, 2]],
+ truncate_top: [0, Some(1), vec![2, 3], 4], [0, vec![1, 3]],
+ num_elements: [0, None, vec![1, 1, 1, 1, 1], 3], [0, vec![1, 1, 1]],
+ }
+}
diff --git a/src/widget.rs b/src/legacy/widget.rs
similarity index 97%
rename from src/widget.rs
rename to src/legacy/widget.rs
index 877b4e1..b34ca0e 100644
--- a/src/widget.rs
+++ b/src/legacy/widget.rs
@@ -5,7 +5,7 @@ use ratatui::{
widgets::{Block, StatefulWidget, Widget},
};
-use crate::{utils::layout_on_viewport, ListState, PreRender};
+use crate::{legacy::utils::layout_on_viewport, ListState, PreRender, ScrollAxis};
/// A [`List`] is a widget for Ratatui that can render an arbitrary list of widgets.
/// It is generic over `T`, where each widget `T` should implement the [`PreRender`]
@@ -239,17 +239,6 @@ fn render_trunc(
};
}
-/// Represents the scroll axis of a list.
-#[derive(Debug, Default, Clone, Copy)]
-pub enum ScrollAxis {
- /// Indicates vertical scrolling. This is the default.
- #[default]
- Vertical,
-
- /// Indicates horizontal scrolling.
- Horizontal,
-}
-
#[cfg(test)]
mod test {
use crate::PreRenderContext;
diff --git a/src/lib.rs b/src/lib.rs
index 92d34e3..41e8af6 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -100,15 +100,18 @@
//! ### Vertically and horizontally scrollable
//!
//!![](examples/tapes/demo.gif?v=1)
+pub mod legacy;
+pub mod list_view;
+pub mod render;
+pub mod scroll_axis;
pub mod state;
-pub mod traits;
-pub mod traits_deprecated;
-pub(crate) mod utils;
-pub mod widget;
+pub(crate) mod utils;
+pub use legacy::traits::{PreRender, PreRenderContext};
+pub use legacy::widget::List;
+pub use list_view::{ListBuildContext, ListBuilder, ListView};
+pub use scroll_axis::ScrollAxis;
pub use state::ListState;
-pub use traits::{PreRender, PreRenderContext};
-pub use widget::{List, ScrollAxis};
#[allow(deprecated)]
-pub use traits_deprecated::ListableWidget;
+pub use legacy::traits_deprecated::ListableWidget;
diff --git a/src/list_view.rs b/src/list_view.rs
new file mode 100644
index 0000000..111a0fe
--- /dev/null
+++ b/src/list_view.rs
@@ -0,0 +1,125 @@
+use crate::ScrollAxis;
+use ratatui::{
+ style::{Style, Styled},
+ widgets::Block,
+};
+
+/// A struct representing a list view widget.
+/// The widget displays a list of items in a scrollable area.
+pub struct ListView<'a, T> {
+ /// The total number of items in the list
+ pub item_count: usize,
+
+ /// A `ListBuilder` responsible for constructing the items in the list.
+ pub builder: ListBuilder,
+
+ /// Specifies the scroll axis. Either `Vertical` or `Horizontal`.
+ pub scroll_axis: ScrollAxis,
+
+ /// The base style of the list view.
+ pub style: Style,
+
+ /// The base block surrounding the widget list.
+ pub block: Option>,
+}
+
+impl<'a, T> ListView<'a, T> {
+ /// Creates a new `ListView` with a builder an item count.
+ #[must_use]
+ pub fn new(builder: ListBuilder, item_count: usize) -> Self {
+ Self {
+ builder,
+ item_count,
+ scroll_axis: ScrollAxis::Vertical,
+ style: Style::default(),
+ block: None,
+ }
+ }
+
+ /// Sets the block style that surrounds the whole List.
+ #[must_use]
+ pub fn block(mut self, block: Block<'a>) -> Self {
+ self.block = Some(block);
+ self
+ }
+
+ /// Checks whether the widget list is empty.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.item_count == 0
+ }
+
+ /// Returns the length of the widget list.
+ #[must_use]
+ pub fn len(&self) -> usize {
+ self.item_count
+ }
+
+ /// Set the base style of the List.
+ #[must_use]
+ pub fn style>(mut self, style: S) -> Self {
+ self.style = style.into();
+ self
+ }
+
+ /// Set the scroll axis of the list.
+ #[must_use]
+ pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self {
+ self.scroll_axis = scroll_axis;
+ self
+ }
+}
+
+impl Styled for ListView<'_, T> {
+ type Item = Self;
+
+ fn style(&self) -> Style {
+ self.style
+ }
+
+ fn set_style>(mut self, style: S) -> Self::Item {
+ self.style = style.into();
+ self
+ }
+}
+
+/// This structure holds information about the item's position, selection
+/// status, scrolling behavior, and size along the cross axis.
+pub struct ListBuildContext {
+ /// The position of the item in the list.
+ pub index: usize,
+
+ /// A boolean flag indicating whether the item is currently selected.
+ pub is_selected: bool,
+
+ /// Defines the axis along which the list can be scrolled.
+ pub scroll_axis: ScrollAxis,
+
+ /// The size of the item along the cross axis.
+ pub cross_axis_size: u16,
+}
+
+/// A type alias for the closure.
+type ListBuildClosure = dyn Fn(&ListBuildContext) -> (T, u16);
+
+/// The builder to for constructing list elements in a `ListView`
+pub struct ListBuilder {
+ closure: Box>,
+}
+
+impl ListBuilder {
+ /// Creates a new `ListBuilder` taking a closure as a parameter
+ pub fn new(closure: F) -> Self
+ where
+ F: Fn(&ListBuildContext) -> (T, u16) + 'static,
+ {
+ ListBuilder {
+ closure: Box::new(closure),
+ }
+ }
+
+ /// Method to call the stored closure.
+ pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
+ (self.closure)(context)
+ }
+}
diff --git a/src/render.rs b/src/render.rs
new file mode 100644
index 0000000..a01e1ac
--- /dev/null
+++ b/src/render.rs
@@ -0,0 +1,429 @@
+use ratatui::{
+ buffer::Buffer,
+ layout::Rect,
+ style::Style,
+ widgets::{StatefulWidget, StatefulWidgetRef, Widget, WidgetRef},
+};
+
+use crate::{
+ utils::{layout_on_viewport, ViewportElement},
+ ListState, ListView, ScrollAxis,
+};
+
+impl StatefulWidgetRef for ListView<'_, T> {
+ type State = ListState;
+
+ fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
+ state.set_num_elements(self.item_count);
+
+ // List is empty
+ if self.item_count == 0 {
+ return;
+ }
+
+ // Set the dimension along the scroll axis and the cross axis
+ let (total_main_axis_size, cross_axis_size) = match self.scroll_axis {
+ ScrollAxis::Vertical => (area.height, area.width),
+ ScrollAxis::Horizontal => (area.width, area.height),
+ };
+
+ // The coordinates of the first item with respect to the top left corner
+ let (mut scroll_axis_pos, cross_axis_pos) = match self.scroll_axis {
+ ScrollAxis::Vertical => (area.top(), area.left()),
+ ScrollAxis::Horizontal => (area.left(), area.top()),
+ };
+
+ // Determine which widgets to show on the viewport and how much space they
+ // get assigned to.
+ let mut viewport = layout_on_viewport(
+ state,
+ &self.builder,
+ self.item_count,
+ total_main_axis_size,
+ cross_axis_size,
+ self.scroll_axis,
+ );
+
+ let (start, end) = (state.offset, viewport.len() + state.offset);
+ for i in start..end {
+ let Some(ViewportElement {
+ main_axis_size,
+ truncate_by,
+ widget,
+ }) = viewport.remove(&i)
+ else {
+ break;
+ };
+ let effective_main_axis_size = main_axis_size.saturating_sub(truncate_by);
+ let area = match self.scroll_axis {
+ ScrollAxis::Vertical => Rect::new(
+ cross_axis_pos,
+ scroll_axis_pos,
+ cross_axis_size,
+ effective_main_axis_size,
+ ),
+ ScrollAxis::Horizontal => Rect::new(
+ scroll_axis_pos,
+ cross_axis_pos,
+ effective_main_axis_size,
+ cross_axis_size,
+ ),
+ };
+
+ // Render truncated widgets.
+ if truncate_by > 0 {
+ let truncate_top = i == 0 && viewport.len() > 1;
+ render_truncated_ref(
+ &widget,
+ area,
+ buf,
+ main_axis_size,
+ truncate_top,
+ self.style,
+ self.scroll_axis,
+ );
+ } else {
+ widget.render(area, buf);
+ }
+
+ scroll_axis_pos += effective_main_axis_size;
+ }
+ }
+}
+
+/// Renders a listable widget within a specified area of a buffer, potentially truncating the widget content based on scrolling direction.
+/// `truncate_top` indicates whether to truncate the content from the top or bottom.
+fn render_truncated_ref(
+ item: &T,
+ available_area: Rect,
+ buf: &mut Buffer,
+ untruncated_size: u16,
+ truncate_top: bool,
+ base_style: Style,
+ scroll_axis: ScrollAxis,
+) {
+ // Create an intermediate buffer for rendering the truncated element
+ 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);
+
+ // Copy the visible part from the intermediate buffer to the main buffer
+ match scroll_axis {
+ ScrollAxis::Vertical => {
+ let offset = if truncate_top {
+ untruncated_size.saturating_sub(available_area.height)
+ } else {
+ 0
+ };
+ for y in available_area.top()..available_area.bottom() {
+ let y_off = y + offset;
+ for x in available_area.left()..available_area.right() {
+ *buf.get_mut(x, y) = hidden_buffer.get(x, y_off).clone();
+ }
+ }
+ }
+ ScrollAxis::Horizontal => {
+ let offset = if truncate_top {
+ untruncated_size.saturating_sub(available_area.width)
+ } else {
+ 0
+ };
+ for x in available_area.left()..available_area.right() {
+ let x_off = x + offset;
+ for y in available_area.top()..available_area.bottom() {
+ *buf.get_mut(x, y) = hidden_buffer.get(x_off, y).clone();
+ }
+ }
+ }
+ };
+}
+
+impl 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);
+
+ // List is empty
+ if self.item_count == 0 {
+ return;
+ }
+
+ // Set the dimension along the scroll axis and the cross axis
+ let (total_main_axis_size, cross_axis_size) = match self.scroll_axis {
+ ScrollAxis::Vertical => (area.height, area.width),
+ ScrollAxis::Horizontal => (area.width, area.height),
+ };
+
+ // The coordinates of the first item with respect to the top left corner
+ let (mut scroll_axis_pos, cross_axis_pos) = match self.scroll_axis {
+ ScrollAxis::Vertical => (area.top(), area.left()),
+ ScrollAxis::Horizontal => (area.left(), area.top()),
+ };
+
+ // Determine which widgets to show on the viewport and how much space they
+ // get assigned to.
+ let mut viewport = layout_on_viewport(
+ state,
+ &self.builder,
+ self.item_count,
+ total_main_axis_size,
+ cross_axis_size,
+ self.scroll_axis,
+ );
+
+ let (start, end) = (state.offset, viewport.len() + state.offset);
+ for i in start..end {
+ let Some(ViewportElement {
+ main_axis_size,
+ truncate_by,
+ widget,
+ }) = viewport.remove(&i)
+ else {
+ break;
+ };
+ let effective_main_axis_size = main_axis_size.saturating_sub(truncate_by);
+ let area = match self.scroll_axis {
+ ScrollAxis::Vertical => Rect::new(
+ cross_axis_pos,
+ scroll_axis_pos,
+ cross_axis_size,
+ effective_main_axis_size,
+ ),
+ ScrollAxis::Horizontal => Rect::new(
+ scroll_axis_pos,
+ cross_axis_pos,
+ effective_main_axis_size,
+ cross_axis_size,
+ ),
+ };
+
+ // Render truncated widgets.
+ if truncate_by > 0 {
+ let truncate_top = i == 0 && viewport.len() > 1;
+ render_truncated(
+ widget,
+ area,
+ buf,
+ main_axis_size,
+ truncate_top,
+ self.style,
+ self.scroll_axis,
+ );
+ } else {
+ widget.render(area, buf);
+ }
+
+ scroll_axis_pos += effective_main_axis_size;
+ }
+ }
+}
+
+/// Renders a listable widget within a specified area of a buffer, potentially truncating the widget content based on scrolling direction.
+/// `truncate_top` indicates whether to truncate the content from the top or bottom.
+fn render_truncated(
+ item: T,
+ available_area: Rect,
+ buf: &mut Buffer,
+ untruncated_size: u16,
+ truncate_top: bool,
+ base_style: Style,
+ scroll_axis: ScrollAxis,
+) {
+ // Create an intermediate buffer for rendering the truncated element
+ 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);
+
+ // Copy the visible part from the intermediate buffer to the main buffer
+ match scroll_axis {
+ ScrollAxis::Vertical => {
+ let offset = if truncate_top {
+ untruncated_size.saturating_sub(available_area.height)
+ } else {
+ 0
+ };
+ for y in available_area.top()..available_area.bottom() {
+ let y_off = y + offset;
+ for x in available_area.left()..available_area.right() {
+ *buf.get_mut(x, y) = hidden_buffer.get(x, y_off).clone();
+ }
+ }
+ }
+ ScrollAxis::Horizontal => {
+ let offset = if truncate_top {
+ untruncated_size.saturating_sub(available_area.width)
+ } else {
+ 0
+ };
+ for x in available_area.left()..available_area.right() {
+ let x_off = x + offset;
+ for y in available_area.top()..available_area.bottom() {
+ *buf.get_mut(x, y) = hidden_buffer.get(x_off, y).clone();
+ }
+ }
+ }
+ };
+}
+
+#[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() {
+ // given
+ let (area, mut buf, list, mut state) = test_data(9);
+
+ // when
+ list.render(area, &mut buf, &mut state);
+
+ // then
+ assert_buffer_eq(
+ buf,
+ Buffer::with_lines(vec![
+ "┌───┐",
+ "│ │",
+ "└───┘",
+ "┌───┐",
+ "│ │",
+ "└───┘",
+ "┌───┐",
+ "│ │",
+ "└───┘",
+ ]),
+ )
+ }
+
+ #[test]
+ fn empty_list() {
+ // given
+ 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);
+
+ // when
+ list.render(area, &mut buf, &mut state);
+
+ // then
+ assert_buffer_eq(buf, Buffer::with_lines(vec![" ", " "]))
+ }
+
+ #[test]
+ fn zero_size() {
+ // given
+ let (area, mut buf, list, mut state) = test_data(0);
+
+ // when
+ list.render(area, &mut buf, &mut state);
+
+ // then
+ assert_buffer_eq(buf, Buffer::empty(area))
+ }
+
+ #[test]
+ fn truncated_bot() {
+ // given
+ let (area, mut buf, list, mut state) = test_data(8);
+
+ // when
+ list.render(area, &mut buf, &mut state);
+
+ // then
+ assert_buffer_eq(
+ buf,
+ Buffer::with_lines(vec![
+ "┌───┐",
+ "│ │",
+ "└───┘",
+ "┌───┐",
+ "│ │",
+ "└───┘",
+ "┌───┐",
+ "│ │",
+ ]),
+ )
+ }
+
+ #[test]
+ fn truncated_top() {
+ // given
+ let (area, mut buf, list, mut state) = test_data(8);
+ state.select(Some(2));
+
+ // when
+ list.render(area, &mut buf, &mut state);
+
+ // then
+ 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");
+ }
+}
diff --git a/src/scroll_axis.rs b/src/scroll_axis.rs
new file mode 100644
index 0000000..2d9904d
--- /dev/null
+++ b/src/scroll_axis.rs
@@ -0,0 +1,10 @@
+/// Represents the scroll axis of a list.
+#[derive(Debug, Default, Clone, Copy)]
+pub enum ScrollAxis {
+ /// Indicates vertical scrolling. This is the default.
+ #[default]
+ Vertical,
+
+ /// Indicates horizontal scrolling.
+ Horizontal,
+}
diff --git a/src/utils.rs b/src/utils.rs
index b9143e5..bd41a28 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -1,26 +1,17 @@
use std::collections::HashMap;
-use crate::{ListState, PreRender, PreRenderContext, ScrollAxis};
-
-/// This method checks how to layout the items on the viewport and if necessary
-/// updates the offset of the first item on the screen.
-///
-/// For this we start with the first item on the screen and iterate until we have
-/// reached the maximum height. If the selected value is within the bounds we do
-/// nothing. If the selected value is out of bounds, the offset is adjusted.
-///
-/// # Returns
-/// - The sizes along the main axis of the elements on the viewport,
-/// and how much they are being truncated to fit on the viewport.
-pub(crate) fn layout_on_viewport(
+use crate::{ListBuildContext, ListBuilder, ListState, ScrollAxis};
+
+pub(crate) fn layout_on_viewport(
state: &mut ListState,
- widgets: &mut [T],
+ builder: &ListBuilder,
+ item_count: usize,
total_main_axis_size: u16,
cross_axis_size: u16,
scroll_axis: ScrollAxis,
-) -> Vec {
+) -> HashMap> {
// The items heights on the viewport will be calculated on the fly.
- let mut viewport_layouts: Vec = Vec::new();
+ let mut viewport: HashMap> = HashMap::new();
// If none is selected, the first item should be show on top of the viewport.
let selected = state.selected.unwrap_or(0);
@@ -31,97 +22,108 @@ pub(crate) fn layout_on_viewport(
state.offset = selected;
}
- let mut main_axis_size_cache: HashMap = HashMap::new();
-
// Check if the selected item is in the current view
- let (mut y, mut index) = (0, state.offset);
+ let (mut y, mut found_last) = (0, false);
let mut found = false;
- for widget in widgets.iter_mut().skip(state.offset) {
- // Get the main axis size of the widget.
- let is_selected = state.selected.map_or(false, |j| index == j);
- let context = PreRenderContext::new(is_selected, cross_axis_size, scroll_axis, index);
+ for index in state.offset..item_count {
+ let mut truncate_by = 0;
+ let available_size: u16 = total_main_axis_size.saturating_sub(y);
- let main_axis_size = widget.pre_render(&context);
- main_axis_size_cache.insert(index, main_axis_size);
+ // Build the widget
+ let context = ListBuildContext {
+ index,
+ is_selected: state.selected.map_or(false, |j| index == j),
+ scroll_axis,
+ cross_axis_size,
+ };
+ let (widget, main_axis_size) = builder.call_closure(&context);
// Out of bounds
- if y + main_axis_size > total_main_axis_size {
- // Truncate the last widget
- let dy = total_main_axis_size - y;
- if dy > 0 {
- viewport_layouts.push(ViewportLayout {
- main_axis_size: dy,
- truncated_by: main_axis_size.saturating_sub(dy),
- });
- }
+ if !found && main_axis_size >= available_size {
break;
}
+
// Selected value is within view/bounds, so we are good
- // but we keep iterating to collect the view heights
- if selected == index {
+ // but we keep iterating to collect the full viewport.
+ if selected == index && main_axis_size <= available_size {
found = true;
}
- y += main_axis_size;
- index += 1;
- viewport_layouts.push(ViewportLayout {
- main_axis_size,
- truncated_by: 0,
- });
+ // Found the last element. We can stop iterating.
+ if found && main_axis_size >= available_size {
+ found_last = true;
+ truncate_by = main_axis_size.saturating_sub(available_size);
+ }
+ let element = ViewportElement::new(widget, main_axis_size, truncate_by);
+ viewport.insert(index, element);
+
+ if found_last {
+ break;
+ }
+
+ // We keep iterating until we collected all viewport elements
+ y += main_axis_size;
}
+
if found {
- return viewport_layouts;
+ return viewport;
}
+ viewport.clear();
+
// The selected item is out of bounds. We iterate backwards from the selected
// item and determine the first widget that still fits on the screen.
- viewport_layouts.clear();
- let (mut y, mut index) = (0, selected);
- let last = widgets.len().saturating_sub(1);
- for widget in widgets.iter_mut().rev().skip(last.saturating_sub(selected)) {
- // Get the main axis size of the widget. At this point we might have already
- // calculated it, so check the cache first.
- let main_axis_size = if let Some(main_axis_size) = main_axis_size_cache.remove(&index) {
- main_axis_size
- } else {
- let is_selected = state.selected.map_or(false, |j| index == j);
- let context = PreRenderContext::new(is_selected, cross_axis_size, scroll_axis, index);
-
- widget.pre_render(&context)
+ let (mut y, mut found_first) = (0, false);
+ for index in (0..=selected).rev() {
+ let available_size = total_main_axis_size - y;
+ let mut truncate_by = 0;
+
+ // Evaluate the widget
+ let context = ListBuildContext {
+ index,
+ is_selected: state.selected.map_or(false, |j| index == j),
+ scroll_axis,
+ cross_axis_size,
};
+ let (widget, main_axis_size) = builder.call_closure(&context);
- // Truncate the first widget
- if y + main_axis_size >= total_main_axis_size {
- let dy = total_main_axis_size - y;
- viewport_layouts.insert(
- 0,
- ViewportLayout {
- main_axis_size: dy,
- truncated_by: main_axis_size.saturating_sub(dy),
- },
- );
+ // We found the first element
+ if available_size <= main_axis_size {
+ found_first = true;
+ // main_axis_size = available_size;
+ truncate_by = main_axis_size.saturating_sub(available_size);
state.offset = index;
- break;
}
- viewport_layouts.insert(
- 0,
- ViewportLayout {
- main_axis_size,
- truncated_by: 0,
- },
- );
+ let element = ViewportElement::new(widget, main_axis_size, truncate_by);
+ viewport.insert(index, element);
+
+ if found_first {
+ break;
+ }
y += main_axis_size;
- index -= 1;
}
- viewport_layouts
+
+ viewport
}
#[derive(Debug, PartialEq, PartialOrd, Eq, Ord)]
-pub(crate) struct ViewportLayout {
+pub(crate) struct ViewportElement {
+ pub(crate) widget: T,
pub(crate) main_axis_size: u16,
- pub(crate) truncated_by: u16,
+ pub(crate) truncate_by: u16,
+}
+
+impl ViewportElement {
+ #[must_use]
+ pub(crate) fn new(widget: T, main_axis_size: u16, truncated_by: u16) -> Self {
+ Self {
+ widget,
+ main_axis_size,
+ truncate_by: truncated_by,
+ }
+ }
}
#[cfg(test)]
@@ -133,9 +135,8 @@ mod tests {
use super::*;
- struct TestItem {
- main_axis_size: u16,
- }
+ #[derive(Debug, Default, PartialEq, Eq)]
+ struct TestItem {}
impl Widget for TestItem {
fn render(self, area: Rect, buf: &mut Buffer)
@@ -146,62 +147,228 @@ mod tests {
}
}
- impl PreRender for TestItem {
- fn pre_render(&mut self, _context: &PreRenderContext) -> u16 {
- self.main_axis_size
- }
+ #[test]
+ fn happy_path() {
+ // given
+ let mut given_state = ListState {
+ num_elements: 2,
+ ..ListState::default()
+ };
+ let given_item_count = 2;
+ let given_sizes = vec![2, 3];
+ let given_total_size = 6;
+ let builder = &ListBuilder::new(move |context| {
+ return (TestItem {}, given_sizes[context.index]);
+ });
+
+ // when
+ let viewport = layout_on_viewport(
+ &mut given_state,
+ builder,
+ given_item_count,
+ given_total_size,
+ 1,
+ ScrollAxis::Vertical,
+ );
+ let offset = given_state.offset;
+
+ // then
+ let expected_offset = 0;
+
+ let mut expected_viewport = HashMap::new();
+ expected_viewport.insert(0, ViewportElement::new(TestItem {}, 2, 0));
+ expected_viewport.insert(1, ViewportElement::new(TestItem {}, 3, 0));
+
+ assert_eq!(offset, expected_offset);
+ assert_eq!(viewport, expected_viewport);
}
- macro_rules! update_view_port_tests {
- ($($name:ident:
- [
- $given_offset:expr,
- $given_selected:expr,
- $given_sizes:expr,
- $given_max_size:expr
- ],
- [
- $expected_offset:expr,
- $expected_sizes:expr
- ],)*) => {
- $(
- #[test]
- fn $name() {
- // given
- let mut given_state = ListState {
- offset: $given_offset,
- selected: $given_selected,
- num_elements: $given_sizes.len(),
- circular: true,
- };
-
- //when
- let mut widgets: Vec = $given_sizes
- .into_iter()
- .map(|main_axis_size| TestItem {
- main_axis_size: main_axis_size as u16,
- })
- .collect();
- let scroll_axis = ScrollAxis::default();
- let layouts = layout_on_viewport(&mut given_state, &mut widgets, $given_max_size, 0, scroll_axis);
- let offset = given_state.offset;
-
- // then
- let main_axis_sizes: Vec = layouts.iter().map(|x| x.main_axis_size).collect();
- assert_eq!(offset, $expected_offset);
- assert_eq!(main_axis_sizes, $expected_sizes);
- }
- )*
- }
+ #[test]
+ fn scroll_down_out_of_bounds() {
+ // given
+ let mut given_state = ListState {
+ num_elements: 3,
+ selected: Some(2),
+ ..ListState::default()
+ };
+ let given_sizes = vec![2, 3, 3];
+ let given_item_count = given_sizes.len();
+ let given_total_size = 6;
+ let builder = &ListBuilder::new(move |context| {
+ return (TestItem {}, given_sizes[context.index]);
+ });
+
+ // when
+ let viewport = layout_on_viewport(
+ &mut given_state,
+ builder,
+ given_item_count,
+ given_total_size,
+ 1,
+ ScrollAxis::Vertical,
+ );
+ let offset = given_state.offset;
+
+ // then
+ let expected_offset = 1;
+
+ let mut expected_viewport = HashMap::new();
+ expected_viewport.insert(1, ViewportElement::new(TestItem {}, 3, 0));
+ expected_viewport.insert(2, ViewportElement::new(TestItem {}, 3, 0));
+
+ assert_eq!(offset, expected_offset);
+ assert_eq!(viewport, expected_viewport);
+ }
+
+ #[test]
+ fn scroll_up() {
+ // given
+ let mut given_state = ListState {
+ num_elements: 4,
+ selected: Some(2),
+ offset: 1,
+ ..ListState::default()
+ };
+ let given_sizes = vec![2, 2, 2, 3];
+ let given_total_size = 6;
+ let given_item_count = given_sizes.len();
+ let builder = &ListBuilder::new(move |context| {
+ return (TestItem {}, given_sizes[context.index]);
+ });
+
+ // when
+ let viewport = layout_on_viewport(
+ &mut given_state,
+ builder,
+ given_item_count,
+ given_total_size,
+ 1,
+ ScrollAxis::Vertical,
+ );
+ let offset = given_state.offset;
+
+ // then
+ let expected_offset = 1;
+
+ let mut expected_viewport = HashMap::new();
+ expected_viewport.insert(1, ViewportElement::new(TestItem {}, 2, 0));
+ expected_viewport.insert(2, ViewportElement::new(TestItem {}, 2, 0));
+ expected_viewport.insert(3, ViewportElement::new(TestItem {}, 3, 1));
+
+ assert_eq!(offset, expected_offset);
+ assert_eq!(viewport, expected_viewport);
}
- update_view_port_tests! {
- happy_path: [0, Some(0), vec![2, 3], 6], [0, vec![2, 3]],
- empty_list: [0, None, Vec::::new(), 4], [0, vec![]],
- update_offset_down: [0, Some(2), vec![2, 3, 3], 6], [1, vec![3, 3]],
- update_offset_up: [1, Some(0), vec![2, 3, 3], 6], [0, vec![2, 3, 1]],
- truncate_bottom: [0, Some(0), vec![2, 3], 4], [0, vec![2, 2]],
- truncate_top: [0, Some(1), vec![2, 3], 4], [0, vec![1, 3]],
- num_elements: [0, None, vec![1, 1, 1, 1, 1], 3], [0, vec![1, 1, 1]],
+ #[test]
+ fn scroll_up_out_of_bounds() {
+ // given
+ let mut given_state = ListState {
+ num_elements: 3,
+ selected: Some(0),
+ offset: 1,
+ ..ListState::default()
+ };
+ let given_sizes = vec![2, 3, 3];
+ let given_total_size = 6;
+ let given_item_count = given_sizes.len();
+ let builder = &ListBuilder::new(move |context| {
+ return (TestItem {}, given_sizes[context.index]);
+ });
+
+ // when
+ let viewport = layout_on_viewport(
+ &mut given_state,
+ builder,
+ given_item_count,
+ given_total_size,
+ 1,
+ ScrollAxis::Vertical,
+ );
+ let offset = given_state.offset;
+
+ // then
+ let expected_offset = 0;
+
+ let mut expected_viewport = HashMap::new();
+ expected_viewport.insert(0, ViewportElement::new(TestItem {}, 2, 0));
+ expected_viewport.insert(1, ViewportElement::new(TestItem {}, 3, 0));
+ expected_viewport.insert(2, ViewportElement::new(TestItem {}, 3, 2));
+
+ assert_eq!(offset, expected_offset);
+ assert_eq!(viewport, expected_viewport);
+ }
+
+ #[test]
+ fn truncate_top() {
+ // given
+ let mut given_state = ListState {
+ num_elements: 2,
+ selected: Some(1),
+ ..ListState::default()
+ };
+ let given_sizes = vec![2, 3];
+ let given_total_size = 4;
+ let given_item_count = given_sizes.len();
+ let builder = &ListBuilder::new(move |context| {
+ return (TestItem {}, given_sizes[context.index]);
+ });
+
+ // when
+ let viewport = layout_on_viewport(
+ &mut given_state,
+ builder,
+ given_item_count,
+ given_total_size,
+ 1,
+ ScrollAxis::Vertical,
+ );
+ let offset = given_state.offset;
+
+ // then
+ let expected_offset = 0;
+
+ let mut expected_viewport = HashMap::new();
+ expected_viewport.insert(0, ViewportElement::new(TestItem {}, 2, 1));
+ expected_viewport.insert(1, ViewportElement::new(TestItem {}, 3, 0));
+
+ assert_eq!(offset, expected_offset);
+ assert_eq!(viewport, expected_viewport);
+ }
+
+ #[test]
+ fn truncate_bot() {
+ // given
+ let mut given_state = ListState {
+ num_elements: 2,
+ selected: Some(0),
+ ..ListState::default()
+ };
+ let given_sizes = vec![2, 3];
+ let given_total_size = 4;
+ let given_item_count = given_sizes.len();
+ let builder = &ListBuilder::new(move |context| {
+ return (TestItem {}, given_sizes[context.index]);
+ });
+
+ // when
+ let viewport = layout_on_viewport(
+ &mut given_state,
+ builder,
+ given_item_count,
+ given_total_size,
+ 1,
+ ScrollAxis::Vertical,
+ );
+ let offset = given_state.offset;
+
+ // then
+ let expected_offset = 0;
+
+ let mut expected_viewport = HashMap::new();
+ expected_viewport.insert(0, ViewportElement::new(TestItem {}, 2, 0));
+ expected_viewport.insert(1, ViewportElement::new(TestItem {}, 3, 1));
+
+ assert_eq!(offset, expected_offset);
+ assert_eq!(viewport, expected_viewport);
}
}