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:
+177
-11
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user