gardesk/garfield / 1d6e7be

Browse files

column: add PDF preview support using poppler

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1d6e7bebef178d4860791ce4298f1d68f93e0dc3
Parents
9dca739
Tree
32ec1e4

8 changed files

StatusFile+-
M Cargo.lock 158 21
M Cargo.toml 4 0
M garfield/Cargo.toml 4 0
M garfield/src/app.rs 26 1
M garfield/src/core/mod.rs 2 0
A garfield/src/core/pdf_preview.rs 210 0
M garfield/src/ui/column_view.rs 112 12
M garfield/src/ui/tab.rs 16 0
Cargo.lockmodified
@@ -125,8 +125,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
125125
 checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0"
126126
 dependencies = [
127127
  "bitflags",
128
- "cairo-sys-rs",
129
- "glib",
128
+ "cairo-sys-rs 0.20.10",
129
+ "glib 0.20.12",
130
+ "libc",
131
+]
132
+
133
+[[package]]
134
+name = "cairo-rs"
135
+version = "0.21.5"
136
+source = "registry+https://github.com/rust-lang/crates.io-index"
137
+checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4"
138
+dependencies = [
139
+ "bitflags",
140
+ "cairo-sys-rs 0.21.5",
141
+ "glib 0.21.5",
130142
  "libc",
131143
 ]
132144
 
@@ -136,7 +148,18 @@ version = "0.20.10"
136148
 source = "registry+https://github.com/rust-lang/crates.io-index"
137149
 checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b"
138150
 dependencies = [
139
- "glib-sys",
151
+ "glib-sys 0.20.10",
152
+ "libc",
153
+ "system-deps",
154
+]
155
+
156
+[[package]]
157
+name = "cairo-sys-rs"
158
+version = "0.21.5"
159
+source = "registry+https://github.com/rust-lang/crates.io-index"
160
+checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c"
161
+dependencies = [
162
+ "glib-sys 0.21.5",
140163
  "libc",
141164
  "system-deps",
142165
 ]
@@ -387,6 +410,7 @@ name = "garfield"
387410
 version = "0.1.0"
388411
 dependencies = [
389412
  "anyhow",
413
+ "cairo-rs 0.21.5",
390414
  "chrono",
391415
  "dirs",
392416
  "freedesktop_entry_parser",
@@ -397,6 +421,7 @@ dependencies = [
397421
  "image",
398422
  "libc",
399423
  "nucleo-matcher",
424
+ "poppler-rs",
400425
  "serde",
401426
  "serde_json",
402427
  "thiserror 2.0.18",
@@ -438,7 +463,7 @@ dependencies = [
438463
 name = "gartk-render"
439464
 version = "0.3.0"
440465
 dependencies = [
441
- "cairo-rs",
466
+ "cairo-rs 0.20.12",
442467
  "gartk-core",
443468
  "gartk-x11",
444469
  "pango",
@@ -499,8 +524,25 @@ dependencies = [
499524
  "futures-core",
500525
  "futures-io",
501526
  "futures-util",
502
- "gio-sys",
503
- "glib",
527
+ "gio-sys 0.20.10",
528
+ "glib 0.20.12",
529
+ "libc",
530
+ "pin-project-lite",
531
+ "smallvec",
532
+]
533
+
534
+[[package]]
535
+name = "gio"
536
+version = "0.21.5"
537
+source = "registry+https://github.com/rust-lang/crates.io-index"
538
+checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a"
539
+dependencies = [
540
+ "futures-channel",
541
+ "futures-core",
542
+ "futures-io",
543
+ "futures-util",
544
+ "gio-sys 0.21.5",
545
+ "glib 0.21.5",
504546
  "libc",
505547
  "pin-project-lite",
506548
  "smallvec",
@@ -512,13 +554,26 @@ version = "0.20.10"
512554
 source = "registry+https://github.com/rust-lang/crates.io-index"
513555
 checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83"
514556
 dependencies = [
515
- "glib-sys",
516
- "gobject-sys",
557
+ "glib-sys 0.20.10",
558
+ "gobject-sys 0.20.10",
517559
  "libc",
518560
  "system-deps",
519561
  "windows-sys 0.59.0",
520562
 ]
521563
 
564
+[[package]]
565
+name = "gio-sys"
566
+version = "0.21.5"
567
+source = "registry+https://github.com/rust-lang/crates.io-index"
568
+checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22"
569
+dependencies = [
570
+ "glib-sys 0.21.5",
571
+ "gobject-sys 0.21.5",
572
+ "libc",
573
+ "system-deps",
574
+ "windows-sys 0.61.2",
575
+]
576
+
522577
 [[package]]
523578
 name = "glib"
524579
 version = "0.20.12"
@@ -531,10 +586,31 @@ dependencies = [
531586
  "futures-executor",
532587
  "futures-task",
533588
  "futures-util",
534
- "gio-sys",
535
- "glib-macros",
536
- "glib-sys",
537
- "gobject-sys",
589
+ "gio-sys 0.20.10",
590
+ "glib-macros 0.20.12",
591
+ "glib-sys 0.20.10",
592
+ "gobject-sys 0.20.10",
593
+ "libc",
594
+ "memchr",
595
+ "smallvec",
596
+]
597
+
598
+[[package]]
599
+name = "glib"
600
+version = "0.21.5"
601
+source = "registry+https://github.com/rust-lang/crates.io-index"
602
+checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
603
+dependencies = [
604
+ "bitflags",
605
+ "futures-channel",
606
+ "futures-core",
607
+ "futures-executor",
608
+ "futures-task",
609
+ "futures-util",
610
+ "gio-sys 0.21.5",
611
+ "glib-macros 0.21.5",
612
+ "glib-sys 0.21.5",
613
+ "gobject-sys 0.21.5",
538614
  "libc",
539615
  "memchr",
540616
  "smallvec",
@@ -553,6 +629,19 @@ dependencies = [
553629
  "syn",
554630
 ]
555631
 
632
+[[package]]
633
+name = "glib-macros"
634
+version = "0.21.5"
635
+source = "registry+https://github.com/rust-lang/crates.io-index"
636
+checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17"
637
+dependencies = [
638
+ "heck",
639
+ "proc-macro-crate",
640
+ "proc-macro2",
641
+ "quote",
642
+ "syn",
643
+]
644
+
556645
 [[package]]
557646
 name = "glib-sys"
558647
 version = "0.20.10"
@@ -563,13 +652,34 @@ dependencies = [
563652
  "system-deps",
564653
 ]
565654
 
655
+[[package]]
656
+name = "glib-sys"
657
+version = "0.21.5"
658
+source = "registry+https://github.com/rust-lang/crates.io-index"
659
+checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c"
660
+dependencies = [
661
+ "libc",
662
+ "system-deps",
663
+]
664
+
566665
 [[package]]
567666
 name = "gobject-sys"
568667
 version = "0.20.10"
569668
 source = "registry+https://github.com/rust-lang/crates.io-index"
570669
 checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda"
571670
 dependencies = [
572
- "glib-sys",
671
+ "glib-sys 0.20.10",
672
+ "libc",
673
+ "system-deps",
674
+]
675
+
676
+[[package]]
677
+name = "gobject-sys"
678
+version = "0.21.5"
679
+source = "registry+https://github.com/rust-lang/crates.io-index"
680
+checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294"
681
+dependencies = [
682
+ "glib-sys 0.21.5",
573683
  "libc",
574684
  "system-deps",
575685
 ]
@@ -807,8 +917,8 @@ version = "0.20.12"
807917
 source = "registry+https://github.com/rust-lang/crates.io-index"
808918
 checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c"
809919
 dependencies = [
810
- "gio",
811
- "glib",
920
+ "gio 0.20.12",
921
+ "glib 0.20.12",
812922
  "libc",
813923
  "pango-sys",
814924
 ]
@@ -819,8 +929,8 @@ version = "0.20.10"
819929
 source = "registry+https://github.com/rust-lang/crates.io-index"
820930
 checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa"
821931
 dependencies = [
822
- "glib-sys",
823
- "gobject-sys",
932
+ "glib-sys 0.20.10",
933
+ "gobject-sys 0.20.10",
824934
  "libc",
825935
  "system-deps",
826936
 ]
@@ -831,8 +941,8 @@ version = "0.20.10"
831941
 source = "registry+https://github.com/rust-lang/crates.io-index"
832942
 checksum = "58890dc451db9964ac2d8874f903a4370a4b3932aa5281ff0c8d9810937ad84f"
833943
 dependencies = [
834
- "cairo-rs",
835
- "glib",
944
+ "cairo-rs 0.20.12",
945
+ "glib 0.20.12",
836946
  "libc",
837947
  "pango",
838948
  "pangocairo-sys",
@@ -844,8 +954,8 @@ version = "0.20.10"
844954
 source = "registry+https://github.com/rust-lang/crates.io-index"
845955
 checksum = "b9952903f88aa93e2927e7bca2d1ebae64fc26545a9280b4ce6bddeda26b5c42"
846956
 dependencies = [
847
- "cairo-sys-rs",
848
- "glib-sys",
957
+ "cairo-sys-rs 0.20.10",
958
+ "glib-sys 0.20.10",
849959
  "libc",
850960
  "pango-sys",
851961
  "system-deps",
@@ -882,6 +992,33 @@ dependencies = [
882992
  "miniz_oxide",
883993
 ]
884994
 
995
+[[package]]
996
+name = "poppler-rs"
997
+version = "0.25.0"
998
+source = "registry+https://github.com/rust-lang/crates.io-index"
999
+checksum = "f654ec8b83bca9adb0ea7e62194a1e5767a094d282d77630ff0ddb2edbc30139"
1000
+dependencies = [
1001
+ "cairo-rs 0.21.5",
1002
+ "gio 0.21.5",
1003
+ "glib 0.21.5",
1004
+ "libc",
1005
+ "poppler-sys-rs",
1006
+]
1007
+
1008
+[[package]]
1009
+name = "poppler-sys-rs"
1010
+version = "0.25.0"
1011
+source = "registry+https://github.com/rust-lang/crates.io-index"
1012
+checksum = "7f59d8616943cf71be2a33d866dee973eaaa3e507eb21eb102c6424f773ea6ad"
1013
+dependencies = [
1014
+ "cairo-sys-rs 0.21.5",
1015
+ "gio-sys 0.21.5",
1016
+ "glib-sys 0.21.5",
1017
+ "gobject-sys 0.21.5",
1018
+ "libc",
1019
+ "system-deps",
1020
+]
1021
+
8851022
 [[package]]
8861023
 name = "proc-macro-crate"
8871024
 version = "3.4.0"
Cargo.tomlmodified
@@ -54,5 +54,9 @@ nucleo-matcher = "0.3"
5454
 # Image handling
5555
 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "ico"] }
5656
 
57
+# PDF rendering
58
+poppler-rs = "0.25"
59
+cairo-rs = { version = "0.21", features = ["png"] }
60
+
5761
 # IPC types (shared between crates)
5862
 garfield-ipc = { path = "garfield-ipc" }
garfield/Cargo.tomlmodified
@@ -47,3 +47,7 @@ garfield-ipc.workspace = true
4747
 
4848
 # Image handling
4949
 image.workspace = true
50
+
51
+# PDF rendering
52
+poppler-rs.workspace = true
53
+cairo-rs.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, ImagePreviewLoader, PreviewLoader, UndoStack,
4
+    Clipboard, ClipboardOperation, FileOperation, ImagePreviewLoader, PdfPreviewLoader, PreviewLoader, UndoStack,
55
     copy_files, move_files, delete_files, create_directory,
66
     trash_files, restore_from_trash,
77
 };
@@ -98,6 +98,8 @@ pub struct App {
9898
     preview_loader: PreviewLoader,
9999
     /// Async image preview loader.
100100
     image_preview_loader: ImagePreviewLoader,
101
+    /// Async PDF preview loader.
102
+    pdf_preview_loader: PdfPreviewLoader,
101103
     /// X11 clipboard manager for system clipboard integration.
102104
     x11_clipboard: ClipboardManager,
103105
 }
@@ -279,6 +281,7 @@ impl App {
279281
             pending_paste: None,
280282
             preview_loader: PreviewLoader::new(),
281283
             image_preview_loader: ImagePreviewLoader::new(),
284
+            pdf_preview_loader: PdfPreviewLoader::new(),
282285
             x11_clipboard,
283286
         };
284287
 
@@ -392,6 +395,16 @@ impl App {
392395
                 ev.request_redraw();
393396
             }
394397
 
398
+            // Poll for completed async PDF preview loads
399
+            if let Some(result) = self.pdf_preview_loader.poll() {
400
+                if let Some(pane) = self.focused_pane_mut() {
401
+                    if let Some(tab) = pane.active_tab_mut() {
402
+                        tab.set_pdf_preview(&result.path, result.image);
403
+                    }
404
+                }
405
+                ev.request_redraw();
406
+            }
407
+
395408
             // Poll for completed grid view thumbnails
396409
             if let Some(pane) = self.focused_pane_mut() {
397410
                 if let Some(tab) = pane.active_tab_mut() {
@@ -404,6 +417,7 @@ impl App {
404417
             // Check for pending preview requests and submit them
405418
             self.process_pending_previews();
406419
             self.process_pending_image_previews();
420
+            self.process_pending_pdf_previews();
407421
 
408422
             // Request thumbnails for visible grid items
409423
             if let Some(pane) = self.focused_pane_mut() {
@@ -2606,6 +2620,17 @@ impl App {
26062620
         }
26072621
     }
26082622
 
2623
+    /// Process pending PDF preview requests.
2624
+    fn process_pending_pdf_previews(&mut self) {
2625
+        if let Some(pane) = self.focused_pane_mut() {
2626
+            if let Some(tab) = pane.active_tab_mut() {
2627
+                if let Some((path, max_width, max_height)) = tab.take_pending_pdf_preview() {
2628
+                    self.pdf_preview_loader.load(path, max_width, max_height);
2629
+                }
2630
+            }
2631
+        }
2632
+    }
2633
+
26092634
     /// Update status bar.
26102635
     fn update_status_bar(&mut self) {
26112636
         let stats = self.focused_pane()
garfield/src/core/mod.rsmodified
@@ -5,6 +5,7 @@ pub mod entry;
55
 pub mod history;
66
 pub mod image_preview;
77
 pub mod operations;
8
+pub mod pdf_preview;
89
 pub mod thumbnail;
910
 pub mod preview_loader;
1011
 pub mod trash;
@@ -20,6 +21,7 @@ pub use operations::{
2021
     copy_files, copy_path, copy_to_path, create_directory, delete_files, delete_path,
2122
     make_unique_name, move_files, move_path, rename_path, OperationResult,
2223
 };
24
+pub use pdf_preview::{is_pdf, PdfPreview, PdfPreviewLoader, PdfPreviewResult};
2325
 pub use preview_loader::{PreviewLoader, PreviewResult};
2426
 pub use thumbnail::{Thumbnail, ThumbnailLoader, THUMBNAIL_SIZE};
2527
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
garfield/src/core/pdf_preview.rsadded
@@ -0,0 +1,210 @@
1
+//! Asynchronous PDF preview loading.
2
+//!
3
+//! This module provides non-blocking PDF rendering to prevent UI lag
4
+//! when selecting PDF 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 a PDF preview.
11
+#[derive(Debug, Clone)]
12
+pub struct PdfPreviewRequest {
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 a PDF preview load operation.
24
+#[derive(Debug)]
25
+pub struct PdfPreviewResult {
26
+    /// Path that was loaded.
27
+    pub path: PathBuf,
28
+    /// Loaded image data (RGBA format), or None if load failed.
29
+    pub image: Option<PdfPreview>,
30
+    /// Request ID for matching.
31
+    pub request_id: u64,
32
+}
33
+
34
+/// Loaded and rendered PDF preview (first page).
35
+#[derive(Debug, Clone)]
36
+pub struct PdfPreview {
37
+    /// RGBA pixel data.
38
+    pub data: Vec<u8>,
39
+    /// Image width.
40
+    pub width: u32,
41
+    /// Image height.
42
+    pub height: u32,
43
+    /// Total number of pages in the PDF.
44
+    pub page_count: usize,
45
+}
46
+
47
+/// Manages asynchronous PDF preview loading with a worker thread.
48
+pub struct PdfPreviewLoader {
49
+    /// Sender to submit load requests.
50
+    request_tx: Sender<PdfPreviewRequest>,
51
+    /// Receiver for completed loads.
52
+    result_rx: Receiver<PdfPreviewResult>,
53
+    /// Worker thread handle.
54
+    _worker: JoinHandle<()>,
55
+    /// Current request ID counter.
56
+    next_request_id: u64,
57
+    /// ID of the most recent request (for ignoring stale results).
58
+    current_request_id: u64,
59
+}
60
+
61
+impl PdfPreviewLoader {
62
+    /// Create a new PDF preview loader with a background worker thread.
63
+    pub fn new() -> Self {
64
+        let (request_tx, request_rx) = mpsc::channel::<PdfPreviewRequest>();
65
+        let (result_tx, result_rx) = mpsc::channel::<PdfPreviewResult>();
66
+
67
+        let worker = thread::spawn(move || {
68
+            Self::worker_loop(request_rx, result_tx);
69
+        });
70
+
71
+        Self {
72
+            request_tx,
73
+            result_rx,
74
+            _worker: worker,
75
+            next_request_id: 0,
76
+            current_request_id: 0,
77
+        }
78
+    }
79
+
80
+    /// Worker thread loop - processes load requests.
81
+    fn worker_loop(request_rx: Receiver<PdfPreviewRequest>, result_tx: Sender<PdfPreviewResult>) {
82
+        while let Ok(request) = request_rx.recv() {
83
+            let image = Self::render_first_page(&request.path, request.max_width, request.max_height);
84
+
85
+            // Send result back
86
+            let _ = result_tx.send(PdfPreviewResult {
87
+                path: request.path,
88
+                image,
89
+                request_id: request.request_id,
90
+            });
91
+        }
92
+    }
93
+
94
+    /// Render the first page of a PDF to an image.
95
+    fn render_first_page(path: &PathBuf, max_width: u32, max_height: u32) -> Option<PdfPreview> {
96
+        use cairo::{Context, Format, ImageSurface};
97
+        use poppler::Document;
98
+
99
+        // Load the PDF document
100
+        let uri = format!("file://{}", path.display());
101
+        let doc = Document::from_file(&uri, None).ok()?;
102
+
103
+        let page_count = doc.n_pages() as usize;
104
+        if page_count == 0 {
105
+            return None;
106
+        }
107
+
108
+        // Get the first page
109
+        let page = doc.page(0)?;
110
+        let (page_width, page_height) = page.size();
111
+
112
+        // Calculate scale to fit within max dimensions
113
+        let scale_x = max_width as f64 / page_width;
114
+        let scale_y = max_height as f64 / page_height;
115
+        let scale = scale_x.min(scale_y).min(2.0); // Cap at 2x for quality
116
+
117
+        let width = (page_width * scale) as i32;
118
+        let height = (page_height * scale) as i32;
119
+
120
+        // Create a Cairo surface to render to
121
+        let mut surface = ImageSurface::create(Format::ARgb32, width, height).ok()?;
122
+
123
+        {
124
+            let ctx = Context::new(&surface).ok()?;
125
+
126
+            // Fill with white background
127
+            ctx.set_source_rgb(1.0, 1.0, 1.0);
128
+            ctx.paint().ok()?;
129
+
130
+            // Scale and render the page
131
+            ctx.scale(scale, scale);
132
+            page.render(&ctx);
133
+        }
134
+
135
+        // Get the pixel data
136
+        surface.flush();
137
+        let stride = surface.stride() as usize;
138
+        let data = surface.data().ok()?;
139
+
140
+        // Convert from ARGB (Cairo) to RGBA
141
+        let mut rgba = Vec::with_capacity((width * height * 4) as usize);
142
+        for y in 0..height as usize {
143
+            for x in 0..width as usize {
144
+                let offset = y * stride + x * 4;
145
+                let b = data[offset];
146
+                let g = data[offset + 1];
147
+                let r = data[offset + 2];
148
+                let a = data[offset + 3];
149
+                rgba.push(r);
150
+                rgba.push(g);
151
+                rgba.push(b);
152
+                rgba.push(a);
153
+            }
154
+        }
155
+
156
+        Some(PdfPreview {
157
+            data: rgba,
158
+            width: width as u32,
159
+            height: height as u32,
160
+            page_count,
161
+        })
162
+    }
163
+
164
+    /// Request loading a PDF preview. Returns the request ID.
165
+    pub fn load(&mut self, path: PathBuf, max_width: u32, max_height: u32) -> u64 {
166
+        self.next_request_id += 1;
167
+        self.current_request_id = self.next_request_id;
168
+
169
+        let request = PdfPreviewRequest {
170
+            path,
171
+            max_width,
172
+            max_height,
173
+            request_id: self.current_request_id,
174
+        };
175
+
176
+        let _ = self.request_tx.send(request);
177
+        self.current_request_id
178
+    }
179
+
180
+    /// Poll for a completed preview load. Returns None if no result ready.
181
+    pub fn poll(&mut self) -> Option<PdfPreviewResult> {
182
+        loop {
183
+            match self.result_rx.try_recv() {
184
+                Ok(result) => {
185
+                    if result.request_id == self.current_request_id {
186
+                        return Some(result);
187
+                    }
188
+                }
189
+                Err(TryRecvError::Empty) => return None,
190
+                Err(TryRecvError::Disconnected) => return None,
191
+            }
192
+        }
193
+    }
194
+
195
+    /// Cancel any pending requests.
196
+    pub fn cancel(&mut self) {
197
+        self.current_request_id = 0;
198
+    }
199
+}
200
+
201
+impl Default for PdfPreviewLoader {
202
+    fn default() -> Self {
203
+        Self::new()
204
+    }
205
+}
206
+
207
+/// Check if a file extension is a PDF.
208
+pub fn is_pdf(extension: Option<&str>) -> bool {
209
+    matches!(extension.map(|e| e.to_lowercase()).as_deref(), Some("pdf"))
210
+}
garfield/src/ui/column_view.rsmodified
@@ -1,6 +1,6 @@
11
 //! Miller columns view (like macOS Finder).
22
 
3
-use crate::core::{read_directory, sort_entries, EntryType, FileEntry, ImagePreview, SortDirection, SortOrder, is_supported_image};
3
+use crate::core::{read_directory, sort_entries, EntryType, FileEntry, ImagePreview, PdfPreview, SortDirection, SortOrder, is_supported_image, is_pdf};
44
 use gartk_core::{Color, Modifiers, Point, Rect};
55
 use gartk_render::{Renderer, TextStyle, Surface};
66
 use std::collections::HashSet;
@@ -91,6 +91,14 @@ pub struct ColumnView {
9191
     image_preview_path: Option<PathBuf>,
9292
     /// Cached Cairo surface for the image preview.
9393
     image_surface: Option<Surface>,
94
+    /// Path that needs PDF preview loading.
95
+    pending_pdf_preview_path: Option<PathBuf>,
96
+    /// Loaded PDF preview.
97
+    pdf_preview: Option<PdfPreview>,
98
+    /// Path of the loaded PDF preview (for matching).
99
+    pdf_preview_path: Option<PathBuf>,
100
+    /// Cached Cairo surface for the PDF preview.
101
+    pdf_surface: Option<Surface>,
94102
     /// View bounds.
95103
     bounds: Rect,
96104
     /// Show hidden files.
@@ -123,6 +131,10 @@ impl ColumnView {
123131
             image_preview: None,
124132
             image_preview_path: None,
125133
             image_surface: None,
134
+            pending_pdf_preview_path: None,
135
+            pdf_preview: None,
136
+            pdf_preview_path: None,
137
+            pdf_surface: None,
126138
             bounds,
127139
             show_hidden: false,
128140
             sort_order: SortOrder::Name,
@@ -208,11 +220,8 @@ impl ColumnView {
208220
                     // Clear current preview while loading
209221
                     self.preview_column = None;
210222
                 }
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;
223
+                // Clear image/PDF preview for directories
224
+                self.clear_file_previews();
216225
             } else {
217226
                 self.pending_preview_path = None;
218227
                 self.preview_column = None;
@@ -227,24 +236,50 @@ impl ColumnView {
227236
                         self.image_preview_path = None;
228237
                         self.image_surface = None;
229238
                     }
230
-                } else {
231
-                    // Not an image - clear image preview
239
+                    // Clear PDF preview when showing image
240
+                    self.pending_pdf_preview_path = None;
241
+                    self.pdf_preview = None;
242
+                    self.pdf_preview_path = None;
243
+                    self.pdf_surface = None;
244
+                } else if is_pdf(entry.extension().as_deref()) {
245
+                    // Check if this is a PDF file that needs preview
246
+                    let needs_load = self.pdf_preview_path.as_ref() != Some(&entry.path);
247
+                    if needs_load {
248
+                        self.pending_pdf_preview_path = Some(entry.path.clone());
249
+                        // Clear current PDF preview while loading
250
+                        self.pdf_preview = None;
251
+                        self.pdf_preview_path = None;
252
+                        self.pdf_surface = None;
253
+                    }
254
+                    // Clear image preview when showing PDF
232255
                     self.pending_image_preview_path = None;
233256
                     self.image_preview = None;
234257
                     self.image_preview_path = None;
235258
                     self.image_surface = None;
259
+                } else {
260
+                    // Not an image or PDF - clear both previews
261
+                    self.clear_file_previews();
236262
                 }
237263
             }
238264
         } else {
239265
             self.pending_preview_path = None;
240266
             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;
267
+            self.clear_file_previews();
245268
         }
246269
     }
247270
 
271
+    /// Clear all file preview state (image and PDF).
272
+    fn clear_file_previews(&mut self) {
273
+        self.pending_image_preview_path = None;
274
+        self.image_preview = None;
275
+        self.image_preview_path = None;
276
+        self.image_surface = None;
277
+        self.pending_pdf_preview_path = None;
278
+        self.pdf_preview = None;
279
+        self.pdf_preview_path = None;
280
+        self.pdf_surface = None;
281
+    }
282
+
248283
     /// Get the path that needs preview loading, if any.
249284
     /// Returns the path and sort settings. Returns None if no preview needed.
250285
     pub fn take_pending_preview(&mut self) -> Option<(PathBuf, SortOrder, SortDirection)> {
@@ -315,6 +350,37 @@ impl ColumnView {
315350
         }
316351
     }
317352
 
353
+    /// Take pending PDF preview request (path, max_width, max_height).
354
+    pub fn take_pending_pdf_preview(&mut self) -> Option<(PathBuf, u32, u32)> {
355
+        self.pending_pdf_preview_path.take().map(|path| {
356
+            let preview_x = self.current_column.bounds.x + self.current_column.bounds.width as i32;
357
+            let preview_width = (self.bounds.x + self.bounds.width as i32 - preview_x) as u32;
358
+            let preview_height = self.bounds.height / 2; // Use half height for PDF
359
+            (path, preview_width.saturating_sub(32), preview_height.saturating_sub(32))
360
+        })
361
+    }
362
+
363
+    /// Set loaded PDF preview.
364
+    pub fn set_pdf_preview(&mut self, path: &PathBuf, pdf: Option<PdfPreview>) {
365
+        // Only set if this is still the path we're displaying
366
+        let visible = self.current_column.visible_entries(self.show_hidden);
367
+        let selected_matches = visible
368
+            .get(self.current_column.selected)
369
+            .map(|e| &e.path == path)
370
+            .unwrap_or(false);
371
+
372
+        if selected_matches {
373
+            if let Some(ref preview) = pdf {
374
+                // Create a Cairo surface from the RGBA data
375
+                self.pdf_surface = Surface::from_rgba(&preview.data, preview.width, preview.height).ok();
376
+            } else {
377
+                self.pdf_surface = None;
378
+            }
379
+            self.pdf_preview = pdf;
380
+            self.pdf_preview_path = Some(path.clone());
381
+        }
382
+    }
383
+
318384
     /// Get visible entries in current column.
319385
     pub fn visible_entries(&self) -> Vec<&FileEntry> {
320386
         self.current_column.visible_entries(self.show_hidden)
@@ -860,6 +926,32 @@ impl ColumnView {
860926
 
861927
             // Move y below the image with padding
862928
             y += img_height as i32 + 24;
929
+        } else if let Some(ref surface) = self.pdf_surface {
930
+            // Render PDF preview
931
+            let img_width = surface.width();
932
+            let img_height = surface.height();
933
+            let max_width = preview_width.saturating_sub(32);
934
+
935
+            let img_x = preview_x + 16 + (max_width.saturating_sub(img_width) / 2) as i32;
936
+            let img_y = y;
937
+
938
+            // Draw the PDF preview using Cairo
939
+            let ctx = renderer.context()?;
940
+            ctx.set_source_surface(surface.cairo_surface(), img_x as f64, img_y as f64)?;
941
+            ctx.paint()?;
942
+
943
+            // Show page count if available
944
+            if let Some(ref pdf) = self.pdf_preview {
945
+                let page_info = format!("Page 1 of {}", pdf.page_count);
946
+                let page_style = TextStyle::new()
947
+                    .font_family(&theme.font_family)
948
+                    .font_size(theme.font_size - 2.0)
949
+                    .color(theme.item_foreground.with_alpha(0.6));
950
+                renderer.text(&page_info, (img_x + 4) as f64, (img_y + img_height as i32 + 4) as f64, &page_style)?;
951
+            }
952
+
953
+            // Move y below the preview with padding
954
+            y += img_height as i32 + 32;
863955
         } else if is_supported_image(entry.extension().as_deref()) && self.pending_image_preview_path.is_some() {
864956
             // Show loading indicator for images
865957
             let loading_style = TextStyle::new()
@@ -868,6 +960,14 @@ impl ColumnView {
868960
                 .color(theme.item_foreground.with_alpha(0.5));
869961
             renderer.text("Loading preview...", x as f64, y as f64, &loading_style)?;
870962
             y += 40;
963
+        } else if is_pdf(entry.extension().as_deref()) && self.pending_pdf_preview_path.is_some() {
964
+            // Show loading indicator for PDFs
965
+            let loading_style = TextStyle::new()
966
+                .font_family(&theme.font_family)
967
+                .font_size(theme.font_size)
968
+                .color(theme.item_foreground.with_alpha(0.5));
969
+            renderer.text("Loading PDF preview...", x as f64, y as f64, &loading_style)?;
970
+            y += 40;
871971
         }
872972
 
873973
         let label_style = TextStyle::new()
garfield/src/ui/tab.rsmodified
@@ -751,6 +751,22 @@ impl Tab {
751751
         }
752752
     }
753753
 
754
+    /// Take pending PDF preview request (path, max_width, max_height).
755
+    pub fn take_pending_pdf_preview(&mut self) -> Option<(PathBuf, u32, u32)> {
756
+        if self.view_mode == ViewMode::Columns {
757
+            self.column_view.take_pending_pdf_preview()
758
+        } else {
759
+            None
760
+        }
761
+    }
762
+
763
+    /// Set loaded PDF preview for column view.
764
+    pub fn set_pdf_preview(&mut self, path: &PathBuf, pdf: Option<crate::core::PdfPreview>) {
765
+        if self.view_mode == ViewMode::Columns {
766
+            self.column_view.set_pdf_preview(path, pdf);
767
+        }
768
+    }
769
+
754770
     /// Poll for completed grid view thumbnails. Returns true if any loaded.
755771
     pub fn poll_thumbnails(&mut self) -> bool {
756772
         self.grid_view.poll_thumbnails()