//! # [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}; use ratatui::{ backend::Backend, buffer::Buffer, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, layout::{Constraint, Layout, Rect}, style::{ palette::tailwind::{BLUE, GREEN, SLATE}, Color, Modifier, Style, Stylize, }, symbols, text::Line, widgets::{ Block, Borders, HighlightSpacing, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, }, Terminal, }; 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; fn main() -> Result<(), Box> { tui::init_error_hooks()?; let terminal = tui::init_terminal()?; let mut app = App::default(); app.run(terminal)?; tui::restore_terminal()?; Ok(()) } /// 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 { items: Vec, state: ListState, } #[derive(Debug)] struct TodoItem { todo: String, info: String, status: Status, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] enum Status { Todo, Completed, } 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!"), ]), } } } 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 } } } impl TodoItem { fn new(status: Status, todo: &str, info: &str) -> Self { Self { status, todo: todo.to_string(), info: info.to_string(), } } } impl App { fn run(&mut self, mut terminal: Terminal) -> io::Result<()> { while !self.should_exit { terminal.draw(|f| f.render_widget(&mut *self, f.area()))?; if let Event::Key(key) = event::read()? { self.handle_key(key); }; } Ok(()) } 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 select_none(&mut self) { self.todo_list.state.select(None); } fn select_next(&mut self) { self.todo_list.state.select_next(); } fn select_previous(&mut self) { self.todo_list.state.select_previous(); } fn select_first(&mut self) { self.todo_list.state.select_first(); } fn select_last(&mut self) { self.todo_list.state.select_last(); } /// 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) { let [header_area, main_area, footer_area] = Layout::vertical([ Constraint::Length(2), 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_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 .todo_list .items .iter() .enumerate() .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 list = List::new(items) .block(block) .highlight_style(SELECTED_STYLE) .highlight_symbol(">") .highlight_spacing(HighlightSpacing::Always); // 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_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.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 selected...".to_string() }; // We show the list item's info under the list in this paragraph 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 Paragraph::new(info) .block(block) .fg(TEXT_FG_COLOR) .wrap(Wrap { trim: false }) .render(area, buf); } } const fn alternate_colors(i: usize) -> Color { if i % 2 == 0 { NORMAL_ROW_BG } else { ALT_ROW_BG_COLOR } } 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) } }; ListItem::new(line) } } 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, }; 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(()) } pub fn init_terminal() -> io::Result> { stdout().execute(EnterAlternateScreen)?; enable_raw_mode()?; Terminal::new(CrosstermBackend::new(stdout())) } pub fn restore_terminal() -> io::Result<()> { stdout().execute(LeaveAlternateScreen)?; disable_raw_mode() } }