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_bulk_move: bool,
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_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>,
@@ -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<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) {
if self.tasks.is_empty() {
return;
+3
View File
@@ -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);
})?;
+72 -25
View File
@@ -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);
+6 -1
View File
@@ -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<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) {
@@ -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),
}
}