From a46a48fb5a63ad185a2244c44efc3fe02cef26b5 Mon Sep 17 00:00:00 2001 From: Felipe Contreras Salinas Date: Sun, 22 Jun 2025 01:25:11 -0400 Subject: [PATCH] feat: add edit mode to TUI --- Cargo.lock | 12 ++ Cargo.toml | 6 +- src/tui/keys.rs | 31 +++- src/tui/mod.rs | 5 +- src/tui/state.rs | 62 ++++++- src/tui/ui.rs | 474 ++++++++++++++++++++++++++++++++++------------- 6 files changed, 447 insertions(+), 143 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ba2ad5..a8b92c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,6 +947,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "tui-textarea", ] [[package]] @@ -2552,6 +2553,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "typenum" version = "1.18.0" diff --git a/Cargo.toml b/Cargo.toml index dcda983..22314b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,13 +33,13 @@ sqlx = { version = "0.8.6", default-features = false, features = [ "sqlite", "tls-rustls", ] } +thiserror = "2.0.12" tokio = { version = "1.45.1", default-features = false, features = [ "macros", "rt-multi-thread", "signal", ] } tokio-util = "0.7.15" -thiserror = "2.0.12" tower-http = { version = "0.6.6", default-features = false, features = ["fs"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", default-features = false, features = [ @@ -48,6 +48,10 @@ tracing-subscriber = { version = "0.3.19", default-features = false, features = "tracing", "tracing-log", ] } +tui-textarea = { version = "0.7.0", default-features = false, features = [ + "ratatui", + "crossterm", +] } [dev-dependencies] axum-test = { version = "17.3.0", features = ["msgpack"] } diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 217cd64..0b7c7c4 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -10,18 +10,26 @@ pub async fn handle_key(state: &mut State, key_event: KeyEvent) { Mode::List => { if state.confirmation.is_some() { match key_event.code { - KeyCode::Char('y') => state.proceed_confirmation().await, + KeyCode::Char('y') => { + state.proceed_confirmation().await; + } KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(), _ => {} - } + }; } else { match key_event.code { KeyCode::Char('d') => state.confirm_deletion(), - KeyCode::Char('e') => state.mode = Mode::Edit, + KeyCode::Char('e') => { + state.set_edit_mode(); + } KeyCode::Home => state.selected_place.select_first(), KeyCode::End => state.selected_place.select_last(), - KeyCode::PageUp => state.prev_page(), - KeyCode::PageDown => state.next_page(), + KeyCode::PageUp => { + state.prev_page(); + } + KeyCode::PageDown => { + state.next_page(); + } KeyCode::Up | KeyCode::Char('k') => state.selected_place.select_previous(), KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(), KeyCode::Esc | KeyCode::Char('q') => state.quit = true, @@ -30,9 +38,16 @@ pub async fn handle_key(state: &mut State, key_event: KeyEvent) { } } Mode::Edit => match (key_event.modifiers, key_event.code) { - (KeyModifiers::NONE, KeyCode::Esc) => state.mode = Mode::List, - (KeyModifiers::NONE, KeyCode::Tab) => {} - (KeyModifiers::SHIFT, KeyCode::Tab) => {} + (_, KeyCode::Esc) => { + state.set_list_mode(); + } + (_, KeyCode::Tab) => { + state.edit_next(); + } + (_, KeyCode::BackTab) => { + state.edit_prev(); + } + (KeyModifiers::CONTROL, KeyCode::Char('s')) => {} _ => {} }, } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3ba35a8..0f45673 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -9,6 +9,7 @@ use anyhow::Result; use state::State; use terminal::Event; +use ui::UI; use crate::places::db_repository::DbPlacesRepository; @@ -16,13 +17,15 @@ use crate::places::db_repository::DbPlacesRepository; pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> { let (_, terminal_height) = crossterm::terminal::size()?; let mut state = State::new(places_repository, terminal_height); + let mut ui = UI::new(&state); let mut tui = terminal::Tui::new()?; let result = loop { match tui.next().await? { Event::Key(key_event) => keys::handle_key(&mut state, key_event).await, Event::Render => { - tui.draw(|frame| ui::ui_draw(&mut state, frame))?; + ui.handle_messages(state.ui_messages.drain(0..)); + tui.draw(|frame| ui.draw(&mut state, frame))?; } Event::Tick => { state.fetch_places().await; diff --git a/src/tui/state.rs b/src/tui/state.rs index 96732be..31bda55 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -2,9 +2,11 @@ use ratatui::widgets::TableState; -use crate::places::{ - db_repository::DbPlacesRepository, models::Place, repository::PlacesRepository, -}; +use crate::places::db_repository::DbPlacesRepository; +use crate::places::models::Place; +use crate::places::repository::PlacesRepository; + +use super::ui::Message; pub struct State { pub height: u16, @@ -15,9 +17,11 @@ pub struct State { places_status: DataStatus, pub confirmation: Option, pub selected_place: TableState, + pub ui_messages: Vec, pub quit: bool, } +#[derive(Copy, Clone)] pub enum Mode { List, Edit, @@ -30,8 +34,7 @@ enum DataStatus { pub enum ConfirmationStatus { Deletion(i64), - #[expect(dead_code)] - Save(i64), + Save(Place), } impl State { @@ -45,18 +48,55 @@ impl State { places: vec![], selected_place: TableState::default(), confirmation: None, + ui_messages: Vec::new(), quit: false, } } + pub fn set_list_mode(&mut self) { + self.mode = Mode::List; + self.push_mode_change(); + } + + pub fn set_edit_mode(&mut self) { + self.mode = Mode::Edit; + self.push_mode_change(); + + let Some(selection) = self.selected_place.selected() else { + return; + }; + let Some(place) = self.places.get(selection) else { + return; + }; + self.ui_messages.push(Message::EditPlace(place.clone())) + } + + fn push_mode_change(&mut self) { + self.ui_messages.push(Message::UpdateAppMode(self.mode)); + } + pub fn next_page(&mut self) { self.page += 1; self.places_status = DataStatus::Old; + self.push_page_change(); } pub fn prev_page(&mut self) { self.page = self.page.saturating_sub(1); self.places_status = DataStatus::Old; + self.push_page_change(); + } + + fn push_page_change(&mut self) { + self.ui_messages.push(Message::UpdatePage(self.page)); + } + + pub fn edit_next(&mut self) { + self.ui_messages.push(Message::EditNext); + } + + pub fn edit_prev(&mut self) { + self.ui_messages.push(Message::EditPrev); } pub async fn fetch_places(&mut self) { @@ -81,6 +121,8 @@ impl State { } } self.places_status = DataStatus::Fresh; + self.ui_messages + .push(Message::UpdatePlaces(self.places.clone())) } pub fn confirm_deletion(&mut self) { @@ -93,6 +135,10 @@ impl State { } } + pub fn confirm_save(&mut self, place: Place) { + self.confirmation = Some(ConfirmationStatus::Save(place)); + } + pub fn cancel_confirmation(&mut self) { self.confirmation = None; } @@ -108,7 +154,11 @@ impl State { tracing::error!("{err}"); } } - ConfirmationStatus::Save(_) => todo!(), + ConfirmationStatus::Save(place) => { + if let Err(err) = self.places_repository.update_place(place.clone()).await { + tracing::error!("{err}"); + } + } } self.confirmation = None; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 33a0782..3dfe9b5 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -3,58 +3,294 @@ use itertools::Itertools; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; -use ratatui::style::{Color, Style, Stylize}; +use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span, Text, ToSpan}; -use ratatui::widgets::{Block, Clear, Padding, Paragraph, Row, Table}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Row, Table}; +use tui_textarea::TextArea; + +use crate::places::models::Place; use super::state::{ConfirmationStatus, Mode, State}; -/// UI drawing -pub fn ui_draw(state: &mut State, f: &mut Frame<'_>) { - let main_split = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(1), - ]) - .split(f.area()); +pub enum Message { + UpdateAppMode(Mode), + UpdatePage(u32), + UpdatePlaces(Vec), + EditPlace(Place), + EditNext, + EditPrev, +} - header_draw(state, f, main_split[0]); - main_draw(state, f, main_split[1]); - footer_draw(state, f, main_split[2]); +pub struct UI { + header: Header, + main: Main, + footer: Footer, } -fn header_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { - let split = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(9), - Constraint::Fill(1), - Constraint::Length(6), +impl UI { + pub fn new(state: &State) -> Self { + Self { + header: Header::new(state), + main: Main::new(state), + footer: Footer::new(state), + } + } + + pub fn handle_messages>(&mut self, messages: M) { + for m in messages { + match m { + Message::UpdateAppMode(mode) => { + self.header.update_app_mode(mode); + self.footer.update_keybindings(mode); + } + Message::UpdatePage(page) => self.header.update_page(page), + Message::UpdatePlaces(places) => self.main.update_places_table(places), + Message::EditPlace(place) => self.main.set_edit_textareas(place), + Message::EditNext => self.main.next_textarea(), + Message::EditPrev => self.main.prev_textarea(), + } + } + } + + /// UI drawing + pub fn draw(&mut self, state: &mut State, f: &mut Frame<'_>) { + let main_split = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .split(f.area()); + + self.header.draw(f, main_split[0]); + self.main.draw(state, f, main_split[1]); + self.footer.draw(f, main_split[2]); + } +} + +struct Header { + app_name: Span<'static>, + app_mode: Span<'static>, + page: Line<'static>, +} + +impl Header { + fn new(state: &State) -> Self { + let app_name = Span::styled( + " huellas ", + Style::new().bg(Color::Gray).fg(Color::Black).bold(), + ); + let app_mode = Self::get_app_mode(state.mode); + let page = Self::get_page(state.page); + Self { + app_name, + app_mode, + page, + } + } + + fn update_app_mode(&mut self, new_mode: Mode) { + self.app_mode = Self::get_app_mode(new_mode) + } + + fn get_app_mode(mode: Mode) -> Span<'static> { + match mode { + Mode::List => " LIST ".to_span().black().on_light_green().bold(), + Mode::Edit => " EDIT ".to_span().black().on_light_blue().bold(), + } + } + + fn update_page(&mut self, new_page: u32) { + self.page = Self::get_page(new_page) + } + + fn get_page(page: u32) -> Line<'static> { + Line::from_iter([ + "Page: ".to_span(), + Span::raw(page.to_string()), + " ".to_span(), ]) - .split(area); - let app_name = Span::styled( - " huellas ", - Style::new().bg(Color::Gray).fg(Color::Black).bold(), - ); - let page = - Line::from_iter(["Page: ".to_span(), state.page.to_span(), " ".to_span()]).right_aligned(); - let app_mode = match state.mode { - Mode::List => Span::styled(" LIST ", Style::new().black().on_light_green().bold()), - Mode::Edit => Span::styled(" EDIT ", Style::new().black().on_light_blue().bold()), - }; - f.render_widget(app_name, split[0]); - f.render_widget(page, split[1]); - f.render_widget(app_mode, split[2]); + .right_aligned() + } + + fn draw(&self, f: &mut Frame<'_>, area: Rect) { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(9), + Constraint::Fill(1), + Constraint::Length(6), + ]) + .split(area); + f.render_widget(&self.app_name, split[0]); + f.render_widget(&self.page, split[1]); + f.render_widget(&self.app_mode, split[2]); + } +} + +struct Main { + places_table: Table<'static>, + edit_textareas: Vec>, + selected_textarea: usize, } -fn main_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { - match state.mode { - Mode::List => list_draw(state, f, area), - Mode::Edit => edit_draw(state, f, area), +impl Main { + fn new(state: &State) -> Self { + let places_table = Self::get_places_table(state.places.clone()); + let edit_textareas = Vec::new(); + let selected_textarea = 0; + Self { + places_table, + edit_textareas, + selected_textarea, + } + } + + fn update_places_table(&mut self, new_places: Vec) { + self.places_table = Self::get_places_table(new_places); + } + + fn get_places_table(places: Vec) -> Table<'static> { + let places = places.into_iter().map(|p| { + Row::new([ + p.id.to_string(), + p.name, + p.latitude.to_string(), + p.longitude.to_string(), + p.icon, + p.address, + p.open_hours, + p.description, + url(p.url), + ]) + }); + + let widths = [ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(7), + Constraint::Length(7), + Constraint::Length(8), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + ]; + + let places_table = Table::new(places, widths) + .header( + Row::new([ + "Id", + "Name", + "Lat", + "Long", + "Icon", + "Address", + "Open Hours", + "Description", + "URL", + ]) + .style(Style::new().white().on_dark_gray().bold()), + ) + .row_highlight_style(Style::new().reversed()); + + places_table + } + + fn set_edit_textareas(&mut self, place: Place) { + let mut name = TextArea::new(vec![place.name]); + name.set_block(Block::default().title("Name")); + let mut latitude = TextArea::new(vec![place.latitude.to_string()]); + latitude.set_block(Block::default().title("Latitude")); + let mut longitude = TextArea::new(vec![place.longitude.to_string()]); + longitude.set_block(Block::default().title("Longitude")); + let mut icon = TextArea::new(vec![place.icon]); + icon.set_block(Block::default().title("Icon")); + let mut address = TextArea::new(vec![place.address]); + address.set_block(Block::default().title("Address")); + let mut url = TextArea::new(vec![place.url.unwrap_or_default()]); + url.set_block(Block::default().title("URL")); + let mut open_hours = TextArea::new(vec![place.open_hours]); + open_hours.set_block(Block::default().title("Open Hours")); + let mut description = TextArea::new(vec![place.description]); + description.set_block(Block::default().title("Description")); + + self.edit_textareas = vec![ + name, + latitude, + longitude, + icon, + address, + url, + open_hours, + description, + ]; + + for textarea in &mut self.edit_textareas { + inactive_textarea(textarea); + } + + active_textarea(&mut self.edit_textareas[0]); + self.selected_textarea = 0; + } + + fn next_textarea(&mut self) { + let n = self.edit_textareas.len(); + if n != 0 { + if let Some(prev_textarea) = self.edit_textareas.get_mut(self.selected_textarea) { + inactive_textarea(prev_textarea); + } + self.selected_textarea = (self.selected_textarea + 1) % n; + if let Some(next_textarea) = self.edit_textareas.get_mut(self.selected_textarea) { + active_textarea(next_textarea); + } + } + } + + fn prev_textarea(&mut self) { + let n = self.edit_textareas.len(); + if n != 0 { + if let Some(prev_textarea) = self.edit_textareas.get_mut(self.selected_textarea) { + inactive_textarea(prev_textarea); + } + self.selected_textarea = (self.selected_textarea + n - 1) % n; + if let Some(next_textarea) = self.edit_textareas.get_mut(self.selected_textarea) { + active_textarea(next_textarea); + } + } + } + + fn draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) { + match state.mode { + Mode::List => self.list_draw(state, f, area), + Mode::Edit => self.edit_draw(state, f, area), + } + confirmation_dialog_draw(state, f, area); + } + + fn list_draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) { + f.render_stateful_widget(&self.places_table, area, &mut state.selected_place); + } + + fn edit_draw(&self, _state: &mut State, f: &mut Frame<'_>, area: Rect) { + let areas: [_; 8] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(5), + Constraint::Min(5), + ]) + .areas(area); + for (textarea, area) in self.edit_textareas.iter().zip(areas.into_iter()) { + f.render_widget(textarea, area); + } } - confirmation_dialog_draw(state, f, area); } fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { @@ -65,7 +301,7 @@ fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60)); let (action, id) = match confirmation { ConfirmationStatus::Deletion(id) => ("delete", id), - ConfirmationStatus::Save(id) => ("save", id), + ConfirmationStatus::Save(place) => ("save", &place.id), }; let confirmation_dialog = Paragraph::new(Text::from_iter([ Line::from_iter([ @@ -84,100 +320,61 @@ fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { f.render_widget(confirmation_dialog, dialog_area); } -fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { - let places = state.places.iter().map(|p| { - Row::new([ - p.id.to_string(), - p.name.clone(), - p.latitude.to_string(), - p.longitude.to_string(), - p.icon.clone(), - p.address.clone(), - p.open_hours.clone(), - p.description.clone(), - url(p.url.as_ref().map(|u| u.as_ref())), - ]) - }); - - let widths = [ - Constraint::Length(3), - Constraint::Fill(1), - Constraint::Length(7), - Constraint::Length(7), - Constraint::Length(8), - Constraint::Fill(1), - Constraint::Fill(1), - Constraint::Fill(1), - Constraint::Fill(1), - ]; - - let places_table = Table::new(places, widths) - .header( - Row::new([ - "Id", - "Name", - "Lat", - "Long", - "Icon", - "Address", - "Open Hours", - "Description", - "URL", - ]) - .style(Style::new().white().on_dark_gray().bold()), - ) - .row_highlight_style(Style::new().reversed()); - - f.render_stateful_widget(places_table, area, &mut state.selected_place); +struct Footer { + keybindings: Paragraph<'static>, } -fn edit_draw(_state: &mut State, f: &mut Frame<'_>, area: Rect) { - let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60)); - let confirmation_dialog = - Paragraph::new(Text::from_iter([ - Line::from("Not implemented yet :(").italic() - ])) - .centered() - .block(Block::bordered().padding(Padding::uniform(1))); +impl Footer { + fn new(state: &State) -> Self { + let keybindings = Self::get_keybindings(state.mode); + Self { keybindings } + } - f.render_widget(Clear, dialog_area); - f.render_widget(confirmation_dialog, dialog_area); -} + fn update_keybindings(&mut self, new_mode: Mode) { + self.keybindings = Self::get_keybindings(new_mode) + } -#[expect(unstable_name_collisions)] -fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { - let separator = Span::styled(" ", Style::new().black().on_black()); - let keybindings = match state.mode { - Mode::List => { - let keybindings = [ - ("j", "Next"), - ("k", "Previous"), - ("Home", "First"), - ("End", "Last"), - ("PgUp", "Prev Page"), - ("PgDown", "Next Page"), - ("e", "Edit"), - ("d", "Delete"), - ] - .map(|(key, action)| keybinding(key, action).to_vec()) - .into_iter() - .intersperse(vec![separator]) - .flatten(); - Paragraph::new(Line::from_iter(keybindings)) - } - Mode::Edit => { - let keybindings = [("Esc", "Close w/o saving")] + #[expect(unstable_name_collisions)] + fn get_keybindings(mode: Mode) -> Paragraph<'static> { + let separator = Span::styled(" ", Style::new().black().on_black()); + let keybindings = match mode { + Mode::List => { + let keybindings = [ + ("j/k", "Next/Previous"), + ("Home/End", "First/Last"), + ("PgUp", "Prev Page"), + ("PgDown", "Next Page"), + ("e", "Edit"), + ("d", "Delete"), + ] .map(|(key, action)| keybinding(key, action).to_vec()) .into_iter() .intersperse(vec![separator]) .flatten(); - Paragraph::new(Line::from_iter(keybindings)) - } - }; - f.render_widget(keybindings, area); + Paragraph::new(Line::from_iter(keybindings)) + } + Mode::Edit => { + let keybindings = [ + ("Esc", "Close w/o saving"), + ("Tab/S-Tab", "Next/prev field"), + ("C-s", "Save"), + ] + .map(|(key, action)| keybinding(key, action).to_vec()) + .into_iter() + .intersperse(vec![separator]) + .flatten(); + Paragraph::new(Line::from_iter(keybindings)) + } + }; + keybindings + } + + fn draw(&self, f: &mut Frame<'_>, area: Rect) { + f.render_widget(&self.keybindings, area); + } } -fn url(url: Option<&str>) -> String { +fn url(url: Option) -> String { match url { Some(url) => { if url.starts_with("https://instagram.com/") { @@ -187,7 +384,7 @@ fn url(url: Option<&str>) -> String { .trim_end_matches("/") ) } else { - url.to_owned() + url } } None => String::new(), @@ -214,3 +411,26 @@ fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); area } + +fn inactive_textarea(textarea: &mut TextArea<'_>) { + textarea.set_cursor_line_style(Style::default()); + textarea.set_cursor_style(Style::default()); + if let Some(block) = textarea.block().map(|block| { + block + .clone() + .borders(Borders::ALL) + .style(Style::default().fg(Color::Gray)) + }) { + textarea.set_block(block); + }; +} + +fn active_textarea(textarea: &mut TextArea<'_>) { + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + if let Some(block) = textarea + .block() + .map(|block| block.clone().style(Style::default().bold())) + { + textarea.set_block(block); + }; +}