From 3b6726a7262d2bacd5f39c84ce5a8d2f78f42117 Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sat, 20 Jun 2026 19:38:12 +0100 Subject: [PATCH] feat(app): keyboard event handling with panel switching and CRUD - App struct with full state (lists, tasks, focus, popups, DB) - Tab cycles focus: Tabs -> TaskList -> Detail -> Tabs - Left/Right arrows switch lists when focus on Tabs - Up/Down navigate tasks (TaskList) or scroll (Detail) - Alt+Up/Down reorder tasks with position persistence - n: create new list or task, d: delete, e: edit title - Enter on Detail opens DatePicker popup - InputPopup with full text editing (navigation, insert, delete) - ConfirmDelete popup before destructive actions - DatePicker adjusts draft_date with Up/Down - main.rs: terminal setup, event loop, raw mode, alternate screen --- src/app.rs | 409 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 62 +++++++- 2 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 src/app.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..dc15c0c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,409 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use chrono::NaiveDateTime; + +use crate::domain::models::*; +use crate::infrastructure::db::Db; +use crate::ui::{Focus, NetworkStatus, Popup}; + +pub struct App { + pub lists: Vec, + pub tasks: Vec, + pub selected_list: usize, + pub selected_task: usize, + pub focus: Focus, + pub show_popup: Option, + pub network_status: NetworkStatus, + pub popup_input: String, + pub popup_cursor: usize, + pub draft_date: NaiveDateTime, + pub should_quit: bool, + pub task_list_scroll: u16, + pub detail_scroll: u16, + pub db: Db, +} + +impl App { + pub fn new(db: Db) -> Self { + let lists = db.get_lists(); + let tasks = if !lists.is_empty() { + let list_id = &lists[0].id; + db.get_tasks(list_id) + } else { + Vec::new() + }; + + Self { + lists, + tasks, + selected_list: 0, + selected_task: 0, + focus: Focus::Tabs, + show_popup: None, + network_status: NetworkStatus::Online, + popup_input: String::new(), + popup_cursor: 0, + draft_date: chrono::Local::now().naive_local(), + should_quit: false, + task_list_scroll: 0, + detail_scroll: 0, + db, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) { + if let Some(ref popup) = self.show_popup.clone() { + self.handle_popup_key(key, popup); + return; + } + + match key.code { + KeyCode::Tab => { + self.focus = match self.focus { + Focus::Tabs => Focus::TaskList, + Focus::TaskList => Focus::Detail, + Focus::Detail => Focus::Tabs, + }; + } + KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { + if self.focus == Focus::TaskList && !self.tasks.is_empty() { + self.reorder_task(-1); + } + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { + if self.focus == Focus::TaskList && !self.tasks.is_empty() { + self.reorder_task(1); + } + } + KeyCode::Up => match self.focus { + Focus::TaskList => { + if self.selected_task > 0 { + self.selected_task -= 1; + self.task_list_scroll = self.task_list_scroll.saturating_sub(1); + } + } + Focus::Detail => { + self.detail_scroll = self.detail_scroll.saturating_sub(1); + } + _ => {} + }, + KeyCode::Down => match self.focus { + Focus::TaskList => { + if self.selected_task + 1 < self.tasks.len() { + self.selected_task += 1; + self.task_list_scroll += 1; + } + } + Focus::Detail => { + self.detail_scroll += 1; + } + _ => {} + }, + KeyCode::Right => { + if self.focus == Focus::Tabs && !self.lists.is_empty() { + if self.selected_list + 1 < self.lists.len() { + self.selected_list += 1; + self.load_tasks(); + } + } + } + KeyCode::Left => { + if self.focus == Focus::Tabs && !self.lists.is_empty() { + if self.selected_list > 0 { + self.selected_list -= 1; + self.load_tasks(); + } + } + } + KeyCode::Char('n') | KeyCode::Char('N') => { + self.popup_input.clear(); + self.popup_cursor = 0; + self.show_popup = Some(Popup::Input); + } + KeyCode::Char('d') | KeyCode::Char('D') => { + self.show_popup = Some(Popup::ConfirmDelete); + } + KeyCode::Char('e') | KeyCode::Char('E') => { + if self.focus == Focus::TaskList && !self.tasks.is_empty() { + let task = &self.tasks[self.selected_task]; + self.popup_input = task.title.clone(); + self.popup_cursor = task.title.len(); + self.show_popup = Some(Popup::Input); + } + } + KeyCode::Enter => { + if self.focus == Focus::Detail && !self.tasks.is_empty() { + self.show_popup = Some(Popup::DatePicker); + } + } + KeyCode::Esc => { + self.show_popup = None; + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + self.should_quit = true; + } + _ => {} + } + } + + fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) { + match popup { + Popup::Input => match key.code { + KeyCode::Esc => { + self.show_popup = None; + } + KeyCode::Enter => { + let input = self.popup_input.trim().to_string(); + if !input.is_empty() { + match self.focus { + Focus::Tabs => { + let list = TaskList { + id: uuid_v4(), + title: input, + }; + self.db.insert_list(&list).ok(); + self.db.push_sync( + SyncAction::Create, + &list.id, + &list.id, + &serde_json::to_string(&list).unwrap_or_default(), + ).ok(); + self.load_lists(); + } + Focus::TaskList => { + if !self.lists.is_empty() { + let list_id = &self.lists[self.selected_list].id; + let task = Task { + id: uuid_v4(), + list_id: list_id.clone(), + title: input, + notes: None, + status: TaskStatus::NeedsAction, + due: None, + position: 0, + }; + self.db.insert_task(&task).ok(); + self.db.push_sync( + SyncAction::Create, + &task.id, + list_id, + &serde_json::to_string(&task).unwrap_or_default(), + ).ok(); + self.load_tasks(); + } + } + Focus::Detail => { + if !self.tasks.is_empty() { + let task = &mut self.tasks[self.selected_task]; + task.title = input; + self.db.update_task(task).ok(); + self.db.push_sync( + SyncAction::Update, + &task.id, + &task.list_id, + &serde_json::to_string(task).unwrap_or_default(), + ).ok(); + self.load_tasks(); + if !self.tasks.is_empty() && self.selected_task < self.tasks.len() { + } + } + } + } + } + self.show_popup = None; + } + KeyCode::Char(c) => { + self.popup_input.insert(self.popup_cursor, c); + self.popup_cursor += 1; + } + KeyCode::Backspace => { + if self.popup_cursor > 0 { + self.popup_cursor -= 1; + self.popup_input.remove(self.popup_cursor); + } + } + KeyCode::Delete => { + if self.popup_cursor < self.popup_input.len() { + self.popup_input.remove(self.popup_cursor); + } + } + KeyCode::Left => { + self.popup_cursor = self.popup_cursor.saturating_sub(1); + } + KeyCode::Right => { + if self.popup_cursor < self.popup_input.len() { + self.popup_cursor += 1; + } + } + KeyCode::Home => { + self.popup_cursor = 0; + } + KeyCode::End => { + self.popup_cursor = self.popup_input.len(); + } + _ => {} + }, + Popup::DatePicker => match key.code { + KeyCode::Esc => { + self.show_popup = None; + } + KeyCode::Enter => { + if !self.tasks.is_empty() { + let task = &mut self.tasks[self.selected_task]; + task.due = Some(self.draft_date); + self.db.update_task(task).ok(); + self.db.push_sync( + SyncAction::Update, + &task.id, + &task.list_id, + &serde_json::to_string(task).unwrap_or_default(), + ).ok(); + self.load_tasks(); + } + self.show_popup = None; + } + KeyCode::Up => { + self.draft_date = self.draft_date + chrono::Duration::hours(1); + } + KeyCode::Down => { + self.draft_date = self.draft_date - chrono::Duration::hours(1); + } + _ => {} + }, + Popup::ConfirmDelete => match key.code { + KeyCode::Esc => { + self.show_popup = None; + } + KeyCode::Enter => { + match self.focus { + Focus::Tabs => { + if self.selected_list < self.lists.len() { + let list_id = self.lists[self.selected_list].id.clone(); + self.db.delete_list(&list_id).ok(); + self.db.push_sync( + SyncAction::Delete, + &list_id, + &list_id, + "", + ).ok(); + self.load_lists(); + if self.selected_list >= self.lists.len() { + self.selected_list = self.lists.len().saturating_sub(1); + } + self.load_tasks(); + } + } + Focus::TaskList | Focus::Detail => { + if !self.tasks.is_empty() && self.selected_task < self.tasks.len() { + let task = &self.tasks[self.selected_task]; + let task_id = task.id.clone(); + let list_id = task.list_id.clone(); + self.db.delete_task(&task_id).ok(); + self.db.push_sync( + SyncAction::Delete, + &task_id, + &list_id, + "", + ).ok(); + self.load_tasks(); + if self.selected_task >= self.tasks.len() { + self.selected_task = self.tasks.len().saturating_sub(1); + } + } + } + } + self.show_popup = None; + } + _ => {} + }, + Popup::DeviceAuth { .. } => match key.code { + KeyCode::Enter | KeyCode::Esc => { + self.show_popup = None; + } + _ => {} + }, + } + } + + fn reorder_task(&mut self, direction: i64) { + if self.tasks.is_empty() { + return; + } + + let new_index = self.selected_task as i64 + direction; + if new_index < 0 || new_index >= self.tasks.len() as i64 { + return; + } + + let task_id = self.tasks[self.selected_task].id.clone(); + let list_id = self.tasks[self.selected_task].list_id.clone(); + + let new_pos = if direction > 0 { + self.tasks[new_index as usize].position + } else { + self.tasks[new_index as usize].position + }; + + if self.db.reorder_task(&task_id, new_pos).is_ok() { + let payload = serde_json::json!({ + "task_id": task_id, + "new_position": new_pos + }); + self.db.push_sync( + SyncAction::Reorder, + &task_id, + &list_id, + &payload.to_string(), + ).ok(); + self.selected_task = new_index as usize; + self.load_tasks(); + } + } + + fn load_lists(&mut self) { + self.lists = self.db.get_lists(); + } + + fn load_tasks(&mut self) { + if self.selected_list < self.lists.len() { + self.tasks = self.db.get_tasks(&self.lists[self.selected_list].id); + } else { + self.tasks.clear(); + } + if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() { + self.selected_task = self.tasks.len() - 1; + } else if self.tasks.is_empty() { + self.selected_task = 0; + } + } +} + +fn uuid_v4() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let nanos = now.as_nanos(); + format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + (nanos >> 32) as u32, + (nanos >> 16) as u16 & 0xffff, + (nanos >> 4) as u16 & 0xfff, + (nanos >> 48) as u16 & 0xffff, + (nanos & 0xfffffffffffff) as u64 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_uuid_v4_format() { + let id = uuid_v4(); + assert_eq!(id.len(), 36); + assert_eq!(&id[8..9], "-"); + assert_eq!(&id[13..14], "-"); + assert_eq!(&id[18..19], "-"); + assert_eq!(&id[23..24], "-"); + } +} diff --git a/src/main.rs b/src/main.rs index e29e268..c26e983 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,63 @@ +mod app; mod domain; -mod ui; mod infrastructure; +mod ui; -fn main() { - println!("Task App - Google Tasks TUI"); +use std::io; + +use crossterm::event::{self, Event}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::ExecutableCommand; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use crate::app::App; +use crate::infrastructure::db::Db; +use crate::ui::{draw, AppView}; + +fn main() -> io::Result<()> { + let db_path = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("task_app") + .join("tasks.db"); + + std::fs::create_dir_all(db_path.parent().unwrap()).ok(); + + let db = Db::new(db_path.to_str().unwrap()).expect("Failed to open database"); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + stdout.execute(EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(db); + + while !app.should_quit { + terminal.draw(|frame| { + let view = AppView { + lists: &app.lists, + tasks: &app.tasks, + selected_list: app.selected_list, + selected_task: app.selected_task, + focus: app.focus.clone(), + show_popup: app.show_popup.as_ref(), + popup_input: &app.popup_input, + popup_cursor: app.popup_cursor, + draft_date: app.draft_date, + network_status: &app.network_status, + task_list_scroll: app.task_list_scroll, + detail_scroll: app.detail_scroll, + }; + draw(frame, view); + })?; + + if let Event::Key(key) = event::read()? { + app.handle_key(key); + } + } + + disable_raw_mode()?; + io::stdout().execute(LeaveAlternateScreen)?; + Ok(()) }