Enhanced BulkAction: 7 options, arrow navigation, PickList, visual selection

- Added: Mark as uncomplete, Set due Tomorrow, Set due Next Week
- Added: Move to existing list (PickList popup)
- ↑/↓ navigate options, Enter executes, 1-7 for number shortcuts
- Selected tasks now show bg(DarkGray) for clearer visual feedback
- BulkAction popup with 7 options, PickList popup for list selection
This commit is contained in:
Ruben Rosario
2026-06-21 19:21:04 +01:00
parent 2fb550229e
commit d669ca5c05
4 changed files with 258 additions and 37 deletions
+177 -11
View File
@@ -54,6 +54,9 @@ pub struct App {
pending_new_key: bool, pending_new_key: bool,
pending_bulk_move: bool, pending_bulk_move: bool,
pub selected_tasks: BTreeSet<usize>, pub selected_tasks: BTreeSet<usize>,
pub bulk_action_selected: usize,
pub popup_list_indices: Vec<(String, String)>,
pub popup_list_selected: usize,
auth_tx: std_mpsc::Sender<AuthEvent>, auth_tx: std_mpsc::Sender<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>, auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>, sync_tx: mpsc::Sender<SyncCommand>,
@@ -127,6 +130,9 @@ impl App {
pending_new_key: false, pending_new_key: false,
pending_bulk_move: false, pending_bulk_move: false,
selected_tasks: BTreeSet::new(), selected_tasks: BTreeSet::new(),
bulk_action_selected: 0,
popup_list_indices: Vec::new(),
popup_list_selected: 0,
auth_tx, auth_tx,
auth_rx, auth_rx,
sync_tx, sync_tx,
@@ -849,19 +855,52 @@ impl App {
KeyCode::Esc => { KeyCode::Esc => {
self.show_popup = None; self.show_popup = None;
} }
KeyCode::Char('1') => { KeyCode::Up => {
self.bulk_mark_completed(); self.bulk_action_selected = self.bulk_action_selected.saturating_sub(1);
self.show_popup = None;
} }
KeyCode::Char('2') => { KeyCode::Down => {
self.bulk_set_due_today(); if self.bulk_action_selected < 6 {
self.show_popup = None; self.bulk_action_selected += 1;
}
} }
KeyCode::Char('3') => { KeyCode::Enter => {
self.popup_input.clear(); let action = self.bulk_action_selected;
self.popup_cursor = 0; self.execute_bulk_action(action);
self.pending_bulk_move = true; if action <= 4 {
self.show_popup = Some(Popup::Input); 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(); self.load_tasks();
} }
fn bulk_mark_uncomplete(&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::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<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(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<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(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<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: 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) { fn reorder_task(&mut self, direction: i64) {
if self.tasks.is_empty() { if self.tasks.is_empty() {
return; return;
+3
View File
@@ -155,6 +155,9 @@ fn main() -> io::Result<()> {
auth_error: app.auth_error.as_deref(), auth_error: app.auth_error.as_deref(),
sync_stats: &app.sync_stats, sync_stats: &app.sync_stats,
selected_tasks: &app.selected_tasks, 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); draw(frame, view);
})?; })?;
+72 -25
View File
@@ -127,7 +127,7 @@ pub fn render_task_list(
}; };
let checkbox_style = if is_selected { 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 { } else if task.status == TaskStatus::Completed {
Style::default().fg(Color::Green) Style::default().fg(Color::Green)
} else { } else {
@@ -135,7 +135,7 @@ pub fn render_task_list(
}; };
let title_style = if is_selected { 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 { } else {
Style::default().fg(DETAIL_COLOR).add_modifier( Style::default().fg(DETAIL_COLOR).add_modifier(
if task.status == TaskStatus::Completed { 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); frame.render_widget(paragraph, popup_area);
} }
pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize) { pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize, selected: usize) {
let popup_area = centered_rect(55, 9, area); let popup_area = centered_rect(55, 12, area);
frame.render_widget(Clear, popup_area); frame.render_widget(Clear, popup_area);
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .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(format!(" Bulk Actions ({} selected) ", count))
.title_alignment(Alignment::Left); .title_alignment(Alignment::Left);
let text = Text::from(vec![ let options = [
Line::from(""), "1. Mark as completed",
Line::from(Span::styled( "2. Mark as uncomplete",
" 1. Mark as completed", "3. Set due date to Today",
Style::default().fg(Color::Cyan), "4. Set due date to Tomorrow",
)), "5. Set due date to Next Week",
Line::from(Span::styled( "6. Move to new list...",
" 2. Set due date to Today", "7. Move to existing list...",
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) 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) .block(block)
.alignment(Alignment::Left); .alignment(Alignment::Left);
+6 -1
View File
@@ -24,6 +24,7 @@ pub enum Popup {
DatePicker, DatePicker,
ConfirmDelete, ConfirmDelete,
BulkAction, BulkAction,
PickList,
DeviceAuth { url: String, code: String }, DeviceAuth { url: String, code: String },
} }
@@ -56,6 +57,9 @@ pub struct AppView<'a> {
pub auth_error: Option<&'a str>, pub auth_error: Option<&'a str>,
pub sync_stats: &'a SyncStats, pub sync_stats: &'a SyncStats,
pub selected_tasks: &'a BTreeSet<usize>, pub selected_tasks: &'a BTreeSet<usize>,
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) { 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::DatePicker => render_date_picker(frame, area, view.draft_date),
Popup::ConfirmDelete => render_confirm_popup(frame, area), 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), Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
} }
} }