2024-01-24 19:50:18 +00:00
|
|
|
//! # [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
|
|
|
|
|
2024-01-23 16:22:37 +00:00
|
|
|
use std::{error::Error, io, io::stdout};
|
2023-06-12 05:07:15 +00:00
|
|
|
|
2024-01-23 16:22:37 +00:00
|
|
|
use color_eyre::config::HookBuilder;
|
2024-05-28 20:23:39 +00:00
|
|
|
use ratatui::{
|
2024-05-29 11:42:29 +00:00
|
|
|
backend::{Backend, CrosstermBackend},
|
|
|
|
buffer::Buffer,
|
2024-05-28 20:23:39 +00:00
|
|
|
crossterm::{
|
|
|
|
event::{self, Event, KeyCode, KeyEventKind},
|
|
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
|
|
ExecutableCommand,
|
|
|
|
},
|
2024-05-29 11:42:29 +00:00
|
|
|
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,
|
|
|
|
},
|
2021-11-01 21:27:46 +00:00
|
|
|
};
|
2024-01-23 16:22:37 +00:00
|
|
|
|
|
|
|
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,
|
|
|
|
}
|
2018-09-23 18:59:51 +00:00
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
struct TodoItem {
|
|
|
|
todo: String,
|
|
|
|
info: String,
|
2024-01-23 16:22:37 +00:00
|
|
|
status: Status,
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl TodoItem {
|
|
|
|
fn new(todo: &str, info: &str, status: Status) -> Self {
|
|
|
|
Self {
|
|
|
|
todo: todo.to_string(),
|
|
|
|
info: info.to_string(),
|
|
|
|
status,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct TodoList {
|
2021-11-11 14:56:37 +00:00
|
|
|
state: ListState,
|
2024-05-24 22:09:24 +00:00
|
|
|
items: Vec<TodoItem>,
|
2024-01-19 08:17:39 +00:00
|
|
|
last_selected: Option<usize>,
|
2021-11-11 14:56:37 +00:00
|
|
|
}
|
|
|
|
|
2024-01-23 16:22:37 +00:00
|
|
|
/// 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.
|
2024-05-24 22:09:24 +00:00
|
|
|
struct App {
|
|
|
|
items: TodoList,
|
2024-01-23 16:22:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
fn main() -> Result<(), Box<dyn Error>> {
|
|
|
|
// 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();
|
2024-03-02 09:06:53 +00:00
|
|
|
panic(info);
|
2024-01-23 16:22:37 +00:00
|
|
|
}));
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn init_terminal() -> color_eyre::Result<Terminal<impl Backend>> {
|
|
|
|
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(())
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl App {
|
2024-03-02 09:06:53 +00:00
|
|
|
fn new() -> Self {
|
|
|
|
Self {
|
2024-05-24 22:09:24 +00:00
|
|
|
items: TodoList::with_items(&[
|
2024-01-23 16:22:37 +00:00
|
|
|
("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) {
|
2024-03-02 09:06:53 +00:00
|
|
|
self.items.state.select(Some(0));
|
2024-01-23 16:22:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
fn go_bottom(&mut self) {
|
2024-03-02 09:06:53 +00:00
|
|
|
self.items.state.select(Some(self.items.items.len() - 1));
|
2024-01-23 16:22:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl App {
|
2024-01-23 16:22:37 +00:00
|
|
|
fn run(&mut self, mut terminal: Terminal<impl Backend>) -> io::Result<()> {
|
|
|
|
loop {
|
|
|
|
self.draw(&mut terminal)?;
|
|
|
|
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
|
|
if key.kind == KeyEventKind::Press {
|
|
|
|
match key.code {
|
2024-05-29 11:42:29 +00:00
|
|
|
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(),
|
2024-01-23 16:22:37 +00:00
|
|
|
_ => {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn draw(&mut self, terminal: &mut Terminal<impl Backend>) -> io::Result<()> {
|
|
|
|
terminal.draw(|f| f.render_widget(self, f.size()))?;
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl Widget for &mut App {
|
2024-01-23 16:22:37 +00:00
|
|
|
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),
|
|
|
|
]);
|
2024-02-02 04:26:35 +00:00
|
|
|
let [header_area, rest_area, footer_area] = vertical.areas(area);
|
2024-01-23 16:22:37 +00:00
|
|
|
|
|
|
|
// 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)]);
|
2024-02-02 04:26:35 +00:00
|
|
|
let [upper_item_list_area, lower_item_list_area] = vertical.areas(rest_area);
|
2024-01-23 16:22:37 +00:00
|
|
|
|
2024-03-02 09:06:53 +00:00
|
|
|
render_title(header_area, buf);
|
2024-01-23 16:22:37 +00:00
|
|
|
self.render_todo(upper_item_list_area, buf);
|
|
|
|
self.render_info(lower_item_list_area, buf);
|
2024-03-02 09:06:53 +00:00
|
|
|
render_footer(footer_area, buf);
|
2024-01-23 16:22:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl App {
|
2024-01-23 16:22:37 +00:00
|
|
|
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).
|
2024-05-02 10:09:48 +00:00
|
|
|
let outer_block = Block::new()
|
2024-01-23 16:22:37 +00:00
|
|
|
.borders(Borders::NONE)
|
2024-05-02 10:09:48 +00:00
|
|
|
.title_alignment(Alignment::Center)
|
2024-01-23 16:22:37 +00:00
|
|
|
.title("TODO List")
|
2024-05-02 10:09:48 +00:00
|
|
|
.fg(TEXT_COLOR)
|
|
|
|
.bg(TODO_HEADER_BG);
|
|
|
|
let inner_block = Block::new()
|
2024-01-23 16:22:37 +00:00
|
|
|
.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<ListItem> = 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 {
|
2024-05-24 22:09:24 +00:00
|
|
|
Status::Completed => format!("✓ DONE: {}", self.items.items[i].info),
|
|
|
|
Status::Todo => format!("TODO: {}", self.items.items[i].info),
|
2024-01-23 16:22:37 +00:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
"Nothing to see here...".to_string()
|
|
|
|
};
|
|
|
|
|
|
|
|
// We show the list item's info under the list in this paragraph
|
2024-05-02 10:09:48 +00:00
|
|
|
let outer_info_block = Block::new()
|
2024-01-23 16:22:37 +00:00
|
|
|
.borders(Borders::NONE)
|
2024-05-02 10:09:48 +00:00
|
|
|
.title_alignment(Alignment::Center)
|
2024-01-23 16:22:37 +00:00
|
|
|
.title("TODO Info")
|
2024-05-02 10:09:48 +00:00
|
|
|
.fg(TEXT_COLOR)
|
|
|
|
.bg(TODO_HEADER_BG);
|
|
|
|
let inner_info_block = Block::new()
|
2024-01-23 16:22:37 +00:00
|
|
|
.borders(Borders::NONE)
|
2024-05-02 10:09:48 +00:00
|
|
|
.padding(Padding::horizontal(1))
|
|
|
|
.bg(NORMAL_ROW_COLOR);
|
2024-01-23 16:22:37 +00:00
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
2024-03-02 09:06:53 +00:00
|
|
|
}
|
2024-01-23 16:22:37 +00:00
|
|
|
|
2024-03-02 09:06:53 +00:00
|
|
|
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.")
|
2024-01-24 11:31:52 +00:00
|
|
|
.centered()
|
2024-01-23 16:22:37 +00:00
|
|
|
.render(area, buf);
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl TodoList {
|
|
|
|
fn with_items(items: &[(&str, &str, Status)]) -> Self {
|
|
|
|
Self {
|
2021-11-11 14:56:37 +00:00
|
|
|
state: ListState::default(),
|
2024-05-24 22:09:24 +00:00
|
|
|
items: items
|
|
|
|
.iter()
|
|
|
|
.map(|(todo, info, status)| TodoItem::new(todo, info, *status))
|
|
|
|
.collect(),
|
2024-01-19 08:17:39 +00:00
|
|
|
last_selected: None,
|
2021-11-11 14:56:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn next(&mut self) {
|
|
|
|
let i = match self.state.selected() {
|
|
|
|
Some(i) => {
|
|
|
|
if i >= self.items.len() - 1 {
|
|
|
|
0
|
|
|
|
} else {
|
|
|
|
i + 1
|
|
|
|
}
|
|
|
|
}
|
2024-01-19 08:17:39 +00:00
|
|
|
None => self.last_selected.unwrap_or(0),
|
2021-11-11 14:56:37 +00:00
|
|
|
};
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
2024-01-19 08:17:39 +00:00
|
|
|
None => self.last_selected.unwrap_or(0),
|
2021-11-11 14:56:37 +00:00
|
|
|
};
|
|
|
|
self.state.select(Some(i));
|
|
|
|
}
|
|
|
|
|
|
|
|
fn unselect(&mut self) {
|
2024-01-19 08:17:39 +00:00
|
|
|
let offset = self.state.offset();
|
|
|
|
self.last_selected = self.state.selected();
|
2021-11-11 14:56:37 +00:00
|
|
|
self.state.select(None);
|
2024-01-19 08:17:39 +00:00
|
|
|
*self.state.offset_mut() = offset;
|
2021-11-11 14:56:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-24 22:09:24 +00:00
|
|
|
impl TodoItem {
|
2024-01-23 16:22:37 +00:00
|
|
|
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),
|
|
|
|
),
|
|
|
|
};
|
2021-11-01 21:27:46 +00:00
|
|
|
|
2024-01-23 16:22:37 +00:00
|
|
|
ListItem::new(line).bg(bg_color)
|
2021-11-01 21:27:46 +00:00
|
|
|
}
|
|
|
|
}
|