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:
Ruben Rosario
2026-06-21 18:59:27 +01:00
parent 10a8d1d75e
commit 2fb550229e
4 changed files with 248 additions and 36 deletions
+66 -19
View File
@@ -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<usize>,
) {
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<ListItem> = 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,