├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── common │ ├── item_container.rs │ └── lib.rs ├── demo.rs ├── simple.rs ├── tapes │ ├── demo.gif │ ├── demo.tape │ ├── simple.gif │ ├── simple.tape │ ├── variants.gif │ └── variants.tape ├── var_sizes.rs ├── variants.rs └── variants │ ├── classic.rs │ ├── config.rs │ ├── fps.rs │ ├── horizontal.rs │ └── scroll_padding.rs └── src ├── legacy ├── mod.rs ├── traits.rs ├── utils.rs └── widget.rs ├── lib.rs ├── state.rs ├── utils.rs └── view.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Continuous Integration 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | - name: Install Rust stable 12 | uses: dtolnay/rust-toolchain@stable 13 | with: 14 | components: rustfmt 15 | - name: Cache Cargo dependencies 16 | uses: Swatinem/rust-cache@v2 17 | - run: cargo fmt --all -- --check 18 | 19 | clippy: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - name: Install Rust stable 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | components: clippy 28 | - name: Cache Cargo dependencies 29 | uses: Swatinem/rust-cache@v2 30 | - run: cargo clippy -- -D warnings 31 | 32 | check: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v4 37 | - name: Install Rust stable 38 | uses: dtolnay/rust-toolchain@stable 39 | - name: Cache Cargo dependencies 40 | uses: Swatinem/rust-cache@v2 41 | - run: cargo check --all --verbose 42 | 43 | test: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | - name: Install Rust stable 49 | uses: dtolnay/rust-toolchain@stable 50 | - name: Cache Cargo dependencies 51 | uses: Swatinem/rust-cache@v2 52 | - run: cargo test --all --verbose 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Released 2 | -------- 3 | 4 | 0.13.3 - 01 Jan 2025 5 | =================== 6 | - Add support for scrollbars 7 | 8 | 0.13.2 - 28 Dec 2024 9 | =================== 10 | - Return [ListState::scroll_offset_index] 11 | 12 | 0.13.1 - 22 Dec 2024 13 | =================== 14 | - More flexible listbuilder lifetimes by @nmandrus1 15 | 16 | 0.13.0 - 22 Okt 2024 17 | =================== 18 | - Bump ratatui to v0.29.0 19 | 20 | 0.12.2 - 15 Sep 2024 21 | =================== 22 | - ListView::scroll_padding added. 23 | Allows to keep a specified number of cells above/below the selected widget visibile while scrolling. 24 | 25 | - ListState::circular got removed. Use ListView::infinite_scrolling instead. 26 | 27 | 0.12.1 - 25 Aug 2024 28 | =================== 29 | - Change scroll up behaviour - keep first element truncated if possible 30 | 31 | 0.12.0 - 17 Aug 2024 32 | =================== 33 | - Bump ratatui to v0.28 34 | 35 | 0.11.0 - 10 Aug 2024 36 | =================== 37 | - Introduces `ListView`, `ListBuilder` and `ListBuildContext` as replacement for `List`, `PreRender` and `PreRenderContext`. 38 | - `List`, `PreRender` and `PreRenderContext` are marked as deprecated. 39 | ```rust 40 | // Builder provides the ListBuildContext. 41 | // Return the widget and the widget's size along its main-axis. 42 | let builder = ListBuilder::new(|context| { 43 | let mut item = Line::from(format!("Item {0}", context.index)); 44 | 45 | if context.index % 2 == 0 { 46 | item.style = Style::default().bg(Color::Rgb(28, 28, 32)) 47 | } else { 48 | item.style = Style::default().bg(Color::Rgb(0, 0, 0)) 49 | }; 50 | 51 | if context.is_selected { 52 | item.style = Style::default() 53 | .bg(Color::Rgb(255, 153, 0)) 54 | .fg(Color::Rgb(28, 28, 32)); 55 | }; 56 | 57 | return (item, 1); 58 | }); 59 | 60 | // Construct the list. ListView takes in the builder and an item count. 61 | let list = ListView::new(builder, 20); 62 | 63 | // Render the list 64 | list.render(area, &mut buf, &mut state); 65 | ``` 66 | 67 | 0.10.1 - 28 July 2024 68 | =================== 69 | - Implement Styled for List (contributor: airblast-dev) 70 | 71 | 0.10.0 - 27 June 2024 72 | =================== 73 | - Bump ratatui to v0.27 74 | 75 | 0.9.0 - 11 May 2024 76 | =================== 77 | - Introduced `PreRender` trait as a replacement for `ListableWidget`. 78 | - This change is non-breaking 79 | - It provides a more concise and clearer interface. 80 | Migration Guide 81 | - Update trait implementations to use the new `pre_render` signature: 82 | 83 | ```rust 84 | fn pre_render(&mut self, context: &PreRenderContext) -> u16 { 85 | let main_axis_size = // The widgets size in the main axis 86 | 87 | if context.is_selected { 88 | self = // You can modify the selected item here 89 | } 90 | 91 | main_axis_size 92 | } 93 | ``` 94 | - Deprecated ListState::selected(). Use the struct field selected instead. 95 | - Updated examples 96 | - Add example for long lists 97 | 98 | 0.8.3 - 1 May 2024 99 | =================== 100 | - Fix: Missing base style on truncated items 101 | 102 | 0.8.2 - 29 February 2024 103 | =================== 104 | - Truncate last element correctly 105 | - Add tests 106 | 107 | 0.8.1 - 24 February 2024 108 | =================== 109 | - Add support for horizontal scroll 110 | - Remove `truncate` option 111 | 112 | **Breaking Change** 113 | The ListableWidgets trait method main_axis_size() was renamed to 114 | size with an additional scroll_direction parameter. This parameter 115 | can be ignored if the ListItem is only used in vertical or horizontal 116 | scroll mode, and not in both. 117 | 118 | 0.7.2 - 14 February 2024 119 | =================== 120 | - Deprecate Listable trait in favor of ListableWidget 121 | - Migration: height() becomes main_axis_size() 122 | - Listable got deprectated, but old apps still compile 123 | - ListableWidget is more descriptive and using 124 | main_axis_size allows for reusing the trait 125 | in horizontal lists, which will come in the future 126 | 127 | 0.7.1 - 10 February 2024 128 | =================== 129 | - Bugfix: Some cases paniced when the truncated element had a size 0 130 | 131 | 0.7.0 - 3 February 2024 132 | =================== 133 | Bump ratatui to version 0.26 134 | 135 | 0.6.1 - 12 January 2024 136 | =================== 137 | **Bugfix** 138 | - Correct truncation of the top element, see issues/6 139 | 140 | 0.6.0 - 23 December 2023 141 | =================== 142 | - Bump ratatui to version 0.25 143 | - Move crossterm from dependency to dev-dependency 144 | 145 | 0.5.0 - 28 October 2023 146 | =================== 147 | **Breaking Changes** 148 | - Change of Listable trait Interface returning Self instead of Option 149 | 150 | **Bugfix** 151 | - Selected widgets height is correctly calculated 152 | 153 | The api should be more stable from now on. 154 | 155 | 0.4.0 - 25 October 2023 156 | =================== 157 | **Breaking Changes** 158 | - Renamed WidgetList to List 159 | - Renamed WidgetListState to ListState 160 | - Renamed trait WidgetItem to Listable 161 | - Interface change of Listable 162 | 163 | Api improvement to make the crate more reusable 164 | and idiomatic in the ratatui ecosystem. 165 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tui-widget-list" 3 | version = "0.13.3" 4 | edition = "2021" 5 | authors = ["preiter "] 6 | description = "Widget List for TUI/Ratatui" 7 | repository = "https://github.com/preiter93/tui-widget-list" 8 | keywords = ["tui", "ratatui", "widget", "list", "widgetlist"] 9 | readme = "README.md" 10 | license = "MIT" 11 | 12 | [dependencies] 13 | ratatui = "0.29" 14 | 15 | [dev-dependencies] 16 | crossterm = "0.28" 17 | 18 | [[example]] 19 | name = "simple" 20 | 21 | [[example]] 22 | name = "var_sizes" 23 | 24 | [[example]] 25 | name = "demo" 26 | 27 | [[example]] 28 | name = "variants" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Philipp Reiter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## A versatile widget list for Ratatui 2 | 3 |
4 | 5 | [![Continuous Integration](https://github.com/preiter93/tui-widget-list/actions/workflows/ci.yml/badge.svg)](https://github.com/preiter93/tui-widget-list/actions/workflows/ci.yml) 6 | 7 |
8 | 9 | This crate provides a stateful widget [`ListView`] implementation for `Ratatui`. The associated [`ListState`], offers functionalities such as navigating to the next and previous items. 10 | The list view support both horizontal and vertical scrolling. 11 | 12 | ### Configuration 13 | The [`ListView`] can be customized with the following options: 14 | - [`ListView::scroll_axis`]: Specifies whether the list is vertically or horizontally scrollable. 15 | 16 | - [`ListView::scroll_padding`]: Specifies whether content should remain visible while scrolling, ensuring that a specified amount of padding is preserved above/below the selected item during scrolling. 17 | - [`ListView::infinite_scrolling`]: Allows the list to wrap around when scrolling past the first or last element. 18 | - [`ListView::style`]: Defines the base style of the list. 19 | - [`ListView::block`]: Optional outer block surrounding the list. 20 | 21 | ### Example 22 | ```rust 23 | use ratatui::prelude::*; 24 | use tui_widget_list::{ListBuilder, ListState, ListView}; 25 | 26 | #[derive(Debug, Clone)] 27 | pub struct ListItem { 28 | text: String, 29 | style: Style, 30 | } 31 | 32 | impl ListItem { 33 | pub fn new>(text: T) -> Self { 34 | Self { 35 | text: text.into(), 36 | style: Style::default(), 37 | } 38 | } 39 | } 40 | 41 | impl Widget for ListItem { 42 | fn render(self, area: Rect, buf: &mut Buffer) { 43 | Line::from(self.text).style(self.style).render(area, buf); 44 | } 45 | } 46 | 47 | pub struct App { 48 | state: ListState, 49 | } 50 | 51 | impl Widget for &mut App { 52 | fn render(self, area: Rect, buf: &mut Buffer) { 53 | let builder = ListBuilder::new(|context| { 54 | let mut item = ListItem::new(&format!("Item {:0}", context.index)); 55 | 56 | // Alternating styles 57 | if context.index % 2 == 0 { 58 | item.style = Style::default().bg(Color::Rgb(28, 28, 32)); 59 | } else { 60 | item.style = Style::default().bg(Color::Rgb(0, 0, 0)); 61 | } 62 | 63 | // Style the selected element 64 | if context.is_selected { 65 | item.style = Style::default() 66 | .bg(Color::Rgb(255, 153, 0)) 67 | .fg(Color::Rgb(28, 28, 32)); 68 | }; 69 | 70 | // Return the size of the widget along the main axis. 71 | let main_axis_size = 1; 72 | 73 | (item, main_axis_size) 74 | }); 75 | 76 | let item_count = 2; 77 | let list = ListView::new(builder, item_count); 78 | let state = &mut self.state; 79 | 80 | list.render(area, buf, state); 81 | } 82 | } 83 | ``` 84 | 85 | For more examples see [tui-widget-list](https://github.com/preiter93/tui-widget-list/tree/main/examples). 86 | 87 | ### Documentation 88 | [docs.rs](https://docs.rs/tui-widget-list/) 89 | 90 | ### Demos 91 | 92 | #### Demo 93 | 94 | ![](examples/tapes/demo.gif?v=1) 95 | 96 | #### Infinite scrolling, scroll padding, horizontal scrolling 97 | 98 | ![](examples/tapes/variants.gif?v=1) 99 | 100 | License: MIT 101 | -------------------------------------------------------------------------------- /examples/common/item_container.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::Rect, 4 | style::{Style, Styled}, 5 | widgets::{Padding, Widget}, 6 | }; 7 | 8 | pub struct ListItemContainer<'a, W> { 9 | child: W, 10 | block: ratatui::widgets::Block<'a>, 11 | style: Style, 12 | } 13 | 14 | impl<'a, W> ListItemContainer<'a, W> { 15 | pub fn new(child: W, padding: Padding) -> Self { 16 | Self { 17 | child, 18 | block: ratatui::widgets::Block::default().padding(padding), 19 | style: Style::default(), 20 | } 21 | } 22 | } 23 | 24 | impl Styled for ListItemContainer<'_, T> { 25 | type Item = Self; 26 | 27 | fn style(&self) -> Style { 28 | self.style 29 | } 30 | 31 | fn set_style>(mut self, style: S) -> Self::Item { 32 | self.style = style.into(); 33 | self 34 | } 35 | } 36 | 37 | impl Widget for ListItemContainer<'_, W> { 38 | fn render(self, area: Rect, buf: &mut Buffer) { 39 | let inner_area = self.block.inner(area); 40 | buf.set_style(area, self.style); 41 | self.block.render(area, buf); 42 | self.child.render(inner_area, buf); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/common/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports, dead_code)] 2 | pub mod item_container; 3 | use crossterm::{ 4 | event::{DisableMouseCapture, EnableMouseCapture}, 5 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 6 | }; 7 | use ratatui::{ 8 | prelude::CrosstermBackend, 9 | style::{Color, Style}, 10 | widgets::{BorderType, Borders, StatefulWidget}, 11 | }; 12 | use std::{ 13 | error::Error, 14 | io::{stdout, Stdout}, 15 | ops::{Deref, DerefMut}, 16 | }; 17 | 18 | pub type Result = std::result::Result>; 19 | 20 | pub struct Colors; 21 | 22 | impl Colors { 23 | pub const BLACK: Color = Color::Rgb(0, 0, 0); 24 | pub const WHITE: Color = Color::Rgb(255, 255, 255); 25 | pub const CHARCOAL: Color = Color::Rgb(28, 28, 32); 26 | pub const ORANGE: Color = Color::Rgb(255, 153, 0); 27 | pub const GRAY: Color = Color::Rgb(96, 96, 96); 28 | pub const TEAL: Color = Color::Rgb(0, 128, 128); 29 | } 30 | 31 | pub struct Block; 32 | impl Block { 33 | pub fn disabled() -> ratatui::widgets::Block<'static> { 34 | return ratatui::widgets::Block::default() 35 | .borders(Borders::ALL) 36 | .style(Style::default().fg(Colors::GRAY)); 37 | } 38 | 39 | pub fn selected() -> ratatui::widgets::Block<'static> { 40 | return ratatui::widgets::Block::default() 41 | .borders(Borders::ALL) 42 | .border_type(BorderType::Double) 43 | .style(Style::default().fg(Colors::WHITE)); 44 | } 45 | } 46 | 47 | pub struct Terminal(ratatui::Terminal>); 48 | 49 | impl Deref for Terminal { 50 | type Target = ratatui::Terminal>; 51 | 52 | fn deref(&self) -> &Self::Target { 53 | &self.0 54 | } 55 | } 56 | 57 | impl DerefMut for Terminal { 58 | fn deref_mut(&mut self) -> &mut Self::Target { 59 | &mut self.0 60 | } 61 | } 62 | 63 | impl Terminal { 64 | pub fn init() -> Result { 65 | crossterm::execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?; 66 | enable_raw_mode()?; 67 | 68 | let backend = CrosstermBackend::new(stdout()); 69 | 70 | let mut terminal = ratatui::Terminal::new(backend)?; 71 | terminal.hide_cursor()?; 72 | 73 | fn panic_hook() { 74 | let original_hook = std::panic::take_hook(); 75 | 76 | std::panic::set_hook(Box::new(move |panic| { 77 | Terminal::reset().unwrap(); 78 | original_hook(panic); 79 | })); 80 | } 81 | 82 | panic_hook(); 83 | 84 | Ok(Self(terminal)) 85 | } 86 | 87 | pub fn reset() -> Result<()> { 88 | disable_raw_mode()?; 89 | crossterm::execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 90 | 91 | Ok(()) 92 | } 93 | 94 | pub fn draw_app(&mut self, widget: W, state: &mut W::State) -> Result<()> 95 | where 96 | W: StatefulWidget, 97 | { 98 | self.0.draw(|frame| { 99 | frame.render_stateful_widget(widget, frame.area(), state); 100 | })?; 101 | Ok(()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/demo.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/lib.rs"] 2 | mod common; 3 | use common::{Colors, Result, Terminal}; 4 | use crossterm::event::{self, Event, KeyCode, KeyEventKind}; 5 | use ratatui::{ 6 | buffer::Buffer, 7 | layout::Rect, 8 | prelude::*, 9 | style::{Color, Style, Styled}, 10 | widgets::{Block, BorderType, Borders, Paragraph, Widget}, 11 | }; 12 | use tui_widget_list::{ListBuilder, ListState, ListView, ScrollAxis}; 13 | 14 | fn main() -> Result<()> { 15 | let mut terminal = Terminal::init()?; 16 | 17 | App::default().run(&mut terminal).unwrap(); 18 | 19 | Terminal::reset()?; 20 | terminal.show_cursor()?; 21 | 22 | Ok(()) 23 | } 24 | 25 | #[derive(Default)] 26 | struct App {} 27 | 28 | #[derive(Default)] 29 | struct AppState { 30 | pub text_list_state: ListState, 31 | pub color_list_state: ListState, 32 | } 33 | 34 | impl App { 35 | pub fn run(&self, terminal: &mut Terminal) -> Result<()> { 36 | let mut state = AppState::default(); 37 | loop { 38 | terminal.draw_app(self, &mut state)?; 39 | 40 | if let Event::Key(key) = event::read()? { 41 | if key.kind == KeyEventKind::Press { 42 | match key.code { 43 | KeyCode::Char('q') => return Ok(()), 44 | KeyCode::Up | KeyCode::Char('k') => state.text_list_state.previous(), 45 | KeyCode::Down | KeyCode::Char('j') => state.text_list_state.next(), 46 | KeyCode::Left | KeyCode::Char('h') => state.color_list_state.previous(), 47 | KeyCode::Right | KeyCode::Char('l') => state.color_list_state.next(), 48 | _ => {} 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | impl StatefulWidget for &App { 57 | type State = AppState; 58 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) 59 | where 60 | Self: Sized, 61 | { 62 | use Constraint::{Min, Percentage}; 63 | let [top, bottom] = Layout::vertical([Percentage(75), Min(0)]).areas(area); 64 | 65 | // Text list 66 | let colors = demo_colors(); 67 | let selected_color = match state.color_list_state.selected { 68 | Some(index) => colors[index], 69 | None => colors[1], 70 | }; 71 | let text_list = TextContainer::demo(selected_color); 72 | text_list.render(top, buf, &mut state.text_list_state); 73 | 74 | // Color list 75 | let color_list = ColoredContainer::demo(); 76 | color_list.render(bottom, buf, &mut state.color_list_state); 77 | } 78 | } 79 | 80 | #[derive(Debug, Clone)] 81 | pub struct TextContainer { 82 | title: String, 83 | content: Vec, 84 | style: Style, 85 | expand: bool, 86 | } 87 | 88 | impl Styled for TextContainer { 89 | type Item = Self; 90 | fn style(&self) -> Style { 91 | self.style 92 | } 93 | 94 | fn set_style>(mut self, style: S) -> Self::Item { 95 | self.style = style.into(); 96 | self 97 | } 98 | } 99 | 100 | impl TextContainer { 101 | pub fn new(title: &str, content: Vec) -> Self { 102 | Self { 103 | title: title.to_string(), 104 | content, 105 | style: Style::default(), 106 | expand: false, 107 | } 108 | } 109 | 110 | fn demo(selected_color: Color) -> ListView<'static, TextContainer> { 111 | let monday: Vec = vec![ 112 | String::from("1. Exercise for 30 minutes"), 113 | String::from("2. Work on the project for 2 hours"), 114 | String::from("3. Read a book for 1 hour"), 115 | String::from("4. Cook dinner"), 116 | ]; 117 | let tuesday: Vec = vec![ 118 | String::from("1. Attend a team meeting at 10 AM"), 119 | String::from("2. Reply to emails"), 120 | String::from("3. Prepare lunch"), 121 | ]; 122 | let wednesday: Vec = vec![ 123 | String::from("1. Update work tasks"), 124 | String::from("2. Conduct code review"), 125 | String::from("3. Attend a training"), 126 | ]; 127 | let thursday: Vec = vec![ 128 | String::from("1. Brainstorm for an upcoming project"), 129 | String::from("2. Document ideas and refine tasks"), 130 | ]; 131 | let friday: Vec = vec![ 132 | String::from("1. Have a recap meeting"), 133 | String::from("2. Attent conference talk"), 134 | String::from("3. Go running for 1 hour"), 135 | ]; 136 | let saturday: Vec = vec![ 137 | String::from("1. Work on coding project"), 138 | String::from("2. Read a chapter from a book"), 139 | String::from("3. Go for a short walk"), 140 | ]; 141 | let sunday: Vec = vec![ 142 | String::from("1. Plan upcoming trip"), 143 | String::from("2. Read in the park"), 144 | String::from("3. Go to dinner with friends"), 145 | ]; 146 | let containers = vec![ 147 | TextContainer::new("Monday", monday), 148 | TextContainer::new("Tuesday", tuesday), 149 | TextContainer::new("Wednesday", wednesday), 150 | TextContainer::new("Thursday", thursday), 151 | TextContainer::new("Friday", friday), 152 | TextContainer::new("Saturday", saturday), 153 | TextContainer::new("Sunday", sunday), 154 | ]; 155 | 156 | let builder = ListBuilder::new(move |context| { 157 | let mut main_axis_size = 2; 158 | 159 | let mut container = containers[context.index].clone(); 160 | 161 | if context.index % 2 == 0 { 162 | container.style = Style::default().bg(Colors::CHARCOAL); 163 | } else { 164 | container.style = Style::default().bg(Colors::BLACK); 165 | } 166 | 167 | if context.is_selected { 168 | container.style = Style::default().bg(selected_color).fg(Colors::CHARCOAL); 169 | container.expand = true; 170 | main_axis_size = 3 + container.content.len() as u16; 171 | } 172 | 173 | (container, main_axis_size) 174 | }); 175 | 176 | ListView::new(builder, 7) 177 | } 178 | } 179 | 180 | impl Widget for TextContainer { 181 | fn render(self, area: Rect, buf: &mut Buffer) { 182 | let mut lines = vec![Line::styled(self.title, self.style)]; 183 | if self.expand { 184 | lines.push(Line::from(String::new())); 185 | lines.extend(self.content.into_iter().map(|x| Line::from(x))); 186 | lines.push(Line::from(String::new())); 187 | } 188 | Paragraph::new(lines) 189 | .alignment(Alignment::Center) 190 | .style(self.style) 191 | .render(area, buf); 192 | } 193 | } 194 | 195 | struct ColoredContainer { 196 | color: Color, 197 | border_style: Style, 198 | border_type: BorderType, 199 | } 200 | 201 | impl ColoredContainer { 202 | fn new(color: Color) -> Self { 203 | Self { 204 | color, 205 | border_style: Style::default(), 206 | border_type: BorderType::Plain, 207 | } 208 | } 209 | 210 | fn demo() -> ListView<'static, ColoredContainer> { 211 | let colors = demo_colors(); 212 | let builder = ListBuilder::new(move |context| { 213 | let color = demo_colors()[context.index]; 214 | 215 | let mut widget = ColoredContainer::new(color); 216 | if context.is_selected { 217 | widget.border_style = Style::default().fg(Color::Black); 218 | widget.border_type = BorderType::Thick; 219 | }; 220 | 221 | (widget, 15) 222 | }); 223 | 224 | ListView::new(builder, colors.len()).scroll_axis(ScrollAxis::Horizontal) 225 | } 226 | } 227 | 228 | impl Widget for ColoredContainer { 229 | fn render(self, area: Rect, buf: &mut Buffer) { 230 | Block::default() 231 | .borders(Borders::ALL) 232 | .border_style(self.border_style) 233 | .border_type(self.border_type) 234 | .bg(self.color) 235 | .render(area, buf); 236 | } 237 | } 238 | 239 | fn demo_colors() -> Vec { 240 | vec![ 241 | Color::Rgb(255, 102, 102), // Neon Red 242 | Color::Rgb(255, 153, 0), // Neon Orange 243 | Color::Rgb(255, 204, 0), // Neon Yellow 244 | Color::Rgb(0, 204, 102), // Neon Green 245 | Color::Rgb(0, 204, 255), // Neon Blue 246 | Color::Rgb(102, 51, 255), // Neon Purple 247 | Color::Rgb(255, 51, 204), // Neon Magenta 248 | Color::Rgb(51, 255, 255), // Neon Cyan 249 | Color::Rgb(255, 102, 255), // Neon Pink 250 | Color::Rgb(102, 255, 255), // Neon Aqua 251 | ] 252 | } 253 | -------------------------------------------------------------------------------- /examples/simple.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/lib.rs"] 2 | mod common; 3 | 4 | use common::{Colors, Result, Terminal}; 5 | use crossterm::event::{self, Event, KeyCode, KeyEventKind}; 6 | use ratatui::{ 7 | prelude::*, 8 | widgets::{Block, Borders, Scrollbar, ScrollbarOrientation}, 9 | }; 10 | use tui_widget_list::{ListBuilder, ListState, ListView}; 11 | 12 | fn main() -> Result<()> { 13 | let mut terminal = Terminal::init()?; 14 | 15 | App::default().run(&mut terminal).unwrap(); 16 | 17 | Terminal::reset()?; 18 | terminal.show_cursor()?; 19 | 20 | Ok(()) 21 | } 22 | 23 | #[derive(Default)] 24 | pub struct App; 25 | 26 | impl App { 27 | pub fn run(&self, terminal: &mut Terminal) -> Result<()> { 28 | let mut state = ListState::default(); 29 | loop { 30 | terminal.draw_app(self, &mut state)?; 31 | 32 | if let Event::Key(key) = event::read()? { 33 | if key.kind == KeyEventKind::Press { 34 | match key.code { 35 | KeyCode::Char('q') => return Ok(()), 36 | KeyCode::Up | KeyCode::Char('k') => state.previous(), 37 | KeyCode::Down | KeyCode::Char('j') => state.next(), 38 | _ => {} 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | 46 | impl StatefulWidget for &App { 47 | type State = ListState; 48 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 49 | let builder = ListBuilder::new(|context| { 50 | let text = format!("Item {0}", context.index); 51 | let mut item = Line::from(text); 52 | 53 | if context.index % 2 == 0 { 54 | item.style = Style::default().bg(Colors::CHARCOAL) 55 | } else { 56 | item.style = Style::default().bg(Colors::BLACK) 57 | }; 58 | 59 | if context.is_selected { 60 | item = prefix_text(item, ">>"); 61 | item.style = Style::default().bg(Colors::ORANGE).fg(Colors::CHARCOAL); 62 | }; 63 | 64 | return (item, 1); 65 | }); 66 | 67 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 68 | .begin_symbol(Some("┐")) 69 | .end_symbol(Some("┘")); 70 | let list = ListView::new(builder, 50) 71 | .block(Block::default().borders(Borders::ALL)) 72 | .scrollbar(scrollbar); 73 | 74 | list.render(area, buf, state); 75 | } 76 | } 77 | 78 | fn prefix_text<'a>(line: Line<'a>, prefix: &'a str) -> Line<'a> { 79 | let mut spans = line.spans; 80 | spans.insert(0, Span::from(prefix)); 81 | ratatui::text::Line::from(spans) 82 | } 83 | -------------------------------------------------------------------------------- /examples/tapes/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/preiter93/tui-widget-list/708470161defe35c786fae50100ef02570655cfc/examples/tapes/demo.gif -------------------------------------------------------------------------------- /examples/tapes/demo.tape: -------------------------------------------------------------------------------- 1 | # To run this script, install vhs and run `vhs ./examples/tapes/demo.tape` 2 | Output "examples/tapes/demo.gif" 3 | Set Margin 10 4 | Set Padding 2 5 | Set BorderRadius 10 6 | Set Width 1200 7 | Set Height 700 8 | Set PlaybackSpeed 1.0 9 | 10 | Hide 11 | Type "cargo run --example demo" 12 | Enter 13 | Sleep 0.5s 14 | Show 15 | 16 | Sleep 1.5s 17 | Down@0.5s 7 18 | Sleep 0.5s 19 | Right@0.5s 11 20 | Sleep 1.5s 21 | -------------------------------------------------------------------------------- /examples/tapes/simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/preiter93/tui-widget-list/708470161defe35c786fae50100ef02570655cfc/examples/tapes/simple.gif -------------------------------------------------------------------------------- /examples/tapes/simple.tape: -------------------------------------------------------------------------------- 1 | # To run this script, install vhs and run `vhs ./examples/tapes/simple.tape` 2 | Output "examples/tapes/simple.gif" 3 | Set Margin 10 4 | Set Padding 2 5 | Set BorderRadius 10 6 | Set Width 1200 7 | Set Height 300 8 | Set PlaybackSpeed 1.0 9 | 10 | Hide 11 | Type "cargo run --example simple" 12 | Enter 13 | Sleep 0.5s 14 | Show 15 | 16 | Sleep 1.5s 17 | Down@0.2s 20 18 | Sleep 1.5s 19 | -------------------------------------------------------------------------------- /examples/tapes/variants.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/preiter93/tui-widget-list/708470161defe35c786fae50100ef02570655cfc/examples/tapes/variants.gif -------------------------------------------------------------------------------- /examples/tapes/variants.tape: -------------------------------------------------------------------------------- 1 | # To run this script, install vhs and run `vhs ./examples/tapes/variants.tape` 2 | Output "examples/tapes/variants.gif" 3 | Set Margin 10 4 | Set Padding 2 5 | Set BorderRadius 10 6 | Set Width 2000 7 | Set Height 1100 8 | Set PlaybackSpeed 1.0 9 | 10 | Hide 11 | Type "cargo run --example variants" 12 | Enter 13 | Sleep 0.5s 14 | Show 15 | 16 | # Classic 17 | Sleep 1.5s 18 | Right@0.5s 1 19 | Down@0.2s 40 20 | 21 | # Infinite 22 | Left@1.5s 1 23 | Down@1.5s 1 24 | 25 | Right@0.5s 1 26 | Down@0.2s 50 27 | Sleep 1.0s 28 | 29 | # Scroll padding 30 | Left@1.5s 1 31 | Down@1.5s 1 32 | 33 | Right@0.5s 1 34 | Down@0.2s 40 35 | Up@0.2s 40 36 | 37 | # Horizontal 38 | Left@1.5s 1 39 | Down@1.5s 1 40 | 41 | Right@0.5s 1 42 | Down@0.5s 10 43 | 44 | Sleep 1.5s 45 | -------------------------------------------------------------------------------- /examples/var_sizes.rs: -------------------------------------------------------------------------------- 1 | #[path = "common/lib.rs"] 2 | mod common; 3 | use common::{Colors, Result, Terminal}; 4 | use crossterm::event::{self, Event, KeyCode, KeyEventKind}; 5 | use ratatui::{ 6 | prelude::*, 7 | widgets::{Block, Borders, Widget}, 8 | }; 9 | use tui_widget_list::{ListBuilder, ListState, ListView}; 10 | 11 | const SIZES: [u16; 19] = [32, 3, 4, 64, 6, 5, 4, 3, 3, 6, 5, 7, 3, 6, 9, 10, 4, 4, 6]; 12 | 13 | fn main() -> Result<()> { 14 | let mut terminal = Terminal::init()?; 15 | 16 | App::default().run(&mut terminal)?; 17 | 18 | Terminal::reset()?; 19 | terminal.show_cursor()?; 20 | 21 | Ok(()) 22 | } 23 | 24 | #[derive(Default)] 25 | pub struct App; 26 | 27 | impl App { 28 | pub fn run(&self, terminal: &mut Terminal) -> Result<()> { 29 | let mut state = ListState::default(); 30 | loop { 31 | terminal.draw_app(self, &mut state)?; 32 | 33 | if let Event::Key(key) = event::read()? { 34 | if key.kind == KeyEventKind::Press { 35 | match key.code { 36 | KeyCode::Char('q') => return Ok(()), 37 | KeyCode::Up | KeyCode::Char('k') => state.previous(), 38 | KeyCode::Down | KeyCode::Char('j') => state.next(), 39 | _ => {} 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | impl StatefulWidget for &App { 48 | type State = ListState; 49 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) 50 | where 51 | Self: Sized, 52 | { 53 | let item_count = SIZES.len(); 54 | 55 | let block = Block::default().borders(Borders::ALL).title("Outer block"); 56 | let builder = ListBuilder::new(move |context| { 57 | let size = SIZES[context.index]; 58 | let mut widget = LineItem::new(format!("Size: {size}")); 59 | 60 | if context.is_selected { 61 | widget.line.style = widget.line.style.bg(Color::White); 62 | }; 63 | 64 | return (widget, size); 65 | }); 66 | let list = ListView::new(builder, item_count) 67 | .bg(Color::Black) 68 | .block(block); 69 | list.render(area, buf, state); 70 | } 71 | } 72 | 73 | #[derive(Debug, Clone)] 74 | pub struct LineItem<'a> { 75 | line: Line<'a>, 76 | } 77 | 78 | impl LineItem<'_> { 79 | pub fn new(text: String) -> Self { 80 | let span = Span::styled(text, Style::default().fg(Colors::TEAL)); 81 | let line = Line::from(span).bg(Colors::CHARCOAL); 82 | Self { line } 83 | } 84 | } 85 | 86 | impl Widget for LineItem<'_> { 87 | fn render(self, area: Rect, buf: &mut Buffer) { 88 | let inner = { 89 | let block = Block::default().borders(Borders::ALL); 90 | block.clone().render(area, buf); 91 | block.inner(area) 92 | }; 93 | 94 | self.line.render(inner, buf); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /examples/variants.rs: -------------------------------------------------------------------------------- 1 | #[path = "variants/classic.rs"] 2 | mod classic; 3 | #[path = "common/lib.rs"] 4 | mod common; 5 | #[path = "variants/config.rs"] 6 | mod config; 7 | #[path = "variants/fps.rs"] 8 | mod fps; 9 | #[path = "variants/horizontal.rs"] 10 | mod horizontal; 11 | #[path = "variants/scroll_padding.rs"] 12 | mod scroll_padding; 13 | use classic::PaddedListView; 14 | use common::{Block, Colors, Result, Terminal}; 15 | use config::{Controls, Variant, VariantsListView}; 16 | use crossterm::event::{self, Event, KeyCode, KeyEventKind}; 17 | use fps::FPSCounter; 18 | use horizontal::HorizontalListView; 19 | use ratatui::{ 20 | buffer::Buffer, 21 | layout::{Constraint, Layout, Rect}, 22 | style::Stylize, 23 | widgets::{StatefulWidget, Widget}, 24 | }; 25 | use scroll_padding::ScrollPaddingListView; 26 | use tui_widget_list::ListState; 27 | 28 | fn main() -> Result<()> { 29 | let mut terminal = Terminal::init()?; 30 | App::default().run(&mut terminal).unwrap(); 31 | 32 | Terminal::reset()?; 33 | terminal.show_cursor()?; 34 | 35 | Ok(()) 36 | } 37 | 38 | #[derive(Default, Clone)] 39 | pub struct App; 40 | 41 | #[derive(Default)] 42 | pub struct AppState { 43 | selected_tab: Tab, 44 | variant_state: ListState, 45 | list_state: ListState, 46 | fps_counter: FPSCounter, 47 | } 48 | 49 | impl AppState { 50 | fn new() -> Self { 51 | let mut scroll_config_state = ListState::default(); 52 | scroll_config_state.select(Some(0)); 53 | Self { 54 | variant_state: scroll_config_state, 55 | ..AppState::default() 56 | } 57 | } 58 | } 59 | 60 | #[derive(PartialEq, Eq, Default)] 61 | enum Tab { 62 | #[default] 63 | Selection, 64 | List, 65 | } 66 | 67 | impl Tab { 68 | fn next(&mut self) { 69 | match self { 70 | Self::Selection => *self = Tab::List, 71 | Self::List => *self = Tab::Selection, 72 | } 73 | } 74 | } 75 | 76 | impl App { 77 | pub fn run(&self, terminal: &mut Terminal) -> Result<()> { 78 | let mut state = AppState::new(); 79 | loop { 80 | terminal.draw_app(self, &mut state)?; 81 | if Self::handle_events(&mut state)? { 82 | return Ok(()); 83 | } 84 | state.fps_counter.update(); 85 | } 86 | } 87 | 88 | /// Handles app events. 89 | /// Returns true if the app should quit. 90 | fn handle_events(state: &mut AppState) -> Result { 91 | if let Event::Key(key) = event::read()? { 92 | if key.kind == KeyEventKind::Press { 93 | let list_state = match state.selected_tab { 94 | Tab::Selection => &mut state.variant_state, 95 | Tab::List => &mut state.list_state, 96 | }; 97 | match key.code { 98 | KeyCode::Char('q') => return Ok(true), 99 | KeyCode::Up | KeyCode::Char('k') => list_state.previous(), 100 | KeyCode::Down | KeyCode::Char('j') => list_state.next(), 101 | KeyCode::Char('f') => state.fps_counter.toggle(), 102 | KeyCode::Tab 103 | | KeyCode::Left 104 | | KeyCode::Char('h') 105 | | KeyCode::Right 106 | | KeyCode::Char('l') => { 107 | state.list_state.select(None); 108 | state.selected_tab.next() 109 | } 110 | _ => {} 111 | } 112 | } 113 | return Ok(false); 114 | } 115 | return Ok(false); 116 | } 117 | } 118 | 119 | impl StatefulWidget for &App { 120 | type State = AppState; 121 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 122 | use Constraint::{Length, Min, Percentage}; 123 | 124 | let [top, main] = Layout::vertical([Length(1), Min(0)]).areas(area); 125 | let [left, right] = Layout::horizontal([Percentage(25), Min(0)]).areas(main); 126 | 127 | // Key mappings 128 | let [top_left, top_right] = Layout::horizontal([Min(0), Length(10)]).areas(top); 129 | Controls::default().render(top_left, buf); 130 | state.fps_counter.render(top_right, buf); 131 | 132 | // Scroll config selection 133 | let block = match state.selected_tab { 134 | Tab::Selection => Block::selected(), 135 | _ => Block::disabled(), 136 | }; 137 | VariantsListView::new() 138 | .block(block) 139 | .render(left, buf, &mut state.variant_state); 140 | 141 | // List demo 142 | let block = match state.selected_tab { 143 | Tab::List => Block::selected(), 144 | _ => Block::disabled(), 145 | }; 146 | let fg = match state.selected_tab { 147 | Tab::List => Colors::WHITE, 148 | _ => Colors::GRAY, 149 | }; 150 | match Variant::from_index(state.variant_state.selected.unwrap_or(0)) { 151 | Variant::Classic => PaddedListView::new(false).block(block).fg(fg).render( 152 | right, 153 | buf, 154 | &mut state.list_state, 155 | ), 156 | Variant::InfiniteScrolling => PaddedListView::new(true).block(block).fg(fg).render( 157 | right, 158 | buf, 159 | &mut state.list_state, 160 | ), 161 | Variant::ScrollPadding => ScrollPaddingListView::new().block(block).fg(fg).render( 162 | right, 163 | buf, 164 | &mut state.list_state, 165 | ), 166 | Variant::Horizontal => HorizontalListView::new().block(block).fg(fg).render( 167 | right, 168 | buf, 169 | &mut state.list_state, 170 | ), 171 | }; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /examples/variants/classic.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{item_container::ListItemContainer, Colors}; 2 | use ratatui::{layout::Alignment, style::Stylize, text::Line, widgets::Padding}; 3 | use tui_widget_list::{ListBuilder, ListView}; 4 | 5 | pub(crate) struct PaddedListView; 6 | 7 | impl PaddedListView { 8 | pub(crate) fn new<'a>( 9 | infinite_scrolling: bool, 10 | ) -> ListView<'a, ListItemContainer<'a, Line<'a>>> { 11 | let builder = ListBuilder::new(|context| { 12 | let mut line = ListItemContainer::new( 13 | Line::from(format!("Item {0}", context.index)).alignment(Alignment::Center), 14 | Padding::vertical(1), 15 | ); 16 | line = match context.is_selected { 17 | true => line.bg(Colors::ORANGE).fg(Colors::CHARCOAL), 18 | false if context.index % 2 == 0 => line.bg(Colors::CHARCOAL), 19 | false => line.bg(Colors::BLACK), 20 | }; 21 | 22 | return (line, 3); 23 | }); 24 | 25 | return ListView::new(builder, 30).infinite_scrolling(infinite_scrolling); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/variants/config.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Alignment, 3 | style::Stylize, 4 | text::Line, 5 | widgets::{Padding, Widget}, 6 | }; 7 | use tui_widget_list::{ListBuilder, ListView}; 8 | 9 | use crate::common::{item_container::ListItemContainer, Colors}; 10 | 11 | #[derive(PartialEq, Eq, Default, Clone)] 12 | pub enum Variant { 13 | #[default] 14 | Classic, 15 | InfiniteScrolling, 16 | ScrollPadding, 17 | Horizontal, 18 | } 19 | 20 | impl Variant { 21 | pub const COUNT: usize = 4; 22 | pub fn from_index(index: usize) -> Self { 23 | match index { 24 | 1 => Variant::InfiniteScrolling, 25 | 2 => Variant::ScrollPadding, 26 | 3 => Variant::Horizontal, 27 | _ => Variant::Classic, 28 | } 29 | } 30 | } 31 | 32 | impl std::fmt::Display for Variant { 33 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 34 | match self { 35 | Variant::Classic => write!(f, "Classic"), 36 | Variant::InfiniteScrolling => write!(f, "Infinite Scrolling"), 37 | Variant::ScrollPadding => write!(f, "Scroll Padding"), 38 | Variant::Horizontal => write!(f, "Horizontal Scrolling"), 39 | } 40 | } 41 | } 42 | 43 | pub struct VariantsListView; 44 | impl VariantsListView { 45 | pub fn new<'a>() -> ListView<'a, ListItemContainer<'a, Line<'a>>> { 46 | let builder = ListBuilder::new(move |context| { 47 | let config = Variant::from_index(context.index); 48 | let line = Line::from(format!("{config}")).alignment(Alignment::Center); 49 | let mut item = ListItemContainer::new(line, Padding::vertical(1)); 50 | 51 | if context.is_selected { 52 | item = item.bg(Colors::ORANGE).fg(Colors::CHARCOAL); 53 | }; 54 | 55 | return (item, 3); 56 | }); 57 | 58 | return ListView::new(builder, Variant::COUNT); 59 | } 60 | } 61 | 62 | #[derive(Default)] 63 | pub struct Controls; 64 | impl Widget for Controls { 65 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { 66 | Line::from("k: Up | j: Down | Tab: Left/Right").render(area, buf); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/variants/fps.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use ratatui::{text::Line, widgets::Widget}; 4 | 5 | pub struct FPSCounter { 6 | last_frame: Instant, 7 | frame_count: u32, 8 | fps: f32, 9 | hide: bool, 10 | } 11 | 12 | impl Default for FPSCounter { 13 | fn default() -> Self { 14 | Self { 15 | last_frame: Instant::now(), 16 | frame_count: 0, 17 | fps: 0.0, 18 | hide: true, 19 | } 20 | } 21 | } 22 | 23 | impl FPSCounter { 24 | pub fn update(&mut self) { 25 | self.frame_count += 1; 26 | let now = Instant::now(); 27 | let duration = now.duration_since(self.last_frame); 28 | 29 | // Update FPS every second 30 | if duration >= Duration::from_secs(1) { 31 | self.fps = self.frame_count as f32 / duration.as_secs_f32(); 32 | self.frame_count = 0; 33 | self.last_frame = now; 34 | } 35 | } 36 | 37 | pub fn toggle(&mut self) { 38 | self.hide = !self.hide; 39 | } 40 | 41 | pub fn get_fps(&self) -> f32 { 42 | self.fps 43 | } 44 | } 45 | impl Widget for &FPSCounter { 46 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { 47 | if self.hide { 48 | return; 49 | } 50 | let text = format!("FPS: {:.2}", self.get_fps()); 51 | Line::from(text).render(area, buf); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/variants/horizontal.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{item_container::ListItemContainer, Colors}; 2 | use ratatui::{layout::Alignment, style::Stylize, text::Line, widgets::Padding}; 3 | use tui_widget_list::{ListBuilder, ListView, ScrollAxis}; 4 | 5 | pub(crate) struct HorizontalListView; 6 | 7 | impl HorizontalListView { 8 | pub(crate) fn new<'a>() -> ListView<'a, ListItemContainer<'a, Line<'a>>> { 9 | let builder = ListBuilder::new(|context| { 10 | let mut line = ListItemContainer::new( 11 | Line::from(format!("Item {0}", context.index)).alignment(Alignment::Center), 12 | Padding::vertical(1), 13 | ); 14 | line = match context.is_selected { 15 | true => line.bg(Colors::ORANGE).fg(Colors::CHARCOAL), 16 | false if context.index % 2 == 0 => line.bg(Colors::CHARCOAL), 17 | false => line.bg(Colors::BLACK), 18 | }; 19 | 20 | return (line, 20); 21 | }); 22 | 23 | return ListView::new(builder, 10).scroll_axis(ScrollAxis::Horizontal); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/variants/scroll_padding.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{item_container::ListItemContainer, Colors}; 2 | use ratatui::{layout::Alignment, style::Stylize, text::Line, widgets::Padding}; 3 | use tui_widget_list::{ListBuilder, ListView}; 4 | 5 | pub(crate) struct ScrollPaddingListView; 6 | 7 | impl ScrollPaddingListView { 8 | pub(crate) fn new<'a>() -> ListView<'a, ListItemContainer<'a, Line<'a>>> { 9 | let builder = ListBuilder::new(|context| { 10 | let mut line = ListItemContainer::new( 11 | Line::from(format!("Item {0}", context.index)).alignment(Alignment::Center), 12 | Padding::vertical(1), 13 | ); 14 | line = match context.is_selected { 15 | true => line.bg(Colors::ORANGE).fg(Colors::CHARCOAL), 16 | false if context.index % 2 == 0 => line.bg(Colors::CHARCOAL), 17 | false => line.bg(Colors::BLACK), 18 | }; 19 | 20 | return (line, 3); 21 | }); 22 | 23 | return ListView::new(builder, 30) 24 | .infinite_scrolling(false) 25 | .scroll_padding(5); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/legacy/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod traits; 2 | mod utils; 3 | pub(crate) mod widget; 4 | -------------------------------------------------------------------------------- /src/legacy/traits.rs: -------------------------------------------------------------------------------- 1 | #![allow(deprecated)] 2 | use ratatui::widgets::Widget; 3 | 4 | use crate::ScrollAxis; 5 | 6 | /// This trait should be implemented for items that are intended to be used within a `List` widget. 7 | #[deprecated(since = "0.11.0", note = "Use ListView with ListBuilder instead.")] 8 | pub trait PreRender: Widget { 9 | /// This method is called before rendering the widget. 10 | /// 11 | /// # Arguments 12 | /// 13 | /// - `self`: Captured by value, allowing modification within the pre-render hook. 14 | /// - `context`: Rendering context providing additional information like selection 15 | /// status, cross-axis size, scroll direction and the widgets index in the list. 16 | /// 17 | /// # Returns 18 | /// 19 | /// - The (modified) widget. 20 | /// - The widget's main axis size, used for layouting. 21 | /// 22 | /// # Example 23 | /// 24 | ///```ignore 25 | /// use ratatui::prelude::*; 26 | /// use tui_widget_list::{PreRenderContext, PreRender}; 27 | /// 28 | /// impl PreRender for MyWidget { 29 | /// fn pre_render(self, context: &PreRenderContext) -> (Self, u16) { 30 | /// // Modify the widget based on the selection state 31 | /// if context.is_selected { 32 | /// self.style = self.style.reversed(); 33 | /// } 34 | /// 35 | /// // Example: set main axis size to 1 36 | /// let main_axis_size = 1; 37 | /// 38 | /// (self, main_axis_size) 39 | /// } 40 | /// } 41 | /// ``` 42 | fn pre_render(&mut self, context: &PreRenderContext) -> u16; 43 | } 44 | 45 | /// The context provided during rendering. 46 | /// 47 | /// It provides a set of information that can be used from [`PreRender::pre_render`]. 48 | #[derive(Debug, Clone)] 49 | #[deprecated(since = "0.11.0", note = "Use ListView with ListBuilder instead.")] 50 | pub struct PreRenderContext { 51 | /// Indicates whether the widget is selected. 52 | pub is_selected: bool, 53 | 54 | /// The cross axis size of the widget. 55 | pub cross_axis_size: u16, 56 | 57 | /// The list's scroll axis: 58 | /// - `vertical` (default) 59 | /// - `horizontal` 60 | pub scroll_axis: ScrollAxis, 61 | 62 | /// The index of the widget in the list. 63 | pub index: usize, 64 | } 65 | 66 | impl PreRenderContext { 67 | pub(crate) fn new( 68 | is_selected: bool, 69 | cross_axis_size: u16, 70 | scroll_axis: ScrollAxis, 71 | index: usize, 72 | ) -> Self { 73 | Self { 74 | is_selected, 75 | cross_axis_size, 76 | scroll_axis, 77 | index, 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/legacy/utils.rs: -------------------------------------------------------------------------------- 1 | #![allow(deprecated)] 2 | use std::collections::HashMap; 3 | 4 | use crate::{ListState, PreRender, PreRenderContext, ScrollAxis}; 5 | 6 | /// This method checks how to layout the items on the viewport and if necessary 7 | /// updates the offset of the first item on the screen. 8 | /// 9 | /// For this we start with the first item on the screen and iterate until we have 10 | /// reached the maximum height. If the selected value is within the bounds we do 11 | /// nothing. If the selected value is out of bounds, the offset is adjusted. 12 | /// 13 | /// # Returns 14 | /// - The sizes along the main axis of the elements on the viewport, 15 | /// and how much they are being truncated to fit on the viewport. 16 | pub(crate) fn layout_on_viewport( 17 | state: &mut ListState, 18 | widgets: &mut [T], 19 | total_main_axis_size: u16, 20 | cross_axis_size: u16, 21 | scroll_axis: ScrollAxis, 22 | ) -> Vec { 23 | // The items heights on the viewport will be calculated on the fly. 24 | let mut viewport_layouts: Vec = Vec::new(); 25 | 26 | // If none is selected, the first item should be show on top of the viewport. 27 | let selected = state.selected.unwrap_or(0); 28 | 29 | // If the selected value is smaller than the offset, we roll 30 | // the offset so that the selected value is at the top 31 | if selected < state.view_state.offset { 32 | state.view_state.offset = selected; 33 | } 34 | 35 | let mut main_axis_size_cache: HashMap = HashMap::new(); 36 | 37 | // Check if the selected item is in the current view 38 | let (mut y, mut index) = (0, state.view_state.offset); 39 | let mut found = false; 40 | for widget in widgets.iter_mut().skip(state.view_state.offset) { 41 | // Get the main axis size of the widget. 42 | let is_selected = state.selected == Some(index); 43 | let context = PreRenderContext::new(is_selected, cross_axis_size, scroll_axis, index); 44 | 45 | let main_axis_size = widget.pre_render(&context); 46 | main_axis_size_cache.insert(index, main_axis_size); 47 | 48 | // Out of bounds 49 | if y + main_axis_size > total_main_axis_size { 50 | // Truncate the last widget 51 | let dy = total_main_axis_size - y; 52 | if dy > 0 { 53 | viewport_layouts.push(ViewportLayout { 54 | main_axis_size: dy, 55 | truncated_by: main_axis_size.saturating_sub(dy), 56 | }); 57 | } 58 | break; 59 | } 60 | // Selected value is within view/bounds, so we are good 61 | // but we keep iterating to collect the view heights 62 | if selected == index { 63 | found = true; 64 | } 65 | y += main_axis_size; 66 | index += 1; 67 | 68 | viewport_layouts.push(ViewportLayout { 69 | main_axis_size, 70 | truncated_by: 0, 71 | }); 72 | } 73 | if found { 74 | return viewport_layouts; 75 | } 76 | 77 | // The selected item is out of bounds. We iterate backwards from the selected 78 | // item and determine the first widget that still fits on the screen. 79 | viewport_layouts.clear(); 80 | let (mut y, mut index) = (0, selected); 81 | let last = widgets.len().saturating_sub(1); 82 | for widget in widgets.iter_mut().rev().skip(last.saturating_sub(selected)) { 83 | // Get the main axis size of the widget. At this point we might have already 84 | // calculated it, so check the cache first. 85 | let main_axis_size = if let Some(main_axis_size) = main_axis_size_cache.remove(&index) { 86 | main_axis_size 87 | } else { 88 | let is_selected = state.selected == Some(index); 89 | let context = PreRenderContext::new(is_selected, cross_axis_size, scroll_axis, index); 90 | 91 | widget.pre_render(&context) 92 | }; 93 | 94 | // Truncate the first widget 95 | if y + main_axis_size >= total_main_axis_size { 96 | let dy = total_main_axis_size - y; 97 | viewport_layouts.insert( 98 | 0, 99 | ViewportLayout { 100 | main_axis_size: dy, 101 | truncated_by: main_axis_size.saturating_sub(dy), 102 | }, 103 | ); 104 | state.view_state.offset = index; 105 | break; 106 | } 107 | 108 | viewport_layouts.insert( 109 | 0, 110 | ViewportLayout { 111 | main_axis_size, 112 | truncated_by: 0, 113 | }, 114 | ); 115 | 116 | y += main_axis_size; 117 | index -= 1; 118 | } 119 | 120 | viewport_layouts 121 | } 122 | 123 | #[derive(Debug, PartialEq, PartialOrd, Eq, Ord)] 124 | pub(crate) struct ViewportLayout { 125 | pub(crate) main_axis_size: u16, 126 | pub(crate) truncated_by: u16, 127 | } 128 | -------------------------------------------------------------------------------- /src/legacy/widget.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::cast_possible_truncation, deprecated)] 2 | use ratatui::{ 3 | prelude::{Buffer, Rect}, 4 | style::{Style, Styled}, 5 | widgets::{Block, StatefulWidget, Widget}, 6 | }; 7 | 8 | use crate::{legacy::utils::layout_on_viewport, ListState, PreRender, ScrollAxis}; 9 | 10 | /// A [`List`] is a widget for Ratatui that can render an arbitrary list of widgets. 11 | /// It is generic over `T`, where each widget `T` should implement the [`PreRender`] 12 | /// trait. 13 | /// `List` is no longer developed. Consider using `ListView`. 14 | #[derive(Clone)] 15 | #[deprecated(since = "0.11.0", note = "Use ListView with ListBuilder instead.")] 16 | pub struct List<'a, T: PreRender> { 17 | /// The list's items. 18 | pub items: Vec, 19 | 20 | /// Style used as a base style for the widget. 21 | style: Style, 22 | 23 | /// Block surrounding the widget list. 24 | block: Option>, 25 | 26 | /// Specifies the scroll axis. Either `Vertical` or `Horizontal`. 27 | scroll_axis: ScrollAxis, 28 | } 29 | 30 | #[allow(deprecated)] 31 | impl<'a, T: PreRender> List<'a, T> { 32 | /// Instantiates a widget list with elements. 33 | /// 34 | /// # Arguments 35 | /// 36 | /// * `items` - A vector of elements implementing the [`PreRender`] trait. 37 | #[must_use] 38 | pub fn new(items: Vec) -> Self { 39 | Self { 40 | items, 41 | style: Style::default(), 42 | block: None, 43 | scroll_axis: ScrollAxis::default(), 44 | } 45 | } 46 | 47 | /// Sets the block style that surrounds the whole List. 48 | #[must_use] 49 | pub fn block(mut self, block: Block<'a>) -> Self { 50 | self.block = Some(block); 51 | self 52 | } 53 | 54 | /// Checks whether the widget list is empty. 55 | #[must_use] 56 | pub fn is_empty(&self) -> bool { 57 | self.items.is_empty() 58 | } 59 | 60 | /// Returns the length of the widget list. 61 | #[must_use] 62 | pub fn len(&self) -> usize { 63 | self.items.len() 64 | } 65 | 66 | /// Set the base style of the List. 67 | #[must_use] 68 | pub fn style>(mut self, style: S) -> Self { 69 | self.style = style.into(); 70 | self 71 | } 72 | 73 | /// Set the scroll direction of the list. 74 | #[must_use] 75 | pub fn scroll_direction(mut self, scroll_axis: ScrollAxis) -> Self { 76 | self.scroll_axis = scroll_axis; 77 | self 78 | } 79 | } 80 | 81 | impl Styled for List<'_, T> { 82 | type Item = Self; 83 | fn style(&self) -> Style { 84 | self.style 85 | } 86 | 87 | fn set_style>(mut self, style: S) -> Self::Item { 88 | self.style = style.into(); 89 | self 90 | } 91 | } 92 | 93 | impl From> for List<'_, T> { 94 | /// Instantiates a [`List`] from a vector of elements implementing 95 | /// the [`PreRender`] trait. 96 | fn from(items: Vec) -> Self { 97 | Self::new(items) 98 | } 99 | } 100 | 101 | impl StatefulWidget for List<'_, T> { 102 | type State = ListState; 103 | 104 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 105 | let style = self.style; 106 | let scroll_axis = self.scroll_axis; 107 | 108 | let mut items = self.items; 109 | let mut block = self.block; 110 | state.set_num_elements(items.len()); 111 | 112 | // Set the base style 113 | buf.set_style(area, style); 114 | let area = match block.take() { 115 | Some(b) => { 116 | let inner_area = b.inner(area); 117 | b.render(area, buf); 118 | inner_area 119 | } 120 | None => area, 121 | }; 122 | 123 | // List is empty 124 | if items.is_empty() { 125 | return; 126 | } 127 | 128 | // Set the dimension along the scroll axis and the cross axis 129 | let (total_main_axis_size, cross_axis_size) = match scroll_axis { 130 | ScrollAxis::Vertical => (area.height, area.width), 131 | ScrollAxis::Horizontal => (area.width, area.height), 132 | }; 133 | 134 | // Determine which widgets to show on the viewport and how much space they 135 | // get assigned to. 136 | let viewport_layouts = layout_on_viewport( 137 | state, 138 | &mut items, 139 | total_main_axis_size, 140 | cross_axis_size, 141 | scroll_axis, 142 | ); 143 | 144 | // Drain out elements that are shown on the view port from the vector of 145 | // all elements. 146 | let num_items_viewport = viewport_layouts.len(); 147 | let (start, end) = ( 148 | state.view_state.offset, 149 | num_items_viewport + state.view_state.offset, 150 | ); 151 | let items_viewport = items.drain(start..end); 152 | 153 | // The starting coordinates of the current item 154 | let (mut scroll_axis_pos, cross_axis_pos) = match scroll_axis { 155 | ScrollAxis::Vertical => (area.top(), area.left()), 156 | ScrollAxis::Horizontal => (area.left(), area.top()), 157 | }; 158 | 159 | // Render the widgets on the viewport 160 | for (i, (viewport_layout, item)) in 161 | viewport_layouts.into_iter().zip(items_viewport).enumerate() 162 | { 163 | let area = match scroll_axis { 164 | ScrollAxis::Vertical => Rect::new( 165 | cross_axis_pos, 166 | scroll_axis_pos, 167 | cross_axis_size, 168 | viewport_layout.main_axis_size, 169 | ), 170 | ScrollAxis::Horizontal => Rect::new( 171 | scroll_axis_pos, 172 | cross_axis_pos, 173 | viewport_layout.main_axis_size, 174 | cross_axis_size, 175 | ), 176 | }; 177 | 178 | // Check if the item needs to be truncated 179 | if viewport_layout.truncated_by > 0 { 180 | let trunc_top = i == 0 && num_items_viewport > 1; 181 | let tot_size = viewport_layout.main_axis_size + viewport_layout.truncated_by; 182 | render_trunc(item, area, buf, tot_size, scroll_axis, trunc_top, style); 183 | } else { 184 | item.render(area, buf); 185 | } 186 | 187 | scroll_axis_pos += viewport_layout.main_axis_size; 188 | } 189 | } 190 | } 191 | 192 | /// Renders a listable widget within a specified area of a buffer, potentially truncating the widget content based on scrolling direction. 193 | /// `truncate_top` indicates whether to truncate the content from the top or bottom. 194 | fn render_trunc( 195 | item: T, 196 | available_area: Rect, 197 | buf: &mut Buffer, 198 | total_size: u16, 199 | scroll_axis: ScrollAxis, 200 | truncate_top: bool, 201 | style: Style, 202 | ) { 203 | // Create an intermediate buffer for rendering the truncated element 204 | let (width, height) = match scroll_axis { 205 | ScrollAxis::Vertical => (available_area.width, total_size), 206 | ScrollAxis::Horizontal => (total_size, available_area.height), 207 | }; 208 | let mut hidden_buffer = Buffer::empty(Rect { 209 | x: available_area.left(), 210 | y: available_area.top(), 211 | width, 212 | height, 213 | }); 214 | hidden_buffer.set_style(hidden_buffer.area, style); 215 | item.render(hidden_buffer.area, &mut hidden_buffer); 216 | 217 | // Copy the visible part from the intermediate buffer to the main buffer 218 | match scroll_axis { 219 | ScrollAxis::Vertical => { 220 | let offset = if truncate_top { 221 | total_size.saturating_sub(available_area.height) 222 | } else { 223 | 0 224 | }; 225 | for y in available_area.top()..available_area.bottom() { 226 | let y_off = y + offset; 227 | for x in available_area.left()..available_area.right() { 228 | *buf.get_mut(x, y) = hidden_buffer.get(x, y_off).clone(); 229 | } 230 | } 231 | } 232 | ScrollAxis::Horizontal => { 233 | let offset = if truncate_top { 234 | total_size.saturating_sub(available_area.width) 235 | } else { 236 | 0 237 | }; 238 | for x in available_area.left()..available_area.right() { 239 | let x_off = x + offset; 240 | for y in available_area.top()..available_area.bottom() { 241 | *buf.get_mut(x, y) = hidden_buffer.get(x_off, y).clone(); 242 | } 243 | } 244 | } 245 | }; 246 | } 247 | 248 | #[cfg(test)] 249 | mod test { 250 | use crate::PreRenderContext; 251 | 252 | use super::*; 253 | use ratatui::widgets::Borders; 254 | 255 | struct TestItem {} 256 | impl Widget for TestItem { 257 | fn render(self, area: Rect, buf: &mut Buffer) 258 | where 259 | Self: Sized, 260 | { 261 | Block::default().borders(Borders::ALL).render(area, buf); 262 | } 263 | } 264 | 265 | impl PreRender for TestItem { 266 | fn pre_render(&mut self, context: &PreRenderContext) -> u16 { 267 | let main_axis_size = match context.scroll_axis { 268 | ScrollAxis::Vertical => 3, 269 | ScrollAxis::Horizontal => 3, 270 | }; 271 | main_axis_size 272 | } 273 | } 274 | 275 | fn init(height: u16) -> (Rect, Buffer, List<'static, TestItem>, ListState) { 276 | let area = Rect::new(0, 0, 5, height); 277 | ( 278 | area, 279 | Buffer::empty(area), 280 | List::new(vec![TestItem {}, TestItem {}, TestItem {}]), 281 | ListState::default(), 282 | ) 283 | } 284 | 285 | #[test] 286 | fn not_truncated() { 287 | // given 288 | let (area, mut buf, list, mut state) = init(9); 289 | 290 | // when 291 | list.render(area, &mut buf, &mut state); 292 | 293 | // then 294 | assert_buffer_eq( 295 | buf, 296 | Buffer::with_lines(vec![ 297 | "┌───┐", 298 | "│ │", 299 | "└───┘", 300 | "┌───┐", 301 | "│ │", 302 | "└───┘", 303 | "┌───┐", 304 | "│ │", 305 | "└───┘", 306 | ]), 307 | ) 308 | } 309 | 310 | #[test] 311 | fn empty_list() { 312 | // given 313 | let (area, mut buf, _, mut state) = init(2); 314 | let list = List::new(Vec::::new()); 315 | 316 | // when 317 | list.render(area, &mut buf, &mut state); 318 | 319 | // then 320 | assert_buffer_eq(buf, Buffer::with_lines(vec![" ", " "])) 321 | } 322 | 323 | #[test] 324 | fn zero_size() { 325 | // given 326 | let (area, mut buf, list, mut state) = init(0); 327 | 328 | // when 329 | list.render(area, &mut buf, &mut state); 330 | 331 | // then 332 | assert_buffer_eq(buf, Buffer::empty(area)) 333 | } 334 | 335 | #[test] 336 | fn bottom_is_truncated() { 337 | // given 338 | let (area, mut buf, list, mut state) = init(8); 339 | 340 | // when 341 | list.render(area, &mut buf, &mut state); 342 | 343 | // then 344 | assert_buffer_eq( 345 | buf, 346 | Buffer::with_lines(vec![ 347 | "┌───┐", 348 | "│ │", 349 | "└───┘", 350 | "┌───┐", 351 | "│ │", 352 | "└───┘", 353 | "┌───┐", 354 | "│ │", 355 | ]), 356 | ) 357 | } 358 | 359 | #[test] 360 | fn top_is_truncated() { 361 | // given 362 | let (area, mut buf, list, mut state) = init(8); 363 | state.select(Some(2)); 364 | 365 | // when 366 | list.render(area, &mut buf, &mut state); 367 | 368 | // then 369 | assert_buffer_eq( 370 | buf, 371 | Buffer::with_lines(vec![ 372 | "│ │", 373 | "└───┘", 374 | "┌───┐", 375 | "│ │", 376 | "└───┘", 377 | "┌───┐", 378 | "│ │", 379 | "└───┘", 380 | ]), 381 | ) 382 | } 383 | 384 | fn assert_buffer_eq(actual: Buffer, expected: Buffer) { 385 | if actual.area != expected.area { 386 | panic!( 387 | "buffer areas not equal expected: {:?} actual: {:?}", 388 | expected, actual 389 | ); 390 | } 391 | let diff = expected.diff(&actual); 392 | if !diff.is_empty() { 393 | panic!( 394 | "buffer contents not equal\nexpected: {:?}\nactual: {:?}", 395 | expected, actual, 396 | ); 397 | } 398 | assert_eq!(actual, expected, "buffers not equal"); 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # A versatile widget list for Ratatui 2 | //! 3 | //!
4 | //! 5 | //! [![Continuous Integration](https://github.com/preiter93/tui-widget-list/actions/workflows/ci.yml/badge.svg)](https://github.com/preiter93/tui-widget-list/actions/workflows/ci.yml) 6 | //! 7 | //!
8 | //! 9 | //! This crate provides a stateful widget [`ListView`] implementation for `Ratatui`. The associated [`ListState`], offers functionalities such as navigating to the next and previous items. 10 | //! The list view support both horizontal and vertical scrolling. 11 | //! 12 | //! ## Configuration 13 | //! The [`ListView`] can be customized with the following options: 14 | //! - [`ListView::scroll_axis`]: Specifies whether the list is vertically or horizontally scrollable. 15 | //! 16 | //! - [`ListView::scroll_padding`]: Specifies whether content should remain visible while scrolling, ensuring that a specified amount of padding is preserved above/below the selected item during scrolling. 17 | //! - [`ListView::infinite_scrolling`]: Allows the list to wrap around when scrolling past the first or last element. 18 | //! - [`ListView::style`]: Defines the base style of the list. 19 | //! - [`ListView::block`]: Optional outer block surrounding the list. 20 | //! 21 | //! ## Example 22 | //!``` 23 | //! use ratatui::prelude::*; 24 | //! use tui_widget_list::{ListBuilder, ListState, ListView}; 25 | //! 26 | //! #[derive(Debug, Clone)] 27 | //! pub struct ListItem { 28 | //! text: String, 29 | //! style: Style, 30 | //! } 31 | //! 32 | //! impl ListItem { 33 | //! pub fn new>(text: T) -> Self { 34 | //! Self { 35 | //! text: text.into(), 36 | //! style: Style::default(), 37 | //! } 38 | //! } 39 | //! } 40 | //! 41 | //! impl Widget for ListItem { 42 | //! fn render(self, area: Rect, buf: &mut Buffer) { 43 | //! Line::from(self.text).style(self.style).render(area, buf); 44 | //! } 45 | //! } 46 | //! 47 | //! pub struct App { 48 | //! state: ListState, 49 | //! } 50 | //! 51 | //! impl Widget for &mut App { 52 | //! fn render(self, area: Rect, buf: &mut Buffer) { 53 | //! let builder = ListBuilder::new(|context| { 54 | //! let mut item = ListItem::new(&format!("Item {:0}", context.index)); 55 | //! 56 | //! // Alternating styles 57 | //! if context.index % 2 == 0 { 58 | //! item.style = Style::default().bg(Color::Rgb(28, 28, 32)); 59 | //! } else { 60 | //! item.style = Style::default().bg(Color::Rgb(0, 0, 0)); 61 | //! } 62 | //! 63 | //! // Style the selected element 64 | //! if context.is_selected { 65 | //! item.style = Style::default() 66 | //! .bg(Color::Rgb(255, 153, 0)) 67 | //! .fg(Color::Rgb(28, 28, 32)); 68 | //! }; 69 | //! 70 | //! // Return the size of the widget along the main axis. 71 | //! let main_axis_size = 1; 72 | //! 73 | //! (item, main_axis_size) 74 | //! }); 75 | //! 76 | //! let item_count = 2; 77 | //! let list = ListView::new(builder, item_count); 78 | //! let state = &mut self.state; 79 | //! 80 | //! list.render(area, buf, state); 81 | //! } 82 | //! } 83 | //!``` 84 | //! 85 | //! For more examples see [tui-widget-list](https://github.com/preiter93/tui-widget-list/tree/main/examples). 86 | //! 87 | //! ## Documentation 88 | //! [docs.rs](https://docs.rs/tui-widget-list/) 89 | //! 90 | //! ## Demos 91 | //! 92 | //! ### Demo 93 | //! 94 | //!![](examples/tapes/demo.gif?v=1) 95 | //! 96 | //! ### Infinite scrolling, scroll padding, horizontal scrolling 97 | //! 98 | //!![](examples/tapes/variants.gif?v=1) 99 | pub(crate) mod legacy; 100 | pub(crate) mod state; 101 | pub(crate) mod utils; 102 | pub(crate) mod view; 103 | 104 | pub use state::ListState; 105 | pub use view::{ListBuildContext, ListBuilder, ListView, ScrollAxis}; 106 | 107 | #[allow(deprecated)] 108 | pub use legacy::{ 109 | traits::{PreRender, PreRenderContext}, 110 | widget::List, 111 | }; 112 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use ratatui::widgets::ScrollbarState; 2 | 3 | use crate::{ListBuildContext, ListBuilder, ScrollAxis}; 4 | 5 | #[allow(clippy::module_name_repetitions)] 6 | #[derive(Debug, Clone)] 7 | pub struct ListState { 8 | /// The selected item. If `None`, no item is currently selected. 9 | pub selected: Option, 10 | 11 | /// The total number of elements in the list. This is necessary to correctly 12 | /// handle item selection. 13 | pub(crate) num_elements: usize, 14 | 15 | /// Indicates if the selection is circular. If true, calling `next` on the last 16 | /// element returns the first, and calling `previous` on the first returns the last. 17 | /// 18 | /// True by default. 19 | pub(crate) infinite_scrolling: bool, 20 | 21 | /// The state for the viewport. Keeps track which item to show 22 | /// first and how much it is truncated. 23 | pub(crate) view_state: ViewState, 24 | 25 | /// The scrollbar state. This is only used if the view is 26 | /// initialzed with a scrollbar. 27 | pub(crate) scrollbar_state: ScrollbarState, 28 | } 29 | 30 | #[derive(Debug, Clone, Default, Eq, PartialEq)] 31 | pub(crate) struct ViewState { 32 | /// The index of the first item displayed on the screen. 33 | pub(crate) offset: usize, 34 | 35 | /// The truncation in rows/columns of the first item displayed on the screen. 36 | pub(crate) first_truncated: u16, 37 | } 38 | 39 | impl Default for ListState { 40 | fn default() -> Self { 41 | Self { 42 | selected: None, 43 | num_elements: 0, 44 | infinite_scrolling: true, 45 | view_state: ViewState::default(), 46 | scrollbar_state: ScrollbarState::new(0).position(0), 47 | } 48 | } 49 | } 50 | 51 | impl ListState { 52 | pub(crate) fn set_infinite_scrolling(&mut self, infinite_scrolling: bool) { 53 | self.infinite_scrolling = infinite_scrolling; 54 | } 55 | 56 | /// Returns the index of the currently selected item, if any. 57 | #[must_use] 58 | #[deprecated(since = "0.9.0", note = "Use ListState's selected field instead.")] 59 | pub fn selected(&self) -> Option { 60 | self.selected 61 | } 62 | 63 | /// Selects an item by its index. 64 | pub fn select(&mut self, index: Option) { 65 | self.selected = index; 66 | if index.is_none() { 67 | self.view_state.offset = 0; 68 | self.scrollbar_state = self.scrollbar_state.position(0); 69 | } 70 | } 71 | 72 | /// Selects the next element of the list. If circular is true, 73 | /// calling next on the last element selects the first. 74 | /// 75 | /// # Example 76 | /// 77 | /// ```rust 78 | /// use tui_widget_list::ListState; 79 | /// 80 | /// let mut list_state = ListState::default(); 81 | /// list_state.next(); 82 | /// ``` 83 | pub fn next(&mut self) { 84 | if self.num_elements == 0 { 85 | return; 86 | } 87 | let i = match self.selected { 88 | Some(i) => { 89 | if i >= self.num_elements - 1 { 90 | if self.infinite_scrolling { 91 | 0 92 | } else { 93 | i 94 | } 95 | } else { 96 | i + 1 97 | } 98 | } 99 | None => 0, 100 | }; 101 | self.select(Some(i)); 102 | } 103 | 104 | /// Selects the previous element of the list. If circular is true, 105 | /// calling previous on the first element selects the last. 106 | /// 107 | /// # Example 108 | /// 109 | /// ```rust 110 | /// use tui_widget_list::ListState; 111 | /// 112 | /// let mut list_state = ListState::default(); 113 | /// list_state.previous(); 114 | /// ``` 115 | pub fn previous(&mut self) { 116 | if self.num_elements == 0 { 117 | return; 118 | } 119 | let i = match self.selected { 120 | Some(i) => { 121 | if i == 0 { 122 | if self.infinite_scrolling { 123 | self.num_elements - 1 124 | } else { 125 | i 126 | } 127 | } else { 128 | i - 1 129 | } 130 | } 131 | None => 0, 132 | }; 133 | self.select(Some(i)); 134 | } 135 | 136 | /// Updates the number of elements that are present in the list. 137 | pub(crate) fn set_num_elements(&mut self, num_elements: usize) { 138 | self.num_elements = num_elements; 139 | } 140 | 141 | /// Updates the current scrollbar content length and position. 142 | pub(crate) fn update_scrollbar_state( 143 | &mut self, 144 | builder: &ListBuilder, 145 | item_count: usize, 146 | main_axis_size: u16, 147 | cross_axis_size: u16, 148 | scroll_axis: ScrollAxis, 149 | ) { 150 | let mut max_scrollbar_position = 0; 151 | let mut cumulative_size = 0; 152 | 153 | for index in (0..item_count).rev() { 154 | let context = ListBuildContext { 155 | index, 156 | is_selected: self.selected == Some(index), 157 | scroll_axis, 158 | cross_axis_size, 159 | }; 160 | let (_, widget_size) = builder.call_closure(&context); 161 | cumulative_size += widget_size; 162 | 163 | if cumulative_size > main_axis_size { 164 | max_scrollbar_position = index + 1; 165 | break; 166 | } 167 | } 168 | 169 | self.scrollbar_state = self.scrollbar_state.content_length(max_scrollbar_position); 170 | self.scrollbar_state = self.scrollbar_state.position(self.view_state.offset); 171 | } 172 | 173 | /// Returns the index of the first item currently displayed on the screen. 174 | #[must_use] 175 | pub fn scroll_offset_index(&self) -> usize { 176 | self.view_state.offset 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Debug; 3 | use std::io::Write; 4 | use std::{cmp::Ordering, fs::OpenOptions}; 5 | 6 | use crate::{view::Truncation, ListBuildContext, ListBuilder, ListState, ScrollAxis}; 7 | 8 | /// Determines the new viewport layout based on the previous viewport state, i.e. 9 | /// the offset of the first element and the truncation of the first element. 10 | /// 11 | /// Iterates over the widgets in the list, evaluates their heights lazily, updates 12 | /// the new view state (offset and truncation of the first element) and returns the 13 | /// widgets that should be rendered in the current viewport. 14 | /// 15 | /// # There 16 | /// There are the following cases to consider: 17 | /// 18 | /// - Selected item is on the viewport 19 | /// - Selected item is above the previous viewport, either truncated or out of bounds 20 | /// - If it is truncated, the viewport will be adjusted to bring the entire item into view. 21 | /// - If it is out of bounds, the viewport will be scrolled upwards to make the selected item visible. 22 | /// - Selected item is below the previous viewport, either truncated or out of bounds 23 | /// - If it is truncated, the viewport will be adjusted to bring the entire item into view. 24 | /// - If it is out of bounds, the viewport will be scrolled downwards to make the selected item visible. 25 | #[allow(clippy::too_many_lines)] 26 | pub(crate) fn layout_on_viewport( 27 | state: &mut ListState, 28 | builder: &ListBuilder, 29 | item_count: usize, 30 | total_main_axis_size: u16, 31 | cross_axis_size: u16, 32 | scroll_axis: ScrollAxis, 33 | scroll_padding: u16, 34 | ) -> HashMap> { 35 | // Cache the widgets and sizes to evaluate the builder less often. 36 | let mut cacher = WidgetCacher::new(builder, scroll_axis, cross_axis_size, state.selected); 37 | 38 | // The items heights on the viewport will be calculated on the fly. 39 | let mut viewport: HashMap> = HashMap::new(); 40 | 41 | // If none is selected, the first item should be show on top of the viewport. 42 | let selected = state.selected.unwrap_or(0); 43 | 44 | // Calculate the effective scroll padding for each widget 45 | let effective_scroll_padding_by_index = calculate_effective_scroll_padding( 46 | state, 47 | builder, 48 | item_count, 49 | cross_axis_size, 50 | scroll_axis, 51 | scroll_padding, 52 | ); 53 | 54 | update_offset( 55 | state, 56 | &mut cacher, 57 | selected, 58 | &effective_scroll_padding_by_index, 59 | ); 60 | 61 | // Begin a forward pass, starting from `view_state.offset`. 62 | let found_selected = forward_pass( 63 | &mut viewport, 64 | state, 65 | &mut cacher, 66 | state.view_state.offset, 67 | item_count, 68 | total_main_axis_size, 69 | selected, 70 | &effective_scroll_padding_by_index, 71 | ); 72 | 73 | if found_selected { 74 | return viewport; 75 | } 76 | 77 | for (key, value) in viewport.drain() { 78 | cacher.insert(key, value.widget, value.main_axis_size); 79 | } 80 | 81 | // Perform a backward pass, starting from the `selected` item. 82 | // This step is only necessary if the forward pass did not 83 | // locate the selected item. 84 | backward_pass( 85 | &mut viewport, 86 | state, 87 | &mut cacher, 88 | item_count, 89 | total_main_axis_size, 90 | selected, 91 | &effective_scroll_padding_by_index, 92 | ); 93 | 94 | viewport 95 | } 96 | 97 | // If the selected value is smaller than the offset, we roll 98 | // the offset so that the selected value is at the top. The complicated 99 | // part is that we also need to account for scroll padding. 100 | fn update_offset( 101 | state: &mut ListState, 102 | cacher: &mut WidgetCacher, 103 | selected: usize, 104 | scroll_padding_by_index: &HashMap, 105 | ) { 106 | // Get the top padding for scrolling or default to 0 if not present 107 | let scroll_padding_top = *scroll_padding_by_index.get(&selected).unwrap_or(&0); 108 | 109 | // Initialize variables 110 | let mut first_element = selected; 111 | let mut first_element_truncated = 0; 112 | let mut available_size = scroll_padding_top; 113 | 114 | // Traverse from the selected index up to the beginning 115 | for index in (0..=selected).rev() { 116 | // Update the first element in view 117 | first_element = index; 118 | 119 | // If no space is available, exit the loop 120 | if available_size == 0 { 121 | break; 122 | } 123 | 124 | // Get the size of the current element 125 | let main_axis_size = cacher.get_height(index); 126 | 127 | // Update the available space 128 | available_size = available_size.saturating_sub(main_axis_size); 129 | 130 | // Calculate the truncated size if there's still space 131 | if available_size > 0 { 132 | first_element_truncated = main_axis_size.saturating_sub(available_size); 133 | } 134 | } 135 | 136 | // Update the view state if needed 137 | if first_element < state.view_state.offset 138 | || (first_element == state.view_state.offset && state.view_state.first_truncated > 0) 139 | { 140 | state.view_state.offset = first_element; 141 | state.view_state.first_truncated = first_element_truncated; 142 | } 143 | } 144 | 145 | /// Iterate forward through the list of widgets. 146 | /// 147 | /// Returns true if the selected widget is inside the viewport. 148 | #[allow(clippy::too_many_arguments)] 149 | fn forward_pass( 150 | viewport: &mut HashMap>, 151 | state: &mut ListState, 152 | cacher: &mut WidgetCacher, 153 | offset: usize, 154 | item_count: usize, 155 | total_main_axis_size: u16, 156 | selected: usize, 157 | scroll_padding_by_index: &HashMap, 158 | ) -> bool { 159 | // Check if the selected item is in the current view 160 | let mut found_last = false; 161 | let mut found_selected = false; 162 | let mut available_size = total_main_axis_size; 163 | for index in offset..item_count { 164 | let is_first = index == state.view_state.offset; 165 | 166 | let (widget, total_main_axis_size) = cacher.get(index); 167 | 168 | let main_axis_size = if is_first { 169 | total_main_axis_size.saturating_sub(state.view_state.first_truncated) 170 | } else { 171 | total_main_axis_size 172 | }; 173 | 174 | // The effective available size considering scroll padding. 175 | let scroll_padding_effective = scroll_padding_by_index.get(&index).unwrap_or(&0); 176 | let available_effective = available_size.saturating_sub(*scroll_padding_effective); 177 | 178 | // Out of bounds 179 | if !found_selected && main_axis_size >= available_effective { 180 | break; 181 | } 182 | 183 | // Selected value is within view/bounds, so we are good 184 | // but we keep iterating to collect the full viewport. 185 | if selected == index { 186 | found_selected = true; 187 | } 188 | 189 | let truncation = match available_size.cmp(&main_axis_size) { 190 | // We found the last element and it fits onto the viewport 191 | Ordering::Equal => { 192 | found_last = true; 193 | if is_first { 194 | Truncation::Bot(state.view_state.first_truncated) 195 | } else { 196 | Truncation::None 197 | } 198 | } 199 | // We found the last element but it needs to be truncated 200 | Ordering::Less => { 201 | found_last = true; 202 | let value = main_axis_size.saturating_sub(available_size); 203 | if is_first { 204 | state.view_state.first_truncated = value; 205 | } 206 | Truncation::Bot(value) 207 | } 208 | Ordering::Greater => { 209 | // The first element was truncated in the last layout run, 210 | // we keep it truncated to handle scroll ups gracefully. 211 | if is_first && state.view_state.first_truncated != 0 { 212 | Truncation::Top(state.view_state.first_truncated) 213 | } else { 214 | Truncation::None 215 | } 216 | } 217 | }; 218 | 219 | viewport.insert( 220 | index, 221 | ViewportElement::new(widget, total_main_axis_size, truncation.clone()), 222 | ); 223 | 224 | if found_last { 225 | break; 226 | } 227 | 228 | available_size -= main_axis_size; 229 | } 230 | 231 | found_selected 232 | } 233 | 234 | // The selected item is out of bounds. We iterate backwards from the selected 235 | // item and determine the first widget that still fits on the screen. 236 | #[allow(clippy::too_many_arguments)] 237 | fn backward_pass( 238 | viewport: &mut HashMap>, 239 | state: &mut ListState, 240 | cacher: &mut WidgetCacher, 241 | item_count: usize, 242 | total_main_axis_size: u16, 243 | selected: usize, 244 | scroll_padding_by_index: &HashMap, 245 | ) { 246 | let mut found_first = false; 247 | let mut available_size = total_main_axis_size; 248 | let scroll_padding_effective = *scroll_padding_by_index.get(&selected).unwrap_or(&0); 249 | for index in (0..=selected).rev() { 250 | let (widget, main_axis_size) = cacher.get(index); 251 | 252 | let available_effective = available_size.saturating_sub(scroll_padding_effective); 253 | 254 | let truncation = match available_effective.cmp(&main_axis_size) { 255 | // We found the first element and it fits into the viewport 256 | Ordering::Equal => { 257 | found_first = true; 258 | state.view_state.offset = index; 259 | state.view_state.first_truncated = 0; 260 | Truncation::None 261 | } 262 | // We found the first element but it needs to be truncated 263 | Ordering::Less => { 264 | found_first = true; 265 | state.view_state.offset = index; 266 | state.view_state.first_truncated = 267 | main_axis_size.saturating_sub(available_effective); 268 | // Truncate from the bottom if there is only one element on the viewport 269 | if index == selected { 270 | Truncation::Bot(state.view_state.first_truncated) 271 | } else { 272 | Truncation::Top(state.view_state.first_truncated) 273 | } 274 | } 275 | Ordering::Greater => Truncation::None, 276 | }; 277 | 278 | let element = ViewportElement::new(widget, main_axis_size, truncation); 279 | viewport.insert(index, element); 280 | 281 | if found_first { 282 | break; 283 | } 284 | 285 | available_size -= main_axis_size; 286 | } 287 | 288 | // Append elements to the list to fill the viewport after the selected item. 289 | // Only necessary for lists with scroll padding. 290 | if scroll_padding_effective > 0 { 291 | available_size = scroll_padding_effective; 292 | for index in selected + 1..item_count { 293 | let (widget, main_axis_size) = cacher.get(index); 294 | 295 | let truncation = match available_size.cmp(&main_axis_size) { 296 | Ordering::Greater | Ordering::Equal => Truncation::None, 297 | Ordering::Less => Truncation::Bot(main_axis_size.saturating_sub(available_size)), 298 | }; 299 | viewport.insert( 300 | index, 301 | ViewportElement::new(widget, main_axis_size, truncation), 302 | ); 303 | 304 | available_size = available_size.saturating_sub(main_axis_size); 305 | // Out of bounds 306 | if available_size == 0 { 307 | break; 308 | } 309 | } 310 | } 311 | } 312 | 313 | /// Calculate the effective scroll padding. 314 | /// Padding is applied until the scroll padding limit is reached, 315 | /// after which elements at the beginning or end of the list do 316 | /// not receive padding. 317 | /// 318 | /// Returns: 319 | /// A `HashMap` where the keys are the indices of the list items and the values are 320 | /// the corresponding padding applied. If the item is not on the list, `scroll_padding` 321 | /// is unaltered. 322 | fn calculate_effective_scroll_padding( 323 | state: &mut ListState, 324 | builder: &ListBuilder, 325 | item_count: usize, 326 | cross_axis_size: u16, 327 | scroll_axis: ScrollAxis, 328 | scroll_padding: u16, 329 | ) -> HashMap { 330 | let mut padding_by_element = HashMap::new(); 331 | let mut total_main_axis_size = 0; 332 | 333 | for index in 0..item_count { 334 | if total_main_axis_size >= scroll_padding { 335 | padding_by_element.insert(index, scroll_padding); 336 | continue; 337 | } 338 | padding_by_element.insert(index, total_main_axis_size); 339 | 340 | let context = ListBuildContext { 341 | index, 342 | is_selected: state.selected == Some(index), 343 | scroll_axis, 344 | cross_axis_size, 345 | }; 346 | 347 | let (_, item_main_axis_size) = builder.call_closure(&context); 348 | total_main_axis_size += item_main_axis_size; 349 | } 350 | 351 | total_main_axis_size = 0; 352 | for index in (0..item_count).rev() { 353 | // Stop applying padding once the scroll padding limit is reached 354 | if total_main_axis_size >= scroll_padding { 355 | break; 356 | } 357 | padding_by_element.insert(index, total_main_axis_size); 358 | 359 | let context = ListBuildContext { 360 | index, 361 | is_selected: state.selected == Some(index), 362 | scroll_axis, 363 | cross_axis_size, 364 | }; 365 | 366 | let (_, item_main_axis_size) = builder.call_closure(&context); 367 | total_main_axis_size += item_main_axis_size; 368 | } 369 | 370 | padding_by_element 371 | } 372 | 373 | struct WidgetCacher<'a, T> { 374 | cache: HashMap, 375 | builder: &'a ListBuilder<'a, T>, 376 | scroll_axis: ScrollAxis, 377 | cross_axis_size: u16, 378 | selected: Option, 379 | } 380 | 381 | impl<'a, T> WidgetCacher<'a, T> { 382 | // Create a new WidgetCacher 383 | fn new( 384 | builder: &'a ListBuilder<'a, T>, 385 | scroll_axis: ScrollAxis, 386 | cross_axis_size: u16, 387 | selected: Option, 388 | ) -> Self { 389 | Self { 390 | cache: HashMap::new(), 391 | builder, 392 | scroll_axis, 393 | cross_axis_size, 394 | selected, 395 | } 396 | } 397 | 398 | // Gets the widget and the height. Removes the widget from the cache. 399 | fn get(&mut self, index: usize) -> (T, u16) { 400 | let is_selected = self.selected == Some(index); 401 | // Check if the widget is already in cache 402 | if let Some((widget, main_axis_size)) = self.cache.remove(&index) { 403 | return (widget, main_axis_size); 404 | } 405 | 406 | // Create the context for the builder 407 | let context = ListBuildContext { 408 | index, 409 | is_selected, 410 | scroll_axis: self.scroll_axis, 411 | cross_axis_size: self.cross_axis_size, 412 | }; 413 | 414 | // Call the builder to get the widget 415 | let (widget, main_axis_size) = self.builder.call_closure(&context); 416 | 417 | (widget, main_axis_size) 418 | } 419 | 420 | // Gets the height. 421 | fn get_height(&mut self, index: usize) -> u16 { 422 | let is_selected = self.selected == Some(index); 423 | // Check if the widget is already in cache 424 | if let Some(&(_, main_axis_size)) = self.cache.get(&index) { 425 | return main_axis_size; 426 | } 427 | 428 | // Create the context for the builder 429 | let context = ListBuildContext { 430 | index, 431 | is_selected, 432 | scroll_axis: self.scroll_axis, 433 | cross_axis_size: self.cross_axis_size, 434 | }; 435 | 436 | // Call the builder to get the widget 437 | let (widget, main_axis_size) = self.builder.call_closure(&context); 438 | 439 | // Store the widget in the cache 440 | self.cache.insert(index, (widget, main_axis_size)); 441 | 442 | main_axis_size 443 | } 444 | 445 | fn insert(&mut self, index: usize, widget: T, main_axis_size: u16) { 446 | self.cache.insert(index, (widget, main_axis_size)); 447 | } 448 | } 449 | 450 | #[allow(dead_code)] 451 | pub fn log_to_file(data: T) { 452 | let mut file = OpenOptions::new() 453 | .create(true) 454 | .append(true) 455 | .open("debug.log") 456 | .unwrap(); 457 | 458 | if let Err(e) = writeln!(file, "{data:?}") { 459 | eprintln!("Couldn't write to file: {e}"); 460 | } 461 | } 462 | 463 | #[derive(Debug, PartialEq, PartialOrd, Eq, Ord)] 464 | pub(crate) struct ViewportElement { 465 | pub(crate) widget: T, 466 | pub(crate) main_axis_size: u16, 467 | pub(crate) truncation: Truncation, 468 | } 469 | 470 | impl ViewportElement { 471 | #[must_use] 472 | pub(crate) fn new(widget: T, main_axis_size: u16, truncation: Truncation) -> Self { 473 | Self { 474 | widget, 475 | main_axis_size, 476 | truncation, 477 | } 478 | } 479 | } 480 | 481 | #[cfg(test)] 482 | mod tests { 483 | use ratatui::{ 484 | prelude::*, 485 | widgets::{Block, Borders}, 486 | }; 487 | 488 | use crate::state::ViewState; 489 | 490 | use super::*; 491 | 492 | #[derive(Debug, Default, PartialEq, Eq)] 493 | struct TestItem {} 494 | 495 | impl Widget for TestItem { 496 | fn render(self, area: Rect, buf: &mut Buffer) 497 | where 498 | Self: Sized, 499 | { 500 | Block::default().borders(Borders::ALL).render(area, buf); 501 | } 502 | } 503 | 504 | // From: 505 | // 506 | // ----- 507 | // | | 0 508 | // | | 509 | // ----- 510 | // | | 1 511 | // | | 512 | // ----- 513 | // 514 | // To: 515 | // 516 | // ----- 517 | // | | 0 <- 518 | // | | 519 | // ----- 520 | // | | 1 521 | // | | 522 | // ----- 523 | #[test] 524 | fn happy_path() { 525 | // given 526 | let mut state = ListState { 527 | num_elements: 2, 528 | ..ListState::default() 529 | }; 530 | let given_item_count = 2; 531 | let given_sizes = vec![2, 2]; 532 | let given_total_size = 6; 533 | 534 | let expected_view_state = ViewState { 535 | offset: 0, 536 | first_truncated: 0, 537 | }; 538 | let expected_viewport = HashMap::from([ 539 | (0, ViewportElement::new(TestItem {}, 2, Truncation::None)), 540 | (1, ViewportElement::new(TestItem {}, 2, Truncation::None)), 541 | ]); 542 | 543 | // when 544 | let viewport = layout_on_viewport( 545 | &mut state, 546 | &ListBuilder::new(move |context| { 547 | return (TestItem {}, given_sizes[context.index]); 548 | }), 549 | given_item_count, 550 | given_total_size, 551 | 1, 552 | ScrollAxis::Vertical, 553 | 0, 554 | ); 555 | 556 | // then 557 | assert_eq!(viewport, expected_viewport); 558 | assert_eq!(state.view_state, expected_view_state); 559 | } 560 | 561 | // From: 562 | // 563 | // | | 0 564 | // ----- 565 | // | | 1 <- 566 | // | | 567 | // ----- 568 | // 569 | // To: 570 | // 571 | // ----- 572 | // | | 0 <- 573 | // | | 574 | // ----- 575 | // | | 1 576 | #[test] 577 | fn scroll_up() { 578 | // given 579 | let view_state = ViewState { 580 | offset: 0, 581 | first_truncated: 1, 582 | }; 583 | let mut state = ListState { 584 | num_elements: 3, 585 | selected: Some(0), 586 | view_state, 587 | ..ListState::default() 588 | }; 589 | let given_sizes = vec![2, 2]; 590 | let given_total_size = 3; 591 | let given_item_count = given_sizes.len(); 592 | 593 | let expected_view_state = ViewState { 594 | offset: 0, 595 | first_truncated: 0, 596 | }; 597 | let expected_viewport = HashMap::from([ 598 | (0, ViewportElement::new(TestItem {}, 2, Truncation::None)), 599 | (1, ViewportElement::new(TestItem {}, 2, Truncation::Bot(1))), 600 | ]); 601 | 602 | // when 603 | let viewport = layout_on_viewport( 604 | &mut state, 605 | &ListBuilder::new(move |context| { 606 | return (TestItem {}, given_sizes[context.index]); 607 | }), 608 | given_item_count, 609 | given_total_size, 610 | 1, 611 | ScrollAxis::Vertical, 612 | 0, 613 | ); 614 | 615 | // then 616 | assert_eq!(viewport, expected_viewport); 617 | assert_eq!(state.view_state, expected_view_state); 618 | } 619 | 620 | // From: 621 | // 622 | // ----- 623 | // | | 0 <- 624 | // | | 625 | // ----- 626 | // | | 1 627 | // 628 | // To: 629 | // 630 | // | | 0 631 | // ----- 632 | // | | 1 <- 633 | // | | 634 | // ----- 635 | #[test] 636 | fn scroll_down() { 637 | // given 638 | let mut state = ListState { 639 | num_elements: 2, 640 | selected: Some(1), 641 | ..ListState::default() 642 | }; 643 | let given_sizes = vec![2, 2]; 644 | let given_item_count = given_sizes.len(); 645 | let given_total_size = 3; 646 | 647 | let expected_view_state = ViewState { 648 | offset: 0, 649 | first_truncated: 1, 650 | }; 651 | let expected_viewport = HashMap::from([ 652 | (0, ViewportElement::new(TestItem {}, 2, Truncation::Top(1))), 653 | (1, ViewportElement::new(TestItem {}, 2, Truncation::None)), 654 | ]); 655 | 656 | // when 657 | let viewport = layout_on_viewport( 658 | &mut state, 659 | &ListBuilder::new(move |context| { 660 | return (TestItem {}, given_sizes[context.index]); 661 | }), 662 | given_item_count, 663 | given_total_size, 664 | 1, 665 | ScrollAxis::Vertical, 666 | 0, 667 | ); 668 | 669 | // then 670 | assert_eq!(viewport, expected_viewport); 671 | assert_eq!(state.view_state, expected_view_state); 672 | } 673 | 674 | // From: 675 | // 676 | // ----- 677 | // | | 0 <- 678 | // | | 679 | // ----- 680 | // | | 1 681 | // | | 682 | // ----- 683 | // 684 | // To: 685 | // 686 | // | | 687 | // ----- 688 | // | | 1 <- 689 | // | | 690 | // ----- 691 | // | | 692 | #[test] 693 | fn scroll_padding_bottom() { 694 | // given 695 | let mut state = ListState { 696 | num_elements: 3, 697 | selected: Some(1), 698 | ..ListState::default() 699 | }; 700 | let given_sizes = vec![2, 2, 2]; 701 | let given_item_count = given_sizes.len(); 702 | let given_total_size = 4; 703 | 704 | let expected_view_state = ViewState { 705 | offset: 0, 706 | first_truncated: 1, 707 | }; 708 | let expected_viewport = HashMap::from([ 709 | (0, ViewportElement::new(TestItem {}, 2, Truncation::Top(1))), 710 | (1, ViewportElement::new(TestItem {}, 2, Truncation::None)), 711 | (2, ViewportElement::new(TestItem {}, 2, Truncation::Bot(1))), 712 | ]); 713 | 714 | // when 715 | let viewport = layout_on_viewport( 716 | &mut state, 717 | &ListBuilder::new(move |context| { 718 | return (TestItem {}, given_sizes[context.index]); 719 | }), 720 | given_item_count, 721 | given_total_size, 722 | 1, 723 | ScrollAxis::Vertical, 724 | 1, 725 | ); 726 | 727 | // then 728 | assert_eq!(viewport, expected_viewport); 729 | assert_eq!(state.view_state, expected_view_state); 730 | } 731 | 732 | // From: 733 | // 734 | // ----- 735 | // | | 1 736 | // | | 737 | // ----- 738 | // | | 2 <- 739 | // | | 740 | // ----- 741 | // 742 | // To: 743 | // 744 | // | | 745 | // ----- 746 | // | | 1 <- 747 | // | | 748 | // ----- 749 | // | | 750 | #[test] 751 | fn scroll_padding_top() { 752 | // given 753 | let view_state = ViewState { 754 | offset: 2, 755 | first_truncated: 0, 756 | }; 757 | let mut state = ListState { 758 | num_elements: 3, 759 | selected: Some(1), 760 | view_state, 761 | ..ListState::default() 762 | }; 763 | let given_sizes = vec![2, 2, 2]; 764 | let given_item_count = given_sizes.len(); 765 | let given_total_size = 4; 766 | 767 | let expected_view_state = ViewState { 768 | offset: 0, 769 | first_truncated: 1, 770 | }; 771 | let expected_viewport = HashMap::from([ 772 | (0, ViewportElement::new(TestItem {}, 2, Truncation::Top(1))), 773 | (1, ViewportElement::new(TestItem {}, 2, Truncation::None)), 774 | (2, ViewportElement::new(TestItem {}, 2, Truncation::Bot(1))), 775 | ]); 776 | 777 | // when 778 | let viewport = layout_on_viewport( 779 | &mut state, 780 | &ListBuilder::new(move |context| { 781 | return (TestItem {}, given_sizes[context.index]); 782 | }), 783 | given_item_count, 784 | given_total_size, 785 | 1, 786 | ScrollAxis::Vertical, 787 | 1, 788 | ); 789 | 790 | // then 791 | assert_eq!(viewport, expected_viewport); 792 | assert_eq!(state.view_state, expected_view_state); 793 | } 794 | 795 | // From: 796 | // 797 | // ----- 798 | // | | 1 <- 799 | // | | 800 | // ----- 801 | // | | 2 802 | // 803 | // To: 804 | // 805 | // ----- 806 | // | | 0 <- 807 | // | | 808 | // ----- 809 | // | | 1 810 | #[test] 811 | fn scroll_up_out_of_viewport() { 812 | // given 813 | let view_state = ViewState { 814 | offset: 1, 815 | first_truncated: 0, 816 | }; 817 | let mut state = ListState { 818 | num_elements: 3, 819 | selected: Some(0), 820 | view_state, 821 | ..ListState::default() 822 | }; 823 | let given_sizes = vec![2, 2, 2]; 824 | let given_total_size = 3; 825 | let given_item_count = given_sizes.len(); 826 | 827 | let expected_view_state = ViewState { 828 | offset: 0, 829 | first_truncated: 0, 830 | }; 831 | let expected_viewport = HashMap::from([ 832 | (0, ViewportElement::new(TestItem {}, 2, Truncation::None)), 833 | (1, ViewportElement::new(TestItem {}, 2, Truncation::Bot(1))), 834 | ]); 835 | 836 | // when 837 | let viewport = layout_on_viewport( 838 | &mut state, 839 | &ListBuilder::new(move |context| { 840 | return (TestItem {}, given_sizes[context.index]); 841 | }), 842 | given_item_count, 843 | given_total_size, 844 | 1, 845 | ScrollAxis::Vertical, 846 | 0, 847 | ); 848 | 849 | // then 850 | assert_eq!(viewport, expected_viewport); 851 | assert_eq!(state.view_state, expected_view_state); 852 | } 853 | 854 | // From: 855 | // 856 | // | | 0 857 | // ----- 858 | // | | 1 859 | // | | 860 | // ----- 861 | // | | 2 <- 862 | // | | 863 | // ----- 864 | // 865 | // To: 866 | // 867 | // | | 0 868 | // ----- 869 | // | | 1 <- 870 | // | | 871 | // ----- 872 | // | | 2 873 | // | | 874 | // ----- 875 | #[test] 876 | fn scroll_up_keep_first_element_truncated() { 877 | // given 878 | let view_state = ViewState { 879 | offset: 0, 880 | first_truncated: 1, 881 | }; 882 | let mut state = ListState { 883 | num_elements: 3, 884 | selected: Some(1), 885 | view_state, 886 | ..ListState::default() 887 | }; 888 | let given_sizes = vec![2, 2, 2]; 889 | let given_total_size = 5; 890 | let given_item_count = given_sizes.len(); 891 | 892 | let expected_view_state = ViewState { 893 | offset: 0, 894 | first_truncated: 1, 895 | }; 896 | let expected_viewport = HashMap::from([ 897 | (0, ViewportElement::new(TestItem {}, 2, Truncation::Top(1))), 898 | (1, ViewportElement::new(TestItem {}, 2, Truncation::None)), 899 | (2, ViewportElement::new(TestItem {}, 2, Truncation::None)), 900 | ]); 901 | 902 | // when 903 | let viewport = layout_on_viewport( 904 | &mut state, 905 | &ListBuilder::new(move |context| { 906 | return (TestItem {}, given_sizes[context.index]); 907 | }), 908 | given_item_count, 909 | given_total_size, 910 | 1, 911 | ScrollAxis::Vertical, 912 | 0, 913 | ); 914 | 915 | // then 916 | assert_eq!(viewport, expected_viewport); 917 | assert_eq!(state.view_state, expected_view_state); 918 | } 919 | 920 | #[test] 921 | fn test_calculate_effective_scroll_padding() { 922 | let mut state = ListState::default(); 923 | let given_sizes = vec![2, 2, 2, 2, 2]; 924 | let item_count = 5; 925 | let scroll_padding = 3; 926 | 927 | let builder = ListBuilder::new(move |context| { 928 | return (TestItem {}, given_sizes[context.index]); 929 | }); 930 | 931 | let scroll_padding = calculate_effective_scroll_padding( 932 | &mut state, 933 | &builder, 934 | item_count, 935 | 1, 936 | ScrollAxis::Vertical, 937 | scroll_padding, 938 | ); 939 | 940 | assert_eq!(*scroll_padding.get(&0).unwrap(), 0); 941 | assert_eq!(*scroll_padding.get(&1).unwrap(), 2); 942 | assert_eq!(*scroll_padding.get(&2).unwrap(), 3); 943 | assert_eq!(*scroll_padding.get(&3).unwrap(), 2); 944 | assert_eq!(*scroll_padding.get(&4).unwrap(), 0); 945 | } 946 | } 947 | -------------------------------------------------------------------------------- /src/view.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Position, Rect}, 4 | style::{Style, Styled}, 5 | widgets::{block::BlockExt, Block, Scrollbar, StatefulWidget, Widget}, 6 | }; 7 | 8 | use crate::{utils::layout_on_viewport, ListState}; 9 | 10 | /// A struct representing a list view. 11 | /// The widget displays a scrollable list of items. 12 | #[allow(clippy::module_name_repetitions)] 13 | pub struct ListView<'a, T> { 14 | /// The total number of items in the list 15 | pub item_count: usize, 16 | 17 | /// A `ListBuilder` responsible for constructing the items in the list. 18 | pub builder: ListBuilder<'a, T>, 19 | 20 | /// Specifies the scroll axis. Either `Vertical` or `Horizontal`. 21 | pub scroll_axis: ScrollAxis, 22 | 23 | /// The base style of the list view. 24 | pub style: Style, 25 | 26 | /// The base block surrounding the widget list. 27 | pub block: Option>, 28 | 29 | /// The scrollbar. 30 | pub scrollbar: Option>, 31 | 32 | /// The scroll padding. 33 | pub(crate) scroll_padding: u16, 34 | 35 | /// Whether infinite scrolling is enabled or not. 36 | /// Disabled by default. 37 | pub(crate) infinite_scrolling: bool, 38 | } 39 | 40 | impl<'a, T> ListView<'a, T> { 41 | /// Creates a new `ListView` with a builder an item count. 42 | #[must_use] 43 | pub fn new(builder: ListBuilder<'a, T>, item_count: usize) -> Self { 44 | Self { 45 | builder, 46 | item_count, 47 | scroll_axis: ScrollAxis::Vertical, 48 | style: Style::default(), 49 | block: None, 50 | scrollbar: None, 51 | scroll_padding: 0, 52 | infinite_scrolling: true, 53 | } 54 | } 55 | 56 | /// Checks whether the widget list is empty. 57 | #[must_use] 58 | pub fn is_empty(&self) -> bool { 59 | self.item_count == 0 60 | } 61 | 62 | /// Returns the length of the widget list. 63 | #[must_use] 64 | pub fn len(&self) -> usize { 65 | self.item_count 66 | } 67 | 68 | /// Sets the block style that surrounds the whole List. 69 | #[must_use] 70 | pub fn block(mut self, block: Block<'a>) -> Self { 71 | self.block = Some(block); 72 | self 73 | } 74 | 75 | /// Sets the scrollbar of the List. 76 | #[must_use] 77 | pub fn scrollbar(mut self, scrollbar: Scrollbar<'a>) -> Self { 78 | self.scrollbar = Some(scrollbar); 79 | self 80 | } 81 | 82 | /// Set the base style of the List. 83 | #[must_use] 84 | pub fn style>(mut self, style: S) -> Self { 85 | self.style = style.into(); 86 | self 87 | } 88 | 89 | /// Set the scroll axis of the list. 90 | #[must_use] 91 | pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self { 92 | self.scroll_axis = scroll_axis; 93 | self 94 | } 95 | 96 | /// Set the scroll padding of the list. 97 | #[must_use] 98 | pub fn scroll_padding(mut self, scroll_padding: u16) -> Self { 99 | self.scroll_padding = scroll_padding; 100 | self 101 | } 102 | 103 | /// Specify whether infinite scrolling should be enabled or not. 104 | #[must_use] 105 | pub fn infinite_scrolling(mut self, infinite_scrolling: bool) -> Self { 106 | self.infinite_scrolling = infinite_scrolling; 107 | self 108 | } 109 | } 110 | 111 | impl Styled for ListView<'_, T> { 112 | type Item = Self; 113 | 114 | fn style(&self) -> Style { 115 | self.style 116 | } 117 | 118 | fn set_style>(mut self, style: S) -> Self::Item { 119 | self.style = style.into(); 120 | self 121 | } 122 | } 123 | 124 | impl<'a, T: Copy + 'a> From> for ListView<'a, T> { 125 | fn from(value: Vec) -> Self { 126 | let item_count = value.len(); 127 | let builder = ListBuilder::new(move |context| (value[context.index], 1)); 128 | 129 | ListView::new(builder, item_count) 130 | } 131 | } 132 | 133 | /// This structure holds information about the item's position, selection 134 | /// status, scrolling behavior, and size along the cross axis. 135 | pub struct ListBuildContext { 136 | /// The position of the item in the list. 137 | pub index: usize, 138 | 139 | /// A boolean flag indicating whether the item is currently selected. 140 | pub is_selected: bool, 141 | 142 | /// Defines the axis along which the list can be scrolled. 143 | pub scroll_axis: ScrollAxis, 144 | 145 | /// The size of the item along the cross axis. 146 | pub cross_axis_size: u16, 147 | } 148 | 149 | /// A type alias for the closure. 150 | type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a; 151 | 152 | /// The builder for constructing list elements in a `ListView` 153 | pub struct ListBuilder<'a, T> { 154 | closure: Box>, 155 | } 156 | 157 | impl<'a, T> ListBuilder<'a, T> { 158 | /// Creates a new `ListBuilder` taking a closure as a parameter 159 | /// 160 | /// # Example 161 | /// ``` 162 | /// use ratatui::text::Line; 163 | /// use tui_widget_list::ListBuilder; 164 | /// 165 | /// let builder = ListBuilder::new(|context| { 166 | /// let mut item = Line::from(format!("Item {:0}", context.index)); 167 | /// 168 | /// // Return the size of the widget along the main axis. 169 | /// let main_axis_size = 1; 170 | /// 171 | /// (item, main_axis_size) 172 | /// }); 173 | /// ``` 174 | pub fn new(closure: F) -> Self 175 | where 176 | F: Fn(&ListBuildContext) -> (T, u16) + 'a, 177 | { 178 | ListBuilder { 179 | closure: Box::new(closure), 180 | } 181 | } 182 | 183 | /// Method to call the stored closure. 184 | pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) { 185 | (self.closure)(context) 186 | } 187 | } 188 | 189 | /// Represents the scroll axis of a list. 190 | #[derive(Debug, Default, Clone, Copy)] 191 | pub enum ScrollAxis { 192 | /// Indicates vertical scrolling. This is the default. 193 | #[default] 194 | Vertical, 195 | 196 | /// Indicates horizontal scrolling. 197 | Horizontal, 198 | } 199 | 200 | impl StatefulWidget for ListView<'_, T> { 201 | type State = ListState; 202 | 203 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 204 | state.set_num_elements(self.item_count); 205 | state.set_infinite_scrolling(self.infinite_scrolling); 206 | 207 | // Set the base style 208 | buf.set_style(area, self.style); 209 | 210 | // Set the base block 211 | self.block.render(area, buf); 212 | let inner_area = self.block.inner_if_some(area); 213 | 214 | // List is empty 215 | if self.item_count == 0 { 216 | return; 217 | } 218 | 219 | // Set the dimension along the scroll axis and the cross axis 220 | let (main_axis_size, cross_axis_size) = match self.scroll_axis { 221 | ScrollAxis::Vertical => (inner_area.height, inner_area.width), 222 | ScrollAxis::Horizontal => (inner_area.width, inner_area.height), 223 | }; 224 | 225 | // The coordinates of the first item with respect to the top left corner 226 | let (mut scroll_axis_pos, cross_axis_pos) = match self.scroll_axis { 227 | ScrollAxis::Vertical => (inner_area.top(), inner_area.left()), 228 | ScrollAxis::Horizontal => (inner_area.left(), inner_area.top()), 229 | }; 230 | 231 | // Determine which widgets to show on the viewport and how much space they 232 | // get assigned to. 233 | let mut viewport = layout_on_viewport( 234 | state, 235 | &self.builder, 236 | self.item_count, 237 | main_axis_size, 238 | cross_axis_size, 239 | self.scroll_axis, 240 | self.scroll_padding, 241 | ); 242 | state.update_scrollbar_state( 243 | &self.builder, 244 | self.item_count, 245 | main_axis_size, 246 | cross_axis_size, 247 | self.scroll_axis, 248 | ); 249 | 250 | let (start, end) = ( 251 | state.view_state.offset, 252 | viewport.len() + state.view_state.offset, 253 | ); 254 | for i in start..end { 255 | let Some(element) = viewport.remove(&i) else { 256 | break; 257 | }; 258 | let visible_main_axis_size = element 259 | .main_axis_size 260 | .saturating_sub(element.truncation.value()); 261 | let area = match self.scroll_axis { 262 | ScrollAxis::Vertical => Rect::new( 263 | cross_axis_pos, 264 | scroll_axis_pos, 265 | cross_axis_size, 266 | visible_main_axis_size, 267 | ), 268 | ScrollAxis::Horizontal => Rect::new( 269 | scroll_axis_pos, 270 | cross_axis_pos, 271 | visible_main_axis_size, 272 | cross_axis_size, 273 | ), 274 | }; 275 | 276 | // Render truncated widgets. 277 | if element.truncation.value() > 0 { 278 | render_truncated( 279 | element.widget, 280 | area, 281 | buf, 282 | element.main_axis_size, 283 | &element.truncation, 284 | self.style, 285 | self.scroll_axis, 286 | ); 287 | } else { 288 | element.widget.render(area, buf); 289 | } 290 | 291 | scroll_axis_pos += visible_main_axis_size; 292 | } 293 | 294 | // Render the scrollbar 295 | if let Some(scrollbar) = self.scrollbar { 296 | scrollbar.render(area, buf, &mut state.scrollbar_state); 297 | } 298 | } 299 | } 300 | 301 | /// Render a truncated widget into a buffer. The method renders the widget fully into 302 | /// a hidden buffer and moves the visible content into `buf`. 303 | fn render_truncated( 304 | item: T, 305 | available_area: Rect, 306 | buf: &mut Buffer, 307 | untruncated_size: u16, 308 | truncation: &Truncation, 309 | base_style: Style, 310 | scroll_axis: ScrollAxis, 311 | ) { 312 | // Create an hidden buffer for rendering the truncated element 313 | let (width, height) = match scroll_axis { 314 | ScrollAxis::Vertical => (available_area.width, untruncated_size), 315 | ScrollAxis::Horizontal => (untruncated_size, available_area.height), 316 | }; 317 | let mut hidden_buffer = Buffer::empty(Rect { 318 | x: available_area.left(), 319 | y: available_area.top(), 320 | width, 321 | height, 322 | }); 323 | hidden_buffer.set_style(hidden_buffer.area, base_style); 324 | item.render(hidden_buffer.area, &mut hidden_buffer); 325 | 326 | // Copy the visible part from the hidden buffer to the main buffer 327 | match scroll_axis { 328 | ScrollAxis::Vertical => { 329 | let offset = match truncation { 330 | Truncation::Top(value) => *value, 331 | _ => 0, 332 | }; 333 | for y in available_area.top()..available_area.bottom() { 334 | let y_off = y + offset; 335 | for x in available_area.left()..available_area.right() { 336 | if let Some(to) = buf.cell_mut(Position::new(x, y)) { 337 | if let Some(from) = hidden_buffer.cell(Position::new(x, y_off)) { 338 | *to = from.clone(); 339 | } 340 | } 341 | } 342 | } 343 | } 344 | ScrollAxis::Horizontal => { 345 | let offset = match truncation { 346 | Truncation::Top(value) => *value, 347 | _ => 0, 348 | }; 349 | for x in available_area.left()..available_area.right() { 350 | let x_off = x + offset; 351 | for y in available_area.top()..available_area.bottom() { 352 | if let Some(to) = buf.cell_mut(Position::new(x, y)) { 353 | if let Some(from) = hidden_buffer.cell(Position::new(x_off, y)) { 354 | *to = from.clone(); 355 | } 356 | } 357 | } 358 | } 359 | } 360 | }; 361 | } 362 | 363 | #[derive(Debug, Clone, Default, PartialEq, PartialOrd, Eq, Ord)] 364 | pub(crate) enum Truncation { 365 | #[default] 366 | None, 367 | Top(u16), 368 | Bot(u16), 369 | } 370 | 371 | impl Truncation { 372 | pub(crate) fn value(&self) -> u16 { 373 | match self { 374 | Self::Top(value) | Self::Bot(value) => *value, 375 | Self::None => 0, 376 | } 377 | } 378 | } 379 | 380 | #[cfg(test)] 381 | mod test { 382 | use crate::ListBuilder; 383 | use ratatui::widgets::Block; 384 | 385 | use super::*; 386 | use ratatui::widgets::Borders; 387 | 388 | struct TestItem {} 389 | impl Widget for TestItem { 390 | fn render(self, area: Rect, buf: &mut Buffer) 391 | where 392 | Self: Sized, 393 | { 394 | Block::default().borders(Borders::ALL).render(area, buf); 395 | } 396 | } 397 | 398 | fn test_data(total_height: u16) -> (Rect, Buffer, ListView<'static, TestItem>, ListState) { 399 | let area = Rect::new(0, 0, 5, total_height); 400 | let list = ListView::new(ListBuilder::new(|_| (TestItem {}, 3)), 3); 401 | (area, Buffer::empty(area), list, ListState::default()) 402 | } 403 | 404 | #[test] 405 | fn not_truncated() { 406 | // given 407 | let (area, mut buf, list, mut state) = test_data(9); 408 | 409 | // when 410 | list.render(area, &mut buf, &mut state); 411 | 412 | // then 413 | assert_buffer_eq( 414 | buf, 415 | Buffer::with_lines(vec![ 416 | "┌───┐", 417 | "│ │", 418 | "└───┘", 419 | "┌───┐", 420 | "│ │", 421 | "└───┘", 422 | "┌───┐", 423 | "│ │", 424 | "└───┘", 425 | ]), 426 | ) 427 | } 428 | 429 | #[test] 430 | fn empty_list() { 431 | // given 432 | let area = Rect::new(0, 0, 5, 2); 433 | let mut buf = Buffer::empty(area); 434 | let mut state = ListState::default(); 435 | let builder = ListBuilder::new(|_| (TestItem {}, 0)); 436 | let list = ListView::new(builder, 0); 437 | 438 | // when 439 | list.render(area, &mut buf, &mut state); 440 | 441 | // then 442 | assert_buffer_eq(buf, Buffer::with_lines(vec![" ", " "])) 443 | } 444 | 445 | #[test] 446 | fn zero_size() { 447 | // given 448 | let (area, mut buf, list, mut state) = test_data(0); 449 | 450 | // when 451 | list.render(area, &mut buf, &mut state); 452 | 453 | // then 454 | assert_buffer_eq(buf, Buffer::empty(area)) 455 | } 456 | 457 | #[test] 458 | fn truncated_bot() { 459 | // given 460 | let (area, mut buf, list, mut state) = test_data(8); 461 | 462 | // when 463 | list.render(area, &mut buf, &mut state); 464 | 465 | // then 466 | assert_buffer_eq( 467 | buf, 468 | Buffer::with_lines(vec![ 469 | "┌───┐", 470 | "│ │", 471 | "└───┘", 472 | "┌───┐", 473 | "│ │", 474 | "└───┘", 475 | "┌───┐", 476 | "│ │", 477 | ]), 478 | ) 479 | } 480 | 481 | #[test] 482 | fn truncated_top() { 483 | // given 484 | let (area, mut buf, list, mut state) = test_data(8); 485 | state.select(Some(2)); 486 | 487 | // when 488 | list.render(area, &mut buf, &mut state); 489 | 490 | // then 491 | assert_buffer_eq( 492 | buf, 493 | Buffer::with_lines(vec![ 494 | "│ │", 495 | "└───┘", 496 | "┌───┐", 497 | "│ │", 498 | "└───┘", 499 | "┌───┐", 500 | "│ │", 501 | "└───┘", 502 | ]), 503 | ) 504 | } 505 | 506 | #[test] 507 | fn scroll_up() { 508 | let (area, mut buf, list, mut state) = test_data(8); 509 | // Select last element and render 510 | state.select(Some(2)); 511 | list.render(area, &mut buf, &mut state); 512 | assert_buffer_eq( 513 | buf, 514 | Buffer::with_lines(vec![ 515 | "│ │", 516 | "└───┘", 517 | "┌───┐", 518 | "│ │", 519 | "└───┘", 520 | "┌───┐", 521 | "│ │", 522 | "└───┘", 523 | ]), 524 | ); 525 | 526 | // Select first element and render 527 | let (_, mut buf, list, _) = test_data(8); 528 | state.select(Some(1)); 529 | list.render(area, &mut buf, &mut state); 530 | assert_buffer_eq( 531 | buf, 532 | Buffer::with_lines(vec![ 533 | "│ │", 534 | "└───┘", 535 | "┌───┐", 536 | "│ │", 537 | "└───┘", 538 | "┌───┐", 539 | "│ │", 540 | "└───┘", 541 | ]), 542 | ) 543 | } 544 | 545 | fn assert_buffer_eq(actual: Buffer, expected: Buffer) { 546 | if actual.area != expected.area { 547 | panic!( 548 | "buffer areas not equal expected: {:?} actual: {:?}", 549 | expected, actual 550 | ); 551 | } 552 | let diff = expected.diff(&actual); 553 | if !diff.is_empty() { 554 | panic!( 555 | "buffer contents not equal\nexpected: {:?}\nactual: {:?}", 556 | expected, actual, 557 | ); 558 | } 559 | assert_eq!(actual, expected, "buffers not equal"); 560 | } 561 | } 562 | --------------------------------------------------------------------------------