From 2fb550229ea36625df6261b8d3d85321c244f89a Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sun, 21 Jun 2026 18:59:27 +0100 Subject: [PATCH] Multi-select tasks with Shift+Arrows and bulk actions - Shift+Up/Down extends selection in task list - Enter opens BulkAction popup with 3 options: 1. Mark as completed 2. Set due date to Today 3. Move to new list (creates list, copies tasks, deletes originals) - Plain Up/Down clears selection, Escape clears it - Selected items highlighted in Yellow --- src/app.rs | 192 +++++++++++++++++++++++++++++++++++++++---- src/main.rs | 1 + src/ui/components.rs | 85 ++++++++++++++----- src/ui/mod.rs | 6 ++ 4 files changed, 248 insertions(+), 36 deletions(-) diff --git a/src/app.rs b/src/app.rs index cdfc425..a248b80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::sync::mpsc as std_mpsc; use std::sync::Arc; @@ -51,6 +52,8 @@ pub struct App { editing_task_id: Option, pending_date_key: bool, pending_new_key: bool, + pending_bulk_move: bool, + pub selected_tasks: BTreeSet, auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, @@ -122,6 +125,8 @@ impl App { editing_task_id: None, pending_date_key: false, pending_new_key: false, + pending_bulk_move: false, + selected_tasks: BTreeSet::new(), auth_tx, auth_rx, sync_tx, @@ -333,6 +338,22 @@ impl App { } } } + KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => { + if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task > 0 { + self.selected_tasks.insert(self.selected_task); + self.selected_task -= 1; + self.selected_tasks.insert(self.selected_task); + self.task_list_scroll = self.task_list_scroll.saturating_sub(1); + } + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => { + if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task + 1 < self.tasks.len() { + self.selected_tasks.insert(self.selected_task); + self.selected_task += 1; + self.selected_tasks.insert(self.selected_task); + self.task_list_scroll += 1; + } + } KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { if self.focus == Focus::TaskList && !self.tasks.is_empty() { self.reorder_task(-1); @@ -346,6 +367,7 @@ impl App { KeyCode::Up => match self.focus { Focus::TaskList => { if self.selected_task > 0 { + self.clear_selection(); self.selected_task -= 1; self.task_list_scroll = self.task_list_scroll.saturating_sub(1); } @@ -361,6 +383,7 @@ impl App { KeyCode::Down => match self.focus { Focus::TaskList => { if self.selected_task + 1 < self.tasks.len() { + self.clear_selection(); self.selected_task += 1; self.task_list_scroll += 1; } @@ -437,24 +460,32 @@ impl App { 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(); + if !self.selected_tasks.is_empty() { + self.show_popup = Some(Popup::BulkAction); + } else { + 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 => { - self.show_popup = None; + if self.show_popup.is_some() { + self.show_popup = None; + } else { + self.clear_selection(); + } } KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.trigger_full_sync(); @@ -492,11 +523,16 @@ impl App { }, Popup::Input => match key.code { KeyCode::Esc => { + self.pending_bulk_move = false; self.show_popup = None; } KeyCode::Enter => { let input = self.popup_input.trim().to_string(); - if !input.is_empty() { + if self.pending_bulk_move && !input.is_empty() { + self.pending_bulk_move = false; + self.bulk_move_to_new_list(&input); + self.show_popup = None; + } else if !input.is_empty() { let list = TaskList { id: uuid_v4(), title: input, @@ -510,8 +546,10 @@ impl App { ).ok(); self.trigger_sync(); self.load_lists(); + self.show_popup = None; + } else { + self.show_popup = None; } - self.show_popup = None; } KeyCode::Char(c) => { self.popup_input.insert(self.popup_cursor, c); @@ -807,9 +845,124 @@ impl App { } _ => {} }, + Popup::BulkAction => match key.code { + KeyCode::Esc => { + self.show_popup = None; + } + KeyCode::Char('1') => { + self.bulk_mark_completed(); + self.show_popup = None; + } + KeyCode::Char('2') => { + self.bulk_set_due_today(); + self.show_popup = None; + } + KeyCode::Char('3') => { + self.popup_input.clear(); + self.popup_cursor = 0; + self.pending_bulk_move = true; + self.show_popup = Some(Popup::Input); + } + _ => {} + }, } } + fn bulk_mark_completed(&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::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.clear_selection(); + self.trigger_sync(); + self.load_tasks(); + } + + fn bulk_set_due_today(&mut self) { + let now = chrono::Local::now().naive_local(); + 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(now); + 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_new_list(&mut self, list_name: &str) { + let list = TaskList { + id: uuid_v4(), + title: list_name.to_string(), + }; + self.db.insert_list(&list).ok(); + self.db.push_sync( + SyncAction::CreateList, + &list.id, + &list.id, + &serde_json::to_string(&list).unwrap_or_default(), + ).ok(); + + 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: list.id.clone(), + 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, + &list.id, + &serde_json::to_string(&new_task).unwrap_or_default(), + ).ok(); + + // Delete original + 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(); + self.load_lists(); + // Switch to the new list + if let Some(pos) = self.lists.iter().position(|l| l.id == list.id) { + self.selected_list = pos; + } + self.load_tasks(); + } + fn reorder_task(&mut self, direction: i64) { if self.tasks.is_empty() { return; @@ -856,6 +1009,7 @@ impl App { } fn load_tasks(&mut self) { + self.selected_tasks.clear(); if self.selected_list < self.lists.len() { let mut tasks = self.db.get_tasks(&self.lists[self.selected_list].id); sort_tasks(&mut tasks); @@ -869,6 +1023,10 @@ impl App { self.selected_task = 0; } } + + fn clear_selection(&mut self) { + self.selected_tasks.clear(); + } } fn sort_tasks(tasks: &mut Vec) { diff --git a/src/main.rs b/src/main.rs index 66bd13d..1834f7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -154,6 +154,7 @@ fn main() -> io::Result<()> { calendar_active_week: app.calendar_active_week, auth_error: app.auth_error.as_deref(), sync_stats: &app.sync_stats, + selected_tasks: &app.selected_tasks, }; draw(frame, view); })?; diff --git a/src/ui/components.rs b/src/ui/components.rs index cb0ffa7..89ccb9d 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use chrono::Datelike; use ratatui::style::{Color, Modifier, Style}; @@ -76,6 +78,7 @@ pub fn render_task_list( selected: usize, focused: bool, _scroll: u16, + selected_tasks: &BTreeSet, ) { let total = tasks.len(); let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count(); @@ -85,7 +88,9 @@ pub fn render_task_list( let items: Vec = tasks .iter() - .map(|task| { + .enumerate() + .map(|(idx, task)| { + let is_selected = selected_tasks.contains(&idx); let checkbox = match task.status { TaskStatus::Completed => "[\u{2713}]", TaskStatus::NeedsAction => "[ ]", @@ -121,25 +126,29 @@ pub fn render_task_list( content_width.saturating_sub(used) }; - let mut spans = vec![ - Span::styled( - checkbox_str, - Style::default().fg(if task.status == TaskStatus::Completed { - Color::Green + let checkbox_style = if is_selected { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else if task.status == TaskStatus::Completed { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + + let title_style = if is_selected { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(DETAIL_COLOR).add_modifier( + if task.status == TaskStatus::Completed { + Modifier::CROSSED_OUT } else { - Color::DarkGray - }), - ), - Span::styled( - display_title, - Style::default().fg(DETAIL_COLOR).add_modifier( - if task.status == TaskStatus::Completed { - Modifier::CROSSED_OUT - } else { - Modifier::empty() - }, - ), - ), + Modifier::empty() + }, + ) + }; + + let mut spans = vec![ + Span::styled(checkbox_str, checkbox_style), + Span::styled(display_title, title_style), ]; if !due_text.is_empty() { @@ -498,6 +507,44 @@ 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); + 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(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 paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, popup_area); +} + pub fn render_device_auth_popup( frame: &mut Frame, area: Rect, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9508d1c..3adc6ae 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,7 @@ pub mod components; +use std::collections::BTreeSet; + use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::Frame; @@ -21,6 +23,7 @@ pub enum Popup { EditTask { field: usize }, DatePicker, ConfirmDelete, + BulkAction, DeviceAuth { url: String, code: String }, } @@ -52,6 +55,7 @@ pub struct AppView<'a> { pub calendar_active_week: usize, pub auth_error: Option<&'a str>, pub sync_stats: &'a SyncStats, + pub selected_tasks: &'a BTreeSet, } pub fn draw(frame: &mut Frame, view: AppView) { @@ -88,6 +92,7 @@ pub fn draw(frame: &mut Frame, view: AppView) { view.selected_task, is_task_list_focused, view.task_list_scroll, + view.selected_tasks, ); let is_detail_focused = view.focus == Focus::Detail; @@ -121,6 +126,7 @@ 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::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error), } }