diff --git a/src/app.rs b/src/app.rs index 58ac9dc..ff68612 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use std::sync::mpsc as std_mpsc; use std::sync::Arc; use chrono::NaiveDateTime; +use chrono::NaiveTime; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc; @@ -29,6 +30,9 @@ pub struct App { pub network_status: NetworkStatus, pub popup_input: String, pub popup_cursor: usize, + pub popup_secondary: String, + pub popup_secondary_cursor: usize, + pub edit_field: usize, pub draft_date: NaiveDateTime, pub should_quit: bool, pub task_list_scroll: u16, @@ -40,6 +44,8 @@ pub struct App { pub auth_error: Option, pub sync_stats: SyncStats, last_sync_version: u64, + editing_task_id: Option, + pending_date_key: bool, auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, @@ -90,6 +96,9 @@ impl App { network_status: NetworkStatus::Online, popup_input: String::new(), popup_cursor: 0, + popup_secondary: String::new(), + popup_secondary_cursor: 0, + edit_field: 0, draft_date: chrono::Local::now().naive_local(), should_quit: false, task_list_scroll: 0, @@ -100,6 +109,8 @@ impl App { auth_error: None, sync_stats: SyncStats::default(), last_sync_version: 0, + editing_task_id: None, + pending_date_key: false, auth_tx, auth_rx, sync_tx, @@ -181,12 +192,68 @@ impl App { let _ = self.sync_tx.try_send(SyncCommand::FullSync); } + fn update_task_due(&mut self, due: chrono::NaiveDateTime) { + if self.tasks.is_empty() || self.selected_task >= self.tasks.len() { + return; + } + let task = &mut self.tasks[self.selected_task]; + task.due = Some(due); + 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.trigger_sync(); + self.load_tasks(); + } + + fn set_due_today(&mut self) { + self.update_task_due(chrono::Local::now().naive_local()); + } + + fn set_due_tomorrow_9am(&mut self) { + let tomorrow = chrono::Local::now().naive_local() + chrono::Duration::days(1); + let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap(); + let due = chrono::NaiveDateTime::new(tomorrow.date(), time); + self.update_task_due(due); + } + + fn set_due_next_week_9am(&mut self) { + let next_week = chrono::Local::now().naive_local() + chrono::Duration::days(7); + let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap(); + let due = chrono::NaiveDateTime::new(next_week.date(), time); + self.update_task_due(due); + } + + fn set_due_next_month_9am(&mut self) { + let next_month = chrono::Local::now().naive_local() + chrono::Duration::days(30); + let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap(); + let due = chrono::NaiveDateTime::new(next_month.date(), time); + self.update_task_due(due); + } + pub fn handle_key(&mut self, key: KeyEvent) { if let Some(ref popup) = self.show_popup.clone() { self.handle_popup_key(key, popup); return; } + if self.pending_date_key { + self.pending_date_key = false; + if self.focus == Focus::TaskList && !self.tasks.is_empty() { + match key.code { + KeyCode::Char('d') => self.set_due_today(), + KeyCode::Char('t') => self.set_due_tomorrow_9am(), + KeyCode::Char('w') => self.set_due_next_week_9am(), + KeyCode::Char('m') => self.set_due_next_month_9am(), + _ => {} + } + } + return; + } + if key.code == KeyCode::Right && key.modifiers.contains(KeyModifiers::CONTROL) { if !self.lists.is_empty() && self.selected_list + 1 < self.lists.len() { self.selected_list += 1; @@ -263,9 +330,20 @@ impl App { } KeyCode::Char('n') | KeyCode::Char('N') => { if !self.needs_auth { - self.popup_input.clear(); - self.popup_cursor = 0; - self.show_popup = Some(Popup::Input); + if self.focus == Focus::Tabs { + self.editing_task_id = None; + self.popup_input.clear(); + self.popup_cursor = 0; + self.show_popup = Some(Popup::Input); + } else if !self.lists.is_empty() { + self.editing_task_id = None; + self.popup_input.clear(); + self.popup_cursor = 0; + self.popup_secondary.clear(); + self.popup_secondary_cursor = 0; + self.edit_field = 0; + self.show_popup = Some(Popup::EditTask { field: 0 }); + } } } KeyCode::Char('d') | KeyCode::Char('D') => { @@ -276,14 +354,38 @@ impl App { KeyCode::Char('e') | KeyCode::Char('E') => { if !self.needs_auth && self.focus == Focus::TaskList && !self.tasks.is_empty() { let task = &self.tasks[self.selected_task]; + self.editing_task_id = Some(task.id.clone()); self.popup_input = task.title.clone(); self.popup_cursor = task.title.len(); - self.show_popup = Some(Popup::Input); + self.popup_secondary = task.notes.clone().unwrap_or_default(); + self.popup_secondary_cursor = self.popup_secondary.len(); + self.edit_field = 0; + self.show_popup = Some(Popup::EditTask { field: 0 }); + } + } + KeyCode::Char('t') | KeyCode::Char('T') => { + if self.focus == Focus::TaskList && !self.tasks.is_empty() { + self.pending_date_key = true; } } KeyCode::Enter => { if self.focus == Focus::Detail && !self.tasks.is_empty() { self.show_popup = Some(Popup::DatePicker); + } else if self.focus == Focus::TaskList && !self.tasks.is_empty() { + let task = &mut self.tasks[self.selected_task]; + task.status = match task.status { + TaskStatus::Completed => TaskStatus::NeedsAction, + TaskStatus::NeedsAction => TaskStatus::Completed, + }; + 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.trigger_sync(); + self.load_tasks(); } } KeyCode::Esc => { @@ -330,85 +432,54 @@ impl App { 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.trigger_sync(); - 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.trigger_sync(); - 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.trigger_sync(); - self.load_tasks(); - } - } + if 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.trigger_sync(); + self.load_lists(); } } self.show_popup = None; } KeyCode::Char(c) => { self.popup_input.insert(self.popup_cursor, c); - self.popup_cursor += 1; + self.popup_cursor += c.len_utf8(); } KeyCode::Backspace => { if self.popup_cursor > 0 { - self.popup_cursor -= 1; - self.popup_input.remove(self.popup_cursor); + let before = self.popup_input.floor_char_boundary(self.popup_cursor - 1); + self.popup_input.replace_range(before..self.popup_cursor, ""); + self.popup_cursor = before; } } KeyCode::Delete => { if self.popup_cursor < self.popup_input.len() { - self.popup_input.remove(self.popup_cursor); + let s = &self.popup_input[self.popup_cursor..]; + if let Some(c) = s.chars().next() { + self.popup_input.replace_range(self.popup_cursor..self.popup_cursor + c.len_utf8(), ""); + } } } KeyCode::Left => { - self.popup_cursor = self.popup_cursor.saturating_sub(1); + if self.popup_cursor > 0 { + self.popup_cursor = self.popup_input.floor_char_boundary(self.popup_cursor - 1); + } } KeyCode::Right => { if self.popup_cursor < self.popup_input.len() { - self.popup_cursor += 1; + let s = &self.popup_input[self.popup_cursor..]; + if let Some(c) = s.chars().next() { + self.popup_cursor += c.len_utf8(); + } } } KeyCode::Home => { @@ -419,6 +490,151 @@ impl App { } _ => {} }, + Popup::EditTask { field } => match key.code { + KeyCode::Esc => { + self.editing_task_id = None; + self.show_popup = None; + } + KeyCode::Tab | KeyCode::Down => { + let new_field = (field + 1) % 2; + self.edit_field = new_field; + self.show_popup = Some(Popup::EditTask { field: new_field }); + } + KeyCode::Up => { + let new_field = (field + 1) % 2; + self.edit_field = new_field; + self.show_popup = Some(Popup::EditTask { field: new_field }); + } + KeyCode::Enter => { + let title = self.popup_input.trim().to_string(); + if title.is_empty() { + return; + } + let notes = self.popup_secondary.trim().to_string(); + let notes_opt = if notes.is_empty() { None } else { Some(notes) }; + + if let Some(task_id) = self.editing_task_id.take() { + if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) { + task.title = title; + task.notes = notes_opt; + 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.trigger_sync(); + self.load_tasks(); + } + } else 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, + notes: notes_opt, + 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.trigger_sync(); + self.load_tasks(); + } + self.show_popup = None; + } + KeyCode::Char(c) => { + if *field == 0 { + self.popup_input.insert(self.popup_cursor, c); + self.popup_cursor += c.len_utf8(); + } else { + self.popup_secondary.insert(self.popup_secondary_cursor, c); + self.popup_secondary_cursor += c.len_utf8(); + } + } + KeyCode::Backspace => { + if *field == 0 { + if self.popup_cursor > 0 { + let before = self.popup_input.floor_char_boundary(self.popup_cursor - 1); + self.popup_input.replace_range(before..self.popup_cursor, ""); + self.popup_cursor = before; + } + } else { + if self.popup_secondary_cursor > 0 { + let before = self.popup_secondary.floor_char_boundary(self.popup_secondary_cursor - 1); + self.popup_secondary.replace_range(before..self.popup_secondary_cursor, ""); + self.popup_secondary_cursor = before; + } + } + } + KeyCode::Delete => { + if *field == 0 { + if self.popup_cursor < self.popup_input.len() { + let s = &self.popup_input[self.popup_cursor..]; + if let Some(c) = s.chars().next() { + self.popup_input.replace_range(self.popup_cursor..self.popup_cursor + c.len_utf8(), ""); + } + } + } else { + if self.popup_secondary_cursor < self.popup_secondary.len() { + let s = &self.popup_secondary[self.popup_secondary_cursor..]; + if let Some(c) = s.chars().next() { + self.popup_secondary.replace_range(self.popup_secondary_cursor..self.popup_secondary_cursor + c.len_utf8(), ""); + } + } + } + } + KeyCode::Left => { + if *field == 0 { + if self.popup_cursor > 0 { + self.popup_cursor = self.popup_input.floor_char_boundary(self.popup_cursor - 1); + } + } else { + if self.popup_secondary_cursor > 0 { + self.popup_secondary_cursor = self.popup_secondary.floor_char_boundary(self.popup_secondary_cursor - 1); + } + } + } + KeyCode::Right => { + if *field == 0 { + if self.popup_cursor < self.popup_input.len() { + let s = &self.popup_input[self.popup_cursor..]; + if let Some(c) = s.chars().next() { + self.popup_cursor += c.len_utf8(); + } + } + } else { + if self.popup_secondary_cursor < self.popup_secondary.len() { + let s = &self.popup_secondary[self.popup_secondary_cursor..]; + if let Some(c) = s.chars().next() { + self.popup_secondary_cursor += c.len_utf8(); + } + } + } + } + KeyCode::Home => { + if *field == 0 { + self.popup_cursor = 0; + } else { + self.popup_secondary_cursor = 0; + } + } + KeyCode::End => { + if *field == 0 { + self.popup_cursor = self.popup_input.len(); + } else { + self.popup_secondary_cursor = self.popup_secondary.len(); + } + } + _ => {} + }, Popup::DatePicker => match key.code { KeyCode::Esc => { self.show_popup = None; diff --git a/src/main.rs b/src/main.rs index a73ed33..384e576 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,6 +135,8 @@ fn main() -> io::Result<()> { show_popup: app.show_popup.as_ref(), popup_input: &app.popup_input, popup_cursor: app.popup_cursor, + popup_secondary: &app.popup_secondary, + popup_secondary_cursor: app.popup_secondary_cursor, draft_date: app.draft_date, network_status: &app.network_status, task_list_scroll: app.task_list_scroll, diff --git a/src/ui/components.rs b/src/ui/components.rs index 079b889..fbc665e 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -1,7 +1,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}; -use ratatui::layout::{Alignment, Rect}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::Frame; use crate::domain::models::*; @@ -238,7 +238,8 @@ pub fn render_input_popup( input: &str, cursor: usize, ) { - let popup_area = centered_rect(60, 3, area); + let popup_area = centered_rect(75, 3, area); + frame.render_widget(Clear, popup_area); let block = Block::default() .borders(Borders::ALL) .style(Style::default().bg(POPUP_BG)) @@ -256,12 +257,103 @@ pub fn render_input_popup( )); } +pub fn render_edit_task_popup( + frame: &mut Frame, + area: Rect, + title: &str, + title_cursor: usize, + notes: &str, + notes_cursor: usize, + active_field: usize, +) { + let popup_area = centered_rect(75, 10, area); + + // Clear the area first to prevent style/symbol bleed from previously rendered widgets + frame.render_widget(Clear, popup_area); + + let outer_block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(POPUP_BORDER)) + .title(" Edit Task "); + let inner_area = outer_block.inner(popup_area); + + // Render outer block with borders and background + let outer_para = Paragraph::new(Text::raw("")) + .style(Style::default().bg(POPUP_BG)) + .block(outer_block); + frame.render_widget(outer_para, popup_area); + + // Split inner area into rows + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(inner_area); + + // ── Title block ── + let title_style = if active_field == 0 { + Style::default().fg(FOCUS_COLOR).bg(POPUP_BG) + } else { + Style::default().fg(Color::DarkGray).bg(POPUP_BG) + }; + let title_block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(title_style) + .title(" Title ") + .title_alignment(Alignment::Left); + let title_para = Paragraph::new(Text::from(Line::from(Span::raw(title)))) + .style(Style::default().bg(POPUP_BG)) + .block(title_block); + frame.render_widget(title_para, rows[0]); + + // ── Notes block ── + let notes_style = if active_field == 1 { + Style::default().fg(FOCUS_COLOR).bg(POPUP_BG) + } else { + Style::default().fg(Color::DarkGray).bg(POPUP_BG) + }; + let notes_block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(notes_style) + .title(" Notes ") + .title_alignment(Alignment::Left); + let notes_para = Paragraph::new(Text::from(Line::from(Span::raw(notes)))) + .style(Style::default().bg(POPUP_BG)) + .block(notes_block); + frame.render_widget(notes_para, rows[2]); + + // ── Hint row ── + let hint = Paragraph::new(Line::from(Span::styled( + " Tab:switch field Enter:save Esc:cancel ", + Style::default().fg(Color::Gray), + ))) + .style(Style::default().bg(POPUP_BG)) + .alignment(Alignment::Center); + frame.render_widget(hint, rows[3]); + + // ── Cursor ── + let (cursor_x, cursor_y) = if active_field == 0 { + (rows[0].x + 1 + title_cursor as u16, rows[0].y + 1) + } else { + (rows[2].x + 1 + notes_cursor as u16, rows[2].y + 1) + }; + frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y)); +} + pub fn render_date_picker( frame: &mut Frame, area: Rect, date: chrono::NaiveDateTime, ) { - let popup_area = centered_rect(50, 7, area); + let popup_area = centered_rect(60, 7, area); + frame.render_widget(Clear, popup_area); let block = Block::default() .borders(Borders::ALL) .style(Style::default().bg(POPUP_BG)) @@ -292,7 +384,8 @@ pub fn render_date_picker( } pub fn render_confirm_popup(frame: &mut Frame, area: Rect) { - let popup_area = centered_rect(40, 5, area); + let popup_area = centered_rect(50, 5, area); + frame.render_widget(Clear, popup_area); let block = Block::default() .borders(Borders::ALL) .style(Style::default().bg(POPUP_BG)) @@ -328,6 +421,7 @@ pub fn render_device_auth_popup( error: Option<&str>, ) { let popup_area = centered_rect(80, 13, area); + frame.render_widget(Clear, popup_area); let border_color = if error.is_some() { Color::Red diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b7b2838..b48d7e5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,6 +17,7 @@ pub enum Focus { #[derive(Debug, Clone, PartialEq)] pub enum Popup { Input, + EditTask { field: usize }, DatePicker, ConfirmDelete, DeviceAuth { url: String, code: String }, @@ -38,6 +39,8 @@ pub struct AppView<'a> { pub show_popup: Option<&'a Popup>, pub popup_input: &'a str, pub popup_cursor: usize, + pub popup_secondary: &'a str, + pub popup_secondary_cursor: usize, pub draft_date: chrono::NaiveDateTime, pub network_status: &'a NetworkStatus, pub task_list_scroll: u16, @@ -94,6 +97,10 @@ pub fn draw(frame: &mut Frame, view: AppView) { if let Some(popup) = view.show_popup { match popup { Popup::Input => render_input_popup(frame, area, view.popup_input, view.popup_cursor), + Popup::EditTask { field } => render_edit_task_popup( + frame, area, view.popup_input, view.popup_cursor, + view.popup_secondary, view.popup_secondary_cursor, *field, + ), Popup::DatePicker => render_date_picker(frame, area, view.draft_date), Popup::ConfirmDelete => render_confirm_popup(frame, area), Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),