From 888ada6a0726328bb96173a3c4a43c3c76e892b2 Mon Sep 17 00:00:00 2001 From: Fabien Freling Date: Fri, 20 Jun 2025 13:03:55 +0200 Subject: [PATCH] use ratatui todo list example --- .gitignore | 2 +- Cargo.lock | 142 ++++++++++++++++++++++ Cargo.toml | 2 + diesel.toml | 9 ++ flake.nix | 3 +- justfile | 15 +++ src/main.rs | 332 +++++++++++++++++++++++++++++++++++++++++----------- 7 files changed, 435 insertions(+), 70 deletions(-) create mode 100644 diesel.toml diff --git a/.gitignore b/.gitignore index b83d222..eb5a316 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/target/ +target diff --git a/Cargo.lock b/Cargo.lock index 506111b..4a03f2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,62 @@ dependencies = [ "syn", ] +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diesel" +version = "2.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a917a9209950404d5be011c81d081a2692a822f73c3d6af586f0cab5ff50f614" +dependencies = [ + "diesel_derives", + "libsqlite3-sys", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52841e97814f407b895d836fa0012091dff79c6268f39ad8155d384c21ae0d26" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +dependencies = [ + "syn", +] + +[[package]] +name = "dsl_auto_type" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -297,6 +353,16 @@ version = "0.2.173" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -355,6 +421,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "object" version = "0.36.7" @@ -411,6 +483,18 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -496,6 +580,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -595,12 +699,44 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "todo" version = "0.1.0" dependencies = [ "color-eyre", "crossterm", + "diesel", "ratatui", ] @@ -686,6 +822,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 201dd34..b2f7bec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,5 @@ edition = "2024" crossterm = "0.28.1" ratatui = "0.29.0" color-eyre = "0.6.3" + +diesel = { version = "2.2", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..ff0dac5 --- /dev/null +++ b/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] + +[migrations_directory] +dir = "/home/ffreling/code/todo/migrations" diff --git a/flake.nix b/flake.nix index 9595d88..8df85e2 100644 --- a/flake.nix +++ b/flake.nix @@ -17,9 +17,10 @@ nativeBuildInputs = [ rust-toolchain cargo-generate - # rust-analyzer-nightly + diesel-cli just + lazysql ]; }; }; diff --git a/justfile b/justfile index e4a4530..b342642 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,5 @@ +set dotenv-load + alias b := build build: cargo build @@ -5,3 +7,16 @@ build: alias r := run run: cargo run + +db-setup: + diesel setup + +db-update desc: + diesel migration generate --diff-schema {{ desc }} + +db-migrate: + diesel migration run + diesel migration redo + +db-inspect: + lazysql $DATABASE_URL diff --git a/src/main.rs b/src/main.rs index 87d0063..7e94155 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,92 +1,288 @@ use color_eyre::Result; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ - DefaultTerminal, Frame, - style::Stylize, + DefaultTerminal, + buffer::Buffer, + crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, + layout::{Constraint, Layout, Rect}, + style::{ + Color, Modifier, Style, Stylize, + palette::tailwind::{BLUE, GREEN, SLATE}, + }, + symbols, text::Line, - widgets::{Block, Paragraph}, + widgets::{ + Block, Borders, HighlightSpacing, List, ListItem, ListState, Padding, Paragraph, + StatefulWidget, Widget, Wrap, + }, }; -fn main() -> color_eyre::Result<()> { +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; + +fn main() -> Result<()> { color_eyre::install()?; let terminal = ratatui::init(); - let result = App::new().run(terminal); + let app_result = App::default().run(terminal); ratatui::restore(); - result + app_result } -/// The main application which holds the state and logic of the application. -#[derive(Debug, Default)] -pub struct App { - /// Is the application running? - running: bool, +struct App { + should_exit: bool, + todo_list: TodoList, +} + +struct TodoList { + items: Vec, + state: ListState, +} + +#[derive(Debug)] +struct TodoItem { + todo: String, + info: String, + status: Status, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum Status { + Todo, + Completed, +} + +impl Default for App { + fn default() -> Self { + Self { + should_exit: false, + todo_list: TodoList::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!", + ), + ]), + } + } +} + +impl FromIterator<(Status, &'static str, &'static str)> for TodoList { + fn from_iter>(iter: I) -> Self { + let items = iter + .into_iter() + .map(|(status, todo, info)| TodoItem::new(status, todo, info)) + .collect(); + let state = ListState::default(); + 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 App { - /// Construct a new instance of [`App`]. - pub fn new() -> Self { - Self::default() - } - - /// Run the application's main loop. - pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { - self.running = true; - while self.running { - terminal.draw(|frame| self.render(frame))?; - self.handle_crossterm_events()?; + fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + while !self.should_exit { + terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?; + if let Event::Key(key) = event::read()? { + self.handle_key(key); + }; } Ok(()) } - /// Renders the user interface. - /// - /// This is where you add new widgets. See the following resources for more information: - /// - /// - - /// - - fn render(&mut self, frame: &mut Frame) { - let title = Line::from("Ratatui Simple Template") - .bold() - .blue() - .centered(); - let text = "Hello, Ratatui!\n\n\ - Created using https://github.com/ratatui/templates\n\ - Press `Esc`, `Ctrl-C` or `q` to stop running."; - frame.render_widget( - Paragraph::new(text) - .block(Block::bordered().title(title)) - .centered(), - frame.area(), - ) - } - - /// Reads the crossterm events and updates the state of [`App`]. - /// - /// If your application needs to perform work in between handling events, you can use the - /// [`event::poll`] function to check if there are any events available with a timeout. - fn handle_crossterm_events(&mut self) -> Result<()> { - match event::read()? { - // it's important to check KeyEventKind::Press to avoid handling key release events - Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key), - Event::Mouse(_) => {} - Event::Resize(_, _) => {} - _ => {} + fn handle_key(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; } - Ok(()) - } - - /// Handles the key events and updates the state of [`App`]. - fn on_key_event(&mut self, key: KeyEvent) { - match (key.modifiers, key.code) { - (_, KeyCode::Esc | KeyCode::Char('q')) - | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.quit(), - // Add other key handlers here. + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_exit = true, + KeyCode::Char('h') | KeyCode::Left => self.select_none(), + KeyCode::Char('j') | KeyCode::Down => self.select_next(), + KeyCode::Char('k') | KeyCode::Up => self.select_previous(), + KeyCode::Char('g') | KeyCode::Home => self.select_first(), + KeyCode::Char('G') | KeyCode::End => self.select_last(), + KeyCode::Enter | KeyCode::Char(' ') => self.toggle_status(), _ => {} } } - /// Set running to false to quit the application. - fn quit(&mut self) { - self.running = false; + fn select_none(&mut self) { + self.todo_list.state.select(None); + } + + fn select_next(&mut self) { + self.todo_list.state.select_next(); + } + fn select_previous(&mut self) { + self.todo_list.state.select_previous(); + } + + fn select_first(&mut self) { + self.todo_list.state.select_first(); + } + + fn select_last(&mut self) { + self.todo_list.state.select_last(); + } + + /// Changes the status of the selected list item + fn toggle_status(&mut self) { + if let Some(i) = self.todo_list.state.selected() { + self.todo_list.items[i].status = match self.todo_list.items[i].status { + Status::Completed => Status::Todo, + Status::Todo => Status::Completed, + } + } + } +} + +impl Widget for &mut App { + fn render(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); + + App::render_header(header_area, buf); + App::render_footer(footer_area, buf); + self.render_list(list_area, buf); + self.render_selected_item(item_area, buf); + } +} + +/// Rendering logic for the app +impl App { + fn render_header(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("Use ↓↑ to move, ← to unselect, → to change status, g/G to go top/bottom.") + .centered() + .render(area, buf); + } + + 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 + .todo_list + .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.todo_list.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(i) = self.todo_list.state.selected() { + match self.todo_list.items[i].status { + Status::Completed => format!("✓ DONE: {}", self.todo_list.items[i].info), + Status::Todo => format!("☐ TODO: {}", self.todo_list.items[i].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) } }