diff --git a/Cargo.lock b/Cargo.lock index e0b7ba3..79c968d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "todo_list" +version = "0.0.0" +dependencies = [ + "crossterm", + "ratatui", + "strum", + "teatui", +] + [[package]] name = "tokio" version = "1.49.0" diff --git a/examples/todo-list/Cargo.toml b/examples/todo-list/Cargo.toml new file mode 100644 index 0000000..49d3ddd --- /dev/null +++ b/examples/todo-list/Cargo.toml @@ -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" } diff --git a/examples/todo-list/src/main.rs b/examples/todo-list/src/main.rs new file mode 100644 index 0000000..29abad2 --- /dev/null +++ b/examples/todo-list/src/main.rs @@ -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> { + teatui::start(init, update::update, view::view, |_, _| None) +} + +fn init() -> (Model, Option<()>) { + (Model::default(), None) +} diff --git a/examples/todo-list/src/message.rs b/examples/todo-list/src/message.rs new file mode 100644 index 0000000..243b705 --- /dev/null +++ b/examples/todo-list/src/message.rs @@ -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 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, + } + } +} diff --git a/examples/todo-list/src/model.rs b/examples/todo-list/src/model.rs new file mode 100644 index 0000000..daa27e3 --- /dev/null +++ b/examples/todo-list/src/model.rs @@ -0,0 +1,364 @@ +use ratatui::widgets::ListState; + +#[derive(Clone, Debug)] +pub struct Model { + pub items: Vec, + 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. It’s 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>(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 }, + } + } +} diff --git a/examples/todo-list/src/update.rs b/examples/todo-list/src/update.rs new file mode 100644 index 0000000..0a3edd7 --- /dev/null +++ b/examples/todo-list/src/update.rs @@ -0,0 +1,16 @@ +use teatui::update::Update; + +use crate::{message::Message, model::Model}; + +pub fn update(model: Model, msg: Message) -> Update { + 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), + } +} diff --git a/examples/todo-list/src/view.rs b/examples/todo-list/src/view.rs new file mode 100644 index 0000000..cd6c9be --- /dev/null +++ b/examples/todo-list/src/view.rs @@ -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 = 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) + } +}