gardesk/garfield / 4a37cd0

Browse files

perf: async preview loading and optimized mouse move redraws

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4a37cd04f65d4b97d37502f3a5aae77617ba4239
Parents
189be9d
Tree
beca963

11 changed files

StatusFile+-
M garfield/src/app.rs 58 19
M garfield/src/core/mod.rs 2 0
A garfield/src/core/preview_loader.rs 138 0
M garfield/src/ui/breadcrumb.rs 6 3
M garfield/src/ui/column_view.rs 72 10
M garfield/src/ui/grid_view.rs 8 4
M garfield/src/ui/list_view.rs 9 6
M garfield/src/ui/sidebar.rs 7 3
M garfield/src/ui/tab.rs 25 1
M garfield/src/ui/tab_bar.rs 10 5
M garfield/src/ui/toolbar.rs 4 2
garfield/src/app.rsmodified
@@ -1,7 +1,7 @@
1
 //! Application state and event loop.
1
 //! Application state and event loop.
2
 
2
 
3
 use garfield::core::{
3
 use garfield::core::{
4
-    Clipboard, ClipboardOperation, FileOperation, UndoStack,
4
+    Clipboard, ClipboardOperation, FileOperation, PreviewLoader, UndoStack,
5
     copy_files, move_files, delete_files, create_directory,
5
     copy_files, move_files, delete_files, create_directory,
6
     trash_files, restore_from_trash,
6
     trash_files, restore_from_trash,
7
 };
7
 };
@@ -94,6 +94,8 @@ pub struct App {
94
     undo_stack: UndoStack,
94
     undo_stack: UndoStack,
95
     /// Pending paste operation with conflicts.
95
     /// Pending paste operation with conflicts.
96
     pending_paste: Option<PendingPaste>,
96
     pending_paste: Option<PendingPaste>,
97
+    /// Async preview loader for column view.
98
+    preview_loader: PreviewLoader,
97
 }
99
 }
98
 
100
 
99
 /// State for a paste operation with conflicts.
101
 /// State for a paste operation with conflicts.
@@ -268,6 +270,7 @@ impl App {
268
             pending_delete_paths: Vec::new(),
270
             pending_delete_paths: Vec::new(),
269
             undo_stack: UndoStack::new(),
271
             undo_stack: UndoStack::new(),
270
             pending_paste: None,
272
             pending_paste: None,
273
+            preview_loader: PreviewLoader::new(),
271
         };
274
         };
272
 
275
 
273
         app.update_status_bar();
276
         app.update_status_bar();
@@ -310,8 +313,9 @@ impl App {
310
                 }
313
                 }
311
                 InputEvent::MouseMove(mouse_event) => {
314
                 InputEvent::MouseMove(mouse_event) => {
312
                     let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
315
                     let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
313
-                    self.handle_mouse_move(pos);
316
+                    if self.handle_mouse_move(pos) {
314
-                    ev.request_redraw();
317
+                        ev.request_redraw();
318
+                    }
315
                 }
319
                 }
316
                 InputEvent::MouseLeave => {
320
                 InputEvent::MouseLeave => {
317
                     self.toolbar.clear_hover();
321
                     self.toolbar.clear_hover();
@@ -339,6 +343,21 @@ impl App {
339
                 _ => {}
343
                 _ => {}
340
             }
344
             }
341
 
345
 
346
+            // Poll for completed async preview loads
347
+            if let Some(result) = self.preview_loader.poll() {
348
+                if let Some(entries) = result.entries {
349
+                    if let Some(pane) = self.focused_pane_mut() {
350
+                        if let Some(tab) = pane.active_tab_mut() {
351
+                            tab.set_preview_entries(&result.path, entries);
352
+                        }
353
+                    }
354
+                }
355
+                ev.request_redraw();
356
+            }
357
+
358
+            // Check for pending preview requests and submit them
359
+            self.process_pending_previews();
360
+
342
             if ev.needs_redraw() {
361
             if ev.needs_redraw() {
343
                 let _ = self.render();
362
                 let _ = self.render();
344
                 ev.redraw_done();
363
                 ev.redraw_done();
@@ -623,42 +642,42 @@ impl App {
623
         }
642
         }
624
     }
643
     }
625
 
644
 
626
-    /// Handle mouse move.
645
+    /// Handle mouse move. Returns true if a redraw is needed.
627
-    fn handle_mouse_move(&mut self, pos: Point) {
646
+    fn handle_mouse_move(&mut self, pos: Point) -> bool {
628
         // Handle progress dialog hover
647
         // Handle progress dialog hover
629
         if self.progress_dialog.is_visible() {
648
         if self.progress_dialog.is_visible() {
630
             self.progress_dialog.on_mouse_move(pos);
649
             self.progress_dialog.on_mouse_move(pos);
631
-            return;
650
+            return true; // Dialogs always redraw for responsiveness
632
         }
651
         }
633
 
652
 
634
         // Handle confirm dialog hover
653
         // Handle confirm dialog hover
635
         if self.confirm_dialog.is_visible() {
654
         if self.confirm_dialog.is_visible() {
636
             self.confirm_dialog.on_mouse_move(pos);
655
             self.confirm_dialog.on_mouse_move(pos);
637
-            return;
656
+            return true;
638
         }
657
         }
639
 
658
 
640
         // Handle conflict dialog hover
659
         // Handle conflict dialog hover
641
         if self.conflict_dialog.is_visible() {
660
         if self.conflict_dialog.is_visible() {
642
             self.conflict_dialog.on_mouse_move(pos);
661
             self.conflict_dialog.on_mouse_move(pos);
643
-            return;
662
+            return true;
644
         }
663
         }
645
 
664
 
646
         // Handle input dialog hover
665
         // Handle input dialog hover
647
         if self.input_dialog.is_visible() {
666
         if self.input_dialog.is_visible() {
648
             self.input_dialog.on_mouse_move(pos);
667
             self.input_dialog.on_mouse_move(pos);
649
-            return;
668
+            return true;
650
         }
669
         }
651
 
670
 
652
         // Handle app picker hover
671
         // Handle app picker hover
653
         if self.app_picker.is_visible() {
672
         if self.app_picker.is_visible() {
654
             self.app_picker.on_mouse_move(pos);
673
             self.app_picker.on_mouse_move(pos);
655
-            return;
674
+            return true;
656
         }
675
         }
657
 
676
 
658
         // Handle context menu hover
677
         // Handle context menu hover
659
         if self.context_menu.is_visible() {
678
         if self.context_menu.is_visible() {
660
             self.context_menu.on_mouse_move(pos);
679
             self.context_menu.on_mouse_move(pos);
661
-            return;
680
+            return true;
662
         }
681
         }
663
 
682
 
664
         // Handle sidebar resize in progress
683
         // Handle sidebar resize in progress
@@ -667,14 +686,14 @@ impl App {
667
             self.sidebar.set_width(new_width);
686
             self.sidebar.set_width(new_width);
668
             let size = self.renderer.size();
687
             let size = self.renderer.size();
669
             self.update_layout(size.width, size.height);
688
             self.update_layout(size.width, size.height);
670
-            return;
689
+            return true;
671
         }
690
         }
672
 
691
 
673
         // Handle pane divider resize in progress
692
         // Handle pane divider resize in progress
674
         if let Some(path) = &self.pane_resize_path {
693
         if let Some(path) = &self.pane_resize_path {
675
             let path_clone = path.clone();
694
             let path_clone = path.clone();
676
             self.root_pane.adjust_split_at(&path_clone, pos);
695
             self.root_pane.adjust_split_at(&path_clone, pos);
677
-            return;
696
+            return true;
678
         }
697
         }
679
 
698
 
680
         // Handle column resize/drag in progress
699
         // Handle column resize/drag in progress
@@ -683,18 +702,22 @@ impl App {
683
                 if let Some(tab) = pane.active_tab_mut() {
702
                 if let Some(tab) = pane.active_tab_mut() {
684
                     tab.on_mouse_move(pos);
703
                     tab.on_mouse_move(pos);
685
                 }
704
                 }
686
-                return;
705
+                return true;
687
             }
706
             }
688
         }
707
         }
689
 
708
 
709
+        let mut needs_redraw = false;
710
+
690
         // Handle tab reorder drag in progress
711
         // Handle tab reorder drag in progress
691
         if self.tab_bar.dragging_tab().is_some() {
712
         if self.tab_bar.dragging_tab().is_some() {
692
             self.tab_bar.update_drag(pos);
713
             self.tab_bar.update_drag(pos);
714
+            needs_redraw = true;
693
         }
715
         }
694
 
716
 
695
         // Handle bookmark reorder drag in progress
717
         // Handle bookmark reorder drag in progress
696
         if self.sidebar.bookmark_drag_index().is_some() {
718
         if self.sidebar.bookmark_drag_index().is_some() {
697
             self.sidebar.update_bookmark_drag(pos);
719
             self.sidebar.update_bookmark_drag(pos);
720
+            needs_redraw = true;
698
         }
721
         }
699
 
722
 
700
         // Handle bookmark drag in progress (dragging from file view)
723
         // Handle bookmark drag in progress (dragging from file view)
@@ -715,18 +738,22 @@ impl App {
715
                 let is_over_drop_zone = self.sidebar.is_bookmark_drop_zone(pos);
738
                 let is_over_drop_zone = self.sidebar.is_bookmark_drop_zone(pos);
716
                 self.sidebar.set_drop_highlight(is_over_drop_zone);
739
                 self.sidebar.set_drop_highlight(is_over_drop_zone);
717
             }
740
             }
741
+            needs_redraw = true;
718
         }
742
         }
719
 
743
 
720
-        self.toolbar.on_mouse_move(pos);
744
+        // Check hover states - only redraw if any changed
721
-        self.breadcrumb.on_mouse_move(pos);
745
+        needs_redraw |= self.toolbar.on_mouse_move(pos);
722
-        self.sidebar.on_mouse_move(pos);
746
+        needs_redraw |= self.breadcrumb.on_mouse_move(pos);
723
-        self.tab_bar.on_mouse_move(pos);
747
+        needs_redraw |= self.sidebar.on_mouse_move(pos);
748
+        needs_redraw |= self.tab_bar.on_mouse_move(pos);
724
 
749
 
725
         if let Some(pane) = self.focused_pane_mut() {
750
         if let Some(pane) = self.focused_pane_mut() {
726
             if let Some(tab) = pane.active_tab_mut() {
751
             if let Some(tab) = pane.active_tab_mut() {
727
-                tab.on_mouse_move(pos);
752
+                needs_redraw |= tab.on_mouse_move(pos);
728
             }
753
             }
729
         }
754
         }
755
+
756
+        needs_redraw
730
     }
757
     }
731
 
758
 
732
     /// Handle a key press.
759
     /// Handle a key press.
@@ -2447,6 +2474,18 @@ impl App {
2447
         }
2474
         }
2448
     }
2475
     }
2449
 
2476
 
2477
+    /// Process pending preview requests from column views.
2478
+    fn process_pending_previews(&mut self) {
2479
+        // Check focused pane's active tab for pending preview
2480
+        if let Some(pane) = self.focused_pane_mut() {
2481
+            if let Some(tab) = pane.active_tab_mut() {
2482
+                if let Some((path, sort_order, sort_direction)) = tab.take_pending_preview() {
2483
+                    self.preview_loader.load(path, sort_order, sort_direction);
2484
+                }
2485
+            }
2486
+        }
2487
+    }
2488
+
2450
     /// Update status bar.
2489
     /// Update status bar.
2451
     fn update_status_bar(&mut self) {
2490
     fn update_status_bar(&mut self) {
2452
         let stats = self.focused_pane()
2491
         let stats = self.focused_pane()
garfield/src/core/mod.rsmodified
@@ -4,6 +4,7 @@ pub mod clipboard;
4
 pub mod entry;
4
 pub mod entry;
5
 pub mod history;
5
 pub mod history;
6
 pub mod operations;
6
 pub mod operations;
7
+pub mod preview_loader;
7
 pub mod trash;
8
 pub mod trash;
8
 pub mod undo;
9
 pub mod undo;
9
 
10
 
@@ -16,5 +17,6 @@ pub use operations::{
16
     copy_files, copy_path, copy_to_path, create_directory, delete_files, delete_path,
17
     copy_files, copy_path, copy_to_path, create_directory, delete_files, delete_path,
17
     make_unique_name, move_files, move_path, rename_path, OperationResult,
18
     make_unique_name, move_files, move_path, rename_path, OperationResult,
18
 };
19
 };
20
+pub use preview_loader::{PreviewLoader, PreviewResult};
19
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
21
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
20
 pub use undo::{FileOperation, UndoStack};
22
 pub use undo::{FileOperation, UndoStack};
garfield/src/core/preview_loader.rsadded
@@ -0,0 +1,138 @@
1
+//! Asynchronous preview loading for directory contents.
2
+//!
3
+//! This module provides non-blocking directory loading to prevent UI lag
4
+//! when selecting directories with many files.
5
+
6
+use crate::core::{read_directory, sort_entries, FileEntry, SortDirection, SortOrder};
7
+use std::path::PathBuf;
8
+use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
9
+use std::thread::{self, JoinHandle};
10
+
11
+/// Request to load a directory preview.
12
+#[derive(Debug, Clone)]
13
+pub struct PreviewRequest {
14
+    /// Path to load.
15
+    pub path: PathBuf,
16
+    /// Sort order to apply.
17
+    pub sort_order: SortOrder,
18
+    /// Sort direction to apply.
19
+    pub sort_direction: SortDirection,
20
+    /// Request ID for matching responses.
21
+    pub request_id: u64,
22
+}
23
+
24
+/// Result of a preview load operation.
25
+#[derive(Debug)]
26
+pub struct PreviewResult {
27
+    /// Path that was loaded.
28
+    pub path: PathBuf,
29
+    /// Loaded entries (None if load failed).
30
+    pub entries: Option<Vec<FileEntry>>,
31
+    /// Request ID for matching.
32
+    pub request_id: u64,
33
+}
34
+
35
+/// Manages asynchronous preview loading with a worker thread.
36
+pub struct PreviewLoader {
37
+    /// Sender to submit load requests.
38
+    request_tx: Sender<PreviewRequest>,
39
+    /// Receiver for completed loads.
40
+    result_rx: Receiver<PreviewResult>,
41
+    /// Worker thread handle.
42
+    _worker: JoinHandle<()>,
43
+    /// Current request ID counter.
44
+    next_request_id: u64,
45
+    /// ID of the most recent request (for ignoring stale results).
46
+    current_request_id: u64,
47
+}
48
+
49
+impl PreviewLoader {
50
+    /// Create a new preview loader with a background worker thread.
51
+    pub fn new() -> Self {
52
+        let (request_tx, request_rx) = mpsc::channel::<PreviewRequest>();
53
+        let (result_tx, result_rx) = mpsc::channel::<PreviewResult>();
54
+
55
+        let worker = thread::spawn(move || {
56
+            Self::worker_loop(request_rx, result_tx);
57
+        });
58
+
59
+        Self {
60
+            request_tx,
61
+            result_rx,
62
+            _worker: worker,
63
+            next_request_id: 0,
64
+            current_request_id: 0,
65
+        }
66
+    }
67
+
68
+    /// Worker thread loop - processes load requests.
69
+    fn worker_loop(request_rx: Receiver<PreviewRequest>, result_tx: Sender<PreviewResult>) {
70
+        while let Ok(request) = request_rx.recv() {
71
+            // Load the directory
72
+            let entries = read_directory(&request.path).ok().map(|mut entries| {
73
+                sort_entries(&mut entries, request.sort_order, request.sort_direction);
74
+                entries
75
+            });
76
+
77
+            // Send result back (ignore send errors - main thread may have dropped receiver)
78
+            let _ = result_tx.send(PreviewResult {
79
+                path: request.path,
80
+                entries,
81
+                request_id: request.request_id,
82
+            });
83
+        }
84
+    }
85
+
86
+    /// Request loading a directory preview. Returns the request ID.
87
+    pub fn load(&mut self, path: PathBuf, sort_order: SortOrder, sort_direction: SortDirection) -> u64 {
88
+        self.next_request_id += 1;
89
+        self.current_request_id = self.next_request_id;
90
+
91
+        let request = PreviewRequest {
92
+            path,
93
+            sort_order,
94
+            sort_direction,
95
+            request_id: self.current_request_id,
96
+        };
97
+
98
+        // Ignore send errors - worker thread may have panicked
99
+        let _ = self.request_tx.send(request);
100
+
101
+        self.current_request_id
102
+    }
103
+
104
+    /// Poll for a completed preview load. Returns None if no result ready.
105
+    /// Only returns results for the most recent request (ignores stale results).
106
+    pub fn poll(&mut self) -> Option<PreviewResult> {
107
+        loop {
108
+            match self.result_rx.try_recv() {
109
+                Ok(result) => {
110
+                    // Only return if this is the current request
111
+                    if result.request_id == self.current_request_id {
112
+                        return Some(result);
113
+                    }
114
+                    // Otherwise, discard stale result and keep polling
115
+                }
116
+                Err(TryRecvError::Empty) => return None,
117
+                Err(TryRecvError::Disconnected) => return None,
118
+            }
119
+        }
120
+    }
121
+
122
+    /// Check if there's a pending load request.
123
+    pub fn is_loading(&self) -> bool {
124
+        // We're loading if we've sent a request but haven't received a matching result
125
+        self.current_request_id > 0
126
+    }
127
+
128
+    /// Cancel any pending requests by invalidating the current request ID.
129
+    pub fn cancel(&mut self) {
130
+        self.current_request_id = 0;
131
+    }
132
+}
133
+
134
+impl Default for PreviewLoader {
135
+    fn default() -> Self {
136
+        Self::new()
137
+    }
138
+}
garfield/src/ui/breadcrumb.rsmodified
@@ -84,14 +84,17 @@ impl Breadcrumb {
84
         self.bounds = bounds;
84
         self.bounds = bounds;
85
     }
85
     }
86
 
86
 
87
-    /// Handle mouse move for hover effects.
87
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
88
-    pub fn on_mouse_move(&mut self, pos: Point) {
88
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
89
+        let old_hovered = self.hovered;
90
+
89
         if !self.bounds.contains_point(pos) {
91
         if !self.bounds.contains_point(pos) {
90
             self.hovered = None;
92
             self.hovered = None;
91
-            return;
93
+            return self.hovered != old_hovered;
92
         }
94
         }
93
 
95
 
94
         self.hovered = self.segments.iter().position(|s| s.bounds.contains_point(pos));
96
         self.hovered = self.segments.iter().position(|s| s.bounds.contains_point(pos));
97
+        self.hovered != old_hovered
95
     }
98
     }
96
 
99
 
97
     /// Handle mouse click. Returns the path to navigate to, if any.
100
     /// Handle mouse click. Returns the path to navigate to, if any.
garfield/src/ui/column_view.rsmodified
@@ -81,6 +81,8 @@ pub struct ColumnView {
81
     current_column: Column,
81
     current_column: Column,
82
     /// Preview column (contents of selected dir or file info).
82
     /// Preview column (contents of selected dir or file info).
83
     preview_column: Option<Column>,
83
     preview_column: Option<Column>,
84
+    /// Path that needs preview loading (set when selection changes to a directory).
85
+    pending_preview_path: Option<PathBuf>,
84
     /// View bounds.
86
     /// View bounds.
85
     bounds: Rect,
87
     bounds: Rect,
86
     /// Show hidden files.
88
     /// Show hidden files.
@@ -108,6 +110,7 @@ impl ColumnView {
108
             parent_column: None,
110
             parent_column: None,
109
             current_column: Column::new(Vec::new(), column_bounds),
111
             current_column: Column::new(Vec::new(), column_bounds),
110
             preview_column: None,
112
             preview_column: None,
113
+            pending_preview_path: None,
111
             bounds,
114
             bounds,
112
             show_hidden: false,
115
             show_hidden: false,
113
             sort_order: SortOrder::Name,
116
             sort_order: SortOrder::Name,
@@ -179,26 +182,69 @@ impl ColumnView {
179
         self.update_preview();
182
         self.update_preview();
180
     }
183
     }
181
 
184
 
182
-    /// Update preview column based on current selection.
185
+    /// Mark that preview needs to be updated based on current selection.
186
+    /// Does not load synchronously - call `take_pending_preview()` to get the path
187
+    /// that needs loading, then `set_preview_entries()` when data is ready.
183
     fn update_preview(&mut self) {
188
     fn update_preview(&mut self) {
184
         let visible = self.current_column.visible_entries(self.show_hidden);
189
         let visible = self.current_column.visible_entries(self.show_hidden);
185
         if let Some(entry) = visible.get(self.current_column.selected).copied() {
190
         if let Some(entry) = visible.get(self.current_column.selected).copied() {
186
             if entry.is_dir() {
191
             if entry.is_dir() {
187
-                let preview_x = self.current_column.bounds.x + self.current_column.bounds.width as i32;
192
+                // Check if we already have the correct preview loaded
188
-                let preview_width = self.bounds.x + self.bounds.width as i32 - preview_x;
193
+                let needs_load = self.pending_preview_path.as_ref() != Some(&entry.path);
189
-                let preview_bounds = Rect::new(preview_x, self.bounds.y, preview_width as u32, self.bounds.height);
194
+                if needs_load {
190
-
195
+                    self.pending_preview_path = Some(entry.path.clone());
191
-                let mut entries = read_directory(&entry.path).unwrap_or_default();
196
+                    // Clear current preview while loading
192
-                sort_entries(&mut entries, self.sort_order, self.sort_direction);
197
+                    self.preview_column = None;
193
-                self.preview_column = Some(Column::new(entries, preview_bounds));
198
+                }
194
             } else {
199
             } else {
200
+                self.pending_preview_path = None;
195
                 self.preview_column = None;
201
                 self.preview_column = None;
196
             }
202
             }
197
         } else {
203
         } else {
204
+            self.pending_preview_path = None;
198
             self.preview_column = None;
205
             self.preview_column = None;
199
         }
206
         }
200
     }
207
     }
201
 
208
 
209
+    /// Get the path that needs preview loading, if any.
210
+    /// Returns the path and sort settings. Returns None if no preview needed.
211
+    pub fn take_pending_preview(&mut self) -> Option<(PathBuf, SortOrder, SortDirection)> {
212
+        self.pending_preview_path.take().map(|path| {
213
+            (path, self.sort_order, self.sort_direction)
214
+        })
215
+    }
216
+
217
+    /// Check if there's a pending preview load for a specific path.
218
+    pub fn has_pending_preview_for(&self, path: &PathBuf) -> bool {
219
+        self.pending_preview_path.as_ref() == Some(path)
220
+    }
221
+
222
+    /// Set preview entries from externally loaded data.
223
+    pub fn set_preview_entries(&mut self, path: &PathBuf, entries: Vec<FileEntry>) {
224
+        // Only set if this is still the path we're waiting for
225
+        let visible = self.current_column.visible_entries(self.show_hidden);
226
+        let selected_is_dir = visible
227
+            .get(self.current_column.selected)
228
+            .map(|e| e.is_dir() && &e.path == path)
229
+            .unwrap_or(false);
230
+
231
+        if selected_is_dir {
232
+            let preview_x = self.current_column.bounds.x + self.current_column.bounds.width as i32;
233
+            let preview_width = self.bounds.x + self.bounds.width as i32 - preview_x;
234
+            let preview_bounds = Rect::new(preview_x, self.bounds.y, preview_width as u32, self.bounds.height);
235
+            self.preview_column = Some(Column::new(entries, preview_bounds));
236
+        }
237
+        // Clear pending if this was what we were waiting for
238
+        if self.pending_preview_path.as_ref() == Some(path) {
239
+            self.pending_preview_path = None;
240
+        }
241
+    }
242
+
243
+    /// Check if preview is currently loading.
244
+    pub fn is_preview_loading(&self) -> bool {
245
+        self.pending_preview_path.is_some()
246
+    }
247
+
202
     /// Get visible entries in current column.
248
     /// Get visible entries in current column.
203
     pub fn visible_entries(&self) -> Vec<&FileEntry> {
249
     pub fn visible_entries(&self) -> Vec<&FileEntry> {
204
         self.current_column.visible_entries(self.show_hidden)
250
         self.current_column.visible_entries(self.show_hidden)
@@ -349,10 +395,13 @@ impl ColumnView {
349
         self.update_columns();
395
         self.update_columns();
350
     }
396
     }
351
 
397
 
352
-    /// Handle mouse move for hover effects.
398
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
353
-    pub fn on_mouse_move(&mut self, pos: Point) {
399
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
400
+        let mut changed = false;
401
+
354
         // Parent column hover
402
         // Parent column hover
355
         if let Some(ref mut parent) = self.parent_column {
403
         if let Some(ref mut parent) = self.parent_column {
404
+            let old_hovered = parent.hovered;
356
             parent.hovered = None;
405
             parent.hovered = None;
357
             if parent.bounds.contains_point(pos) {
406
             if parent.bounds.contains_point(pos) {
358
                 let visible = parent.visible_entries(self.show_hidden);
407
                 let visible = parent.visible_entries(self.show_hidden);
@@ -365,9 +414,13 @@ impl ColumnView {
365
                     }
414
                     }
366
                 }
415
                 }
367
             }
416
             }
417
+            if parent.hovered != old_hovered {
418
+                changed = true;
419
+            }
368
         }
420
         }
369
 
421
 
370
         // Current column hover
422
         // Current column hover
423
+        let old_current_hovered = self.current_column.hovered;
371
         self.current_column.hovered = None;
424
         self.current_column.hovered = None;
372
         if self.current_column.bounds.contains_point(pos) {
425
         if self.current_column.bounds.contains_point(pos) {
373
             let visible = self.visible_entries();
426
             let visible = self.visible_entries();
@@ -380,9 +433,13 @@ impl ColumnView {
380
                 }
433
                 }
381
             }
434
             }
382
         }
435
         }
436
+        if self.current_column.hovered != old_current_hovered {
437
+            changed = true;
438
+        }
383
 
439
 
384
         // Preview column hover
440
         // Preview column hover
385
         if let Some(ref mut preview) = self.preview_column {
441
         if let Some(ref mut preview) = self.preview_column {
442
+            let old_preview_hovered = preview.hovered;
386
             preview.hovered = None;
443
             preview.hovered = None;
387
             if preview.bounds.contains_point(pos) {
444
             if preview.bounds.contains_point(pos) {
388
                 let visible = preview.visible_entries(self.show_hidden);
445
                 let visible = preview.visible_entries(self.show_hidden);
@@ -395,7 +452,12 @@ impl ColumnView {
395
                     }
452
                     }
396
                 }
453
                 }
397
             }
454
             }
455
+            if preview.hovered != old_preview_hovered {
456
+                changed = true;
457
+            }
398
         }
458
         }
459
+
460
+        changed
399
     }
461
     }
400
 
462
 
401
     /// Get the entry at the given position (for drag detection).
463
     /// Get the entry at the given position (for drag detection).
garfield/src/ui/grid_view.rsmodified
@@ -339,18 +339,20 @@ impl GridView {
339
         Rect::new(x, y, cell_size, cell_size)
339
         Rect::new(x, y, cell_size, cell_size)
340
     }
340
     }
341
 
341
 
342
-    /// Handle mouse move for hover effects and rubber band drag.
342
+    /// Handle mouse move for hover effects and rubber band drag. Returns true if state changed.
343
-    pub fn on_mouse_move(&mut self, pos: Point) {
343
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
344
         // Handle rubber band drag
344
         // Handle rubber band drag
345
         if self.drag_start.is_some() {
345
         if self.drag_start.is_some() {
346
             self.drag_current = Some(pos);
346
             self.drag_current = Some(pos);
347
             self.update_rubber_band_selection();
347
             self.update_rubber_band_selection();
348
-            return;
348
+            return true; // Rubber band always needs redraw
349
         }
349
         }
350
 
350
 
351
+        let old_hovered = self.hovered;
352
+
351
         if !self.bounds.contains_point(pos) {
353
         if !self.bounds.contains_point(pos) {
352
             self.hovered = None;
354
             self.hovered = None;
353
-            return;
355
+            return self.hovered != old_hovered;
354
         }
356
         }
355
 
357
 
356
         let visible = self.visible_entries();
358
         let visible = self.visible_entries();
@@ -364,6 +366,8 @@ impl GridView {
364
                 break;
366
                 break;
365
             }
367
             }
366
         }
368
         }
369
+
370
+        self.hovered != old_hovered
367
     }
371
     }
368
 
372
 
369
     /// Start rubber band selection.
373
     /// Start rubber band selection.
garfield/src/ui/list_view.rsmodified
@@ -348,8 +348,8 @@ impl ListView {
348
         self.resizing_column.is_some()
348
         self.resizing_column.is_some()
349
     }
349
     }
350
 
350
 
351
-    /// Handle mouse move (for hover and resize).
351
+    /// Handle mouse move (for hover and resize). Returns true if state changed.
352
-    pub fn on_mouse_move(&mut self, pos: Point) {
352
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
353
         // Handle active resize
353
         // Handle active resize
354
         if let Some(divider_index) = self.resizing_column {
354
         if let Some(divider_index) = self.resizing_column {
355
             let new_x = pos.x;
355
             let new_x = pos.x;
@@ -379,12 +379,14 @@ impl ListView {
379
                 }
379
                 }
380
                 _ => {}
380
                 _ => {}
381
             }
381
             }
382
-            return;
382
+            return true; // Resizing always needs redraw
383
         }
383
         }
384
 
384
 
385
+        let old_hovered = self.hovered_header;
386
+
385
         if !self.bounds.contains_point(pos) {
387
         if !self.bounds.contains_point(pos) {
386
             self.hovered_header = None;
388
             self.hovered_header = None;
387
-            return;
389
+            return self.hovered_header != old_hovered;
388
         }
390
         }
389
 
391
 
390
         let header = self.header_bounds();
392
         let header = self.header_bounds();
@@ -392,19 +394,20 @@ impl ListView {
392
             // Check for column resize zones
394
             // Check for column resize zones
393
             if self.divider_at(pos).is_some() {
395
             if self.divider_at(pos).is_some() {
394
                 self.hovered_header = None;
396
                 self.hovered_header = None;
395
-                return;
397
+                return self.hovered_header != old_hovered;
396
             }
398
             }
397
 
399
 
398
             // Check column headers for hover
400
             // Check column headers for hover
399
             for col in [Column::Name, Column::Size, Column::Modified] {
401
             for col in [Column::Name, Column::Size, Column::Modified] {
400
                 if self.column_header_bounds(col).contains_point(pos) {
402
                 if self.column_header_bounds(col).contains_point(pos) {
401
                     self.hovered_header = Some(col);
403
                     self.hovered_header = Some(col);
402
-                    return;
404
+                    return self.hovered_header != old_hovered;
403
                 }
405
                 }
404
             }
406
             }
405
         }
407
         }
406
 
408
 
407
         self.hovered_header = None;
409
         self.hovered_header = None;
410
+        self.hovered_header != old_hovered
408
     }
411
     }
409
 
412
 
410
     /// Handle header click for sorting. Returns (new_order, new_direction) if sort changed.
413
     /// Handle header click for sorting. Returns (new_order, new_direction) if sort changed.
garfield/src/ui/sidebar.rsmodified
@@ -548,11 +548,13 @@ impl Sidebar {
548
         }
548
         }
549
     }
549
     }
550
 
550
 
551
-    /// Handle mouse move for hover effects.
551
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
552
-    pub fn on_mouse_move(&mut self, pos: Point) {
552
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
553
+        let old_hovered = self.hovered;
554
+
553
         if !self.visible || !self.bounds.contains_point(pos) {
555
         if !self.visible || !self.bounds.contains_point(pos) {
554
             self.hovered = None;
556
             self.hovered = None;
555
-            return;
557
+            return self.hovered != old_hovered;
556
         }
558
         }
557
 
559
 
558
         self.hovered = None;
560
         self.hovered = None;
@@ -564,6 +566,8 @@ impl Sidebar {
564
                 }
566
                 }
565
             }
567
             }
566
         }
568
         }
569
+
570
+        self.hovered != old_hovered
567
     }
571
     }
568
 
572
 
569
     /// Handle mouse click. Returns the path to navigate to, if any.
573
     /// Handle mouse click. Returns the path to navigate to, if any.
garfield/src/ui/tab.rsmodified
@@ -370,7 +370,7 @@ impl Tab {
370
 
370
 
371
     // === Mouse handling ===
371
     // === Mouse handling ===
372
 
372
 
373
-    pub fn on_mouse_move(&mut self, pos: Point) {
373
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
374
         match self.view_mode {
374
         match self.view_mode {
375
             ViewMode::List => self.list_view.on_mouse_move(pos),
375
             ViewMode::List => self.list_view.on_mouse_move(pos),
376
             ViewMode::Grid => self.grid_view.on_mouse_move(pos),
376
             ViewMode::Grid => self.grid_view.on_mouse_move(pos),
@@ -673,6 +673,30 @@ impl Tab {
673
             ViewMode::Columns => self.column_view.render(renderer),
673
             ViewMode::Columns => self.column_view.render(renderer),
674
         }
674
         }
675
     }
675
     }
676
+
677
+    // === Async Preview Support ===
678
+
679
+    /// Take pending preview request for column view (if in column mode).
680
+    /// Returns (path, sort_order, sort_direction) if a preview needs loading.
681
+    pub fn take_pending_preview(&mut self) -> Option<(PathBuf, SortOrder, SortDirection)> {
682
+        if self.view_mode == ViewMode::Columns {
683
+            self.column_view.take_pending_preview()
684
+        } else {
685
+            None
686
+        }
687
+    }
688
+
689
+    /// Set preview entries for column view.
690
+    pub fn set_preview_entries(&mut self, path: &PathBuf, entries: Vec<FileEntry>) {
691
+        if self.view_mode == ViewMode::Columns {
692
+            self.column_view.set_preview_entries(path, entries);
693
+        }
694
+    }
695
+
696
+    /// Check if preview is currently loading.
697
+    pub fn is_preview_loading(&self) -> bool {
698
+        self.view_mode == ViewMode::Columns && self.column_view.is_preview_loading()
699
+    }
676
 }
700
 }
677
 
701
 
678
 impl RenameState {
702
 impl RenameState {
garfield/src/ui/tab_bar.rsmodified
@@ -125,13 +125,16 @@ impl TabBar {
125
         })
125
         })
126
     }
126
     }
127
 
127
 
128
-    /// Handle mouse move for hover effects.
128
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
129
-    pub fn on_mouse_move(&mut self, pos: Point) {
129
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
130
+        let old_hovered_tab = self.hovered_tab;
131
+        let old_hovered_close = self.hovered_close;
132
+
130
         self.hovered_tab = None;
133
         self.hovered_tab = None;
131
         self.hovered_close = None;
134
         self.hovered_close = None;
132
 
135
 
133
         if !self.bounds.contains_point(pos) {
136
         if !self.bounds.contains_point(pos) {
134
-            return;
137
+            return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
135
         }
138
         }
136
 
139
 
137
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
140
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
@@ -140,13 +143,15 @@ impl TabBar {
140
                 if let Some(close_bounds) = self.close_button_bounds(i) {
143
                 if let Some(close_bounds) = self.close_button_bounds(i) {
141
                     if close_bounds.contains_point(pos) {
144
                     if close_bounds.contains_point(pos) {
142
                         self.hovered_close = Some(i);
145
                         self.hovered_close = Some(i);
143
-                        return;
146
+                        return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
144
                     }
147
                     }
145
                 }
148
                 }
146
                 self.hovered_tab = Some(i);
149
                 self.hovered_tab = Some(i);
147
-                return;
150
+                return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
148
             }
151
             }
149
         }
152
         }
153
+
154
+        self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close
150
     }
155
     }
151
 
156
 
152
     /// Handle click. Returns (clicked_tab, is_close_button).
157
     /// Handle click. Returns (clicked_tab, is_close_button).
garfield/src/ui/toolbar.rsmodified
@@ -199,9 +199,11 @@ impl Toolbar {
199
         });
199
         });
200
     }
200
     }
201
 
201
 
202
-    /// Handle mouse move.
202
+    /// Handle mouse move. Returns true if hover state changed.
203
-    pub fn on_mouse_move(&mut self, pos: Point) {
203
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
204
+        let old_hovered = self.hovered;
204
         self.hovered = self.buttons.iter().position(|b| b.bounds.contains_point(pos));
205
         self.hovered = self.buttons.iter().position(|b| b.bounds.contains_point(pos));
206
+        self.hovered != old_hovered
205
     }
207
     }
206
 
208
 
207
     /// Clear hover state.
209
     /// Clear hover state.