mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-10 15:14:27 +00:00
362 lines
12 KiB
Rust
362 lines
12 KiB
Rust
//! # [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 crossterm::{
|
|
event::{self, Event, KeyCode, KeyEventKind},
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
ExecutableCommand,
|
|
};
|
|
use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
|
|
|
|
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<'a> {
|
|
todo: &'a str,
|
|
info: &'a str,
|
|
status: Status,
|
|
}
|
|
|
|
struct StatefulList<'a> {
|
|
state: ListState,
|
|
items: Vec<TodoItem<'a>>,
|
|
last_selected: Option<usize>,
|
|
}
|
|
|
|
/// 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<'a> {
|
|
items: StatefulList<'a>,
|
|
}
|
|
|
|
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();
|
|
panic(info)
|
|
}));
|
|
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(())
|
|
}
|
|
|
|
impl App<'_> {
|
|
fn new<'a>() -> App<'a> {
|
|
App {
|
|
items: StatefulList::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<impl Backend>) -> io::Result<()> {
|
|
loop {
|
|
self.draw(&mut terminal)?;
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
if key.kind == KeyEventKind::Press {
|
|
use KeyCode::*;
|
|
match key.code {
|
|
Char('q') | Esc => return Ok(()),
|
|
Char('h') | Left => self.items.unselect(),
|
|
Char('j') | Down => self.items.next(),
|
|
Char('k') | Up => self.items.previous(),
|
|
Char('l') | Right | Enter => self.change_status(),
|
|
Char('g') => self.go_top(),
|
|
Char('G') => self.go_bottom(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw(&mut self, terminal: &mut Terminal<impl Backend>) -> 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] = area.split(&vertical);
|
|
|
|
// 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] = rest_area.split(&vertical);
|
|
|
|
self.render_title(header_area, buf);
|
|
self.render_todo(upper_item_list_area, buf);
|
|
self.render_info(lower_item_list_area, buf);
|
|
self.render_footer(footer_area, buf);
|
|
}
|
|
}
|
|
|
|
impl App<'_> {
|
|
fn render_title(&self, area: Rect, buf: &mut Buffer) {
|
|
Paragraph::new("Ratatui List Example")
|
|
.bold()
|
|
.centered()
|
|
.render(area, buf);
|
|
}
|
|
|
|
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::default()
|
|
.borders(Borders::NONE)
|
|
.fg(TEXT_COLOR)
|
|
.bg(TODO_HEADER_BG)
|
|
.title("TODO List")
|
|
.title_alignment(Alignment::Center);
|
|
let inner_block = Block::default()
|
|
.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 {
|
|
Status::Completed => "✓ DONE: ".to_string() + self.items.items[i].info,
|
|
Status::Todo => "TODO: ".to_string() + 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::default()
|
|
.borders(Borders::NONE)
|
|
.fg(TEXT_COLOR)
|
|
.bg(TODO_HEADER_BG)
|
|
.title("TODO Info")
|
|
.title_alignment(Alignment::Center);
|
|
let inner_info_block = Block::default()
|
|
.borders(Borders::NONE)
|
|
.bg(NORMAL_ROW_COLOR)
|
|
.padding(Padding::horizontal(1));
|
|
|
|
// 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_footer(&self, 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 StatefulList<'_> {
|
|
fn with_items<'a>(items: [(&'a str, &'a str, Status); 6]) -> StatefulList<'a> {
|
|
StatefulList {
|
|
state: ListState::default(),
|
|
items: items.iter().map(TodoItem::from).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)
|
|
}
|
|
}
|
|
|
|
impl<'a> From<&(&'a str, &'a str, Status)> for TodoItem<'a> {
|
|
fn from((todo, info, status): &(&'a str, &'a str, Status)) -> Self {
|
|
Self {
|
|
todo,
|
|
info,
|
|
status: *status,
|
|
}
|
|
}
|
|
}
|