gardesk/garfield / ef80a58

Browse files

grid: add image thumbnails, column: add image preview, add scroll support

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ef80a58582f4a44c97d83f700daf2f3f9476240f
Parents
9f89f23
Tree
16a4ab7

12 changed files

StatusFile+-
M Cargo.lock 166 0
M Cargo.toml 3 0
M garfield/Cargo.toml 3 0
M garfield/src/app.rs 68 2
A garfield/src/core/image_preview.rs 179 0
M garfield/src/core/mod.rs 4 0
A garfield/src/core/thumbnail.rs 219 0
M garfield/src/ui/column_view.rs 127 5
M garfield/src/ui/grid_view.rs 135 26
M garfield/src/ui/list_view.rs 26 0
M garfield/src/ui/sidebar.rs 6 0
M garfield/src/ui/tab.rs 42 0
Cargo.lockmodified
@@ -2,6 +2,12 @@
22
 # It is not intended for manual editing.
33
 version = 4
44
 
5
+[[package]]
6
+name = "adler2"
7
+version = "2.0.1"
8
+source = "registry+https://github.com/rust-lang/crates.io-index"
9
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
+
511
 [[package]]
612
 name = "aho-corasick"
713
 version = "1.1.4"
@@ -100,6 +106,18 @@ version = "3.19.1"
100106
 source = "registry+https://github.com/rust-lang/crates.io-index"
101107
 checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
102108
 
109
+[[package]]
110
+name = "bytemuck"
111
+version = "1.24.0"
112
+source = "registry+https://github.com/rust-lang/crates.io-index"
113
+checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
114
+
115
+[[package]]
116
+name = "byteorder-lite"
117
+version = "0.1.0"
118
+source = "registry+https://github.com/rust-lang/crates.io-index"
119
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
120
+
103121
 [[package]]
104122
 name = "cairo-rs"
105123
 version = "0.20.12"
@@ -202,6 +220,12 @@ version = "0.7.7"
202220
 source = "registry+https://github.com/rust-lang/crates.io-index"
203221
 checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
204222
 
223
+[[package]]
224
+name = "color_quant"
225
+version = "1.1.0"
226
+source = "registry+https://github.com/rust-lang/crates.io-index"
227
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
228
+
205229
 [[package]]
206230
 name = "colorchoice"
207231
 version = "1.0.4"
@@ -214,6 +238,15 @@ version = "0.8.7"
214238
 source = "registry+https://github.com/rust-lang/crates.io-index"
215239
 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
216240
 
241
+[[package]]
242
+name = "crc32fast"
243
+version = "1.5.0"
244
+source = "registry+https://github.com/rust-lang/crates.io-index"
245
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
246
+dependencies = [
247
+ "cfg-if",
248
+]
249
+
217250
 [[package]]
218251
 name = "dirs"
219252
 version = "6.0.0"
@@ -251,12 +284,31 @@ dependencies = [
251284
  "windows-sys 0.61.2",
252285
 ]
253286
 
287
+[[package]]
288
+name = "fdeflate"
289
+version = "0.3.7"
290
+source = "registry+https://github.com/rust-lang/crates.io-index"
291
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
292
+dependencies = [
293
+ "simd-adler32",
294
+]
295
+
254296
 [[package]]
255297
 name = "find-msvc-tools"
256298
 version = "0.1.8"
257299
 source = "registry+https://github.com/rust-lang/crates.io-index"
258300
 checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
259301
 
302
+[[package]]
303
+name = "flate2"
304
+version = "1.1.8"
305
+source = "registry+https://github.com/rust-lang/crates.io-index"
306
+checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
307
+dependencies = [
308
+ "crc32fast",
309
+ "miniz_oxide",
310
+]
311
+
260312
 [[package]]
261313
 name = "freedesktop_entry_parser"
262314
 version = "1.3.0"
@@ -342,6 +394,7 @@ dependencies = [
342394
  "gartk-core",
343395
  "gartk-render",
344396
  "gartk-x11",
397
+ "image",
345398
  "libc",
346399
  "nucleo-matcher",
347400
  "serde",
@@ -426,6 +479,16 @@ dependencies = [
426479
  "wasi",
427480
 ]
428481
 
482
+[[package]]
483
+name = "gif"
484
+version = "0.14.1"
485
+source = "registry+https://github.com/rust-lang/crates.io-index"
486
+checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
487
+dependencies = [
488
+ "color_quant",
489
+ "weezl",
490
+]
491
+
429492
 [[package]]
430493
 name = "gio"
431494
 version = "0.20.12"
@@ -547,6 +610,34 @@ dependencies = [
547610
  "cc",
548611
 ]
549612
 
613
+[[package]]
614
+name = "image"
615
+version = "0.25.9"
616
+source = "registry+https://github.com/rust-lang/crates.io-index"
617
+checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
618
+dependencies = [
619
+ "bytemuck",
620
+ "byteorder-lite",
621
+ "color_quant",
622
+ "gif",
623
+ "image-webp",
624
+ "moxcms",
625
+ "num-traits",
626
+ "png",
627
+ "zune-core",
628
+ "zune-jpeg",
629
+]
630
+
631
+[[package]]
632
+name = "image-webp"
633
+version = "0.2.4"
634
+source = "registry+https://github.com/rust-lang/crates.io-index"
635
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
636
+dependencies = [
637
+ "byteorder-lite",
638
+ "quick-error",
639
+]
640
+
550641
 [[package]]
551642
 name = "indexmap"
552643
 version = "2.13.0"
@@ -634,6 +725,26 @@ version = "0.2.1"
634725
 source = "registry+https://github.com/rust-lang/crates.io-index"
635726
 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
636727
 
728
+[[package]]
729
+name = "miniz_oxide"
730
+version = "0.8.9"
731
+source = "registry+https://github.com/rust-lang/crates.io-index"
732
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
733
+dependencies = [
734
+ "adler2",
735
+ "simd-adler32",
736
+]
737
+
738
+[[package]]
739
+name = "moxcms"
740
+version = "0.7.11"
741
+source = "registry+https://github.com/rust-lang/crates.io-index"
742
+checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
743
+dependencies = [
744
+ "num-traits",
745
+ "pxfm",
746
+]
747
+
637748
 [[package]]
638749
 name = "nom"
639750
 version = "7.1.3"
@@ -758,6 +869,19 @@ version = "0.3.32"
758869
 source = "registry+https://github.com/rust-lang/crates.io-index"
759870
 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
760871
 
872
+[[package]]
873
+name = "png"
874
+version = "0.18.0"
875
+source = "registry+https://github.com/rust-lang/crates.io-index"
876
+checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
877
+dependencies = [
878
+ "bitflags",
879
+ "crc32fast",
880
+ "fdeflate",
881
+ "flate2",
882
+ "miniz_oxide",
883
+]
884
+
761885
 [[package]]
762886
 name = "proc-macro-crate"
763887
 version = "3.4.0"
@@ -776,6 +900,21 @@ dependencies = [
776900
  "unicode-ident",
777901
 ]
778902
 
903
+[[package]]
904
+name = "pxfm"
905
+version = "0.1.27"
906
+source = "registry+https://github.com/rust-lang/crates.io-index"
907
+checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
908
+dependencies = [
909
+ "num-traits",
910
+]
911
+
912
+[[package]]
913
+name = "quick-error"
914
+version = "2.0.1"
915
+source = "registry+https://github.com/rust-lang/crates.io-index"
916
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
917
+
779918
 [[package]]
780919
 name = "quote"
781920
 version = "1.0.43"
@@ -908,6 +1047,12 @@ version = "1.3.0"
9081047
 source = "registry+https://github.com/rust-lang/crates.io-index"
9091048
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
9101049
 
1050
+[[package]]
1051
+name = "simd-adler32"
1052
+version = "0.3.8"
1053
+source = "registry+https://github.com/rust-lang/crates.io-index"
1054
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
1055
+
9111056
 [[package]]
9121057
 name = "slab"
9131058
 version = "0.4.11"
@@ -1208,6 +1353,12 @@ dependencies = [
12081353
  "unicode-ident",
12091354
 ]
12101355
 
1356
+[[package]]
1357
+name = "weezl"
1358
+version = "0.1.12"
1359
+source = "registry+https://github.com/rust-lang/crates.io-index"
1360
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
1361
+
12111362
 [[package]]
12121363
 name = "winapi-util"
12131364
 version = "0.1.11"
@@ -1398,3 +1549,18 @@ name = "zmij"
13981549
 version = "1.0.16"
13991550
 source = "registry+https://github.com/rust-lang/crates.io-index"
14001551
 checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
1552
+
1553
+[[package]]
1554
+name = "zune-core"
1555
+version = "0.5.1"
1556
+source = "registry+https://github.com/rust-lang/crates.io-index"
1557
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
1558
+
1559
+[[package]]
1560
+name = "zune-jpeg"
1561
+version = "0.5.11"
1562
+source = "registry+https://github.com/rust-lang/crates.io-index"
1563
+checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2"
1564
+dependencies = [
1565
+ "zune-core",
1566
+]
Cargo.tomlmodified
@@ -51,5 +51,8 @@ freedesktop_entry_parser = "1.3"
5151
 # Fuzzy matching
5252
 nucleo-matcher = "0.3"
5353
 
54
+# Image handling
55
+image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "ico"] }
56
+
5457
 # IPC types (shared between crates)
5558
 garfield-ipc = { path = "garfield-ipc" }
garfield/Cargo.tomlmodified
@@ -44,3 +44,6 @@ nucleo-matcher.workspace = true
4444
 
4545
 # IPC
4646
 garfield-ipc.workspace = true
47
+
48
+# Image handling
49
+image.workspace = true
garfield/src/app.rsmodified
@@ -1,7 +1,7 @@
11
 //! Application state and event loop.
22
 
33
 use garfield::core::{
4
-    Clipboard, ClipboardOperation, FileOperation, PreviewLoader, UndoStack,
4
+    Clipboard, ClipboardOperation, FileOperation, ImagePreviewLoader, PreviewLoader, UndoStack,
55
     copy_files, move_files, delete_files, create_directory,
66
     trash_files, restore_from_trash,
77
 };
@@ -96,6 +96,8 @@ pub struct App {
9696
     pending_paste: Option<PendingPaste>,
9797
     /// Async preview loader for column view.
9898
     preview_loader: PreviewLoader,
99
+    /// Async image preview loader.
100
+    image_preview_loader: ImagePreviewLoader,
99101
 }
100102
 
101103
 /// State for a paste operation with conflicts.
@@ -271,6 +273,7 @@ impl App {
271273
             undo_stack: UndoStack::new(),
272274
             pending_paste: None,
273275
             preview_loader: PreviewLoader::new(),
276
+            image_preview_loader: ImagePreviewLoader::new(),
274277
         };
275278
 
276279
         app.update_status_bar();
@@ -340,10 +343,16 @@ impl App {
340343
                 InputEvent::CloseRequested => {
341344
                     self.should_quit = true;
342345
                 }
346
+                InputEvent::Scroll(scroll_event) => {
347
+                    let pos = Point::new(scroll_event.position.x, scroll_event.position.y);
348
+                    if self.handle_scroll(pos, scroll_event.delta_x, scroll_event.delta_y) {
349
+                        ev.request_redraw();
350
+                    }
351
+                }
343352
                 _ => {}
344353
             }
345354
 
346
-            // Poll for completed async preview loads
355
+            // Poll for completed async preview loads (directories)
347356
             if let Some(result) = self.preview_loader.poll() {
348357
                 if let Some(entries) = result.entries {
349358
                     if let Some(pane) = self.focused_pane_mut() {
@@ -355,8 +364,35 @@ impl App {
355364
                 ev.request_redraw();
356365
             }
357366
 
367
+            // Poll for completed async image preview loads
368
+            if let Some(result) = self.image_preview_loader.poll() {
369
+                if let Some(pane) = self.focused_pane_mut() {
370
+                    if let Some(tab) = pane.active_tab_mut() {
371
+                        tab.set_image_preview(&result.path, result.image);
372
+                    }
373
+                }
374
+                ev.request_redraw();
375
+            }
376
+
377
+            // Poll for completed grid view thumbnails
378
+            if let Some(pane) = self.focused_pane_mut() {
379
+                if let Some(tab) = pane.active_tab_mut() {
380
+                    if tab.poll_thumbnails() {
381
+                        ev.request_redraw();
382
+                    }
383
+                }
384
+            }
385
+
358386
             // Check for pending preview requests and submit them
359387
             self.process_pending_previews();
388
+            self.process_pending_image_previews();
389
+
390
+            // Request thumbnails for visible grid items
391
+            if let Some(pane) = self.focused_pane_mut() {
392
+                if let Some(tab) = pane.active_tab_mut() {
393
+                    tab.request_visible_thumbnails();
394
+                }
395
+            }
360396
 
361397
             if ev.needs_redraw() {
362398
                 let _ = self.render();
@@ -774,6 +810,25 @@ impl App {
774810
         needs_redraw
775811
     }
776812
 
813
+    /// Handle mouse scroll. Returns true if a redraw is needed.
814
+    fn handle_scroll(&mut self, pos: Point, _delta_x: i32, delta_y: i32) -> bool {
815
+        // Check if scroll is over the content area (not sidebar, toolbar, etc.)
816
+        if let Some(pane) = self.focused_pane_mut() {
817
+            if let Some(tab) = pane.active_tab_mut() {
818
+                if tab.bounds().contains_point(pos) {
819
+                    return tab.on_scroll(delta_y);
820
+                }
821
+            }
822
+        }
823
+
824
+        // Check if scroll is over sidebar
825
+        if self.sidebar.bounds().contains_point(pos) {
826
+            return self.sidebar.on_scroll(delta_y);
827
+        }
828
+
829
+        false
830
+    }
831
+
777832
     /// Handle a key press.
778833
     fn handle_key(&mut self, key: &Key, modifiers: &gartk_core::Modifiers) {
779834
         // Handle progress dialog when visible (blocks all other input)
@@ -2506,6 +2561,17 @@ impl App {
25062561
         }
25072562
     }
25082563
 
2564
+    /// Process pending image preview requests.
2565
+    fn process_pending_image_previews(&mut self) {
2566
+        if let Some(pane) = self.focused_pane_mut() {
2567
+            if let Some(tab) = pane.active_tab_mut() {
2568
+                if let Some((path, max_width, max_height)) = tab.take_pending_image_preview() {
2569
+                    self.image_preview_loader.load(path, max_width, max_height);
2570
+                }
2571
+            }
2572
+        }
2573
+    }
2574
+
25092575
     /// Update status bar.
25102576
     fn update_status_bar(&mut self) {
25112577
         let stats = self.focused_pane()
garfield/src/core/image_preview.rsadded
@@ -0,0 +1,179 @@
1
+//! Asynchronous image preview loading.
2
+//!
3
+//! This module provides non-blocking image loading and scaling to prevent UI lag
4
+//! when selecting image files for preview.
5
+
6
+use std::path::PathBuf;
7
+use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
8
+use std::thread::{self, JoinHandle};
9
+
10
+/// Request to load an image preview.
11
+#[derive(Debug, Clone)]
12
+pub struct ImagePreviewRequest {
13
+    /// Path to load.
14
+    pub path: PathBuf,
15
+    /// Maximum width for the preview.
16
+    pub max_width: u32,
17
+    /// Maximum height for the preview.
18
+    pub max_height: u32,
19
+    /// Request ID for matching responses.
20
+    pub request_id: u64,
21
+}
22
+
23
+/// Result of an image preview load operation.
24
+#[derive(Debug)]
25
+pub struct ImagePreviewResult {
26
+    /// Path that was loaded.
27
+    pub path: PathBuf,
28
+    /// Loaded image data (RGBA format), or None if load failed.
29
+    pub image: Option<ImagePreview>,
30
+    /// Request ID for matching.
31
+    pub request_id: u64,
32
+}
33
+
34
+/// Loaded and scaled image preview.
35
+#[derive(Debug, Clone)]
36
+pub struct ImagePreview {
37
+    /// RGBA pixel data.
38
+    pub data: Vec<u8>,
39
+    /// Image width.
40
+    pub width: u32,
41
+    /// Image height.
42
+    pub height: u32,
43
+}
44
+
45
+/// Manages asynchronous image preview loading with a worker thread.
46
+pub struct ImagePreviewLoader {
47
+    /// Sender to submit load requests.
48
+    request_tx: Sender<ImagePreviewRequest>,
49
+    /// Receiver for completed loads.
50
+    result_rx: Receiver<ImagePreviewResult>,
51
+    /// Worker thread handle.
52
+    _worker: JoinHandle<()>,
53
+    /// Current request ID counter.
54
+    next_request_id: u64,
55
+    /// ID of the most recent request (for ignoring stale results).
56
+    current_request_id: u64,
57
+}
58
+
59
+impl ImagePreviewLoader {
60
+    /// Create a new image preview loader with a background worker thread.
61
+    pub fn new() -> Self {
62
+        let (request_tx, request_rx) = mpsc::channel::<ImagePreviewRequest>();
63
+        let (result_tx, result_rx) = mpsc::channel::<ImagePreviewResult>();
64
+
65
+        let worker = thread::spawn(move || {
66
+            Self::worker_loop(request_rx, result_tx);
67
+        });
68
+
69
+        Self {
70
+            request_tx,
71
+            result_rx,
72
+            _worker: worker,
73
+            next_request_id: 0,
74
+            current_request_id: 0,
75
+        }
76
+    }
77
+
78
+    /// Worker thread loop - processes load requests.
79
+    fn worker_loop(request_rx: Receiver<ImagePreviewRequest>, result_tx: Sender<ImagePreviewResult>) {
80
+        while let Ok(request) = request_rx.recv() {
81
+            let image = Self::load_and_scale(&request.path, request.max_width, request.max_height);
82
+
83
+            // Send result back
84
+            let _ = result_tx.send(ImagePreviewResult {
85
+                path: request.path,
86
+                image,
87
+                request_id: request.request_id,
88
+            });
89
+        }
90
+    }
91
+
92
+    /// Load and scale an image to fit within the given dimensions.
93
+    fn load_and_scale(path: &PathBuf, max_width: u32, max_height: u32) -> Option<ImagePreview> {
94
+        use image::GenericImageView;
95
+
96
+        // Load the image
97
+        let img = image::open(path).ok()?;
98
+
99
+        let (orig_width, orig_height) = img.dimensions();
100
+
101
+        // Calculate scale to fit within max dimensions while preserving aspect ratio
102
+        let scale_x = max_width as f64 / orig_width as f64;
103
+        let scale_y = max_height as f64 / orig_height as f64;
104
+        let scale = scale_x.min(scale_y).min(1.0); // Don't upscale
105
+
106
+        let new_width = (orig_width as f64 * scale) as u32;
107
+        let new_height = (orig_height as f64 * scale) as u32;
108
+
109
+        // Resize if needed
110
+        let resized = if scale < 1.0 {
111
+            img.resize(new_width, new_height, image::imageops::FilterType::Triangle)
112
+        } else {
113
+            img
114
+        };
115
+
116
+        // Convert to RGBA
117
+        let rgba = resized.to_rgba8();
118
+        let (width, height) = rgba.dimensions();
119
+
120
+        Some(ImagePreview {
121
+            data: rgba.into_raw(),
122
+            width,
123
+            height,
124
+        })
125
+    }
126
+
127
+    /// Request loading an image preview. Returns the request ID.
128
+    pub fn load(&mut self, path: PathBuf, max_width: u32, max_height: u32) -> u64 {
129
+        self.next_request_id += 1;
130
+        self.current_request_id = self.next_request_id;
131
+
132
+        let request = ImagePreviewRequest {
133
+            path,
134
+            max_width,
135
+            max_height,
136
+            request_id: self.current_request_id,
137
+        };
138
+
139
+        let _ = self.request_tx.send(request);
140
+        self.current_request_id
141
+    }
142
+
143
+    /// Poll for a completed preview load. Returns None if no result ready.
144
+    pub fn poll(&mut self) -> Option<ImagePreviewResult> {
145
+        loop {
146
+            match self.result_rx.try_recv() {
147
+                Ok(result) => {
148
+                    if result.request_id == self.current_request_id {
149
+                        return Some(result);
150
+                    }
151
+                }
152
+                Err(TryRecvError::Empty) => return None,
153
+                Err(TryRecvError::Disconnected) => return None,
154
+            }
155
+        }
156
+    }
157
+
158
+    /// Cancel any pending requests.
159
+    pub fn cancel(&mut self) {
160
+        self.current_request_id = 0;
161
+    }
162
+}
163
+
164
+impl Default for ImagePreviewLoader {
165
+    fn default() -> Self {
166
+        Self::new()
167
+    }
168
+}
169
+
170
+/// Check if a file extension is a supported image format.
171
+pub fn is_supported_image(extension: Option<&str>) -> bool {
172
+    match extension {
173
+        Some(ext) => matches!(
174
+            ext.to_lowercase().as_str(),
175
+            "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp" | "ico"
176
+        ),
177
+        None => false,
178
+    }
179
+}
garfield/src/core/mod.rsmodified
@@ -3,7 +3,9 @@
33
 pub mod clipboard;
44
 pub mod entry;
55
 pub mod history;
6
+pub mod image_preview;
67
 pub mod operations;
8
+pub mod thumbnail;
79
 pub mod preview_loader;
810
 pub mod trash;
911
 pub mod undo;
@@ -13,10 +15,12 @@ pub use entry::{
1315
     read_directory, sort_entries, EntryType, FileEntry, SortDirection, SortOrder,
1416
 };
1517
 pub use history::History;
18
+pub use image_preview::{is_supported_image, ImagePreview, ImagePreviewLoader, ImagePreviewResult};
1619
 pub use operations::{
1720
     copy_files, copy_path, copy_to_path, create_directory, delete_files, delete_path,
1821
     make_unique_name, move_files, move_path, rename_path, OperationResult,
1922
 };
2023
 pub use preview_loader::{PreviewLoader, PreviewResult};
24
+pub use thumbnail::{Thumbnail, ThumbnailLoader, THUMBNAIL_SIZE};
2125
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
2226
 pub use undo::{FileOperation, UndoStack};
garfield/src/core/thumbnail.rsadded
@@ -0,0 +1,219 @@
1
+//! Thumbnail loading and caching for grid view.
2
+//!
3
+//! Provides async thumbnail generation with in-memory caching.
4
+
5
+use std::collections::HashMap;
6
+use std::path::PathBuf;
7
+use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
8
+use std::thread::{self, JoinHandle};
9
+
10
+/// Default thumbnail size.
11
+pub const THUMBNAIL_SIZE: u32 = 64;
12
+
13
+/// Maximum number of cached thumbnails.
14
+const MAX_CACHE_SIZE: usize = 500;
15
+
16
+/// A loaded thumbnail.
17
+#[derive(Debug, Clone)]
18
+pub struct Thumbnail {
19
+    /// RGBA pixel data.
20
+    pub data: Vec<u8>,
21
+    /// Width in pixels.
22
+    pub width: u32,
23
+    /// Height in pixels.
24
+    pub height: u32,
25
+}
26
+
27
+/// Request to load a thumbnail.
28
+#[derive(Debug, Clone)]
29
+struct ThumbnailRequest {
30
+    path: PathBuf,
31
+    size: u32,
32
+    request_id: u64,
33
+}
34
+
35
+/// Result of a thumbnail load.
36
+#[derive(Debug)]
37
+pub struct ThumbnailResult {
38
+    pub path: PathBuf,
39
+    pub thumbnail: Option<Thumbnail>,
40
+}
41
+
42
+/// Manages async thumbnail loading with caching.
43
+pub struct ThumbnailLoader {
44
+    /// Sender for requests.
45
+    request_tx: Sender<ThumbnailRequest>,
46
+    /// Receiver for results.
47
+    result_rx: Receiver<ThumbnailResult>,
48
+    /// Worker thread.
49
+    _worker: JoinHandle<()>,
50
+    /// Request ID counter.
51
+    next_request_id: u64,
52
+    /// Paths currently being loaded.
53
+    pending: HashMap<PathBuf, u64>,
54
+    /// In-memory cache.
55
+    cache: HashMap<PathBuf, Thumbnail>,
56
+    /// LRU order tracking (most recently used at end).
57
+    lru_order: Vec<PathBuf>,
58
+}
59
+
60
+impl ThumbnailLoader {
61
+    /// Create a new thumbnail loader.
62
+    pub fn new() -> Self {
63
+        let (request_tx, request_rx) = mpsc::channel::<ThumbnailRequest>();
64
+        let (result_tx, result_rx) = mpsc::channel::<ThumbnailResult>();
65
+
66
+        let worker = thread::spawn(move || {
67
+            Self::worker_loop(request_rx, result_tx);
68
+        });
69
+
70
+        Self {
71
+            request_tx,
72
+            result_rx,
73
+            _worker: worker,
74
+            next_request_id: 0,
75
+            pending: HashMap::new(),
76
+            cache: HashMap::new(),
77
+            lru_order: Vec::new(),
78
+        }
79
+    }
80
+
81
+    /// Worker thread loop.
82
+    fn worker_loop(request_rx: Receiver<ThumbnailRequest>, result_tx: Sender<ThumbnailResult>) {
83
+        while let Ok(request) = request_rx.recv() {
84
+            let thumbnail = Self::load_thumbnail(&request.path, request.size);
85
+            let _ = result_tx.send(ThumbnailResult {
86
+                path: request.path,
87
+                thumbnail,
88
+            });
89
+        }
90
+    }
91
+
92
+    /// Load and scale a thumbnail.
93
+    fn load_thumbnail(path: &PathBuf, size: u32) -> Option<Thumbnail> {
94
+        use image::GenericImageView;
95
+
96
+        let img = image::open(path).ok()?;
97
+        let (orig_w, orig_h) = img.dimensions();
98
+
99
+        // Calculate scale to fit within size while preserving aspect ratio
100
+        let scale = (size as f64 / orig_w as f64).min(size as f64 / orig_h as f64).min(1.0);
101
+        let new_w = (orig_w as f64 * scale) as u32;
102
+        let new_h = (orig_h as f64 * scale) as u32;
103
+
104
+        // Use fast nearest-neighbor for thumbnails
105
+        let resized = if scale < 1.0 {
106
+            img.resize(new_w, new_h, image::imageops::FilterType::Triangle)
107
+        } else {
108
+            img
109
+        };
110
+
111
+        let rgba = resized.to_rgba8();
112
+        let (width, height) = rgba.dimensions();
113
+
114
+        Some(Thumbnail {
115
+            data: rgba.into_raw(),
116
+            width,
117
+            height,
118
+        })
119
+    }
120
+
121
+    /// Get a cached thumbnail, or request loading if not cached.
122
+    /// Returns Some(thumbnail) if cached, None if loading or not an image.
123
+    pub fn get_or_load(&mut self, path: &PathBuf) -> Option<&Thumbnail> {
124
+        // Check cache first
125
+        if self.cache.contains_key(path) {
126
+            // Update LRU order
127
+            self.touch_lru(path);
128
+            return self.cache.get(path);
129
+        }
130
+
131
+        // Check if already pending
132
+        if self.pending.contains_key(path) {
133
+            return None;
134
+        }
135
+
136
+        // Request loading
137
+        self.next_request_id += 1;
138
+        let request_id = self.next_request_id;
139
+
140
+        let request = ThumbnailRequest {
141
+            path: path.clone(),
142
+            size: THUMBNAIL_SIZE,
143
+            request_id,
144
+        };
145
+
146
+        if self.request_tx.send(request).is_ok() {
147
+            self.pending.insert(path.clone(), request_id);
148
+        }
149
+
150
+        None
151
+    }
152
+
153
+    /// Poll for completed thumbnail loads.
154
+    pub fn poll(&mut self) -> Vec<PathBuf> {
155
+        let mut loaded = Vec::new();
156
+
157
+        loop {
158
+            match self.result_rx.try_recv() {
159
+                Ok(result) => {
160
+                    self.pending.remove(&result.path);
161
+                    if let Some(thumbnail) = result.thumbnail {
162
+                        self.insert_cache(result.path.clone(), thumbnail);
163
+                        loaded.push(result.path);
164
+                    }
165
+                }
166
+                Err(TryRecvError::Empty) => break,
167
+                Err(TryRecvError::Disconnected) => break,
168
+            }
169
+        }
170
+
171
+        loaded
172
+    }
173
+
174
+    /// Insert into cache with LRU eviction.
175
+    fn insert_cache(&mut self, path: PathBuf, thumbnail: Thumbnail) {
176
+        // Evict if at capacity
177
+        while self.cache.len() >= MAX_CACHE_SIZE {
178
+            if let Some(oldest) = self.lru_order.first().cloned() {
179
+                self.cache.remove(&oldest);
180
+                self.lru_order.remove(0);
181
+            } else {
182
+                break;
183
+            }
184
+        }
185
+
186
+        self.cache.insert(path.clone(), thumbnail);
187
+        self.lru_order.push(path);
188
+    }
189
+
190
+    /// Update LRU order for a path.
191
+    fn touch_lru(&mut self, path: &PathBuf) {
192
+        if let Some(pos) = self.lru_order.iter().position(|p| p == path) {
193
+            self.lru_order.remove(pos);
194
+            self.lru_order.push(path.clone());
195
+        }
196
+    }
197
+
198
+    /// Clear the cache.
199
+    pub fn clear_cache(&mut self) {
200
+        self.cache.clear();
201
+        self.lru_order.clear();
202
+    }
203
+
204
+    /// Check if a path is cached.
205
+    pub fn is_cached(&self, path: &PathBuf) -> bool {
206
+        self.cache.contains_key(path)
207
+    }
208
+
209
+    /// Get a cached thumbnail without requesting a load.
210
+    pub fn get_cached(&self, path: &PathBuf) -> Option<&Thumbnail> {
211
+        self.cache.get(path)
212
+    }
213
+}
214
+
215
+impl Default for ThumbnailLoader {
216
+    fn default() -> Self {
217
+        Self::new()
218
+    }
219
+}
garfield/src/ui/column_view.rsmodified
@@ -1,8 +1,8 @@
11
 //! Miller columns view (like macOS Finder).
22
 
3
-use crate::core::{read_directory, sort_entries, EntryType, FileEntry, SortDirection, SortOrder};
3
+use crate::core::{read_directory, sort_entries, EntryType, FileEntry, ImagePreview, SortDirection, SortOrder, is_supported_image};
44
 use gartk_core::{Color, Modifiers, Point, Rect};
5
-use gartk_render::{Renderer, TextStyle};
5
+use gartk_render::{Renderer, TextStyle, Surface};
66
 use std::collections::HashSet;
77
 use std::path::PathBuf;
88
 
@@ -83,6 +83,14 @@ pub struct ColumnView {
8383
     preview_column: Option<Column>,
8484
     /// Path that needs preview loading (set when selection changes to a directory).
8585
     pending_preview_path: Option<PathBuf>,
86
+    /// Path that needs image preview loading.
87
+    pending_image_preview_path: Option<PathBuf>,
88
+    /// Loaded image preview.
89
+    image_preview: Option<ImagePreview>,
90
+    /// Path of the loaded image preview (for matching).
91
+    image_preview_path: Option<PathBuf>,
92
+    /// Cached Cairo surface for the image preview.
93
+    image_surface: Option<Surface>,
8694
     /// View bounds.
8795
     bounds: Rect,
8896
     /// Show hidden files.
@@ -111,6 +119,10 @@ impl ColumnView {
111119
             current_column: Column::new(Vec::new(), column_bounds),
112120
             preview_column: None,
113121
             pending_preview_path: None,
122
+            pending_image_preview_path: None,
123
+            image_preview: None,
124
+            image_preview_path: None,
125
+            image_surface: None,
114126
             bounds,
115127
             show_hidden: false,
116128
             sort_order: SortOrder::Name,
@@ -196,13 +208,40 @@ impl ColumnView {
196208
                     // Clear current preview while loading
197209
                     self.preview_column = None;
198210
                 }
211
+                // Clear image preview for directories
212
+                self.pending_image_preview_path = None;
213
+                self.image_preview = None;
214
+                self.image_preview_path = None;
215
+                self.image_surface = None;
199216
             } else {
200217
                 self.pending_preview_path = None;
201218
                 self.preview_column = None;
219
+
220
+                // Check if this is an image file that needs preview
221
+                if is_supported_image(entry.extension().as_deref()) {
222
+                    let needs_load = self.image_preview_path.as_ref() != Some(&entry.path);
223
+                    if needs_load {
224
+                        self.pending_image_preview_path = Some(entry.path.clone());
225
+                        // Clear current image preview while loading
226
+                        self.image_preview = None;
227
+                        self.image_preview_path = None;
228
+                        self.image_surface = None;
229
+                    }
230
+                } else {
231
+                    // Not an image - clear image preview
232
+                    self.pending_image_preview_path = None;
233
+                    self.image_preview = None;
234
+                    self.image_preview_path = None;
235
+                    self.image_surface = None;
236
+                }
202237
             }
203238
         } else {
204239
             self.pending_preview_path = None;
205240
             self.preview_column = None;
241
+            self.pending_image_preview_path = None;
242
+            self.image_preview = None;
243
+            self.image_preview_path = None;
244
+            self.image_surface = None;
206245
         }
207246
     }
208247
 
@@ -245,6 +284,37 @@ impl ColumnView {
245284
         self.pending_preview_path.is_some()
246285
     }
247286
 
287
+    /// Take pending image preview request (path, max_width, max_height).
288
+    pub fn take_pending_image_preview(&mut self) -> Option<(PathBuf, u32, u32)> {
289
+        self.pending_image_preview_path.take().map(|path| {
290
+            let preview_x = self.current_column.bounds.x + self.current_column.bounds.width as i32;
291
+            let preview_width = (self.bounds.x + self.bounds.width as i32 - preview_x) as u32;
292
+            let preview_height = self.bounds.height / 2; // Use half height for image
293
+            (path, preview_width.saturating_sub(32), preview_height.saturating_sub(32))
294
+        })
295
+    }
296
+
297
+    /// Set loaded image preview.
298
+    pub fn set_image_preview(&mut self, path: &PathBuf, image: Option<ImagePreview>) {
299
+        // Only set if this is still the path we're displaying
300
+        let visible = self.current_column.visible_entries(self.show_hidden);
301
+        let selected_matches = visible
302
+            .get(self.current_column.selected)
303
+            .map(|e| &e.path == path)
304
+            .unwrap_or(false);
305
+
306
+        if selected_matches {
307
+            if let Some(ref preview) = image {
308
+                // Create a Cairo surface from the RGBA data
309
+                self.image_surface = Surface::from_rgba(&preview.data, preview.width, preview.height).ok();
310
+            } else {
311
+                self.image_surface = None;
312
+            }
313
+            self.image_preview = image;
314
+            self.image_preview_path = Some(path.clone());
315
+        }
316
+    }
317
+
248318
     /// Get visible entries in current column.
249319
     pub fn visible_entries(&self) -> Vec<&FileEntry> {
250320
         self.current_column.visible_entries(self.show_hidden)
@@ -460,6 +530,31 @@ impl ColumnView {
460530
         changed
461531
     }
462532
 
533
+    /// Handle mouse scroll. Returns true if scrolled.
534
+    pub fn on_scroll(&mut self, delta_y: i32) -> bool {
535
+        // Scroll the current column
536
+        let visible = self.visible_entries();
537
+        let total_rows = visible.len();
538
+        let visible_rows = self.current_column.visible_rows();
539
+
540
+        if total_rows <= visible_rows {
541
+            return false;
542
+        }
543
+
544
+        let max_scroll = total_rows.saturating_sub(visible_rows);
545
+        let old_offset = self.current_column.scroll_offset;
546
+
547
+        if delta_y < 0 {
548
+            let rows = ((-delta_y) as usize / 3).max(1);
549
+            self.current_column.scroll_offset = self.current_column.scroll_offset.saturating_sub(rows);
550
+        } else if delta_y > 0 {
551
+            let rows = (delta_y as usize / 3).max(1);
552
+            self.current_column.scroll_offset = (self.current_column.scroll_offset + rows).min(max_scroll);
553
+        }
554
+
555
+        self.current_column.scroll_offset != old_offset
556
+    }
557
+
463558
     /// Get the entry at the given position (for drag detection).
464559
     pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> {
465560
         // Check current column (main selection column)
@@ -745,6 +840,36 @@ impl ColumnView {
745840
             1.0,
746841
         )?;
747842
 
843
+        let mut y = self.bounds.y + 16;
844
+        let x = preview_x + 16;
845
+
846
+        // Render image preview if available
847
+        if let Some(ref surface) = self.image_surface {
848
+            // Center the image in the preview area
849
+            let img_width = surface.width();
850
+            let img_height = surface.height();
851
+            let max_width = preview_width.saturating_sub(32);
852
+
853
+            let img_x = preview_x + 16 + (max_width.saturating_sub(img_width) / 2) as i32;
854
+            let img_y = y;
855
+
856
+            // Draw the image using Cairo
857
+            let ctx = renderer.context()?;
858
+            ctx.set_source_surface(surface.cairo_surface(), img_x as f64, img_y as f64)?;
859
+            ctx.paint()?;
860
+
861
+            // Move y below the image with padding
862
+            y += img_height as i32 + 24;
863
+        } else if is_supported_image(entry.extension().as_deref()) && self.pending_image_preview_path.is_some() {
864
+            // Show loading indicator for images
865
+            let loading_style = TextStyle::new()
866
+                .font_family(&theme.font_family)
867
+                .font_size(theme.font_size)
868
+                .color(theme.item_foreground.with_alpha(0.5));
869
+            renderer.text("Loading preview...", x as f64, y as f64, &loading_style)?;
870
+            y += 40;
871
+        }
872
+
748873
         let label_style = TextStyle::new()
749874
             .font_family(&theme.font_family)
750875
             .font_size(theme.font_size - 1.0)
@@ -755,9 +880,6 @@ impl ColumnView {
755880
             .font_size(theme.font_size)
756881
             .color(theme.item_foreground);
757882
 
758
-        let mut y = self.bounds.y + 16;
759
-        let x = preview_x + 16;
760
-
761883
         // File name
762884
         renderer.text("Name", x as f64, y as f64, &label_style)?;
763885
         y += 18;
garfield/src/ui/grid_view.rsmodified
@@ -1,10 +1,11 @@
11
 //! Grid/icon view for displaying directory contents.
22
 
3
-use crate::core::{EntryType, FileEntry, SortDirection, SortOrder};
3
+use crate::core::{is_supported_image, EntryType, FileEntry, SortDirection, SortOrder, ThumbnailLoader};
44
 use crate::ui::tab::RenameState;
55
 use gartk_core::{Color, Modifiers, Point, Rect};
6
-use gartk_render::{Renderer, TextAlign, TextStyle};
7
-use std::collections::HashSet;
6
+use gartk_render::{Renderer, Surface, TextAlign, TextStyle};
7
+use std::collections::{HashMap, HashSet};
8
+use std::path::PathBuf;
89
 use std::time::{Duration, Instant};
910
 
1011
 /// Padding around cells.
@@ -98,6 +99,10 @@ pub struct GridView {
9899
     last_selection_update: Option<Instant>,
99100
     /// Last time we requested a redraw during drag (for frame rate limiting).
100101
     last_drag_render: Option<Instant>,
102
+    /// Thumbnail loader for image files.
103
+    thumbnail_loader: ThumbnailLoader,
104
+    /// Cached thumbnail surfaces.
105
+    thumbnail_cache: HashMap<PathBuf, Surface>,
101106
 }
102107
 
103108
 impl GridView {
@@ -121,6 +126,8 @@ impl GridView {
121126
             icon_size,
122127
             last_selection_update: None,
123128
             last_drag_render: None,
129
+            thumbnail_loader: ThumbnailLoader::new(),
130
+            thumbnail_cache: HashMap::new(),
124131
         }
125132
     }
126133
 
@@ -170,6 +177,60 @@ impl GridView {
170177
         self.selected.insert(0);
171178
         self.selection_anchor = Some(0);
172179
         self.scroll_offset = 0;
180
+        // Clear thumbnail cache when directory changes
181
+        self.thumbnail_cache.clear();
182
+        self.thumbnail_loader.clear_cache();
183
+    }
184
+
185
+    /// Poll for completed thumbnail loads. Returns true if any thumbnails were loaded.
186
+    pub fn poll_thumbnails(&mut self) -> bool {
187
+        let loaded = self.thumbnail_loader.poll();
188
+        let mut any_loaded = false;
189
+
190
+        for path in loaded {
191
+            // Create surface from cached thumbnail (borrow ends after and_then)
192
+            let surface_opt = self.thumbnail_loader
193
+                .get_cached(&path)
194
+                .and_then(|thumbnail| {
195
+                    Surface::from_rgba(&thumbnail.data, thumbnail.width, thumbnail.height).ok()
196
+                });
197
+
198
+            // Now insert (no borrow of thumbnail_loader active)
199
+            if let Some(surface) = surface_opt {
200
+                self.thumbnail_cache.insert(path, surface);
201
+                any_loaded = true;
202
+            }
203
+        }
204
+
205
+        any_loaded
206
+    }
207
+
208
+    /// Request thumbnails for visible image files.
209
+    pub fn request_visible_thumbnails(&mut self) {
210
+        let visible_count = self.visible_count();
211
+        let visible_rows = self.visible_rows();
212
+        let start_index = self.scroll_offset * self.columns;
213
+        let end_index = (start_index + visible_rows * self.columns).min(visible_count);
214
+
215
+        // Collect paths to load (immutable borrow of entries)
216
+        let paths_to_load: Vec<PathBuf> = (start_index..end_index)
217
+            .filter_map(|i| {
218
+                self.visible_entry(i).and_then(|entry| {
219
+                    if is_supported_image(entry.extension().as_deref())
220
+                        && !self.thumbnail_cache.contains_key(&entry.path)
221
+                    {
222
+                        Some(entry.path.clone())
223
+                    } else {
224
+                        None
225
+                    }
226
+                })
227
+            })
228
+            .collect();
229
+
230
+        // Now request loading (mutable borrow of thumbnail_loader)
231
+        for path in paths_to_load {
232
+            self.thumbnail_loader.get_or_load(&path);
233
+        }
173234
     }
174235
 
175236
     /// Get visible entries (respecting hidden filter).
@@ -427,6 +488,33 @@ impl GridView {
427488
         self.hovered != old_hovered
428489
     }
429490
 
491
+    /// Handle mouse scroll. Returns true if scrolled.
492
+    pub fn on_scroll(&mut self, delta_y: i32) -> bool {
493
+        let visible_count = self.visible_count();
494
+        let total_rows = (visible_count + self.columns - 1) / self.columns;
495
+        let visible_rows = self.visible_rows();
496
+
497
+        if total_rows <= visible_rows {
498
+            return false; // No scrolling needed
499
+        }
500
+
501
+        let max_scroll = total_rows.saturating_sub(visible_rows);
502
+        let old_offset = self.scroll_offset;
503
+
504
+        // Scroll by number of rows (negative delta = scroll up)
505
+        if delta_y < 0 {
506
+            // Scroll up
507
+            let rows = ((-delta_y) as usize / 3).max(1);
508
+            self.scroll_offset = self.scroll_offset.saturating_sub(rows);
509
+        } else if delta_y > 0 {
510
+            // Scroll down
511
+            let rows = (delta_y as usize / 3).max(1);
512
+            self.scroll_offset = (self.scroll_offset + rows).min(max_scroll);
513
+        }
514
+
515
+        self.scroll_offset != old_offset
516
+    }
517
+
430518
     /// Start rubber band selection.
431519
     pub fn start_drag(&mut self, pos: Point) {
432520
         self.drag_start = Some(pos);
@@ -613,32 +701,53 @@ impl GridView {
613701
                 renderer.stroke_rect(cell, theme.selection_background.with_alpha(0.5), 1.0)?;
614702
             }
615703
 
616
-            // Icon (placeholder using text)
617
-            let icon = match entry.entry_type {
618
-                EntryType::Directory => "\u{1F4C1}",  // folder emoji
619
-                EntryType::Symlink => "\u{1F517}",    // link emoji
620
-                _ => Self::file_icon_for_extension(entry.extension().as_deref()),
621
-            };
622
-
623
-            let icon_color = if is_selected {
624
-                theme.selection_foreground
625
-            } else {
626
-                match entry.entry_type {
627
-                    EntryType::Directory => dir_color,
628
-                    EntryType::Symlink => symlink_color,
629
-                    _ => theme.item_foreground,
704
+            // Check for cached thumbnail first (for image files)
705
+            let mut rendered_thumbnail = false;
706
+            if is_supported_image(entry.extension().as_deref()) {
707
+                if let Some(surface) = self.thumbnail_cache.get(&entry.path) {
708
+                    // Render the thumbnail centered in the icon area
709
+                    let thumb_w = surface.width();
710
+                    let thumb_h = surface.height();
711
+                    let icon_area_size = icon_size_px;
712
+                    let thumb_x = cell.x + (cell.width as i32 - thumb_w as i32) / 2;
713
+                    let thumb_y = cell.y + 8 + (icon_area_size as i32 - thumb_h as i32) / 2;
714
+
715
+                    let ctx = renderer.context()?;
716
+                    ctx.set_source_surface(surface.cairo_surface(), thumb_x as f64, thumb_y as f64)?;
717
+                    ctx.paint()?;
718
+                    rendered_thumbnail = true;
630719
                 }
631
-            };
720
+                // Thumbnails are requested via request_visible_thumbnails() called before render
721
+            }
632722
 
633
-            let icon_style = TextStyle::new()
634
-                .font_family(&theme.font_family)
635
-                .font_size(icon_font_size)
636
-                .color(icon_color);
723
+            // Fall back to emoji icon if no thumbnail
724
+            if !rendered_thumbnail {
725
+                let icon = match entry.entry_type {
726
+                    EntryType::Directory => "\u{1F4C1}",  // folder emoji
727
+                    EntryType::Symlink => "\u{1F517}",    // link emoji
728
+                    _ => Self::file_icon_for_extension(entry.extension().as_deref()),
729
+                };
637730
 
638
-            // Center icon horizontally in cell
639
-            let icon_center_x = cell.x + cell.width as i32 / 2;
640
-            let icon_center_y = cell.y + 10 + (icon_font_size / 2.0) as i32;
641
-            renderer.text_centered(icon, Point::new(icon_center_x, icon_center_y), &icon_style)?;
731
+                let icon_color = if is_selected {
732
+                    theme.selection_foreground
733
+                } else {
734
+                    match entry.entry_type {
735
+                        EntryType::Directory => dir_color,
736
+                        EntryType::Symlink => symlink_color,
737
+                        _ => theme.item_foreground,
738
+                    }
739
+                };
740
+
741
+                let icon_style = TextStyle::new()
742
+                    .font_family(&theme.font_family)
743
+                    .font_size(icon_font_size)
744
+                    .color(icon_color);
745
+
746
+                // Center icon horizontally in cell
747
+                let icon_center_x = cell.x + cell.width as i32 / 2;
748
+                let icon_center_y = cell.y + 10 + (icon_font_size / 2.0) as i32;
749
+                renderer.text_centered(icon, Point::new(icon_center_x, icon_center_y), &icon_style)?;
750
+            }
642751
 
643752
             // Rectangle for the text area below the icon
644753
             let text_rect = Rect::new(
garfield/src/ui/list_view.rsmodified
@@ -410,6 +410,32 @@ impl ListView {
410410
         self.hovered_header != old_hovered
411411
     }
412412
 
413
+    /// Handle mouse scroll. Returns true if scrolled.
414
+    pub fn on_scroll(&mut self, delta_y: i32) -> bool {
415
+        let visible = self.visible_entries();
416
+        let total_rows = visible.len();
417
+        let visible_rows = self.visible_rows();
418
+
419
+        if total_rows <= visible_rows {
420
+            return false; // No scrolling needed
421
+        }
422
+
423
+        let max_scroll = total_rows.saturating_sub(visible_rows);
424
+        let old_offset = self.scroll_offset;
425
+
426
+        if delta_y < 0 {
427
+            // Scroll up
428
+            let rows = ((-delta_y) as usize / 3).max(1);
429
+            self.scroll_offset = self.scroll_offset.saturating_sub(rows);
430
+        } else if delta_y > 0 {
431
+            // Scroll down
432
+            let rows = (delta_y as usize / 3).max(1);
433
+            self.scroll_offset = (self.scroll_offset + rows).min(max_scroll);
434
+        }
435
+
436
+        self.scroll_offset != old_offset
437
+    }
438
+
413439
     /// Handle header click for sorting. Returns (new_order, new_direction) if sort changed.
414440
     /// Note: Call divider_at() first to check for resize initiation.
415441
     pub fn on_header_click(&mut self, pos: Point) -> Option<(SortOrder, SortDirection)> {
garfield/src/ui/sidebar.rsmodified
@@ -570,6 +570,12 @@ impl Sidebar {
570570
         self.hovered != old_hovered
571571
     }
572572
 
573
+    /// Handle mouse scroll. Returns true if scrolled.
574
+    /// Sidebar doesn't currently support scrolling, but this handles the event.
575
+    pub fn on_scroll(&mut self, _delta_y: i32) -> bool {
576
+        false // Sidebar doesn't scroll currently
577
+    }
578
+
573579
     /// Handle mouse click. Returns the path to navigate to, if any.
574580
     pub fn on_click(&self, pos: Point) -> Option<PathBuf> {
575581
         if !self.visible || !self.bounds.contains_point(pos) {
garfield/src/ui/tab.rsmodified
@@ -264,6 +264,20 @@ impl Tab {
264264
         self.column_view.set_bounds(bounds);
265265
     }
266266
 
267
+    /// Get bounds.
268
+    pub fn bounds(&self) -> Rect {
269
+        self.bounds
270
+    }
271
+
272
+    /// Handle mouse scroll. Returns true if scrolled.
273
+    pub fn on_scroll(&mut self, delta_y: i32) -> bool {
274
+        match self.view_mode {
275
+            ViewMode::List => self.list_view.on_scroll(delta_y),
276
+            ViewMode::Grid => self.grid_view.on_scroll(delta_y),
277
+            ViewMode::Columns => self.column_view.on_scroll(delta_y),
278
+        }
279
+    }
280
+
267281
     /// Handle sort change from list view header click.
268282
     pub fn set_sort(&mut self, order: SortOrder, direction: SortDirection) {
269283
         self.sort_order = order;
@@ -720,6 +734,34 @@ impl Tab {
720734
     pub fn is_preview_loading(&self) -> bool {
721735
         self.view_mode == ViewMode::Columns && self.column_view.is_preview_loading()
722736
     }
737
+
738
+    /// Take pending image preview request (path, max_width, max_height).
739
+    pub fn take_pending_image_preview(&mut self) -> Option<(PathBuf, u32, u32)> {
740
+        if self.view_mode == ViewMode::Columns {
741
+            self.column_view.take_pending_image_preview()
742
+        } else {
743
+            None
744
+        }
745
+    }
746
+
747
+    /// Set loaded image preview for column view.
748
+    pub fn set_image_preview(&mut self, path: &PathBuf, image: Option<crate::core::ImagePreview>) {
749
+        if self.view_mode == ViewMode::Columns {
750
+            self.column_view.set_image_preview(path, image);
751
+        }
752
+    }
753
+
754
+    /// Poll for completed grid view thumbnails. Returns true if any loaded.
755
+    pub fn poll_thumbnails(&mut self) -> bool {
756
+        self.grid_view.poll_thumbnails()
757
+    }
758
+
759
+    /// Request thumbnails for visible items in grid view.
760
+    pub fn request_visible_thumbnails(&mut self) {
761
+        if self.view_mode == ViewMode::Grid {
762
+            self.grid_view.request_visible_thumbnails();
763
+        }
764
+    }
723765
 }
724766
 
725767
 impl RenameState {