feat: bulk move/delete operations in focus list
1 file changed, +65 -1
+65-1src/views/browser.rs
| @@ -55,6 +55,8 @@ pub struct BrowserView { | ||
| 55 | 55 | focused: Vec<Vec<PathSegment>>, |
| 56 | 56 | pub focus_mode: bool, |
| 57 | 57 | focus_dirty: bool, |
| 58 | + // Bulk selection in focus root (indices into self.focused) | |
| 59 | + bulk_selected: std::collections::BTreeSet<usize>, | |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | impl BrowserView { |
| @@ -75,6 +77,7 @@ impl BrowserView { | ||
| 75 | 77 | focused: Vec::new(), |
| 76 | 78 | focus_mode: false, |
| 77 | 79 | focus_dirty: false, |
| 80 | + bulk_selected: std::collections::BTreeSet::new(), | |
| 78 | 81 | } |
| 79 | 82 | } |
| 80 | 83 | |
| @@ -432,6 +435,61 @@ impl BrowserView { | ||
| 432 | 435 | self.focus_mode = !self.focus_mode; |
| 433 | 436 | self.path.clear(); |
| 434 | 437 | self.selection = 0; |
| 438 | + self.bulk_selected.clear(); | |
| 439 | + } | |
| 440 | + | |
| 441 | + // Bulk operations (focus root only) | |
| 442 | + if in_focus_root { | |
| 443 | + // Space: toggle bulk select on current item, move down | |
| 444 | + if ui.input(|i| i.key_pressed(egui::Key::Space)) { | |
| 445 | + if !self.bulk_selected.remove(&self.selection) { | |
| 446 | + self.bulk_selected.insert(self.selection); | |
| 447 | + } | |
| 448 | + if self.selection + 1 < self.focused.len() { | |
| 449 | + self.selection += 1; | |
| 450 | + } | |
| 451 | + } | |
| 452 | + // Escape: clear bulk selection | |
| 453 | + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { | |
| 454 | + self.bulk_selected.clear(); | |
| 455 | + } | |
| 456 | + // 'p': move selected items below current position | |
| 457 | + if ui.input(|i| i.key_pressed(egui::Key::P) && !i.modifiers.ctrl) && !self.bulk_selected.is_empty() { | |
| 458 | + // Collect selected items (in reverse order to preserve indices during removal) | |
| 459 | + let mut selected_items: Vec<Vec<PathSegment>> = self.bulk_selected.iter().rev() | |
| 460 | + .filter_map(|&idx| { | |
| 461 | + if idx < self.focused.len() { Some(self.focused[idx].clone()) } else { None } | |
| 462 | + }) | |
| 463 | + .collect(); | |
| 464 | + selected_items.reverse(); | |
| 465 | + // Remove selected from their current positions (reverse order) | |
| 466 | + for &idx in self.bulk_selected.iter().rev() { | |
| 467 | + if idx < self.focused.len() { | |
| 468 | + self.focused.remove(idx); | |
| 469 | + } | |
| 470 | + } | |
| 471 | + // Adjust insertion point (current selection may have shifted) | |
| 472 | + let insert_at = (self.selection + 1).min(self.focused.len()); | |
| 473 | + // Insert after current position | |
| 474 | + for (i, item) in selected_items.into_iter().enumerate() { | |
| 475 | + self.focused.insert(insert_at + i, item); | |
| 476 | + } | |
| 477 | + self.bulk_selected.clear(); | |
| 478 | + self.focus_dirty = true; | |
| 479 | + } | |
| 480 | + // 'd': delete selected items from focus list | |
| 481 | + if ui.input(|i| i.key_pressed(egui::Key::D)) && !self.bulk_selected.is_empty() { | |
| 482 | + for &idx in self.bulk_selected.iter().rev() { | |
| 483 | + if idx < self.focused.len() { | |
| 484 | + self.focused.remove(idx); | |
| 485 | + } | |
| 486 | + } | |
| 487 | + self.bulk_selected.clear(); | |
| 488 | + if self.selection >= self.focused.len() && !self.focused.is_empty() { | |
| 489 | + self.selection = self.focused.len() - 1; | |
| 490 | + } | |
| 491 | + self.focus_dirty = true; | |
| 492 | + } | |
| 435 | 493 | } |
| 436 | 494 | |
| 437 | 495 | // Copy selected value: c or Ctrl+C |
| @@ -803,6 +861,7 @@ impl BrowserView { | ||
| 803 | 861 | let current_path = self.path.clone(); |
| 804 | 862 | let focused = self.focused.clone(); |
| 805 | 863 | let is_focus_root = self.focus_mode && self.path.is_empty(); |
| 864 | + let bulk_selected = self.bulk_selected.clone(); | |
| 806 | 865 | |
| 807 | 866 | // Map original selection index → filtered list position |
| 808 | 867 | let filtered_pos = entries.iter().position(|(orig, _)| *orig == self.selection) |
| @@ -818,7 +877,12 @@ impl BrowserView { | ||
| 818 | 877 | let (orig_idx, entry) = &entries[i]; |
| 819 | 878 | let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i); |
| 820 | 879 | |
| 821 | - let bg = if is_selected { | |
| 880 | + let is_bulk = is_focus_root && bulk_selected.contains(orig_idx); | |
| 881 | + let bg = if is_bulk { | |
| 882 | + egui::Color32::from_rgba_unmultiplied( | |
| 883 | + CatppuccinMocha::MAUVE.r(), CatppuccinMocha::MAUVE.g(), CatppuccinMocha::MAUVE.b(), 30, | |
| 884 | + ) | |
| 885 | + } else if is_selected { | |
| 822 | 886 | CatppuccinMocha::SURFACE0 |
| 823 | 887 | } else if is_hovered { |
| 824 | 888 | egui::Color32::from_rgba_unmultiplied( |