From 039ccb471f3dbdb8696fc07e26642bbc604a6f33 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 30 May 2024 23:34:59 -0700 Subject: [PATCH] feat(list): add list navigation methods (first, last, previous, next) Also cleans up the list example significantly (see also ) Fixes: --- examples/list.rs | 492 ++++++++++++++++++++------------------------ src/symbols.rs | 22 ++ src/widgets/list.rs | 182 +++++++++++++++- 3 files changed, 422 insertions(+), 274 deletions(-) diff --git a/examples/list.rs b/examples/list.rs index 9499fbb81..48d23f23a 100644 --- a/examples/list.rs +++ b/examples/list.rs @@ -13,19 +13,19 @@ //! [examples]: https://github.com/ratatui-org/ratatui/blob/main/examples //! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md -use std::{error::Error, io, io::stdout}; +use std::{error::Error, io}; -use color_eyre::config::HookBuilder; +use crossterm::event::KeyEvent; use ratatui::{ - backend::{Backend, CrosstermBackend}, + backend::Backend, buffer::Buffer, - crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, + crossterm::event::{self, Event, KeyCode, KeyEventKind}, + layout::{Constraint, Layout, Rect}, + style::{ + palette::tailwind::{BLUE, GREEN, SLATE}, + Color, Modifier, Style, Stylize, }, - layout::{Alignment, Constraint, Layout, Rect}, - style::{palette::tailwind, Color, Modifier, Style, Stylize}, + symbols, terminal::Terminal, text::Line, widgets::{ @@ -34,342 +34,302 @@ use ratatui::{ }, }; -const TODO_HEADER_BG: Color = tailwind::BLUE.c950; -const NORMAL_ROW_COLOR: Color = tailwind::SLATE.c950; -const ALT_ROW_COLOR: Color = tailwind::SLATE.c900; -const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300; -const TEXT_COLOR: Color = tailwind::SLATE.c200; -const COMPLETED_TEXT_COLOR: Color = tailwind::GREEN.c500; +const TODO_HEADER_STYLE: Style = Style::new().fg(SLATE.c100).bg(BLUE.c800); +const NORMAL_ROW_BG: Color = SLATE.c950; +const ALT_ROW_BG_COLOR: Color = SLATE.c900; +const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD); +const TEXT_FG_COLOR: Color = SLATE.c200; +const COMPLETED_TEXT_FG_COLOR: Color = GREEN.c500; -#[derive(Copy, Clone)] -enum Status { - Todo, - Completed, -} +fn main() -> Result<(), Box> { + tui::init_error_hooks()?; + let terminal = tui::init_terminal()?; -struct TodoItem { - todo: String, - info: String, - status: Status, + let mut app = App::default(); + app.run(terminal)?; + + tui::restore_terminal()?; + Ok(()) } -impl TodoItem { - fn new(todo: &str, info: &str, status: Status) -> Self { - Self { - todo: todo.to_string(), - info: info.to_string(), - status, - } - } +/// This struct holds the current state of the app. In particular, it has the `todo_list` field +/// which is a wrapper around `ListState`. Keeping track of the state lets us render the +/// associated widget with its state and have access to features such as natural scrolling. +/// +/// Check the event handling at the bottom to see how to change the state on incoming events. Check +/// the drawing logic for items on how to specify the highlighting style for selected items. +struct App { + should_exit: bool, + todo_list: TodoList, } struct TodoList { - state: ListState, items: Vec, - last_selected: Option, + state: ListState, } -/// This struct holds the current state of the app. In particular, it has the `items` field which is -/// a wrapper around `ListState`. Keeping track of the items state let us render the associated -/// widget with its state and have access to features such as natural scrolling. -/// -/// Check the event handling at the bottom to see how to change the state on incoming events. -/// Check the drawing logic for items on how to specify the highlighting style for selected items. -struct App { - items: TodoList, +#[derive(Debug)] +struct TodoItem { + todo: String, + info: String, + status: Status, } -fn main() -> Result<(), Box> { - // setup terminal - init_error_hooks()?; - let terminal = init_terminal()?; - - // create app and run it - App::new().run(terminal)?; - - restore_terminal()?; - - Ok(()) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum Status { + Todo, + Completed, } -fn init_error_hooks() -> color_eyre::Result<()> { - let (panic, error) = HookBuilder::default().into_hooks(); - let panic = panic.into_panic_hook(); - let error = error.into_eyre_hook(); - color_eyre::eyre::set_hook(Box::new(move |e| { - let _ = restore_terminal(); - error(e) - }))?; - std::panic::set_hook(Box::new(move |info| { - let _ = restore_terminal(); - panic(info); - })); - Ok(()) +impl Default for App { + fn default() -> Self { + Self { + should_exit: false, + todo_list: TodoList::from_iter([ + (Status::Todo, "Rewrite everything with Rust!", "I can't hold my inner voice. He tells me to rewrite the complete universe with Rust"), + (Status::Completed, "Rewrite all of your tui apps with Ratatui", "Yes, you heard that right. Go and replace your tui with Ratatui."), + (Status::Todo, "Pet your cat", "Minnak loves to be pet by you! Don't forget to pet and give some treats!"), + (Status::Todo, "Walk with your dog", "Max is bored, go walk with him!"), + (Status::Completed, "Pay the bills", "Pay the train subscription!!!"), + (Status::Completed, "Refactor list example", "If you see this info that means I completed this task!"), + ]), + } + } } -fn init_terminal() -> color_eyre::Result> { - enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::new(backend)?; - Ok(terminal) +impl FromIterator<(Status, &'static str, &'static str)> for TodoList { + fn from_iter>(iter: I) -> Self { + let items = iter + .into_iter() + .map(|(status, todo, info)| TodoItem::new(status, todo, info)) + .collect(); + let state = ListState::default(); + Self { items, state } + } } -fn restore_terminal() -> color_eyre::Result<()> { - disable_raw_mode()?; - stdout().execute(LeaveAlternateScreen)?; - Ok(()) +impl TodoItem { + fn new(status: Status, todo: &str, info: &str) -> Self { + Self { + status, + todo: todo.to_string(), + info: info.to_string(), + } + } } impl App { - fn new() -> Self { - Self { - items: TodoList::with_items(&[ - ("Rewrite everything with Rust!", "I can't hold my inner voice. He tells me to rewrite the complete universe with Rust", Status::Todo), - ("Rewrite all of your tui apps with Ratatui", "Yes, you heard that right. Go and replace your tui with Ratatui.", Status::Completed), - ("Pet your cat", "Minnak loves to be pet by you! Don't forget to pet and give some treats!", Status::Todo), - ("Walk with your dog", "Max is bored, go walk with him!", Status::Todo), - ("Pay the bills", "Pay the train subscription!!!", Status::Completed), - ("Refactor list example", "If you see this info that means I completed this task!", Status::Completed), - ]), + fn run(&mut self, mut terminal: Terminal) -> io::Result<()> { + while !self.should_exit { + terminal.draw(|f| f.render_widget(&mut *self, f.size()))?; + if let Event::Key(key) = event::read()? { + self.handle_key(key); + }; } + Ok(()) } - /// Changes the status of the selected list item - fn change_status(&mut self) { - if let Some(i) = self.items.state.selected() { - self.items.items[i].status = match self.items.items[i].status { - Status::Completed => Status::Todo, - Status::Todo => Status::Completed, + fn handle_key(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_exit = true, + KeyCode::Char('h') | KeyCode::Left => self.select_none(), + KeyCode::Char('j') | KeyCode::Down => self.select_next(), + KeyCode::Char('k') | KeyCode::Up => self.select_previous(), + KeyCode::Char('g') | KeyCode::Home => self.select_first(), + KeyCode::Char('G') | KeyCode::End => self.select_last(), + KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => { + self.toggle_status(); } + _ => {} } } - fn go_top(&mut self) { - self.items.state.select(Some(0)); + fn select_none(&mut self) { + self.todo_list.state.select(None); } - fn go_bottom(&mut self) { - self.items.state.select(Some(self.items.items.len() - 1)); + fn select_next(&mut self) { + self.todo_list.state.select_next(); + } + fn select_previous(&mut self) { + self.todo_list.state.select_previous(); } -} -impl App { - fn run(&mut self, mut terminal: Terminal) -> io::Result<()> { - loop { - self.draw(&mut terminal)?; + fn select_first(&mut self) { + self.todo_list.state.select_first(); + } - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), - KeyCode::Char('h') | KeyCode::Left => self.items.unselect(), - KeyCode::Char('j') | KeyCode::Down => self.items.next(), - KeyCode::Char('k') | KeyCode::Up => self.items.previous(), - KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => { - self.change_status(); - } - KeyCode::Char('g') => self.go_top(), - KeyCode::Char('G') => self.go_bottom(), - _ => {} - } - } - } - } + fn select_last(&mut self) { + self.todo_list.state.select_last(); } - fn draw(&mut self, terminal: &mut Terminal) -> io::Result<()> { - terminal.draw(|f| f.render_widget(self, f.size()))?; - Ok(()) + /// Changes the status of the selected list item + fn toggle_status(&mut self) { + if let Some(i) = self.todo_list.state.selected() { + self.todo_list.items[i].status = match self.todo_list.items[i].status { + Status::Completed => Status::Todo, + Status::Todo => Status::Completed, + } + } } } impl Widget for &mut App { fn render(self, area: Rect, buf: &mut Buffer) { - // Create a space for header, todo list and the footer. - let vertical = Layout::vertical([ - Constraint::Length(2), - Constraint::Min(0), + let [header_area, main_area, footer_area] = Layout::vertical([ Constraint::Length(2), - ]); - let [header_area, rest_area, footer_area] = vertical.areas(area); - - // Create two chunks with equal vertical screen space. One for the list and the other for - // the info block. - let vertical = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]); - let [upper_item_list_area, lower_item_list_area] = vertical.areas(rest_area); - - render_title(header_area, buf); - self.render_todo(upper_item_list_area, buf); - self.render_info(lower_item_list_area, buf); - render_footer(footer_area, buf); + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(area); + + let [list_area, item_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(main_area); + + App::render_header(header_area, buf); + App::render_footer(footer_area, buf); + self.render_list(list_area, buf); + self.render_selected_item(item_area, buf); } } +/// Rendering logic for the app impl App { - fn render_todo(&mut self, area: Rect, buf: &mut Buffer) { - // We create two blocks, one is for the header (outer) and the other is for list (inner). - let outer_block = Block::new() - .borders(Borders::NONE) - .title_alignment(Alignment::Center) - .title("TODO List") - .fg(TEXT_COLOR) - .bg(TODO_HEADER_BG); - let inner_block = Block::new() - .borders(Borders::NONE) - .fg(TEXT_COLOR) - .bg(NORMAL_ROW_COLOR); - - // We get the inner area from outer_block. We'll use this area later to render the table. - let outer_area = area; - let inner_area = outer_block.inner(outer_area); - - // We can render the header in outer_area. - outer_block.render(outer_area, buf); + fn render_header(area: Rect, buf: &mut Buffer) { + Paragraph::new("Ratatui List Example") + .bold() + .centered() + .render(area, buf); + } + + fn render_footer(area: Rect, buf: &mut Buffer) { + Paragraph::new("Use ↓↑ to move, ← to unselect, → to change status, g/G to go top/bottom.") + .centered() + .render(area, buf); + } + + fn render_list(&mut self, area: Rect, buf: &mut Buffer) { + let block = Block::new() + .title(Line::raw("TODO List").centered()) + .borders(Borders::TOP) + .border_set(symbols::border::EMPTY) + .border_style(TODO_HEADER_STYLE) + .bg(NORMAL_ROW_BG); // Iterate through all elements in the `items` and stylize them. let items: Vec = self - .items + .todo_list .items .iter() .enumerate() - .map(|(i, todo_item)| todo_item.to_list_item(i)) + .map(|(i, todo_item)| { + let color = alternate_colors(i); + ListItem::from(todo_item).bg(color) + }) .collect(); // Create a List from all list items and highlight the currently selected one - let items = List::new(items) - .block(inner_block) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::REVERSED) - .fg(SELECTED_STYLE_FG), - ) + let list = List::new(items) + .block(block) + .highlight_style(SELECTED_STYLE) .highlight_symbol(">") .highlight_spacing(HighlightSpacing::Always); - // We can now render the item list - // (look careful we are using StatefulWidget's render.) - // ratatui::widgets::StatefulWidget::render as stateful_render - StatefulWidget::render(items, inner_area, buf, &mut self.items.state); + // We need to disambiguate this trait method as both `Widget` and `StatefulWidget` share the + // same method name `render`. + StatefulWidget::render(list, area, buf, &mut self.todo_list.state); } - fn render_info(&self, area: Rect, buf: &mut Buffer) { + fn render_selected_item(&self, area: Rect, buf: &mut Buffer) { // We get the info depending on the item's state. - let info = if let Some(i) = self.items.state.selected() { - match self.items.items[i].status { - Status::Completed => format!("✓ DONE: {}", self.items.items[i].info), - Status::Todo => format!("TODO: {}", self.items.items[i].info), + let info = if let Some(i) = self.todo_list.state.selected() { + match self.todo_list.items[i].status { + Status::Completed => format!("✓ DONE: {}", self.todo_list.items[i].info), + Status::Todo => format!("☐ TODO: {}", self.todo_list.items[i].info), } } else { - "Nothing to see here...".to_string() + "Nothing selected...".to_string() }; // We show the list item's info under the list in this paragraph - let outer_info_block = Block::new() - .borders(Borders::NONE) - .title_alignment(Alignment::Center) - .title("TODO Info") - .fg(TEXT_COLOR) - .bg(TODO_HEADER_BG); - let inner_info_block = Block::new() - .borders(Borders::NONE) - .padding(Padding::horizontal(1)) - .bg(NORMAL_ROW_COLOR); - - // This is a similar process to what we did for list. outer_info_area will be used for - // header inner_info_area will be used for the list info. - let outer_info_area = area; - let inner_info_area = outer_info_block.inner(outer_info_area); - - // We can render the header. Inner info will be rendered later - outer_info_block.render(outer_info_area, buf); - - let info_paragraph = Paragraph::new(info) - .block(inner_info_block) - .fg(TEXT_COLOR) - .wrap(Wrap { trim: false }); + let block = Block::new() + .title(Line::raw("TODO Info").centered()) + .borders(Borders::TOP) + .border_set(symbols::border::EMPTY) + .border_style(TODO_HEADER_STYLE) + .bg(NORMAL_ROW_BG) + .padding(Padding::horizontal(1)); // We can now render the item info - info_paragraph.render(inner_info_area, buf); + Paragraph::new(info) + .block(block) + .fg(TEXT_FG_COLOR) + .wrap(Wrap { trim: false }) + .render(area, buf); } } -fn render_title(area: Rect, buf: &mut Buffer) { - Paragraph::new("Ratatui List Example") - .bold() - .centered() - .render(area, buf); -} - -fn render_footer(area: Rect, buf: &mut Buffer) { - Paragraph::new("\nUse ↓↑ to move, ← to unselect, → to change status, g/G to go top/bottom.") - .centered() - .render(area, buf); -} - -impl TodoList { - fn with_items(items: &[(&str, &str, Status)]) -> Self { - Self { - state: ListState::default(), - items: items - .iter() - .map(|(todo, info, status)| TodoItem::new(todo, info, *status)) - .collect(), - last_selected: None, - } +const fn alternate_colors(i: usize) -> Color { + if i % 2 == 0 { + NORMAL_ROW_BG + } else { + ALT_ROW_BG_COLOR } +} - fn next(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i >= self.items.len() - 1 { - 0 - } else { - i + 1 - } +impl From<&TodoItem> for ListItem<'_> { + fn from(value: &TodoItem) -> Self { + let line = match value.status { + Status::Todo => Line::styled(format!(" ☐ {}", value.todo), TEXT_FG_COLOR), + Status::Completed => { + Line::styled(format!(" ✓ {}", value.todo), COMPLETED_TEXT_FG_COLOR) } - None => self.last_selected.unwrap_or(0), }; - self.state.select(Some(i)); + ListItem::new(line) } +} - fn previous(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - self.items.len() - 1 - } else { - i - 1 - } - } - None => self.last_selected.unwrap_or(0), - }; - self.state.select(Some(i)); +mod tui { + use std::{io, io::stdout}; + + use color_eyre::config::HookBuilder; + use ratatui::{ + backend::{Backend, CrosstermBackend}, + crossterm::{ + terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + }, + ExecutableCommand, + }, + terminal::Terminal, + }; + + pub fn init_error_hooks() -> color_eyre::Result<()> { + let (panic, error) = HookBuilder::default().into_hooks(); + let panic = panic.into_panic_hook(); + let error = error.into_eyre_hook(); + color_eyre::eyre::set_hook(Box::new(move |e| { + let _ = restore_terminal(); + error(e) + }))?; + std::panic::set_hook(Box::new(move |info| { + let _ = restore_terminal(); + panic(info); + })); + Ok(()) } - fn unselect(&mut self) { - let offset = self.state.offset(); - self.last_selected = self.state.selected(); - self.state.select(None); - *self.state.offset_mut() = offset; + pub fn init_terminal() -> io::Result> { + stdout().execute(EnterAlternateScreen)?; + enable_raw_mode()?; + Terminal::new(CrosstermBackend::new(stdout())) } -} - -impl TodoItem { - fn to_list_item(&self, index: usize) -> ListItem { - let bg_color = match index % 2 { - 0 => NORMAL_ROW_COLOR, - _ => ALT_ROW_COLOR, - }; - let line = match self.status { - Status::Todo => Line::styled(format!(" ☐ {}", self.todo), TEXT_COLOR), - Status::Completed => Line::styled( - format!(" ✓ {}", self.todo), - (COMPLETED_TEXT_COLOR, bg_color), - ), - }; - ListItem::new(line).bg(bg_color) + pub fn restore_terminal() -> io::Result<()> { + stdout().execute(LeaveAlternateScreen)?; + disable_raw_mode() } } diff --git a/src/symbols.rs b/src/symbols.rs index 069e23d98..d11942cce 100644 --- a/src/symbols.rs +++ b/src/symbols.rs @@ -470,6 +470,28 @@ pub mod border { horizontal_top: QUADRANT_TOP_HALF, horizontal_bottom: QUADRANT_BOTTOM_HALF, }; + + pub const SOLID: Set = Set { + top_left: QUADRANT_BLOCK, + top_right: QUADRANT_BLOCK, + bottom_left: QUADRANT_BLOCK, + bottom_right: QUADRANT_BLOCK, + vertical_left: QUADRANT_BLOCK, + vertical_right: QUADRANT_BLOCK, + horizontal_top: QUADRANT_BLOCK, + horizontal_bottom: QUADRANT_BLOCK, + }; + + pub const EMPTY: Set = Set { + top_left: " ", + top_right: " ", + bottom_left: " ", + bottom_right: " ", + vertical_left: " ", + vertical_right: " ", + horizontal_top: " ", + horizontal_bottom: " ", + }; } pub const DOT: &str = "•"; diff --git a/src/widgets/list.rs b/src/widgets/list.rs index d884b4bbc..c81abd022 100755 --- a/src/widgets/list.rs +++ b/src/widgets/list.rs @@ -156,6 +156,72 @@ impl ListState { self.offset = 0; } } + + /// Selects the next item or the first one if no item is selected + /// + /// Note: until the list is rendered, the number of items is not known, so the index is set to + /// `0` and will be corrected when the list is rendered + /// + /// # Examples + /// + /// ```rust + /// # use ratatui::{prelude::*, widgets::*}; + /// let mut state = ListState::default(); + /// state.select_next(); + /// ``` + pub fn select_next(&mut self) { + let next = self.selected.map_or(0, |i| i.saturating_add(1)); + self.select(Some(next)); + } + + /// Selects the previous item or the last one if no item is selected + /// + /// Note: until the list is rendered, the number of items is not known, so the index is set to + /// `usize::MAX` and will be corrected when the list is rendered + /// + /// # Examples + /// + /// ```rust + /// # use ratatui::{prelude::*, widgets::*}; + /// let mut state = ListState::default(); + /// state.select_previous(); + /// ``` + pub fn select_previous(&mut self) { + let previous = self.selected.map_or(usize::MAX, |i| i.saturating_sub(1)); + self.select(Some(previous)); + } + + /// Selects the first item + /// + /// Note: until the list is rendered, the number of items is not known, so the index is set to + /// `0` and will be corrected when the list is rendered + /// + /// # Examples + /// + /// ```rust + /// # use ratatui::{prelude::*, widgets::*}; + /// let mut state = ListState::default(); + /// state.select_first(); + /// ``` + pub fn select_first(&mut self) { + self.select(Some(0)); + } + + /// Selects the last item + /// + /// Note: until the list is rendered, the number of items is not known, so the index is set to + /// `usize::MAX` and will be corrected when the list is rendered + /// + /// # Examples + /// + /// ```rust + /// # use ratatui::{prelude::*, widgets::*}; + /// let mut state = ListState::default(); + /// state.select_last(); + /// ``` + pub fn select_last(&mut self) { + self.select(Some(usize::MAX)); + } } /// A single item in a [`List`] @@ -881,10 +947,20 @@ impl StatefulWidgetRef for List<'_> { self.block.render_ref(area, buf); let list_area = self.block.inner_if_some(area); - if list_area.is_empty() || self.items.is_empty() { + if list_area.is_empty() { return; } + if self.items.is_empty() { + state.select(None); + return; + } + + // If the selected index is out of bounds, set it to the last item + if state.selected.is_some_and(|s| s >= self.items.len()) { + state.select(Some(self.items.len().saturating_sub(1))); + } + let list_height = list_area.height as usize; let (first_visible_index, last_visible_index) = @@ -1008,6 +1084,11 @@ mod tests { use super::*; + #[fixture] + fn single_line_buf() -> Buffer { + Buffer::empty(Rect::new(0, 0, 10, 1)) + } + #[test] fn test_list_state_selected() { let mut state = ListState::default(); @@ -1035,6 +1116,96 @@ mod tests { assert_eq!(state.offset, 0); } + #[test] + fn test_list_state_navigation() { + let mut state = ListState::default(); + state.select_first(); + assert_eq!(state.selected, Some(0)); + + state.select_previous(); // should not go below 0 + assert_eq!(state.selected, Some(0)); + + state.select_next(); + assert_eq!(state.selected, Some(1)); + + state.select_previous(); + assert_eq!(state.selected, Some(0)); + + state.select_last(); + assert_eq!(state.selected, Some(usize::MAX)); + + state.select_next(); // should not go above usize::MAX + assert_eq!(state.selected, Some(usize::MAX)); + + state.select_previous(); + assert_eq!(state.selected, Some(usize::MAX - 1)); + + state.select_next(); + assert_eq!(state.selected, Some(usize::MAX)); + + let mut state = ListState::default(); + state.select_next(); + assert_eq!(state.selected, Some(0)); + + let mut state = ListState::default(); + state.select_previous(); + assert_eq!(state.selected, Some(usize::MAX)); + } + + #[rstest] + fn test_list_state_empty_list(mut single_line_buf: Buffer) { + let mut state = ListState::default(); + + let items: Vec = Vec::new(); + let list = List::new(items); + state.select_first(); + StatefulWidget::render(list, single_line_buf.area, &mut single_line_buf, &mut state); + assert_eq!(state.selected, None); + } + + #[rstest] + fn test_list_state_single_item(mut single_line_buf: Buffer) { + let mut state = ListState::default(); + + let items = vec![ListItem::new("Item 1")]; + let list = List::new(items); + state.select_first(); + StatefulWidget::render( + &list, + single_line_buf.area, + &mut single_line_buf, + &mut state, + ); + assert_eq!(state.selected, Some(0)); + + state.select_last(); + StatefulWidget::render( + &list, + single_line_buf.area, + &mut single_line_buf, + &mut state, + ); + assert_eq!(state.selected, Some(0)); + + state.select_previous(); + StatefulWidget::render( + &list, + single_line_buf.area, + &mut single_line_buf, + &mut state, + ); + assert_eq!(state.selected, Some(0)); + + state.select_next(); + StatefulWidget::render( + &list, + single_line_buf.area, + &mut single_line_buf, + &mut state, + ); + assert_eq!(state.selected, Some(0)); + } + #[test] fn test_list_item_new_from_str() { let item = ListItem::new("Test item"); @@ -1301,7 +1472,7 @@ mod tests { &single_item, Some(1), [ - " Item 0 ", + ">>Item 0 ", " ", " ", " ", @@ -1359,7 +1530,7 @@ mod tests { [ " Item 0 ", " Item 1 ", - " Item 2 ", + ">>Item 2 ", " ", " ", ], @@ -2098,11 +2269,6 @@ mod tests { ]); } - #[fixture] - fn single_line_buf() -> Buffer { - Buffer::empty(Rect::new(0, 0, 10, 1)) - } - /// Regression test for a bug where highlight symbol being greater than width caused a panic due /// to subtraction with underflow. ///