diff --git a/src/app.rs b/src/app.rs index a248b80..4d9c7ce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,6 +54,9 @@ pub struct App { pending_new_key: bool, pending_bulk_move: bool, pub selected_tasks: BTreeSet, + pub bulk_action_selected: usize, + pub popup_list_indices: Vec<(String, String)>, + pub popup_list_selected: usize, auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, @@ -127,6 +130,9 @@ impl App { pending_new_key: false, pending_bulk_move: false, selected_tasks: BTreeSet::new(), + bulk_action_selected: 0, + popup_list_indices: Vec::new(), + popup_list_selected: 0, auth_tx, auth_rx, sync_tx, @@ -849,19 +855,52 @@ impl App { KeyCode::Esc => { self.show_popup = None; } - KeyCode::Char('1') => { - self.bulk_mark_completed(); - self.show_popup = None; + KeyCode::Up => { + self.bulk_action_selected = self.bulk_action_selected.saturating_sub(1); } - KeyCode::Char('2') => { - self.bulk_set_due_today(); - self.show_popup = None; + KeyCode::Down => { + if self.bulk_action_selected < 6 { + self.bulk_action_selected += 1; + } } - KeyCode::Char('3') => { - self.popup_input.clear(); - self.popup_cursor = 0; - self.pending_bulk_move = true; - self.show_popup = Some(Popup::Input); + KeyCode::Enter => { + let action = self.bulk_action_selected; + self.execute_bulk_action(action); + if action <= 4 { + self.show_popup = None; + } + } + KeyCode::Char(c) => { + if let Some(n) = c.to_digit(10) { + let idx = n as usize - 1; + if idx <= 6 { + self.execute_bulk_action(idx); + if idx <= 4 { + self.show_popup = None; + } + } + } + } + _ => {} + }, + Popup::PickList => match key.code { + KeyCode::Esc => { + self.show_popup = Some(Popup::BulkAction); + } + KeyCode::Up => { + self.popup_list_selected = self.popup_list_selected.saturating_sub(1); + } + KeyCode::Down => { + if self.popup_list_selected + 1 < self.popup_list_indices.len() { + self.popup_list_selected += 1; + } + } + KeyCode::Enter => { + if !self.popup_list_indices.is_empty() { + let list_id = self.popup_list_indices[self.popup_list_selected].1.clone(); + self.bulk_move_to_existing_list(&list_id); + self.show_popup = None; + } } _ => {} }, @@ -963,6 +1002,133 @@ impl App { self.load_tasks(); } + fn bulk_mark_uncomplete(&mut self) { + let indices: Vec = self.selected_tasks.iter().copied().collect(); + for &i in &indices { + if i >= self.tasks.len() { continue; } + let task = &mut self.tasks[i]; + task.status = TaskStatus::NeedsAction; + 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.clear_selection(); + self.trigger_sync(); + self.load_tasks(); + } + + fn bulk_set_due_tomorrow(&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); + let indices: Vec = self.selected_tasks.iter().copied().collect(); + for &i in &indices { + if i >= self.tasks.len() { continue; } + let task = &mut self.tasks[i]; + 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.clear_selection(); + self.trigger_sync(); + self.load_tasks(); + } + + fn bulk_set_due_next_week(&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); + let indices: Vec = self.selected_tasks.iter().copied().collect(); + for &i in &indices { + if i >= self.tasks.len() { continue; } + let task = &mut self.tasks[i]; + 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.clear_selection(); + self.trigger_sync(); + self.load_tasks(); + } + + fn bulk_move_to_existing_list(&mut self, target_list_id: &str) { + let indices: Vec = self.selected_tasks.iter().copied().collect(); + for &i in &indices { + if i >= self.tasks.len() { continue; } + let original = &self.tasks[i]; + let new_task = Task { + id: uuid_v4(), + list_id: target_list_id.to_string(), + title: original.title.clone(), + notes: original.notes.clone(), + status: original.status.clone(), + due: original.due, + position: 0, + created_at: None, + updated_at: None, + }; + self.db.insert_task(&new_task).ok(); + self.db.push_sync( + SyncAction::Create, + &new_task.id, + target_list_id, + &serde_json::to_string(&new_task).unwrap_or_default(), + ).ok(); + + self.db.delete_task(&original.id).ok(); + self.db.push_sync( + SyncAction::Delete, + &original.id, + &original.list_id, + "", + ).ok(); + } + self.clear_selection(); + self.trigger_sync(); + if let Some(pos) = self.lists.iter().position(|l| l.id == target_list_id) { + self.selected_list = pos; + } + self.load_tasks(); + } + + fn execute_bulk_action(&mut self, action_idx: usize) { + match action_idx { + 0 => self.bulk_mark_completed(), + 1 => self.bulk_mark_uncomplete(), + 2 => self.bulk_set_due_today(), + 3 => self.bulk_set_due_tomorrow(), + 4 => self.bulk_set_due_next_week(), + 5 => { + self.popup_input.clear(); + self.popup_cursor = 0; + self.pending_bulk_move = true; + self.show_popup = Some(Popup::Input); + } + 6 => { + self.popup_list_indices = self.lists.iter() + .map(|l| (l.title.clone(), l.id.clone())) + .collect(); + self.popup_list_selected = 0; + self.show_popup = Some(Popup::PickList); + } + _ => {} + } + } + fn reorder_task(&mut self, direction: i64) { if self.tasks.is_empty() { return; diff --git a/src/main.rs b/src/main.rs index 1834f7f..3c2a31d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,6 +155,9 @@ fn main() -> io::Result<()> { auth_error: app.auth_error.as_deref(), sync_stats: &app.sync_stats, selected_tasks: &app.selected_tasks, + bulk_action_selected: app.bulk_action_selected, + popup_list_indices: &app.popup_list_indices, + popup_list_selected: app.popup_list_selected, }; draw(frame, view); })?; diff --git a/src/ui/components.rs b/src/ui/components.rs index 89ccb9d..6e4b7b1 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -127,7 +127,7 @@ pub fn render_task_list( }; let checkbox_style = if is_selected { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray) } else if task.status == TaskStatus::Completed { Style::default().fg(Color::Green) } else { @@ -135,7 +135,7 @@ pub fn render_task_list( }; let title_style = if is_selected { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray) } else { Style::default().fg(DETAIL_COLOR).add_modifier( if task.status == TaskStatus::Completed { @@ -507,8 +507,8 @@ pub fn render_confirm_popup(frame: &mut Frame, area: Rect) { frame.render_widget(paragraph, popup_area); } -pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize) { - let popup_area = centered_rect(55, 9, area); +pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize, selected: usize) { + let popup_area = centered_rect(55, 12, area); frame.render_widget(Clear, popup_area); let block = Block::default() .borders(Borders::ALL) @@ -517,28 +517,75 @@ pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize) { .title(format!(" Bulk Actions ({} selected) ", count)) .title_alignment(Alignment::Left); - let text = Text::from(vec![ - Line::from(""), - Line::from(Span::styled( - " 1. Mark as completed", - Style::default().fg(Color::Cyan), - )), - Line::from(Span::styled( - " 2. Set due date to Today", - Style::default().fg(Color::Cyan), - )), - Line::from(Span::styled( - " 3. Move to new list...", - Style::default().fg(Color::Cyan), - )), - Line::from(""), - Line::from(Span::styled( - " Press 1-3 or Esc to cancel", - Style::default().fg(Color::DarkGray), - )), - ]); + let options = [ + "1. Mark as completed", + "2. Mark as uncomplete", + "3. Set due date to Today", + "4. Set due date to Tomorrow", + "5. Set due date to Next Week", + "6. Move to new list...", + "7. Move to existing list...", + ]; - let paragraph = Paragraph::new(text) + let mut lines = vec![Line::from("")]; + for (i, opt) in options.iter().enumerate() { + let style = if i == selected { + Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + lines.push(Line::from(Span::styled( + format!(" {}", opt), + style, + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " ↑/↓: navigate Enter:ok 1-7:shortcut Esc:cancel", + Style::default().fg(Color::DarkGray), + ))); + + let paragraph = Paragraph::new(Text::from(lines)) + .block(block) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, popup_area); +} + +pub fn render_pick_list_popup( + frame: &mut Frame, + area: Rect, + lists: &[(String, String)], + selected: usize, +) { + let popup_area = centered_rect(60, 10, area); + frame.render_widget(Clear, popup_area); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(POPUP_BORDER)) + .title(" Select List ") + .title_alignment(Alignment::Left); + + let mut lines = vec![Line::from("")]; + for (i, (title, _)) in lists.iter().enumerate() { + let style = if i == selected { + Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + lines.push(Line::from(Span::styled( + format!(" {}", title), + style, + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " ↑/↓: navigate Enter:ok Esc:cancel", + Style::default().fg(Color::DarkGray), + ))); + + let paragraph = Paragraph::new(Text::from(lines)) .block(block) .alignment(Alignment::Left); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3adc6ae..c0970f4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -24,6 +24,7 @@ pub enum Popup { DatePicker, ConfirmDelete, BulkAction, + PickList, DeviceAuth { url: String, code: String }, } @@ -56,6 +57,9 @@ pub struct AppView<'a> { pub auth_error: Option<&'a str>, pub sync_stats: &'a SyncStats, pub selected_tasks: &'a BTreeSet, + pub bulk_action_selected: usize, + pub popup_list_indices: &'a [(String, String)], + pub popup_list_selected: usize, } pub fn draw(frame: &mut Frame, view: AppView) { @@ -126,7 +130,8 @@ pub fn draw(frame: &mut Frame, view: AppView) { ), Popup::DatePicker => render_date_picker(frame, area, view.draft_date), Popup::ConfirmDelete => render_confirm_popup(frame, area), - Popup::BulkAction => render_bulk_action_popup(frame, area, view.selected_tasks.len()), + Popup::BulkAction => render_bulk_action_popup(frame, area, view.selected_tasks.len(), view.bulk_action_selected), + Popup::PickList => render_pick_list_popup(frame, area, view.popup_list_indices, view.popup_list_selected), Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error), } }