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); } }