feat: add new example for working with lists

This commit is contained in:
JasterV 2026-01-30 11:38:42 +01:00
parent 92118d929e
commit 4d7ba57f96
7 changed files with 635 additions and 0 deletions

10
Cargo.lock generated
View file

@ -1304,6 +1304,16 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "todo_list"
version = "0.0.0"
dependencies = [
"crossterm",
"ratatui",
"strum",
"teatui",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"

View file

@ -0,0 +1,12 @@
[package]
name = "todo_list"
publish = false
version = "0.0.0"
edition.workspace = true
authors.workspace = true
[dependencies]
crossterm.workspace = true
ratatui.workspace = true
strum = "0.27.2"
teatui = { path = "../../teatui" }

View file

@ -0,0 +1,18 @@
//! # [TeaTui] List example
use crate::model::Model;
use message::Message;
use teatui::ProgramError;
mod message;
mod model;
mod update;
mod view;
fn main() -> Result<(), ProgramError<Model, Message, ()>> {
teatui::start(init, update::update, view::view, |_, _| None)
}
fn init() -> (Model, Option<()>) {
(Model::default(), None)
}

View file

@ -0,0 +1,75 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
#[derive(Debug)]
pub enum Message {
NoOp,
Exit,
SelectNext,
SelectNone,
SelectPrevious,
SelectFirst,
SelectLast,
ToggleStatus,
}
impl From<crossterm::event::Event> for Message {
fn from(value: crossterm::event::Event) -> Self {
match value {
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Char('q'),
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::Exit,
Event::Key(KeyEvent {
code: KeyCode::Char('l') | KeyCode::Char(' ') | KeyCode::Right,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::ToggleStatus,
Event::Key(KeyEvent {
code: KeyCode::Char('h') | KeyCode::Left,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectNone,
Event::Key(KeyEvent {
code: KeyCode::Char('j') | KeyCode::Down,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectNext,
Event::Key(KeyEvent {
code: KeyCode::Char('k') | KeyCode::Up,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectPrevious,
Event::Key(KeyEvent {
code: KeyCode::Char('g') | KeyCode::Home,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectFirst,
Event::Key(KeyEvent {
code: KeyCode::Char('G') | KeyCode::End,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectLast,
Event::FocusGained
| Event::FocusLost
| Event::Key(_)
| Event::Mouse(_)
| Event::Paste(_)
| Event::Resize(_, _) => Self::NoOp,
}
}
}

View file

@ -0,0 +1,364 @@
use ratatui::widgets::ListState;
#[derive(Clone, Debug)]
pub struct Model {
pub items: Vec<TodoItem>,
pub state: ListState,
}
#[derive(Clone, Debug)]
pub struct TodoItem {
pub todo: String,
pub info: String,
pub status: Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Status {
Todo,
Completed,
}
impl Default for Model {
fn default() -> Self {
Model::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!",
),
(
Status::Todo,
"Implement a neural network in pure Rust",
"Because why use Python when you can have zero-cost abstractions and borrow checker headaches?",
),
(
Status::Todo,
"Buy more coffee",
"The compile times are getting longer, or maybe I'm just getting slower. Either way, caffeine is required.",
),
(
Status::Completed,
"Fix that one annoying lifetime error",
"It only took three days and a sacrifice to the crab god, but it finally compiles!",
),
(
Status::Todo,
"Clean the mechanical keyboard",
"There is a non-zero amount of crumbs between the blue switches. Its affecting my WPM.",
),
(
Status::Todo,
"Read the 'Rustonomicon'",
"Into the dark depths of unsafe Rust we go. May the pointer aliasing rules have mercy.",
),
(
Status::Completed,
"Hydrate",
"Drank a full glass of water. Look at me, practicing self-care while writing low-level code.",
),
(
Status::Todo,
"Argue about memory safety on the internet",
"Someone said C is 'fine.' I must cordially explain why they are mistaken.",
),
(
Status::Todo,
"Organize the .dotfiles",
"Spend 4 hours configuring Neovim instead of actually working on the project.",
),
(
Status::Completed,
"Update dependencies",
"Ran 'cargo update'. Only 14 breaking changes in the ecosystem today. A new record!",
),
(
Status::Todo,
"Research async-trait internals",
"Why is it a macro? Why is life a macro? I need to know.",
),
(
Status::Todo,
"Go to the gym",
"Strong body, strong memory management. Don't let your muscles leak like a C++ string.",
),
(
Status::Completed,
"Explain ownership to a rubber duck",
"The duck didn't get it at first, but after a few squeaks, it finally understood move semantics.",
),
(
Status::Todo,
"Check for compiler updates",
"Is there a new nightly? I need those experimental features for no reason at all.",
),
(
Status::Todo,
"Debug the deadlocked thread",
"It's not a bug, it's just a very dedicated pause in execution.",
),
(
Status::Completed,
"Star every crate I use on GitHub",
"Showing love to the maintainers who keep my 'cargo build' alive.",
),
(
Status::Todo,
"Write a custom proc-macro",
"I want to generate code that generates code. We need to go deeper.",
),
(
Status::Todo,
"Finally learn how Pin works",
"I've read the docs five times. Maybe the sixth time is the charm?",
),
(
Status::Completed,
"Delete the 'node_modules' folder",
"Regained 40GB of disk space. Nature is healing.",
),
(
Status::Todo,
"Optimize the Docker image size",
"From 1.2GB to 10MB using multi-stage builds and Alpine. Efficiency feels good.",
),
(
Status::Todo,
"Fix the CI/CD pipeline",
"It works on my machine, but GitHub Actions thinks otherwise.",
),
(
Status::Completed,
"Replace a 5-line bash script with 200 lines of Rust",
"Was it worth it? Yes. It's type-safe now.",
),
(
Status::Todo,
"Actually write documentation",
"The code is the documentation... said no one ever (and lived).",
),
(
Status::Todo,
"Try a different terminal emulator",
"Is Alacritty faster than Kitty? Let the benchmarking begin.",
),
(
Status::Completed,
"Nap for 20 minutes",
"Brain was stuck in a recursion loop. Had to reboot the system.",
),
(
Status::Todo,
"Configure a status bar for the TWM",
"I need to see my CPU temperature in 16-bit color at all times.",
),
(
Status::Todo,
"Rewrite the parser using Nom",
"Regex is great, but combinators are cooler.",
),
(
Status::Completed,
"Find a cool Ferris sticker",
"My laptop lid still has 2 square inches of empty space.",
),
(
Status::Todo,
"Master Vim motions",
"H, J, K, L... why did I just delete the entire main function?",
),
(
Status::Todo,
"Experiment with WebAssembly",
"Rust in the browser is the future, and the future is now.",
),
(
Status::Completed,
"Add a dark mode to the UI",
"My eyes are no longer burning. Success.",
),
(
Status::Todo,
"Submit a PR to an open-source project",
"Fixing a typo counts as a contribution, right?",
),
(
Status::Todo,
"Explain Cow to a non-programmer",
"No, not the animal. It's 'Clone On Write'. Why are you walking away?",
),
(
Status::Completed,
"Organize the physical desk",
"Found a missing USB drive and three half-empty coffee mugs.",
),
(
Status::Todo,
"Write unit tests for the edge cases",
"What happens if the input is an emoji and a null byte? Let's find out.",
),
(
Status::Todo,
"Learn a new functional programming language",
"Just to see how it feels. Don't worry Rust, I'm not leaving you.",
),
(
Status::Completed,
"Solve a LeetCode hard in Rust",
"The borrow checker was the real final boss.",
),
(
Status::Todo,
"Set up a Raspberry Pi server",
"It will sit in the corner and run a Telegram bot for two days before I forget about it.",
),
(
Status::Todo,
"Refactor the error handling",
"Switching from 'unwrap()' to 'thiserror' because I'm a professional now.",
),
(
Status::Completed,
"Explain the orphan rule to a coworker",
"I think I confused them more, but I feel smarter.",
),
(
Status::Todo,
"Benchmark the hot path",
"Flamegraphs are like modern art, but for nerds.",
),
(
Status::Todo,
"Buy a more ergonomic chair",
"My back is screaming in a language I don't understand.",
),
(
Status::Completed,
"Finally finish this list",
"36 items in total. Now I just have to actually do them.",
),
])
}
}
impl FromIterator<(Status, &'static str, &'static str)> for Model {
fn from_iter<I: IntoIterator<Item = (Status, &'static str, &'static str)>>(iter: I) -> Self {
let items: Vec<_> = iter
.into_iter()
.map(|(status, todo, info)| TodoItem::new(status, todo, info))
.collect();
let state = ListState::default().with_selected(Some(0));
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 Model {
pub fn selected(&self) -> Option<&TodoItem> {
self.state
.selected()
.and_then(|index| self.items.get(index))
}
pub fn select_none(self) -> Self {
let state = ListState::default().with_selected(None);
Self {
items: self.items,
state,
}
}
pub fn select_first(self) -> Self {
let state = ListState::default().with_selected(Some(0));
Model {
items: self.items,
state,
}
}
pub fn select_last(self) -> Self {
let state = ListState::default().with_selected(Some(self.items.len() - 1));
Model {
items: self.items,
state,
}
}
pub fn select_next(self) -> Self {
match self.state.selected() {
Some(index) => {
let state =
ListState::default().with_selected(Some((index + 1) % self.items.len()));
Model { state, ..self }
}
None => self.select_first(),
}
}
pub fn select_previous(self) -> Self {
match self.state.selected() {
Some(0) => self.select_last(),
Some(index) => {
let state = ListState::default().with_selected(Some(index.saturating_sub(1)));
Model { state, ..self }
}
None => self.select_last(),
}
}
pub fn toggle_status(mut self) -> Self {
let selected_index = self.state.selected();
match selected_index {
Some(index) => {
self.items[index].status = match self.items[index].status {
Status::Todo => Status::Completed,
Status::Completed => Status::Todo,
};
Model { ..self }
}
None => Model { ..self },
}
}
}

View file

@ -0,0 +1,16 @@
use teatui::update::Update;
use crate::{message::Message, model::Model};
pub fn update(model: Model, msg: Message) -> Update<Model, ()> {
match msg {
Message::NoOp => Update::Next(model, None),
Message::Exit => Update::Exit,
Message::SelectNext => Update::Next(model.select_next(), None),
Message::SelectNone => Update::Next(model.select_none(), None),
Message::SelectPrevious => Update::Next(model.select_previous(), None),
Message::SelectFirst => Update::Next(model.select_first(), None),
Message::SelectLast => Update::Next(model.select_last(), None),
Message::ToggleStatus => Update::Next(model.toggle_status(), None),
}
}

View file

@ -0,0 +1,140 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{
Color, Modifier, Style, Stylize,
palette::tailwind::{BLUE, GREEN, SLATE},
},
symbols,
text::Line,
widgets::{
Block, Borders, HighlightSpacing, List, ListItem, Padding, Paragraph, StatefulWidget,
Widget, Wrap,
},
};
use crate::model::{Model, Status, TodoItem};
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;
pub fn view(model: Model) -> AppWidget {
AppWidget { model }
}
pub struct AppWidget {
model: Model,
}
impl Widget for AppWidget {
fn render(mut 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);
Paragraph::new("Ratatui List Example")
.bold()
.centered()
.render(header_area, buf);
Paragraph::new("Use ↓↑ to move, ← to unselect, → to change status, g/G to go top/bottom.")
.centered()
.render(footer_area, buf);
self.render_list(list_area, buf);
self.render_selected_item(item_area, buf);
}
}
/// Rendering logic for the app
impl AppWidget {
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<ListItem> = self
.model
.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.model.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(item) = self.model.selected() {
match item.status {
Status::Completed => format!("✓ DONE: {}", item.info),
Status::Todo => format!("☐ TODO: {}", item.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)
}
}