gardesk/garfield / d15d3c3

Browse files

ui: add paste conflict resolution and help modal scroll

- Add ConflictDialog with Replace/Skip/Keep Both/Cancel options
- Detect file conflicts before paste, show dialog with bell alert
- Handle conflicts: skip, replace existing, or auto-rename (Keep Both)
- Add scrollbar indicator to help modal
- Add scroll limit to prevent over-scrolling
- Add mouse wheel scroll support for help modal
- Add new keybinds to help: Bookmarks section, Shift+Up/Down, q to quit
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d15d3c3d6243662339d419d6547cd9213535c8d4
Parents
bfdcd76
Tree
13adcf0

4 changed files

StatusFile+-
M garfield/src/app.rs 221 1
M garfield/src/ui/dialog.rs 293 0
M garfield/src/ui/help_modal.rs 70 2
M garfield/src/ui/mod.rs 1 1
garfield/src/app.rsmodified
@@ -6,7 +6,7 @@ use garfield::core::{
66
     trash_files, restore_from_trash,
77
 };
88
 use garfield::ui::pane::SplitDirection;
9
-use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, DialogResult, HelpModal, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
9
+use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, DialogResult, HelpModal, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
1010
 use anyhow::Result;
1111
 use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme};
1212
 use gartk_render::{Renderer, Surface, TextStyle};
@@ -76,12 +76,30 @@ pub struct App {
7676
     clipboard: Clipboard,
7777
     /// Confirmation dialog.
7878
     confirm_dialog: ConfirmDialog,
79
+    /// Conflict resolution dialog.
80
+    conflict_dialog: ConflictDialog,
7981
     /// Progress dialog for long operations.
8082
     progress_dialog: ProgressDialog,
8183
     /// Paths pending delete confirmation.
8284
     pending_delete_paths: Vec<PathBuf>,
8385
     /// Undo/redo stack for file operations.
8486
     undo_stack: UndoStack,
87
+    /// Pending paste operation with conflicts.
88
+    pending_paste: Option<PendingPaste>,
89
+}
90
+
91
+/// State for a paste operation with conflicts.
92
+struct PendingPaste {
93
+    /// Files to paste.
94
+    files: Vec<PathBuf>,
95
+    /// Clipboard operation type.
96
+    operation: ClipboardOperation,
97
+    /// Destination directory.
98
+    dest_dir: PathBuf,
99
+    /// Files that conflict (exist in destination).
100
+    conflicts: Vec<PathBuf>,
101
+    /// Current conflict index being resolved.
102
+    current_conflict: usize,
85103
 }
86104
 
87105
 impl App {
@@ -174,6 +192,9 @@ impl App {
174192
         // Create confirm dialog (full window bounds)
175193
         let confirm_dialog = ConfirmDialog::new(Rect::new(0, 0, width, height));
176194
 
195
+        // Create conflict dialog (full window bounds)
196
+        let conflict_dialog = ConflictDialog::new(Rect::new(0, 0, width, height));
197
+
177198
         // Create progress dialog (full window bounds)
178199
         let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height));
179200
 
@@ -223,9 +244,11 @@ impl App {
223244
             drag_active: false,
224245
             clipboard: Clipboard::new(),
225246
             confirm_dialog,
247
+            conflict_dialog,
226248
             progress_dialog,
227249
             pending_delete_paths: Vec::new(),
228250
             undo_stack: UndoStack::new(),
251
+            pending_paste: None,
229252
         };
230253
 
231254
         app.update_status_bar();
@@ -324,6 +347,14 @@ impl App {
324347
             return;
325348
         }
326349
 
350
+        // Check conflict dialog
351
+        if self.conflict_dialog.is_visible() {
352
+            if let Some(action) = self.conflict_dialog.on_click(pos) {
353
+                self.handle_conflict_action(action);
354
+            }
355
+            return;
356
+        }
357
+
327358
         // Check help modal (clicking outside closes it)
328359
         if self.help_modal.on_click(pos) {
329360
             return;
@@ -557,6 +588,12 @@ impl App {
557588
             return;
558589
         }
559590
 
591
+        // Handle conflict dialog hover
592
+        if self.conflict_dialog.is_visible() {
593
+            self.conflict_dialog.on_mouse_move(pos);
594
+            return;
595
+        }
596
+
560597
         // Handle sidebar resize in progress
561598
         if self.sidebar_resizing {
562599
             let new_width = (pos.x - self.sidebar.bounds().x).max(0) as u32;
@@ -641,6 +678,14 @@ impl App {
641678
             return;
642679
         }
643680
 
681
+        // Handle conflict dialog when visible
682
+        if self.conflict_dialog.is_visible() {
683
+            if let Some(action) = self.conflict_dialog.handle_key(key) {
684
+                self.handle_conflict_action(action);
685
+            }
686
+            return;
687
+        }
688
+
644689
         // Handle help modal when visible
645690
         if self.help_modal.is_visible() {
646691
             match key {
@@ -1280,6 +1325,40 @@ impl App {
12801325
         };
12811326
 
12821327
         if let Some((files, op)) = self.clipboard.take() {
1328
+            // Check for conflicts first
1329
+            let conflicts: Vec<PathBuf> = files.iter()
1330
+                .filter_map(|f| {
1331
+                    f.file_name().and_then(|name| {
1332
+                        let dest = dest_dir.join(name);
1333
+                        if dest.exists() { Some(f.clone()) } else { None }
1334
+                    })
1335
+                })
1336
+                .collect();
1337
+
1338
+            if !conflicts.is_empty() {
1339
+                // Ring bell to alert user
1340
+                self.bell();
1341
+
1342
+                // Show conflict dialog for the first conflict
1343
+                let first_conflict_name = conflicts[0]
1344
+                    .file_name()
1345
+                    .map(|n| n.to_string_lossy().to_string())
1346
+                    .unwrap_or_default();
1347
+
1348
+                self.conflict_dialog.show(&first_conflict_name);
1349
+
1350
+                // Store pending paste state
1351
+                self.pending_paste = Some(PendingPaste {
1352
+                    files,
1353
+                    operation: op,
1354
+                    dest_dir,
1355
+                    conflicts,
1356
+                    current_conflict: 0,
1357
+                });
1358
+                return;
1359
+            }
1360
+
1361
+            // No conflicts - proceed with paste
12831362
             let count = files.len();
12841363
             let sources = files.clone();
12851364
             let result = match op {
@@ -1398,6 +1477,143 @@ impl App {
13981477
         }
13991478
     }
14001479
 
1480
+    /// Handle conflict dialog result.
1481
+    fn handle_conflict_action(&mut self, action: ConflictAction) {
1482
+        let pending = match self.pending_paste.take() {
1483
+            Some(p) => p,
1484
+            None => return,
1485
+        };
1486
+
1487
+        let apply_to_all = self.conflict_dialog.apply_to_all();
1488
+
1489
+        match action {
1490
+            ConflictAction::Cancel => {
1491
+                // Cancel the entire operation
1492
+                self.status_bar.set_status_message("Paste cancelled");
1493
+            }
1494
+            ConflictAction::Skip => {
1495
+                // Skip conflicting files, paste the rest
1496
+                self.complete_paste_with_skip(pending, apply_to_all);
1497
+            }
1498
+            ConflictAction::Replace => {
1499
+                // Replace conflicting files
1500
+                self.complete_paste_with_replace(pending, apply_to_all);
1501
+            }
1502
+            ConflictAction::KeepBoth => {
1503
+                // Auto-rename and paste all
1504
+                self.complete_paste_with_rename(pending, apply_to_all);
1505
+            }
1506
+        }
1507
+    }
1508
+
1509
+    /// Complete paste, skipping conflicts.
1510
+    fn complete_paste_with_skip(&mut self, pending: PendingPaste, _apply_to_all: bool) {
1511
+        let non_conflicting: Vec<_> = pending.files.iter()
1512
+            .filter(|f| !pending.conflicts.contains(f))
1513
+            .cloned()
1514
+            .collect();
1515
+
1516
+        if non_conflicting.is_empty() {
1517
+            self.status_bar.set_status_message("All files skipped (conflicts)");
1518
+            return;
1519
+        }
1520
+
1521
+        let result = match pending.operation {
1522
+            ClipboardOperation::Copy => copy_files(&non_conflicting, &pending.dest_dir),
1523
+            ClipboardOperation::Cut => move_files(&non_conflicting, &pending.dest_dir),
1524
+        };
1525
+
1526
+        let skipped = pending.conflicts.len();
1527
+        if result.success {
1528
+            let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" };
1529
+            self.status_bar.set_status_message(format!("{} {} (skipped {})", non_conflicting.len(), action, skipped));
1530
+        }
1531
+        self.refresh();
1532
+    }
1533
+
1534
+    /// Complete paste, replacing conflicts.
1535
+    fn complete_paste_with_replace(&mut self, pending: PendingPaste, _apply_to_all: bool) {
1536
+        // Delete conflicting files first
1537
+        for conflict in &pending.conflicts {
1538
+            if let Some(name) = conflict.file_name() {
1539
+                let dest = pending.dest_dir.join(name);
1540
+                let _ = std::fs::remove_file(&dest).or_else(|_| std::fs::remove_dir_all(&dest));
1541
+            }
1542
+        }
1543
+
1544
+        let result = match pending.operation {
1545
+            ClipboardOperation::Copy => copy_files(&pending.files, &pending.dest_dir),
1546
+            ClipboardOperation::Cut => move_files(&pending.files, &pending.dest_dir),
1547
+        };
1548
+
1549
+        if result.success {
1550
+            let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" };
1551
+            self.status_bar.set_status_message(format!("{} {} (replaced {})", pending.files.len(), action, pending.conflicts.len()));
1552
+        }
1553
+        self.refresh();
1554
+    }
1555
+
1556
+    /// Complete paste, auto-renaming conflicts.
1557
+    fn complete_paste_with_rename(&mut self, pending: PendingPaste, _apply_to_all: bool) {
1558
+        use garfield::core::make_unique_name;
1559
+
1560
+        let mut success_count = 0;
1561
+        let mut sources = Vec::new();
1562
+        let mut destinations = Vec::new();
1563
+
1564
+        for file in &pending.files {
1565
+            if let Some(name) = file.file_name() {
1566
+                let dest = pending.dest_dir.join(name);
1567
+                let final_dest = if dest.exists() {
1568
+                    // Auto-rename
1569
+                    let unique_name = make_unique_name(&pending.dest_dir, name.to_string_lossy().as_ref());
1570
+                    pending.dest_dir.join(unique_name)
1571
+                } else {
1572
+                    dest
1573
+                };
1574
+
1575
+                let result = match pending.operation {
1576
+                    ClipboardOperation::Copy => {
1577
+                        if file.is_dir() {
1578
+                            garfield::core::copy_path(file, &pending.dest_dir)
1579
+                        } else {
1580
+                            std::fs::copy(file, &final_dest).map(|_| final_dest.clone())
1581
+                        }
1582
+                    }
1583
+                    ClipboardOperation::Cut => {
1584
+                        std::fs::rename(file, &final_dest).map(|_| final_dest.clone())
1585
+                    }
1586
+                };
1587
+
1588
+                if let Ok(dest_path) = result {
1589
+                    success_count += 1;
1590
+                    sources.push(file.clone());
1591
+                    destinations.push(dest_path);
1592
+                }
1593
+            }
1594
+        }
1595
+
1596
+        // Record for undo
1597
+        if !destinations.is_empty() {
1598
+            let undo_op = match pending.operation {
1599
+                ClipboardOperation::Copy => FileOperation::Copy { sources, destinations },
1600
+                ClipboardOperation::Cut => FileOperation::Move { sources, destinations },
1601
+            };
1602
+            self.undo_stack.push(undo_op);
1603
+        }
1604
+
1605
+        let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" };
1606
+        self.status_bar.set_status_message(format!("{} {} (auto-renamed conflicts)", success_count, action));
1607
+        self.refresh();
1608
+    }
1609
+
1610
+    /// Ring the terminal bell.
1611
+    fn bell(&self) {
1612
+        // X11 bell
1613
+        let _ = self.window.connection().inner().bell(0);
1614
+        let _ = self.window.connection().flush();
1615
+    }
1616
+
14011617
     /// Create a new folder in the current directory.
14021618
     fn create_new_folder(&mut self) {
14031619
         let current_dir = self.focused_pane()
@@ -1784,6 +2000,7 @@ impl App {
17842000
 
17852001
         self.help_modal.set_bounds(Rect::new(0, 0, width, height));
17862002
         self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height));
2003
+        self.conflict_dialog.set_bounds(Rect::new(0, 0, width, height));
17872004
         self.progress_dialog.set_bounds(Rect::new(0, 0, width, height));
17882005
     }
17892006
 
@@ -1851,6 +2068,9 @@ impl App {
18512068
         // Draw confirm dialog overlay (on top of everything)
18522069
         self.confirm_dialog.render(&self.renderer)?;
18532070
 
2071
+        // Draw conflict dialog overlay (on top of everything)
2072
+        self.conflict_dialog.render(&self.renderer)?;
2073
+
18542074
         // Draw progress dialog overlay (on top of everything)
18552075
         self.progress_dialog.render(&self.renderer)?;
18562076
 
garfield/src/ui/dialog.rsmodified
@@ -583,3 +583,296 @@ impl ProgressDialog {
583583
         Ok(())
584584
     }
585585
 }
586
+
587
+/// Result of a conflict dialog.
588
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589
+pub enum ConflictAction {
590
+    /// Replace the existing file.
591
+    Replace,
592
+    /// Skip this file.
593
+    Skip,
594
+    /// Keep both (auto-rename).
595
+    KeepBoth,
596
+    /// Cancel the entire operation.
597
+    Cancel,
598
+}
599
+
600
+/// A modal dialog for file conflict resolution.
601
+pub struct ConflictDialog {
602
+    /// Window bounds (for centering).
603
+    bounds: Rect,
604
+    /// Conflicting file name.
605
+    filename: String,
606
+    /// Whether the dialog is visible.
607
+    visible: bool,
608
+    /// Currently focused button (0-3).
609
+    focused_button: usize,
610
+    /// Hovered button.
611
+    hovered_button: Option<usize>,
612
+    /// Apply to all remaining conflicts.
613
+    apply_to_all: bool,
614
+}
615
+
616
+impl ConflictDialog {
617
+    /// Create a new conflict dialog.
618
+    pub fn new(bounds: Rect) -> Self {
619
+        Self {
620
+            bounds,
621
+            filename: String::new(),
622
+            visible: false,
623
+            focused_button: 2, // Default to Keep Both (safest)
624
+            hovered_button: None,
625
+            apply_to_all: false,
626
+        }
627
+    }
628
+
629
+    /// Set bounds.
630
+    pub fn set_bounds(&mut self, bounds: Rect) {
631
+        self.bounds = bounds;
632
+    }
633
+
634
+    /// Show the dialog for a conflicting file.
635
+    pub fn show(&mut self, filename: &str) {
636
+        self.filename = filename.to_string();
637
+        self.visible = true;
638
+        self.focused_button = 2; // Default to Keep Both
639
+        self.hovered_button = None;
640
+        self.apply_to_all = false;
641
+    }
642
+
643
+    /// Check if visible.
644
+    pub fn is_visible(&self) -> bool {
645
+        self.visible
646
+    }
647
+
648
+    /// Check if apply to all is set.
649
+    pub fn apply_to_all(&self) -> bool {
650
+        self.apply_to_all
651
+    }
652
+
653
+    /// Hide the dialog.
654
+    pub fn hide(&mut self) {
655
+        self.visible = false;
656
+    }
657
+
658
+    /// Handle key press. Returns Some(action) if dialog should close.
659
+    pub fn handle_key(&mut self, key: &Key) -> Option<ConflictAction> {
660
+        if !self.visible {
661
+            return None;
662
+        }
663
+
664
+        match key {
665
+            Key::Escape => {
666
+                self.hide();
667
+                Some(ConflictAction::Cancel)
668
+            }
669
+            Key::Return => {
670
+                self.hide();
671
+                Some(match self.focused_button {
672
+                    0 => ConflictAction::Replace,
673
+                    1 => ConflictAction::Skip,
674
+                    2 => ConflictAction::KeepBoth,
675
+                    _ => ConflictAction::Cancel,
676
+                })
677
+            }
678
+            Key::Tab | Key::Right => {
679
+                self.focused_button = (self.focused_button + 1) % 4;
680
+                None
681
+            }
682
+            Key::Left => {
683
+                self.focused_button = if self.focused_button == 0 { 3 } else { self.focused_button - 1 };
684
+                None
685
+            }
686
+            Key::Char('a') | Key::Char('A') => {
687
+                // Toggle apply to all
688
+                self.apply_to_all = !self.apply_to_all;
689
+                None
690
+            }
691
+            _ => None,
692
+        }
693
+    }
694
+
695
+    /// Handle mouse move.
696
+    pub fn on_mouse_move(&mut self, pos: Point) {
697
+        if !self.visible {
698
+            return;
699
+        }
700
+
701
+        let buttons = self.button_rects();
702
+        self.hovered_button = buttons.iter().position(|r| r.contains_point(pos));
703
+    }
704
+
705
+    /// Handle click. Returns Some(action) if a button was clicked.
706
+    pub fn on_click(&mut self, pos: Point) -> Option<ConflictAction> {
707
+        if !self.visible {
708
+            return None;
709
+        }
710
+
711
+        let dialog_rect = self.dialog_rect();
712
+        if !dialog_rect.contains_point(pos) {
713
+            self.hide();
714
+            return Some(ConflictAction::Cancel);
715
+        }
716
+
717
+        // Check checkbox
718
+        let checkbox_rect = self.checkbox_rect();
719
+        if checkbox_rect.contains_point(pos) {
720
+            self.apply_to_all = !self.apply_to_all;
721
+            return None;
722
+        }
723
+
724
+        let buttons = self.button_rects();
725
+        for (i, rect) in buttons.iter().enumerate() {
726
+            if rect.contains_point(pos) {
727
+                self.hide();
728
+                return Some(match i {
729
+                    0 => ConflictAction::Replace,
730
+                    1 => ConflictAction::Skip,
731
+                    2 => ConflictAction::KeepBoth,
732
+                    _ => ConflictAction::Cancel,
733
+                });
734
+            }
735
+        }
736
+
737
+        None
738
+    }
739
+
740
+    /// Get the dialog rectangle.
741
+    fn dialog_rect(&self) -> Rect {
742
+        let dialog_width = 420.min(self.bounds.width.saturating_sub(40));
743
+        let dialog_height = 180.min(self.bounds.height.saturating_sub(40));
744
+        let x = self.bounds.x + (self.bounds.width as i32 - dialog_width as i32) / 2;
745
+        let y = self.bounds.y + (self.bounds.height as i32 - dialog_height as i32) / 2;
746
+        Rect::new(x, y, dialog_width, dialog_height)
747
+    }
748
+
749
+    /// Get button rectangles [Replace, Skip, Keep Both, Cancel].
750
+    fn button_rects(&self) -> [Rect; 4] {
751
+        let dialog = self.dialog_rect();
752
+        let button_width = 85;
753
+        let button_height = 28;
754
+        let button_y = dialog.y + dialog.height as i32 - button_height as i32 - 16;
755
+        let button_gap = 8;
756
+        let total_width = button_width * 4 + button_gap * 3;
757
+        let start_x = dialog.x + (dialog.width as i32 - total_width as i32) / 2;
758
+
759
+        [
760
+            Rect::new(start_x, button_y, button_width as u32, button_height),
761
+            Rect::new(start_x + button_width + button_gap, button_y, button_width as u32, button_height),
762
+            Rect::new(start_x + (button_width + button_gap) * 2, button_y, button_width as u32, button_height),
763
+            Rect::new(start_x + (button_width + button_gap) * 3, button_y, button_width as u32, button_height),
764
+        ]
765
+    }
766
+
767
+    /// Get checkbox rectangle.
768
+    fn checkbox_rect(&self) -> Rect {
769
+        let dialog = self.dialog_rect();
770
+        Rect::new(dialog.x + 20, dialog.y + 95, 16, 16)
771
+    }
772
+
773
+    /// Render the dialog.
774
+    pub fn render(&self, renderer: &Renderer) -> Result<()> {
775
+        if !self.visible {
776
+            return Ok(());
777
+        }
778
+
779
+        let theme = renderer.theme();
780
+
781
+        // Dim background overlay
782
+        renderer.fill_rect(self.bounds, gartk_core::Color::from_u8(0, 0, 0, 180))?;
783
+
784
+        let dialog_rect = self.dialog_rect();
785
+
786
+        // Dialog background
787
+        renderer.fill_rounded_rect(dialog_rect, 8.0, theme.background)?;
788
+        renderer.stroke_rounded_rect(dialog_rect, 8.0, theme.border, 1.0)?;
789
+
790
+        // Title
791
+        let title_style = TextStyle::new()
792
+            .font_family(&theme.font_family)
793
+            .font_size(theme.font_size + 2.0)
794
+            .color(theme.foreground);
795
+
796
+        renderer.text(
797
+            "File Already Exists",
798
+            (dialog_rect.x + 20) as f64,
799
+            (dialog_rect.y + 20) as f64,
800
+            &title_style,
801
+        )?;
802
+
803
+        // Message
804
+        let msg_style = TextStyle::new()
805
+            .font_family(&theme.font_family)
806
+            .font_size(theme.font_size)
807
+            .color(theme.item_foreground);
808
+
809
+        let message = format!("\"{}\" already exists in the destination.", self.filename);
810
+        renderer.text(
811
+            &message,
812
+            (dialog_rect.x + 20) as f64,
813
+            (dialog_rect.y + 52) as f64,
814
+            &msg_style,
815
+        )?;
816
+
817
+        renderer.text(
818
+            "What would you like to do?",
819
+            (dialog_rect.x + 20) as f64,
820
+            (dialog_rect.y + 72) as f64,
821
+            &msg_style,
822
+        )?;
823
+
824
+        // Checkbox for "Apply to all"
825
+        let checkbox_rect = self.checkbox_rect();
826
+        renderer.stroke_rounded_rect(checkbox_rect, 2.0, theme.border, 1.0)?;
827
+        if self.apply_to_all {
828
+            // Draw checkmark
829
+            let cx = checkbox_rect.x as f64 + 3.0;
830
+            let cy = checkbox_rect.y as f64 + 8.0;
831
+            renderer.line(cx, cy, cx + 4.0, cy + 4.0, theme.foreground, 2.0)?;
832
+            renderer.line(cx + 4.0, cy + 4.0, cx + 10.0, cy - 4.0, theme.foreground, 2.0)?;
833
+        }
834
+
835
+        let checkbox_label_style = TextStyle::new()
836
+            .font_family(&theme.font_family)
837
+            .font_size(theme.font_size - 1.0)
838
+            .color(theme.item_foreground);
839
+
840
+        renderer.text(
841
+            "Apply to all (A)",
842
+            (checkbox_rect.x + 22) as f64,
843
+            (checkbox_rect.y) as f64,
844
+            &checkbox_label_style,
845
+        )?;
846
+
847
+        // Buttons
848
+        let buttons = self.button_rects();
849
+        let labels = ["Replace", "Skip", "Keep Both", "Cancel"];
850
+
851
+        let button_style = TextStyle::new()
852
+            .font_family(&theme.font_family)
853
+            .font_size(theme.font_size - 1.0)
854
+            .color(theme.foreground);
855
+
856
+        for (i, (rect, label)) in buttons.iter().zip(labels.iter()).enumerate() {
857
+            let focused = self.focused_button == i;
858
+            let hovered = self.hovered_button == Some(i);
859
+
860
+            let bg = if focused || hovered {
861
+                theme.item_hover_background
862
+            } else {
863
+                theme.item_background
864
+            };
865
+            renderer.fill_rounded_rect(*rect, 4.0, bg)?;
866
+            if focused {
867
+                renderer.stroke_rounded_rect(*rect, 4.0, theme.foreground, 2.0)?;
868
+            }
869
+
870
+            let text_width = renderer.measure_text(label, &button_style)?.width;
871
+            let text_x = rect.x + (rect.width as i32 - text_width as i32) / 2;
872
+            let text_y = rect.y + (rect.height as i32 - (theme.font_size - 1.0) as i32) / 2;
873
+            renderer.text(label, text_x as f64, text_y as f64, &button_style)?;
874
+        }
875
+
876
+        Ok(())
877
+    }
878
+}
garfield/src/ui/help_modal.rsmodified
@@ -9,6 +9,7 @@ pub struct HelpModal {
99
     bounds: Rect,
1010
     visible: bool,
1111
     scroll_offset: i32,
12
+    content_height: i32,
1213
 }
1314
 
1415
 /// A keybind entry for display.
@@ -52,6 +53,7 @@ const KEYBINDS: &[(&str, &[KeybindEntry])] = &[
5253
         KeybindEntry { key: "Ctrl+A", description: "Select all" },
5354
         KeybindEntry { key: "Ctrl+Click", description: "Toggle selection" },
5455
         KeybindEntry { key: "Shift+Click", description: "Range select" },
56
+        KeybindEntry { key: "Shift+Up/Down", description: "Extend selection" },
5557
     ]),
5658
     ("File Operations", &[
5759
         KeybindEntry { key: "Ctrl+C", description: "Copy" },
@@ -64,11 +66,16 @@ const KEYBINDS: &[(&str, &[KeybindEntry])] = &[
6466
         KeybindEntry { key: "F2", description: "Rename" },
6567
         KeybindEntry { key: "Ctrl+Shift+N", description: "New folder" },
6668
     ]),
69
+    ("Bookmarks", &[
70
+        KeybindEntry { key: "Ctrl+D", description: "Add bookmark" },
71
+        KeybindEntry { key: "Drag folder", description: "Drop on sidebar" },
72
+    ]),
6773
     ("Other", &[
6874
         KeybindEntry { key: "Ctrl+L", description: "Edit address" },
6975
         KeybindEntry { key: "F5", description: "Refresh" },
7076
         KeybindEntry { key: "F1", description: "Toggle help" },
71
-        KeybindEntry { key: "Escape", description: "Close modal" },
77
+        KeybindEntry { key: "Escape", description: "Close modal/quit" },
78
+        KeybindEntry { key: "q", description: "Quit" },
7279
     ]),
7380
 ];
7481
 
@@ -79,9 +86,25 @@ impl HelpModal {
7986
             bounds,
8087
             visible: false,
8188
             scroll_offset: 0,
89
+            content_height: Self::calculate_content_height(),
8290
         }
8391
     }
8492
 
93
+    /// Calculate total content height based on keybind entries.
94
+    fn calculate_content_height() -> i32 {
95
+        let line_height = 21; // Approximate: font_size * 1.5
96
+        let section_gap = 8;
97
+        let section_header_extra = 4;
98
+
99
+        let mut height = 0;
100
+        for (_, entries) in KEYBINDS {
101
+            height += line_height + section_header_extra; // Section header
102
+            height += entries.len() as i32 * line_height; // Entries
103
+            height += section_gap;
104
+        }
105
+        height
106
+    }
107
+
85108
     /// Set bounds.
86109
     pub fn set_bounds(&mut self, bounds: Rect) {
87110
         self.bounds = bounds;
@@ -127,6 +150,18 @@ impl HelpModal {
127150
         true
128151
     }
129152
 
153
+    /// Handle mouse wheel scroll.
154
+    pub fn on_scroll(&mut self, delta: i32) {
155
+        if !self.visible {
156
+            return;
157
+        }
158
+        if delta > 0 {
159
+            self.scroll_up();
160
+        } else if delta < 0 {
161
+            self.scroll_down();
162
+        }
163
+    }
164
+
130165
     /// Scroll up.
131166
     pub fn scroll_up(&mut self) {
132167
         self.scroll_offset = (self.scroll_offset - 20).max(0);
@@ -134,7 +169,10 @@ impl HelpModal {
134169
 
135170
     /// Scroll down.
136171
     pub fn scroll_down(&mut self) {
137
-        self.scroll_offset += 20;
172
+        let modal_rect = self.modal_rect();
173
+        let visible_height = modal_rect.height as i32 - 60; // Account for title
174
+        let max_scroll = (self.content_height - visible_height).max(0);
175
+        self.scroll_offset = (self.scroll_offset + 20).min(max_scroll);
138176
     }
139177
 
140178
     /// Get the modal rectangle (centered in bounds).
@@ -187,6 +225,36 @@ impl HelpModal {
187225
 
188226
         self.render_keybinds(renderer, content_rect, theme)?;
189227
 
228
+        // Render scroll indicator if content overflows
229
+        let visible_height = content_rect.height as i32;
230
+        if self.content_height > visible_height {
231
+            self.render_scrollbar(renderer, modal_rect, visible_height, theme)?;
232
+        }
233
+
234
+        Ok(())
235
+    }
236
+
237
+    /// Render a scrollbar indicator.
238
+    fn render_scrollbar(&self, renderer: &Renderer, modal_rect: Rect, visible_height: i32, theme: &Theme) -> Result<()> {
239
+        let scrollbar_width = 4;
240
+        let scrollbar_x = modal_rect.x + modal_rect.width as i32 - scrollbar_width - 8;
241
+        let scrollbar_y = modal_rect.y + 50;
242
+        let scrollbar_height = visible_height as u32;
243
+
244
+        // Track background
245
+        let track_rect = Rect::new(scrollbar_x, scrollbar_y, scrollbar_width as u32, scrollbar_height);
246
+        renderer.fill_rounded_rect(track_rect, 2.0, theme.item_background)?;
247
+
248
+        // Thumb
249
+        let thumb_ratio = visible_height as f64 / self.content_height as f64;
250
+        let thumb_height = ((scrollbar_height as f64 * thumb_ratio) as u32).max(20);
251
+        let max_scroll = (self.content_height - visible_height).max(1);
252
+        let scroll_ratio = self.scroll_offset as f64 / max_scroll as f64;
253
+        let thumb_y = scrollbar_y + ((scrollbar_height - thumb_height) as f64 * scroll_ratio) as i32;
254
+
255
+        let thumb_rect = Rect::new(scrollbar_x, thumb_y, scrollbar_width as u32, thumb_height);
256
+        renderer.fill_rounded_rect(thumb_rect, 2.0, theme.item_foreground)?;
257
+
190258
         Ok(())
191259
     }
192260
 
garfield/src/ui/mod.rsmodified
@@ -15,7 +15,7 @@ pub mod tab_bar;
1515
 pub mod toolbar;
1616
 
1717
 pub use address_bar::AddressBar;
18
-pub use dialog::{ConfirmDialog, DialogResult, ProgressDialog, ProgressInfo};
18
+pub use dialog::{ConfirmDialog, ConflictAction, ConflictDialog, DialogResult, ProgressDialog, ProgressInfo};
1919
 pub use breadcrumb::Breadcrumb;
2020
 pub use column_view::{ColumnClickResult, ColumnView};
2121
 pub use grid_view::GridView;