mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-14 00:47:14 +00:00
8061813f32
Consensus is that explicit imports make it easier to understand the example code. This commit removes the prelude import from all examples and replaces it with the necessary imports, and expands other glob imports (widget::*, Constraint::*, KeyCode::*, etc.) everywhere else. Prelude glob imports not in examples are not covered by this PR. See https://github.com/ratatui-org/ratatui/issues/1150 for more details.
375 lines
12 KiB
Rust
375 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 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<TodoItem>,
|
|
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 {
|
|
items: TodoList,
|
|
}
|
|
|
|
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() -> 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<impl Backend>) -> 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<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] = 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<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 => 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)
|
|
}
|
|
}
|