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
This commit is contained in:
+175
-17
@@ -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<String>,
|
||||
pending_date_key: bool,
|
||||
pending_new_key: bool,
|
||||
pending_bulk_move: bool,
|
||||
pub selected_tasks: BTreeSet<usize>,
|
||||
auth_tx: std_mpsc::Sender<AuthEvent>,
|
||||
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||
sync_tx: mpsc::Sender<SyncCommand>,
|
||||
@@ -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<usize> = 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<usize> = 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<usize> = 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<Task>) {
|
||||
|
||||
Reference in New Issue
Block a user