//! # [Ratatui] List example //! //! The latest version of this example is available in the [examples] folder in the repository. //! //! Please note that the examples are designed to be run against the `main` branch of the Github //! repository. This means that you may not be able to compile with the latest release version on //! crates.io, or the one that you have installed locally. //! //! See the [examples readme] for more information on finding examples that match the version of the //! library you are using. //! //! [Ratatui]: https://github.com/ratatui-org/ratatui //! [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 color_eyre::config::HookBuilder; use ratatui::{ backend::{Backend, CrosstermBackend}, buffer::Buffer, crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }, layout::{Alignment, Constraint, Layout, Rect}, style::{palette::tailwind, Color, Modifier, Style, Stylize}, terminal::Terminal, text::Line, widgets::{ Block, Borders, HighlightSpacing, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, }, }; 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; #[derive(Copy, Clone)] enum Status { Todo, Completed, } struct TodoItem { todo: String, info: String, status: Status, } impl TodoItem { fn new(todo: &str, info: &str, status: Status) -> Self { Self { todo: todo.to_string(), info: info.to_string(), status, } } } struct TodoList { state: ListState, items: Vec, last_selected: Option, } /// 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, } 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(()) } 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 init_terminal() -> color_eyre::Result> { enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout()); let terminal = Terminal::new(backend)?; Ok(terminal) } fn restore_terminal() -> color_eyre::Result<()> { disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; Ok(()) } 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), ]), } } /// 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 go_top(&mut self) { self.items.state.select(Some(0)); } fn go_bottom(&mut self) { self.items.state.select(Some(self.items.items.len() - 1)); } } impl App { fn run(&mut self, mut terminal: Terminal) -> io::Result<()> { loop { self.draw(&mut terminal)?; 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 draw(&mut self, terminal: &mut Terminal) -> io::Result<()> { terminal.draw(|f| f.render_widget(self, f.size()))?; Ok(()) } } 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), 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); } } 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); // Iterate through all elements in the `items` and stylize them. let items: Vec = self .items .items .iter() .enumerate() .map(|(i, todo_item)| todo_item.to_list_item(i)) .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), ) .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); } fn render_info(&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), } } else { "Nothing to see here...".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 }); // We can now render the item info info_paragraph.render(inner_info_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, } } fn next(&mut self) { let i = match self.state.selected() { Some(i) => { if i >= self.items.len() - 1 { 0 } else { i + 1 } } None => self.last_selected.unwrap_or(0), }; self.state.select(Some(i)); } 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)); } fn unselect(&mut self) { let offset = self.state.offset(); self.last_selected = self.state.selected(); self.state.select(None); *self.state.offset_mut() = offset; } } 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) } }