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 @@
11
 //! Application state and event loop.
22
 
33
 use garfield::core::{
4
-    Clipboard, ClipboardOperation, FileOperation, UndoStack,
4
+    Clipboard, ClipboardOperation, FileOperation, PreviewLoader, UndoStack,
55
     copy_files, move_files, delete_files, create_directory,
66
     trash_files, restore_from_trash,
77
 };
@@ -94,6 +94,8 @@ pub struct App {
9494
     undo_stack: UndoStack,
9595
     /// Pending paste operation with conflicts.
9696
     pending_paste: Option<PendingPaste>,
97
+    /// Async preview loader for column view.
98
+    preview_loader: PreviewLoader,
9799
 }
98100
 
99101
 /// State for a paste operation with conflicts.
@@ -268,6 +270,7 @@ impl App {
268270
             pending_delete_paths: Vec::new(),
269271
             undo_stack: UndoStack::new(),
270272
             pending_paste: None,
273
+            preview_loader: PreviewLoader::new(),
271274
         };
272275
 
273276
         app.update_status_bar();
@@ -310,8 +313,9 @@ impl App {
310313
                 }
311314
                 InputEvent::MouseMove(mouse_event) => {
312315
                     let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
313
-                    self.handle_mouse_move(pos);
314
-                    ev.request_redraw();
316
+                    if self.handle_mouse_move(pos) {
317
+                        ev.request_redraw();
318
+                    }
315319
                 }
316320
                 InputEvent::MouseLeave => {
317321
                     self.toolbar.clear_hover();
@@ -339,6 +343,21 @@ impl App {
339343
                 _ => {}
340344
             }
341345
 
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
+
342361
             if ev.needs_redraw() {
343362
                 let _ = self.render();
344363
                 ev.redraw_done();
@@ -623,42 +642,42 @@ impl App {
623642
         }
624643
     }
625644
 
626
-    /// Handle mouse move.
627
-    fn handle_mouse_move(&mut self, pos: Point) {
645
+    /// Handle mouse move. Returns true if a redraw is needed.
646
+    fn handle_mouse_move(&mut self, pos: Point) -> bool {
628647
         // Handle progress dialog hover
629648
         if self.progress_dialog.is_visible() {
630649
             self.progress_dialog.on_mouse_move(pos);
631
-            return;
650
+            return true; // Dialogs always redraw for responsiveness
632651
         }
633652
 
634653
         // Handle confirm dialog hover
635654
         if self.confirm_dialog.is_visible() {
636655
             self.confirm_dialog.on_mouse_move(pos);
637
-            return;
656
+            return true;
638657
         }
639658
 
640659
         // Handle conflict dialog hover
641660
         if self.conflict_dialog.is_visible() {
642661
             self.conflict_dialog.on_mouse_move(pos);
643
-            return;
662
+            return true;
644663
         }
645664
 
646665
         // Handle input dialog hover
647666
         if self.input_dialog.is_visible() {
648667
             self.input_dialog.on_mouse_move(pos);
649
-            return;
668
+            return true;
650669
         }
651670
 
652671
         // Handle app picker hover
653672
         if self.app_picker.is_visible() {
654673
             self.app_picker.on_mouse_move(pos);
655
-            return;
674
+            return true;
656675
         }
657676
 
658677
         // Handle context menu hover
659678
         if self.context_menu.is_visible() {
660679
             self.context_menu.on_mouse_move(pos);
661
-            return;
680
+            return true;
662681
         }
663682
 
664683
         // Handle sidebar resize in progress
@@ -667,14 +686,14 @@ impl App {
667686
             self.sidebar.set_width(new_width);
668687
             let size = self.renderer.size();
669688
             self.update_layout(size.width, size.height);
670
-            return;
689
+            return true;
671690
         }
672691
 
673692
         // Handle pane divider resize in progress
674693
         if let Some(path) = &self.pane_resize_path {
675694
             let path_clone = path.clone();
676695
             self.root_pane.adjust_split_at(&path_clone, pos);
677
-            return;
696
+            return true;
678697
         }
679698
 
680699
         // Handle column resize/drag in progress
@@ -683,18 +702,22 @@ impl App {
683702
                 if let Some(tab) = pane.active_tab_mut() {
684703
                     tab.on_mouse_move(pos);
685704
                 }
686
-                return;
705
+                return true;
687706
             }
688707
         }
689708
 
709
+        let mut needs_redraw = false;
710
+
690711
         // Handle tab reorder drag in progress
691712
         if self.tab_bar.dragging_tab().is_some() {
692713
             self.tab_bar.update_drag(pos);
714
+            needs_redraw = true;
693715
         }
694716
 
695717
         // Handle bookmark reorder drag in progress
696718
         if self.sidebar.bookmark_drag_index().is_some() {
697719
             self.sidebar.update_bookmark_drag(pos);
720
+            needs_redraw = true;
698721
         }
699722
 
700723
         // Handle bookmark drag in progress (dragging from file view)
@@ -715,18 +738,22 @@ impl App {
715738
                 let is_over_drop_zone = self.sidebar.is_bookmark_drop_zone(pos);
716739
                 self.sidebar.set_drop_highlight(is_over_drop_zone);
717740
             }
741
+            needs_redraw = true;
718742
         }
719743
 
720
-        self.toolbar.on_mouse_move(pos);
721
-        self.breadcrumb.on_mouse_move(pos);
722
-        self.sidebar.on_mouse_move(pos);
723
-        self.tab_bar.on_mouse_move(pos);
744
+        // Check hover states - only redraw if any changed
745
+        needs_redraw |= self.toolbar.on_mouse_move(pos);
746
+        needs_redraw |= self.breadcrumb.on_mouse_move(pos);
747
+        needs_redraw |= self.sidebar.on_mouse_move(pos);
748
+        needs_redraw |= self.tab_bar.on_mouse_move(pos);
724749
 
725750
         if let Some(pane) = self.focused_pane_mut() {
726751
             if let Some(tab) = pane.active_tab_mut() {
727
-                tab.on_mouse_move(pos);
752
+                needs_redraw |= tab.on_mouse_move(pos);
728753
             }
729754
         }
755
+
756
+        needs_redraw
730757
     }
731758
 
732759
     /// Handle a key press.
@@ -2447,6 +2474,18 @@ impl App {
24472474
         }
24482475
     }
24492476
 
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
+
24502489
     /// Update status bar.
24512490
     fn update_status_bar(&mut self) {
24522491
         let stats = self.focused_pane()
garfield/src/core/mod.rsmodified
@@ -4,6 +4,7 @@ pub mod clipboard;
44
 pub mod entry;
55
 pub mod history;
66
 pub mod operations;
7
+pub mod preview_loader;
78
 pub mod trash;
89
 pub mod undo;
910
 
@@ -16,5 +17,6 @@ pub use operations::{
1617
     copy_files, copy_path, copy_to_path, create_directory, delete_files, delete_path,
1718
     make_unique_name, move_files, move_path, rename_path, OperationResult,
1819
 };
20
+pub use preview_loader::{PreviewLoader, PreviewResult};
1921
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
2022
 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 {
8484
         self.bounds = bounds;
8585
     }
8686
 
87
-    /// Handle mouse move for hover effects.
88
-    pub fn on_mouse_move(&mut self, pos: Point) {
87
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
88
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
89
+        let old_hovered = self.hovered;
90
+
8991
         if !self.bounds.contains_point(pos) {
9092
             self.hovered = None;
91
-            return;
93
+            return self.hovered != old_hovered;
9294
         }
9395
 
9496
         self.hovered = self.segments.iter().position(|s| s.bounds.contains_point(pos));
97
+        self.hovered != old_hovered
9598
     }
9699
 
97100
     /// Handle mouse click. Returns the path to navigate to, if any.
garfield/src/ui/column_view.rsmodified
@@ -81,6 +81,8 @@ pub struct ColumnView {
8181
     current_column: Column,
8282
     /// Preview column (contents of selected dir or file info).
8383
     preview_column: Option<Column>,
84
+    /// Path that needs preview loading (set when selection changes to a directory).
85
+    pending_preview_path: Option<PathBuf>,
8486
     /// View bounds.
8587
     bounds: Rect,
8688
     /// Show hidden files.
@@ -108,6 +110,7 @@ impl ColumnView {
108110
             parent_column: None,
109111
             current_column: Column::new(Vec::new(), column_bounds),
110112
             preview_column: None,
113
+            pending_preview_path: None,
111114
             bounds,
112115
             show_hidden: false,
113116
             sort_order: SortOrder::Name,
@@ -179,26 +182,69 @@ impl ColumnView {
179182
         self.update_preview();
180183
     }
181184
 
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.
183188
     fn update_preview(&mut self) {
184189
         let visible = self.current_column.visible_entries(self.show_hidden);
185190
         if let Some(entry) = visible.get(self.current_column.selected).copied() {
186191
             if entry.is_dir() {
187
-                let preview_x = self.current_column.bounds.x + self.current_column.bounds.width as i32;
188
-                let preview_width = self.bounds.x + self.bounds.width as i32 - preview_x;
189
-                let preview_bounds = Rect::new(preview_x, self.bounds.y, preview_width as u32, self.bounds.height);
190
-
191
-                let mut entries = read_directory(&entry.path).unwrap_or_default();
192
-                sort_entries(&mut entries, self.sort_order, self.sort_direction);
193
-                self.preview_column = Some(Column::new(entries, preview_bounds));
192
+                // Check if we already have the correct preview loaded
193
+                let needs_load = self.pending_preview_path.as_ref() != Some(&entry.path);
194
+                if needs_load {
195
+                    self.pending_preview_path = Some(entry.path.clone());
196
+                    // Clear current preview while loading
197
+                    self.preview_column = None;
198
+                }
194199
             } else {
200
+                self.pending_preview_path = None;
195201
                 self.preview_column = None;
196202
             }
197203
         } else {
204
+            self.pending_preview_path = None;
198205
             self.preview_column = None;
199206
         }
200207
     }
201208
 
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
+
202248
     /// Get visible entries in current column.
203249
     pub fn visible_entries(&self) -> Vec<&FileEntry> {
204250
         self.current_column.visible_entries(self.show_hidden)
@@ -349,10 +395,13 @@ impl ColumnView {
349395
         self.update_columns();
350396
     }
351397
 
352
-    /// Handle mouse move for hover effects.
353
-    pub fn on_mouse_move(&mut self, pos: Point) {
398
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
399
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
400
+        let mut changed = false;
401
+
354402
         // Parent column hover
355403
         if let Some(ref mut parent) = self.parent_column {
404
+            let old_hovered = parent.hovered;
356405
             parent.hovered = None;
357406
             if parent.bounds.contains_point(pos) {
358407
                 let visible = parent.visible_entries(self.show_hidden);
@@ -365,9 +414,13 @@ impl ColumnView {
365414
                     }
366415
                 }
367416
             }
417
+            if parent.hovered != old_hovered {
418
+                changed = true;
419
+            }
368420
         }
369421
 
370422
         // Current column hover
423
+        let old_current_hovered = self.current_column.hovered;
371424
         self.current_column.hovered = None;
372425
         if self.current_column.bounds.contains_point(pos) {
373426
             let visible = self.visible_entries();
@@ -380,9 +433,13 @@ impl ColumnView {
380433
                 }
381434
             }
382435
         }
436
+        if self.current_column.hovered != old_current_hovered {
437
+            changed = true;
438
+        }
383439
 
384440
         // Preview column hover
385441
         if let Some(ref mut preview) = self.preview_column {
442
+            let old_preview_hovered = preview.hovered;
386443
             preview.hovered = None;
387444
             if preview.bounds.contains_point(pos) {
388445
                 let visible = preview.visible_entries(self.show_hidden);
@@ -395,7 +452,12 @@ impl ColumnView {
395452
                     }
396453
                 }
397454
             }
455
+            if preview.hovered != old_preview_hovered {
456
+                changed = true;
457
+            }
398458
         }
459
+
460
+        changed
399461
     }
400462
 
401463
     /// Get the entry at the given position (for drag detection).
garfield/src/ui/grid_view.rsmodified
@@ -339,18 +339,20 @@ impl GridView {
339339
         Rect::new(x, y, cell_size, cell_size)
340340
     }
341341
 
342
-    /// Handle mouse move for hover effects and rubber band drag.
343
-    pub fn on_mouse_move(&mut self, pos: Point) {
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) -> bool {
344344
         // Handle rubber band drag
345345
         if self.drag_start.is_some() {
346346
             self.drag_current = Some(pos);
347347
             self.update_rubber_band_selection();
348
-            return;
348
+            return true; // Rubber band always needs redraw
349349
         }
350350
 
351
+        let old_hovered = self.hovered;
352
+
351353
         if !self.bounds.contains_point(pos) {
352354
             self.hovered = None;
353
-            return;
355
+            return self.hovered != old_hovered;
354356
         }
355357
 
356358
         let visible = self.visible_entries();
@@ -364,6 +366,8 @@ impl GridView {
364366
                 break;
365367
             }
366368
         }
369
+
370
+        self.hovered != old_hovered
367371
     }
368372
 
369373
     /// Start rubber band selection.
garfield/src/ui/list_view.rsmodified
@@ -348,8 +348,8 @@ impl ListView {
348348
         self.resizing_column.is_some()
349349
     }
350350
 
351
-    /// Handle mouse move (for hover and resize).
352
-    pub fn on_mouse_move(&mut self, pos: Point) {
351
+    /// Handle mouse move (for hover and resize). Returns true if state changed.
352
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
353353
         // Handle active resize
354354
         if let Some(divider_index) = self.resizing_column {
355355
             let new_x = pos.x;
@@ -379,12 +379,14 @@ impl ListView {
379379
                 }
380380
                 _ => {}
381381
             }
382
-            return;
382
+            return true; // Resizing always needs redraw
383383
         }
384384
 
385
+        let old_hovered = self.hovered_header;
386
+
385387
         if !self.bounds.contains_point(pos) {
386388
             self.hovered_header = None;
387
-            return;
389
+            return self.hovered_header != old_hovered;
388390
         }
389391
 
390392
         let header = self.header_bounds();
@@ -392,19 +394,20 @@ impl ListView {
392394
             // Check for column resize zones
393395
             if self.divider_at(pos).is_some() {
394396
                 self.hovered_header = None;
395
-                return;
397
+                return self.hovered_header != old_hovered;
396398
             }
397399
 
398400
             // Check column headers for hover
399401
             for col in [Column::Name, Column::Size, Column::Modified] {
400402
                 if self.column_header_bounds(col).contains_point(pos) {
401403
                     self.hovered_header = Some(col);
402
-                    return;
404
+                    return self.hovered_header != old_hovered;
403405
                 }
404406
             }
405407
         }
406408
 
407409
         self.hovered_header = None;
410
+        self.hovered_header != old_hovered
408411
     }
409412
 
410413
     /// Handle header click for sorting. Returns (new_order, new_direction) if sort changed.
garfield/src/ui/sidebar.rsmodified
@@ -548,11 +548,13 @@ impl Sidebar {
548548
         }
549549
     }
550550
 
551
-    /// Handle mouse move for hover effects.
552
-    pub fn on_mouse_move(&mut self, pos: Point) {
551
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
552
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
553
+        let old_hovered = self.hovered;
554
+
553555
         if !self.visible || !self.bounds.contains_point(pos) {
554556
             self.hovered = None;
555
-            return;
557
+            return self.hovered != old_hovered;
556558
         }
557559
 
558560
         self.hovered = None;
@@ -564,6 +566,8 @@ impl Sidebar {
564566
                 }
565567
             }
566568
         }
569
+
570
+        self.hovered != old_hovered
567571
     }
568572
 
569573
     /// Handle mouse click. Returns the path to navigate to, if any.
garfield/src/ui/tab.rsmodified
@@ -370,7 +370,7 @@ impl Tab {
370370
 
371371
     // === Mouse handling ===
372372
 
373
-    pub fn on_mouse_move(&mut self, pos: Point) {
373
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
374374
         match self.view_mode {
375375
             ViewMode::List => self.list_view.on_mouse_move(pos),
376376
             ViewMode::Grid => self.grid_view.on_mouse_move(pos),
@@ -673,6 +673,30 @@ impl Tab {
673673
             ViewMode::Columns => self.column_view.render(renderer),
674674
         }
675675
     }
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
+    }
676700
 }
677701
 
678702
 impl RenameState {
garfield/src/ui/tab_bar.rsmodified
@@ -125,13 +125,16 @@ impl TabBar {
125125
         })
126126
     }
127127
 
128
-    /// Handle mouse move for hover effects.
129
-    pub fn on_mouse_move(&mut self, pos: Point) {
128
+    /// Handle mouse move for hover effects. Returns true if hover state changed.
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
+
130133
         self.hovered_tab = None;
131134
         self.hovered_close = None;
132135
 
133136
         if !self.bounds.contains_point(pos) {
134
-            return;
137
+            return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
135138
         }
136139
 
137140
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
@@ -140,13 +143,15 @@ impl TabBar {
140143
                 if let Some(close_bounds) = self.close_button_bounds(i) {
141144
                     if close_bounds.contains_point(pos) {
142145
                         self.hovered_close = Some(i);
143
-                        return;
146
+                        return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
144147
                     }
145148
                 }
146149
                 self.hovered_tab = Some(i);
147
-                return;
150
+                return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
148151
             }
149152
         }
153
+
154
+        self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close
150155
     }
151156
 
152157
     /// Handle click. Returns (clicked_tab, is_close_button).
garfield/src/ui/toolbar.rsmodified
@@ -199,9 +199,11 @@ impl Toolbar {
199199
         });
200200
     }
201201
 
202
-    /// Handle mouse move.
203
-    pub fn on_mouse_move(&mut self, pos: Point) {
202
+    /// Handle mouse move. Returns true if hover state changed.
203
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
204
+        let old_hovered = self.hovered;
204205
         self.hovered = self.buttons.iter().position(|b| b.bounds.contains_point(pos));
206
+        self.hovered != old_hovered
205207
     }
206208
 
207209
     /// Clear hover state.