Skip to main content

trail/workers/
image_decode.rs

1//! Image decoding worker.
2//!
3//! Decodes an image file off the UI thread and turns it into a
4//! `PreviewContent::Image` carrying the encoder state for the terminal's
5//! inline-image protocol. Which protocol that is, and whether pixel previews
6//! are enabled at all, is decided by [`crate::preview::graphics`].
7//!
8//! Decoding is the expensive part (a large JPEG can take tens of milliseconds),
9//! which is why it runs on `spawn_blocking` and reports back through
10//! `WorkerMsg::ImageMeta` tagged with the preview generation. Resizing and
11//! encoding for the pane's current size happen later, on the UI thread, and are
12//! cached by `ratatui-image` until the pane changes size.
13
14use std::path::PathBuf;
15
16use image::GenericImageView;
17use tokio::sync::mpsc;
18
19use crate::preview::graphics::{self, ImagePreview};
20use crate::preview::provider::PreviewContent;
21use crate::workers::WorkerMsg;
22
23// ── Spawn helper ─────────────────────────────────────────────────────────────
24
25/// Decodes `path` off-thread, sending a `WorkerMsg::ImageMeta` result through
26/// `tx` tagged with `generation`.
27///
28/// The generation tag is what lets `workers::merge` drop a result whose
29/// selection has already been abandoned.
30pub fn spawn_image_decode(path: PathBuf, generation: u64, tx: mpsc::Sender<WorkerMsg>) {
31    tokio::spawn(async move {
32        let path_clone = path.clone();
33        let content = tokio::task::spawn_blocking(move || decode_image_sync(&path_clone))
34            .await
35            .unwrap_or_else(|_| PreviewContent::Binary(vec!["[image decode error]".to_owned()]));
36
37        let msg = WorkerMsg::ImageMeta {
38            generation,
39            path: path.clone(),
40            content,
41        };
42        // Ignore send errors — the UI thread may have exited.
43        let _ = tx.send(msg).await;
44    });
45}
46
47// ── Blocking decode ───────────────────────────────────────────────────────────
48
49/// Performs the blocking decode and builds the renderable preview.
50///
51/// Falls back to `PreviewContent::Binary` metadata lines when the file cannot
52/// be decoded (an unsupported format such as SVG, or a truncated file) or when
53/// image previews are disabled, so the pane always shows something useful.
54fn decode_image_sync(path: &std::path::Path) -> PreviewContent {
55    let image = match image::open(path) {
56        Ok(image) => image,
57        Err(e) => {
58            return PreviewContent::Binary(vec![
59                format!("  Type    : {} image", format_label(path)),
60                format!("  Size    : {}", file_size(path)),
61                String::new(),
62                format!("  Cannot decode: {e}"),
63            ])
64        }
65    };
66
67    let (width, height) = image.dimensions();
68    let colour = image.color();
69    let (cell_width, cell_height) = graphics::cell_size();
70    let protocol = graphics::active();
71
72    let caption = format!(
73        "  {} · {}×{} px · {} · {} {}×{}",
74        format_label(path),
75        width,
76        height,
77        file_size(path),
78        protocol.label(),
79        cell_width,
80        cell_height,
81    );
82
83    match graphics::build(image) {
84        Some(protocol) => PreviewContent::Image(ImagePreview::new(protocol, caption)),
85        // Image previews are switched off via `[preview] image_protocol`.
86        None => PreviewContent::Binary(vec![
87            format!("  Type    : {} image", format_label(path)),
88            format!("  Size    : {}", file_size(path)),
89            format!("  Dims    : {width}×{height} px"),
90            format!("  Colour  : {colour:?}"),
91            String::new(),
92            "  (image previews disabled: [preview] image_protocol = \"none\")".to_owned(),
93        ]),
94    }
95}
96
97/// The uppercased file extension, used as a human-readable format label.
98fn format_label(path: &std::path::Path) -> String {
99    path.extension()
100        .and_then(|e| e.to_str())
101        .map(|e| e.to_uppercase())
102        .unwrap_or_else(|| "Unknown".to_owned())
103}
104
105/// The file size formatted for display, or `"unknown"` if it cannot be read.
106fn file_size(path: &std::path::Path) -> String {
107    std::fs::metadata(path)
108        .map(|m| humansize::format_size(m.len(), humansize::DECIMAL))
109        .unwrap_or_else(|_| "unknown".to_owned())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::io::Write;
116
117    /// Writes a tiny valid PNG to a temp file and returns the handle.
118    fn sample_png() -> tempfile::NamedTempFile {
119        let image = image::DynamicImage::new_rgb8(4, 3);
120        let mut bytes = std::io::Cursor::new(Vec::new());
121        image
122            .write_to(&mut bytes, image::ImageFormat::Png)
123            .expect("encode sample png");
124
125        let mut file = tempfile::Builder::new()
126            .suffix(".png")
127            .tempfile()
128            .expect("temp file");
129        file.write_all(&bytes.into_inner()).expect("write png");
130        file.flush().expect("flush png");
131        file
132    }
133
134    #[test]
135    fn decodes_a_real_image_into_an_image_preview() {
136        let file = sample_png();
137        // The test process is not a terminal, so detection lands on
138        // Halfblocks — which still produces a drawable preview.
139        match decode_image_sync(file.path()) {
140            PreviewContent::Image(preview) => {
141                assert!(
142                    preview.caption.contains("4×3 px"),
143                    "caption should carry the decoded dimensions, got {:?}",
144                    preview.caption
145                );
146            }
147            other => panic!("expected an image preview, got {other:?}"),
148        }
149    }
150
151    #[test]
152    fn undecodable_file_falls_back_to_metadata() {
153        let mut file = tempfile::Builder::new()
154            .suffix(".png")
155            .tempfile()
156            .expect("temp file");
157        file.write_all(b"not actually a png").expect("write");
158        file.flush().expect("flush");
159
160        match decode_image_sync(file.path()) {
161            PreviewContent::Binary(lines) => {
162                assert!(
163                    lines.iter().any(|l| l.contains("Cannot decode")),
164                    "expected a decode failure line, got {lines:?}"
165                );
166            }
167            other => panic!("expected metadata fallback, got {other:?}"),
168        }
169    }
170
171    #[test]
172    fn format_label_falls_back_when_there_is_no_extension() {
173        assert_eq!(format_label(std::path::Path::new("photo.JpEg")), "JPEG");
174        assert_eq!(format_label(std::path::Path::new("photo")), "Unknown");
175    }
176}