Skip to main content

mach/
image.rs

1//! Images referenced from a task's description.
2//!
3//! A description line may carry a markdown-style reference —
4//! `![alt](~/shot.png)` — and mach draws the picture itself. Terminals
5//! that speak kitty, iTerm2 or sixel graphics show the real image;
6//! everywhere else it falls back to unicode half blocks.
7//!
8//! Animated GIFs play in the full-size preview (double-click / Enter).
9
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::path::{Path, PathBuf};
12use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError, TrySendError};
13use std::sync::{Arc, OnceLock};
14use std::time::{Duration, Instant};
15
16use image::codecs::gif::GifDecoder;
17use image::imageops::FilterType;
18use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, Limits};
19use ratatui_image::FontSize;
20use ratatui_image::picker::{Picker, ProtocolType};
21use ratatui_image::protocol::StatefulProtocol;
22
23const IMAGE_EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"];
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) struct ManagedAttachmentFormat {
27    pub extension: &'static str,
28    pub media_type: &'static str,
29}
30
31const MANAGED_ATTACHMENT_FORMATS: [(ImageFormat, ManagedAttachmentFormat); 4] = [
32    (
33        ImageFormat::Png,
34        ManagedAttachmentFormat {
35            extension: "png",
36            media_type: "image/png",
37        },
38    ),
39    (
40        ImageFormat::Jpeg,
41        ManagedAttachmentFormat {
42            extension: "jpg",
43            media_type: "image/jpeg",
44        },
45    ),
46    (
47        ImageFormat::Gif,
48        ManagedAttachmentFormat {
49            extension: "gif",
50            media_type: "image/gif",
51        },
52    ),
53    (
54        ImageFormat::WebP,
55        ManagedAttachmentFormat {
56            extension: "webp",
57            media_type: "image/webp",
58        },
59    ),
60];
61
62pub(crate) fn managed_attachment_format(format: ImageFormat) -> Option<ManagedAttachmentFormat> {
63    MANAGED_ATTACHMENT_FORMATS
64        .iter()
65        .find_map(|(candidate, metadata)| (*candidate == format).then_some(*metadata))
66}
67
68pub(crate) fn managed_attachment_format_for_media_type(
69    media_type: &str,
70) -> Option<ManagedAttachmentFormat> {
71    MANAGED_ATTACHMENT_FORMATS
72        .iter()
73        .find_map(|(_, metadata)| (metadata.media_type == media_type).then_some(*metadata))
74}
75
76pub(crate) fn is_managed_attachment_extension(extension: &str) -> bool {
77    MANAGED_ATTACHMENT_FORMATS
78        .iter()
79        .any(|(_, metadata)| metadata.extension == extension)
80}
81
82/// Max long edge for stills in description / preview.
83const MAX_STILL_EDGE: u32 = 1920;
84/// Max long edge for GIF frames (encoded once per index).
85const MAX_GIF_EDGE: u32 = 720;
86/// Max frames decoded from one GIF.
87const MAX_GIF_FRAMES: usize = 48;
88/// Max successful or failed decode outcomes kept in the LRU cache.
89const MAX_CACHE_ENTRIES: usize = 48;
90/// Aggregate decoded-pixel budget for still images. Protocol encodings are
91/// released with the form; decoded pixels are the persistent cache cost.
92const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024;
93/// Reject implausibly large canvases before a decoder allocates them.
94const MAX_DECODE_DIMENSION: u32 = 8192;
95const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024;
96/// Decode work is CPU and memory heavy; keep the UI responsive under a description
97/// containing many images instead of spawning one thread per path.
98const MAX_DECODE_WORKERS: usize = 2;
99
100/// A private PNG staged from clipboard pixels for the lifetime of an open
101/// form. Saving imports it into the content-addressed store; cancelling or
102/// closing the form removes the source file.
103#[derive(Debug)]
104pub(crate) struct TemporaryImage {
105    path: PathBuf,
106}
107
108impl TemporaryImage {
109    pub(crate) fn path(&self) -> &Path {
110        &self.path
111    }
112}
113
114impl Drop for TemporaryImage {
115    fn drop(&mut self) {
116        let _ = std::fs::remove_file(&self.path);
117    }
118}
119
120/// Validate RGBA clipboard pixels and encode them into a private temporary
121/// PNG. The returned owner keeps the source alive until a form is saved or
122/// dismissed.
123pub(crate) fn stage_clipboard_image(
124    image: arboard::ImageData<'_>,
125) -> Result<TemporaryImage, String> {
126    use image::ImageEncoder;
127    use std::io::Write;
128
129    let width = u32::try_from(image.width)
130        .map_err(|_| "clipboard image width exceeds the supported limit".to_string())?;
131    let height = u32::try_from(image.height)
132        .map_err(|_| "clipboard image height exceeds the supported limit".to_string())?;
133    if width == 0 || height == 0 {
134        return Err("clipboard image dimensions must be nonzero".into());
135    }
136    if width > MAX_DECODE_DIMENSION || height > MAX_DECODE_DIMENSION {
137        return Err(format!(
138            "clipboard image dimensions exceed the {MAX_DECODE_DIMENSION}-pixel safety limit"
139        ));
140    }
141    let expected = image
142        .width
143        .checked_mul(image.height)
144        .and_then(|pixels| pixels.checked_mul(4))
145        .ok_or_else(|| "clipboard image dimensions overflow its pixel buffer".to_string())?;
146    if expected != image.bytes.len() {
147        return Err(format!(
148            "clipboard image pixel buffer has {} bytes; expected {expected}",
149            image.bytes.len()
150        ));
151    }
152    if expected as u64 > MAX_DECODE_ALLOC {
153        return Err(format!(
154            "clipboard image pixel buffer exceeds the {} MiB safety limit",
155            MAX_DECODE_ALLOC / 1024 / 1024
156        ));
157    }
158
159    let path = std::env::temp_dir().join(format!("mach-clipboard-{}.png", uuid::Uuid::new_v4()));
160    let mut options = std::fs::OpenOptions::new();
161    options.write(true).create_new(true);
162    #[cfg(unix)]
163    {
164        use std::os::unix::fs::OpenOptionsExt;
165        options.mode(0o600);
166    }
167    let mut file = options
168        .open(&path)
169        .map_err(|error| format!("could not create clipboard image staging file: {error}"))?;
170    let encoded = (|| {
171        image::codecs::png::PngEncoder::new(&mut file)
172            .write_image(&image.bytes, width, height, image::ColorType::Rgba8.into())
173            .map_err(|error| format!("could not encode clipboard image as PNG: {error}"))?;
174        file.flush()
175            .map_err(|error| format!("could not flush clipboard image: {error}"))?;
176        let byte_len = file
177            .metadata()
178            .map_err(|error| format!("could not inspect clipboard image: {error}"))?
179            .len();
180        if byte_len > crate::store::MAX_ATTACHMENT_BYTES {
181            return Err(format!(
182                "clipboard image exceeds the {} MiB attachment limit",
183                crate::store::MAX_ATTACHMENT_BYTES / 1024 / 1024
184            ));
185        }
186        Ok(())
187    })();
188    drop(file);
189    if let Err(error) = encoded {
190        let _ = std::fs::remove_file(&path);
191        return Err(error);
192    }
193    Ok(TemporaryImage { path })
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq)]
197pub(crate) struct AttachmentCatalog {
198    files: HashMap<String, String>,
199}
200
201impl AttachmentCatalog {
202    pub fn set(&mut self, attachments: &[crate::store::Attachment]) {
203        self.files = attachments
204            .iter()
205            .map(|attachment| (attachment.id.clone(), attachment.storage_name.clone()))
206            .collect();
207    }
208
209    pub fn resolve(&self, reference: &str, images_root: &Path) -> PathBuf {
210        self.files
211            .get(reference)
212            .map(|storage_name| images_root.join(storage_name))
213            .unwrap_or_else(|| expand_in(reference, images_root))
214    }
215
216    pub fn contains(&self, reference: &str) -> bool {
217        self.files.contains_key(reference)
218    }
219}
220
221fn decode_limits() -> Limits {
222    let mut limits = Limits::default();
223    limits.max_image_width = Some(MAX_DECODE_DIMENSION);
224    limits.max_image_height = Some(MAX_DECODE_DIMENSION);
225    limits.max_alloc = Some(MAX_DECODE_ALLOC);
226    limits
227}
228
229/// Shrink so the longer side is at most `max_edge` (no-op when already smaller).
230fn fit(img: DynamicImage, max_edge: u32) -> DynamicImage {
231    let (w, h) = (img.width(), img.height());
232    let edge = w.max(h);
233    if edge <= max_edge {
234        return img;
235    }
236    let scale = max_edge as f64 / edge as f64;
237    let nw = ((w as f64) * scale).round().max(1.0) as u32;
238    let nh = ((h as f64) * scale).round().max(1.0) as u32;
239    img.resize(nw, nh, FilterType::Triangle)
240}
241
242/// Whether a string looks like an image path (extension check; no FS).
243pub fn looks_like_image(text: &str) -> bool {
244    let Some(t) = reference_path(text) else {
245        return false;
246    };
247    if let Some((_, ext)) = t.rsplit_once('.') {
248        let ext = ext.split(['/', '\\', '?', '#']).next().unwrap_or(ext);
249        if IMAGE_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
250            return true;
251        }
252    }
253    false
254}
255
256/// An existing image file named by `text`, if that is what it is.
257pub fn path_if_image(text: &str) -> Option<PathBuf> {
258    path_if_image_in(text, &default_images_root())
259}
260
261/// Resolve a raw or Markdown image reference against an explicit images root.
262/// Relative paths never depend on the process working directory.
263pub fn path_if_image_in(text: &str, images_root: &Path) -> Option<PathBuf> {
264    let reference = reference_path(text)?;
265    if !looks_like_image(reference) {
266        return None;
267    }
268    let path = expand_in(reference, images_root);
269    path.is_file().then_some(path)
270}
271
272/// Resolves `~`, `file://` URLs and escaped spaces; the rest is left to
273/// the filesystem.
274pub fn expand(path: &str) -> PathBuf {
275    expand_in(path, &default_images_root())
276}
277
278pub fn expand_in(path: &str, images_root: &Path) -> PathBuf {
279    let path = reference_path(path).unwrap_or(path).trim();
280    let path = path.strip_prefix("file://").unwrap_or(path);
281    let path = path.replace("%20", " ");
282    if let Some(rest) = path.strip_prefix("~/")
283        && let Some(home) = dirs::home_dir()
284    {
285        return home.join(rest);
286    }
287    let path = PathBuf::from(path);
288    if path.is_absolute() {
289        path
290    } else {
291        images_root.join(path)
292    }
293}
294
295/// Extract the path from either `path.png` or `![alt](path.png)`.
296pub(crate) fn reference_path(text: &str) -> Option<&str> {
297    let text = text.trim();
298    if !text.starts_with("![") {
299        return (!text.is_empty()).then_some(text);
300    }
301    let close = text.find("](")?;
302    if !text.ends_with(')') || close + 2 >= text.len() - 1 {
303        return None;
304    }
305    Some(text[close + 2..text.len() - 1].trim())
306}
307
308/// What the terminal says a cell is worth in pixels, when it will say.
309///
310/// Every size here is counted in cells and encoded as `cells × cell_size`
311/// pixels, so this number has to be right or the terminal draws a picture
312/// that does not fit the cells it was given and clips the overflow.
313fn terminal_cell_size() -> Option<FontSize> {
314    let ws = ratatui::crossterm::terminal::window_size().ok()?;
315    // The pixel fields are optional in the ioctl; zero means "not told".
316    if ws.width == 0 || ws.height == 0 || ws.columns == 0 || ws.rows == 0 {
317        return None;
318    }
319    Some(FontSize::new(ws.width / ws.columns, ws.height / ws.rows))
320}
321
322fn tmux_without_passthrough() -> bool {
323    if std::env::var_os("TMUX").is_none() {
324        return false;
325    }
326    match std::process::Command::new("tmux")
327        .args(["show", "-gv", "allow-passthrough"])
328        .output()
329    {
330        Ok(out) => String::from_utf8_lossy(&out.stdout).trim() != "on",
331        Err(_) => true,
332    }
333}
334
335/// Uppercase type label for a path (`GIF`, `PNG`, …).
336pub fn type_label(path: &Path) -> String {
337    path.extension()
338        .and_then(|e| e.to_str())
339        .map(|e| e.to_ascii_uppercase())
340        .filter(|e| !e.is_empty())
341        .unwrap_or_else(|| "IMG".to_string())
342}
343
344pub fn is_gif(path: &Path) -> bool {
345    if path
346        .extension()
347        .and_then(|e| e.to_str())
348        .is_some_and(|e| e.eq_ignore_ascii_case("gif"))
349    {
350        return true;
351    }
352    // Sniff magic bytes — some temp clipboard paths omit a reliable suffix.
353    let Ok(mut f) = std::fs::File::open(path) else {
354        return false;
355    };
356    use std::io::Read;
357    let mut magic = [0u8; 6];
358    matches!(f.read(&mut magic), Ok(6) if &magic == b"GIF87a" || &magic == b"GIF89a")
359}
360
361/// One decoded GIF ready to play in the full-size preview.
362pub struct GifPlayback {
363    frames: Vec<Arc<DynamicImage>>,
364    delays: Vec<Duration>,
365    index: usize,
366    next_at: Instant,
367    paused: bool,
368}
369
370impl GifPlayback {
371    pub fn load(path: &Path) -> Result<Self, String> {
372        let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
373        let reader = std::io::BufReader::new(file);
374        let mut decoder =
375            GifDecoder::new(reader).map_err(|e| format!("{}: {e}", path.display()))?;
376        decoder
377            .set_limits(decode_limits())
378            .map_err(|e| format!("{}: {e}", path.display()))?;
379        let mut frames = Vec::new();
380        let mut delays = Vec::new();
381        // Stream frames so a 500-frame meme does not decode entirely first.
382        for (i, frame) in decoder.into_frames().enumerate() {
383            if i >= MAX_GIF_FRAMES {
384                break;
385            }
386            let frame = frame.map_err(|e| format!("{}: {e}", path.display()))?;
387            // Delay is already a ratio of milliseconds — convert via Duration.
388            let mut delay = Duration::from(frame.delay());
389            // GIF delay of 0 is commonly treated as ~100ms.
390            if delay.is_zero() {
391                delay = Duration::from_millis(100);
392            }
393            // Floor so slow terminals can keep up; cap wild values.
394            if delay < Duration::from_millis(40) {
395                delay = Duration::from_millis(40);
396            }
397            if delay > Duration::from_secs(10) {
398                delay = Duration::from_secs(10);
399            }
400            delays.push(delay);
401            let rgba = DynamicImage::ImageRgba8(frame.into_buffer());
402            frames.push(Arc::new(fit(rgba, MAX_GIF_EDGE)));
403        }
404        if frames.is_empty() {
405            return Err(format!("{}: empty GIF", path.display()));
406        }
407        let delay0 = delays[0];
408        Ok(Self {
409            frames,
410            delays,
411            index: 0,
412            next_at: Instant::now() + delay0,
413            paused: false,
414        })
415    }
416
417    pub fn frame_count(&self) -> usize {
418        self.frames.len()
419    }
420
421    /// 1-based index for the UI (`1/12`).
422    pub fn frame_number(&self) -> usize {
423        self.index + 1
424    }
425
426    pub fn is_animated(&self) -> bool {
427        self.frames.len() > 1
428    }
429
430    pub fn is_paused(&self) -> bool {
431        self.paused
432    }
433
434    /// Toggle pause/resume. When resuming, the next frame is scheduled
435    /// from now so playback does not jump.
436    pub fn toggle_pause(&mut self) {
437        if self.frames.len() <= 1 {
438            return;
439        }
440        self.paused = !self.paused;
441        if !self.paused {
442            self.next_at = Instant::now() + self.delays[self.index];
443        }
444    }
445
446    /// Advance when the current frame's delay has elapsed.
447    pub fn tick(&mut self) -> bool {
448        if self.paused || self.frames.len() <= 1 {
449            return false;
450        }
451        let now = Instant::now();
452        if now < self.next_at {
453            return false;
454        }
455        self.index = (self.index + 1) % self.frames.len();
456        // Schedule from *now* so a slow redraw does not skip many frames.
457        self.next_at = now + self.delays[self.index];
458        true
459    }
460
461    pub fn current(&self) -> &DynamicImage {
462        &self.frames[self.index]
463    }
464
465    fn frame_arc(&self, idx: usize) -> Arc<DynamicImage> {
466        Arc::clone(&self.frames[idx])
467    }
468}
469
470struct GifJob {
471    path: PathBuf,
472    result: Sender<Result<GifPlayback, String>>,
473}
474
475fn gif_worker() -> &'static SyncSender<GifJob> {
476    static WORKER: OnceLock<SyncSender<GifJob>> = OnceLock::new();
477    WORKER.get_or_init(|| {
478        // One active decode and at most one queued request. Forms can be
479        // opened/closed faster than a large GIF decodes; an unbounded queue
480        // would otherwise keep obsolete work alive long after the UI moved on.
481        let (jobs, receiver) = mpsc::sync_channel::<GifJob>(1);
482        let _ = std::thread::Builder::new()
483            .name("mach-gif-decode".into())
484            .spawn(move || {
485                while let Ok(job) = receiver.recv() {
486                    let _ = job.result.send(GifPlayback::load(&job.path));
487                }
488            });
489        jobs
490    })
491}
492
493/// One asynchronous GIF decode owned by the open form. Jobs share one process
494/// worker, so rapidly changing previews cannot create unbounded decode threads.
495pub struct GifLoad {
496    path: PathBuf,
497    receiver: Receiver<Result<GifPlayback, String>>,
498}
499
500impl GifLoad {
501    pub fn start(path: PathBuf) -> Self {
502        let (sender, receiver) = mpsc::channel();
503        let job = GifJob {
504            path: path.clone(),
505            result: sender,
506        };
507        match gif_worker().try_send(job) {
508            Ok(()) => {}
509            Err(TrySendError::Full(job)) => {
510                let _ = job.result.send(Err(
511                    "GIF decoder is busy; try opening the image again".into()
512                ));
513            }
514            Err(TrySendError::Disconnected(job)) => {
515                let _ = job
516                    .result
517                    .send(Err("GIF decode worker stopped".to_string()));
518            }
519        }
520        Self { path, receiver }
521    }
522
523    pub fn path(&self) -> &Path {
524        &self.path
525    }
526
527    pub fn poll(&self) -> Option<Result<GifPlayback, String>> {
528        match self.receiver.try_recv() {
529            Ok(result) => Some(result),
530            Err(TryRecvError::Empty) => None,
531            Err(TryRecvError::Disconnected) => Some(Err("GIF load failed".to_string())),
532        }
533    }
534}
535
536/// One file: decoded pixels stay in RAM. Description and full-screen preview keep
537/// separate protocols so closing the preview does not force a slow
538/// re-encode the next time it opens (description is ~10 rows; preview is large).
539struct CachedImage {
540    image: Arc<DynamicImage>,
541    protocol: Option<StatefulProtocol>,
542    preview_protocol: Option<StatefulProtocol>,
543}
544
545/// Result of asking the store for a drawable protocol.
546pub enum ImageReady<'a> {
547    Ready(&'a mut StatefulProtocol),
548    /// Decode still running on a worker thread.
549    Loading,
550    Failed(String),
551}
552
553/// Decoded images, kept so a redraw does not re-read the file.
554pub struct ImageStore {
555    images_root: PathBuf,
556    attachments: AttachmentCatalog,
557    picker: Option<Picker>,
558    cache: HashMap<PathBuf, Result<CachedImage, String>>,
559    cache_bytes: usize,
560    cache_budget: usize,
561    /// LRU order of cache keys (front = oldest).
562    lru: VecDeque<PathBuf>,
563    /// In-flight background decodes.
564    pending: HashMap<PathBuf, Receiver<Result<Arc<DynamicImage>, String>>>,
565    /// FIFO work waiting for one of the bounded decode slots.
566    queued: VecDeque<PathBuf>,
567    queued_paths: HashSet<PathBuf>,
568    /// Encoded GIF frames for the open preview (one encode per frame index).
569    gif_protocols: Vec<Option<StatefulProtocol>>,
570}
571
572impl Default for ImageStore {
573    fn default() -> Self {
574        Self {
575            images_root: default_images_root(),
576            attachments: AttachmentCatalog::default(),
577            picker: None,
578            cache: HashMap::new(),
579            cache_bytes: 0,
580            cache_budget: MAX_CACHE_BYTES,
581            lru: VecDeque::new(),
582            pending: HashMap::new(),
583            queued: VecDeque::new(),
584            queued_paths: HashSet::new(),
585            gif_protocols: Vec::new(),
586        }
587    }
588}
589
590/// [`FontSize`] carries no `PartialEq`.
591fn same_cell(a: FontSize, b: FontSize) -> bool {
592    a.width == b.width && a.height == b.height
593}
594
595impl ImageStore {
596    pub fn with_root(images_root: PathBuf) -> Self {
597        Self {
598            images_root,
599            ..Self::default()
600        }
601    }
602
603    pub fn set_root(&mut self, images_root: PathBuf) {
604        if self.images_root != images_root {
605            self.images_root = images_root;
606            self.cache.clear();
607            self.cache_bytes = 0;
608            self.lru.clear();
609            self.pending.clear();
610            self.queued.clear();
611            self.queued_paths.clear();
612            self.release(true);
613        }
614    }
615
616    pub fn root(&self) -> &Path {
617        &self.images_root
618    }
619
620    pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
621        self.attachments.set(attachments);
622    }
623
624    pub fn resolve(&self, reference: &str) -> PathBuf {
625        self.attachments.resolve(reference, &self.images_root)
626    }
627
628    /// Probe the terminal graphics protocol. Call before the alternate screen.
629    /// Falls back to halfblocks if unsupported or under tmux without passthrough.
630    pub fn detect() -> Self {
631        let picker = if tmux_without_passthrough() {
632            // Without `allow-passthrough on`, graphics escapes corrupt the screen.
633            Picker::halfblocks()
634        } else {
635            Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks())
636        };
637        Self {
638            picker: Some(picker),
639            ..Self::default()
640        }
641    }
642
643    fn decode(path: &Path) -> Result<Arc<DynamicImage>, String> {
644        Ok(Arc::new(fit(load_dynamic(path)?, MAX_STILL_EDGE)))
645    }
646
647    fn touch_lru(&mut self, path: &Path) {
648        self.lru.retain(|p| p != path);
649        self.lru.push_back(path.to_path_buf());
650    }
651
652    fn evict_if_needed(&mut self) {
653        while self.lru.len() > MAX_CACHE_ENTRIES || self.cache_bytes > self.cache_budget {
654            let Some(old) = self.lru.pop_front() else {
655                break;
656            };
657            if let Some(cached) = self.cache.remove(&old)
658                && let Ok(cached) = cached
659            {
660                self.cache_bytes = self
661                    .cache_bytes
662                    .saturating_sub(cached.image.as_bytes().len());
663            }
664        }
665    }
666
667    fn insert_decoded(&mut self, path: PathBuf, result: Result<Arc<DynamicImage>, String>) {
668        self.lru.retain(|cached| cached != &path);
669        if let Some(Ok(cached)) = self.cache.remove(&path) {
670            self.cache_bytes = self
671                .cache_bytes
672                .saturating_sub(cached.image.as_bytes().len());
673        }
674        let cached = match result {
675            Ok(image) => {
676                let bytes = image.as_bytes().len();
677                if bytes > self.cache_budget {
678                    Err(format!(
679                        "decoded image is {bytes} bytes; cache limit is {} bytes",
680                        self.cache_budget
681                    ))
682                } else {
683                    self.cache_bytes = self.cache_bytes.saturating_add(bytes);
684                    Ok(CachedImage {
685                        image,
686                        protocol: None,
687                        preview_protocol: None,
688                    })
689                }
690            }
691            Err(error) => Err(error),
692        };
693        self.cache.insert(path.clone(), cached);
694        self.touch_lru(&path);
695        self.evict_if_needed();
696    }
697
698    /// Start decoding `paths` on worker threads. Safe to call repeatedly;
699    /// already-cached or in-flight paths are skipped. The form can open
700    /// immediately while this runs.
701    pub fn prefetch(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
702        for path in paths {
703            if self.cache.contains_key(&path) || self.pending.contains_key(&path) {
704                continue;
705            }
706            if self.queued_paths.insert(path.clone()) {
707                self.queued.push_back(path);
708            }
709        }
710        self.start_queued();
711    }
712
713    fn start_queued(&mut self) {
714        while self.pending.len() < MAX_DECODE_WORKERS {
715            let Some(path) = self.queued.pop_front() else {
716                break;
717            };
718            self.queued_paths.remove(&path);
719            let (tx, rx) = mpsc::channel();
720            let path_bg = path.clone();
721            match std::thread::Builder::new()
722                .name("mach-image-decode".into())
723                .spawn(move || {
724                    let _ = tx.send(Self::decode(&path_bg));
725                }) {
726                Ok(_) => {
727                    self.pending.insert(path, rx);
728                }
729                Err(error) => self
730                    .insert_decoded(path, Err(format!("could not start image decoder: {error}"))),
731            }
732        }
733    }
734
735    /// Pull finished background decodes into the cache.
736    /// Returns true when at least one image became ready (caller should redraw).
737    pub fn poll_pending(&mut self) -> bool {
738        let keys: Vec<PathBuf> = self.pending.keys().cloned().collect();
739        let mut any = false;
740        for key in keys {
741            let Some(rx) = self.pending.get(&key) else {
742                continue;
743            };
744            match rx.try_recv() {
745                Ok(result) => {
746                    self.pending.remove(&key);
747                    self.insert_decoded(key, result);
748                    any = true;
749                }
750                Err(TryRecvError::Empty) => {}
751                Err(TryRecvError::Disconnected) => {
752                    self.pending.remove(&key);
753                    self.insert_decoded(key, Err("image load failed".into()));
754                    any = true;
755                }
756            }
757        }
758        self.start_queued();
759        any
760    }
761
762    pub fn has_pending(&self) -> bool {
763        !self.pending.is_empty() || !self.queued.is_empty()
764    }
765
766    /// If the terminal cell size changed, drop encodings and rebuild.
767    ///
768    /// Needed when moving between displays of different DPI: the grid size
769    /// is unchanged so there is no resize event, but pixel-per-cell is.
770    /// Decoded bitmaps stay cached; only protocols are invalidated.
771    pub fn recheck_cell_size(&mut self) -> bool {
772        if self.cache.is_empty() && self.gif_protocols.is_empty() {
773            return false;
774        }
775        // Halfblocks are cell glyphs, not pixel protocols.
776        if self
777            .picker
778            .as_ref()
779            .is_none_or(|p| p.protocol_type() == ProtocolType::Halfblocks)
780        {
781            return false;
782        }
783        let Some(cell) = terminal_cell_size() else {
784            return false;
785        };
786        self.adopt_cell_size(cell)
787    }
788
789    /// Apply a new cell size if it differs; keep the startup protocol type.
790    fn adopt_cell_size(&mut self, cell: FontSize) -> bool {
791        let Some(picker) = self.picker.as_ref() else {
792            return false;
793        };
794        if same_cell(picker.font_size(), cell) {
795            return false;
796        }
797        let protocol = picker.protocol_type();
798        #[allow(deprecated, reason = "the only way to set a Picker's font size")]
799        let mut picker = Picker::from_fontsize(cell);
800        picker.set_protocol_type(protocol);
801        self.picker = Some(picker);
802        self.release(true);
803        true
804    }
805
806    /// The protocol for `path`, without blocking on disk I/O.
807    ///
808    /// Missing files are fetched in the background; the first call returns
809    /// [`ImageReady::Loading`] until a later [`Self::poll_pending`] lands them.
810    pub fn get(&mut self, path: &Path) -> ImageReady<'_> {
811        self.protocol_for(path, false)
812    }
813
814    /// Full-screen preview protocol — kept separate from the description thumb so
815    /// open → close → open does not thrash encode size every time.
816    pub fn get_preview(&mut self, path: &Path) -> ImageReady<'_> {
817        self.protocol_for(path, true)
818    }
819
820    fn protocol_for(&mut self, path: &Path, preview: bool) -> ImageReady<'_> {
821        // `poll_pending` is the event loop's job once per tick.
822        match self.cache.get(path) {
823            None => {
824                if !self.pending.contains_key(path) {
825                    self.prefetch(std::iter::once(path.to_path_buf()));
826                }
827                return ImageReady::Loading;
828            }
829            Some(Err(error)) => return ImageReady::Failed(error.clone()),
830            Some(Ok(_)) => {}
831        }
832        self.touch_lru(path);
833
834        let Self { cache, picker, .. } = self;
835        let Some(Ok(CachedImage {
836            image,
837            protocol,
838            preview_protocol,
839        })) = cache.get_mut(path)
840        else {
841            return ImageReady::Loading;
842        };
843
844        // Description and preview hold separate protocols so switching between them
845        // does not re-encode the decoded image.
846        let slot = if preview { preview_protocol } else { protocol };
847        let protocol = match slot {
848            Some(protocol) => protocol,
849            empty @ None => {
850                let Some(picker) = picker.as_mut() else {
851                    return ImageReady::Failed("no image support".into());
852                };
853                empty.insert(picker.new_resize_protocol((**image).clone()))
854            }
855        };
856        ImageReady::Ready(protocol)
857    }
858
859    /// Protocol for the current GIF frame (encode once per frame index).
860    pub fn preview_frame(&mut self, gif: &GifPlayback) -> Result<&mut StatefulProtocol, String> {
861        let idx = gif.index;
862        let n = gif.frame_count();
863        if self.gif_protocols.len() != n {
864            self.gif_protocols = (0..n).map(|_| None).collect();
865        }
866        match &mut self.gif_protocols[idx] {
867            Some(protocol) => Ok(protocol),
868            slot @ None => {
869                let picker = self.picker.as_mut().ok_or("no image support")?;
870                let image = gif.frame_arc(idx);
871                Ok(slot.insert(picker.new_resize_protocol((*image).clone())))
872            }
873        }
874    }
875
876    pub fn clear_preview(&mut self) {
877        self.gif_protocols.clear();
878    }
879
880    /// Drop the description protocols (the terminal deletes those pictures) but
881    /// keep the decoded pixels. The next `get` rebuilds from RAM — no disk.
882    ///
883    /// Needed after a Clear over a graphics-protocol image, e.g. when the
884    /// `/` menu closes.
885    pub fn clear_cache(&mut self) {
886        self.release(false);
887    }
888
889    /// Drop every placed protocol when leaving the task form.
890    pub fn release_form_graphics(&mut self) {
891        self.release(true);
892    }
893
894    fn release(&mut self, including_preview_protocols: bool) {
895        for cached in self.cache.values_mut().flatten() {
896            cached.protocol = None;
897            if including_preview_protocols {
898                cached.preview_protocol = None;
899            }
900        }
901        self.clear_preview();
902    }
903}
904
905/// Open and decode an image file with path-labeled errors.
906pub fn load_dynamic(path: &Path) -> Result<DynamicImage, String> {
907    let mut reader = image::ImageReader::open(path)
908        .map_err(|e| format!("{}: {e}", path.display()))?
909        .with_guessed_format()
910        .map_err(|e| format!("{}: {e}", path.display()))?;
911    reader.limits(decode_limits());
912    reader
913        .decode()
914        .map_err(|e| format!("{}: {e}", path.display()))
915}
916
917/// The `~`-relative form of a path, so bodies stay readable.
918pub fn short(path: &Path) -> String {
919    short_in(path, &default_images_root())
920}
921
922/// Stable fallback used by standalone editor tests. Production injects the
923/// active store's images directory into [`ImageStore`] and
924/// [`crate::description::DescriptionEditor`].
925pub fn default_images_root() -> PathBuf {
926    dirs::home_dir()
927        .map(|home| home.join(".mach"))
928        .unwrap_or_else(|| std::env::temp_dir().join("mach"))
929        .join("images")
930}
931
932pub fn short_in(path: &Path, images_root: &Path) -> String {
933    if let Ok(relative) = path.strip_prefix(images_root)
934        && !relative.as_os_str().is_empty()
935    {
936        return relative.display().to_string();
937    }
938    if let Some(home) = dirs::home_dir()
939        && let Ok(rest) = path.strip_prefix(&home)
940    {
941        return format!("~/{}", rest.display());
942    }
943    path.display().to_string()
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    #[test]
951    fn recognises_picture_extensions() {
952        assert!(looks_like_image("/tmp/a.png"));
953        assert!(
954            looks_like_image("shot.JPEG"),
955            "extension is case-insensitive"
956        );
957        assert!(!looks_like_image("notes.txt"));
958        assert!(!looks_like_image("remember to send the png to Dana"));
959        assert!(looks_like_image("![diagram](architecture.png)"));
960        assert!(
961            !looks_like_image("legacy.bmp"),
962            "BMP is not compiled into the decoder"
963        );
964    }
965
966    #[test]
967    fn resolves_relative_references_against_an_injected_images_root() {
968        let root = std::env::temp_dir().join(format!("mach-image-root-{}", std::process::id()));
969        let _ = std::fs::create_dir_all(&root);
970        let path = root.join("diagram.png");
971        std::fs::write(&path, b"not decoded in this test").unwrap();
972
973        assert_eq!(
974            path_if_image_in("![diagram](diagram.png)", &root),
975            Some(path)
976        );
977    }
978
979    #[test]
980    fn clipboard_pixels_are_staged_as_a_private_temporary_png() {
981        use std::borrow::Cow;
982
983        let staged = stage_clipboard_image(arboard::ImageData {
984            width: 2,
985            height: 1,
986            bytes: Cow::Owned(vec![255, 0, 0, 255, 0, 255, 0, 255]),
987        })
988        .expect("stage clipboard image");
989        let path = staged.path().to_path_buf();
990        let decoded = load_dynamic(&path).expect("decode staged PNG");
991        assert_eq!((decoded.width(), decoded.height()), (2, 1));
992        #[cfg(unix)]
993        {
994            use std::os::unix::fs::PermissionsExt;
995            assert_eq!(
996                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
997                0o600
998            );
999        }
1000
1001        drop(staged);
1002        assert!(!path.exists(), "form-owned clipboard staging must clean up");
1003    }
1004
1005    #[test]
1006    fn malformed_clipboard_pixel_buffers_are_rejected_before_encoding() {
1007        use std::borrow::Cow;
1008
1009        let error = stage_clipboard_image(arboard::ImageData {
1010            width: 2,
1011            height: 2,
1012            bytes: Cow::Owned(vec![0; 4]),
1013        })
1014        .expect_err("RGBA buffer is shorter than its dimensions");
1015        assert!(error.contains("pixel buffer"), "{error}");
1016    }
1017
1018    #[test]
1019    fn resolves_attachment_ids_through_the_managed_catalog() {
1020        let root = PathBuf::from("/tmp/mach-managed-images");
1021        let id = "a".repeat(64);
1022        let attachment = crate::store::Attachment {
1023            id: id.clone(),
1024            sha256: id.clone(),
1025            media_type: "image/png".into(),
1026            byte_len: 12,
1027            storage_name: format!("{id}.png"),
1028        };
1029        let mut store = ImageStore::with_root(root.clone());
1030        store.set_attachments(&[attachment]);
1031
1032        assert_eq!(store.resolve(&id), root.join(format!("{id}.png")));
1033        assert_eq!(store.resolve("draft.png"), root.join("draft.png"));
1034    }
1035
1036    #[test]
1037    fn expands_a_file_url_with_escaped_spaces() {
1038        assert_eq!(
1039            expand("file:///tmp/my%20shot.PNG"),
1040            PathBuf::from("/tmp/my shot.PNG")
1041        );
1042    }
1043
1044    #[test]
1045    fn expands_a_home_relative_path() {
1046        let path = expand("~/pic.png");
1047        assert!(path.is_absolute() || dirs::home_dir().is_none());
1048        assert!(path.ends_with("pic.png"));
1049    }
1050
1051    #[test]
1052    fn only_an_existing_file_counts_as_a_picture() {
1053        let real = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
1054        assert!(path_if_image(real).is_some());
1055        assert!(path_if_image("/tmp/definitely-not-here.png").is_none());
1056        assert!(path_if_image(real.trim_end_matches(".png")).is_none());
1057    }
1058
1059    /// A store holding one decoded image, so there is an encoding to lose.
1060    fn store_with_an_image() -> ImageStore {
1061        let mut store = ImageStore {
1062            picker: Some(Picker::halfblocks()),
1063            ..Default::default()
1064        };
1065        store.cache.insert(
1066            PathBuf::from("/tmp/x.png"),
1067            Ok(CachedImage {
1068                image: Arc::new(DynamicImage::new_rgba8(4, 4)),
1069                protocol: None,
1070                preview_protocol: None,
1071            }),
1072        );
1073        store
1074    }
1075
1076    #[test]
1077    fn the_first_changed_reading_rebuilds_the_picker() {
1078        // The window is already showing a clipped picture by now, so this
1079        // must not wait to be sure.
1080        let mut store = store_with_an_image();
1081        let was = store.picker.as_ref().unwrap().font_size();
1082        let moved = FontSize::new(was.width * 2, was.height * 2);
1083
1084        assert!(store.adopt_cell_size(moved));
1085        let now = store.picker.as_ref().unwrap().font_size();
1086        assert!(same_cell(now, moved), "the picker measures in the new size");
1087    }
1088
1089    #[test]
1090    fn a_steady_cell_size_is_left_alone() {
1091        let mut store = store_with_an_image();
1092        let same = store.picker.as_ref().unwrap().font_size();
1093        for _ in 0..5 {
1094            assert!(
1095                !store.adopt_cell_size(same),
1096                "nothing moved, so nothing to re-encode"
1097            );
1098        }
1099    }
1100
1101    #[test]
1102    fn one_move_costs_one_rebuild_however_often_it_is_polled() {
1103        let mut store = store_with_an_image();
1104        let was = store.picker.as_ref().unwrap().font_size();
1105        let moved = FontSize::new(was.width * 2, was.height * 2);
1106
1107        let rebuilds = (0..20).filter(|_| store.adopt_cell_size(moved)).count();
1108        assert_eq!(rebuilds, 1);
1109    }
1110
1111    #[test]
1112    fn nothing_cached_means_nothing_to_check() {
1113        let mut store = ImageStore {
1114            picker: Some(Picker::halfblocks()),
1115            ..Default::default()
1116        };
1117        let was = store.picker.as_ref().unwrap().font_size();
1118        assert!(!store.recheck_cell_size());
1119        assert!(
1120            same_cell(store.picker.as_ref().unwrap().font_size(), was),
1121            "the picker was never touched"
1122        );
1123    }
1124
1125    #[test]
1126    fn prefetch_uses_a_bounded_number_of_decode_workers() {
1127        let mut store = ImageStore::default();
1128        let paths = (0..8).map(|index| PathBuf::from(format!("/missing/{index}.png")));
1129        store.prefetch(paths);
1130        assert!(store.pending.len() <= MAX_DECODE_WORKERS);
1131        assert_eq!(store.pending.len() + store.queued.len(), 8);
1132    }
1133
1134    #[test]
1135    fn decoded_cache_evicts_by_bytes_before_entry_count() {
1136        let mut store = ImageStore {
1137            cache_budget: 32,
1138            ..ImageStore::default()
1139        };
1140        let image = || Arc::new(DynamicImage::ImageRgba8(image::RgbaImage::new(2, 2)));
1141        let paths: Vec<_> = (0..3)
1142            .map(|index| PathBuf::from(format!("small-{index}.png")))
1143            .collect();
1144
1145        for path in &paths {
1146            store.insert_decoded(path.clone(), Ok(image()));
1147        }
1148
1149        assert_eq!(store.cache_bytes, 32);
1150        assert!(!store.cache.contains_key(&paths[0]));
1151        assert!(store.cache.contains_key(&paths[1]));
1152        assert!(store.cache.contains_key(&paths[2]));
1153    }
1154
1155    #[test]
1156    fn one_image_larger_than_the_cache_budget_is_reported_not_cached() {
1157        let mut store = ImageStore {
1158            cache_budget: 15,
1159            ..ImageStore::default()
1160        };
1161        let path = PathBuf::from("too-large.png");
1162        let image = Arc::new(DynamicImage::ImageRgba8(image::RgbaImage::new(2, 2)));
1163
1164        store.insert_decoded(path.clone(), Ok(image));
1165
1166        assert_eq!(store.cache_bytes, 0);
1167        assert!(matches!(
1168            store.cache.get(&path),
1169            Some(Err(error)) if error.contains("cache limit")
1170        ));
1171    }
1172
1173    #[test]
1174    fn failed_decodes_share_the_cache_entry_limit() {
1175        let mut store = ImageStore::default();
1176        for index in 0..(MAX_CACHE_ENTRIES + 12) {
1177            store.insert_decoded(
1178                PathBuf::from(format!("missing-{index}.png")),
1179                Err("missing".into()),
1180            );
1181        }
1182
1183        assert_eq!(store.cache.len(), MAX_CACHE_ENTRIES);
1184        assert_eq!(store.lru.len(), MAX_CACHE_ENTRIES);
1185    }
1186
1187    #[test]
1188    fn oversized_canvas_is_rejected_from_still_and_gif_decoders() {
1189        let path =
1190            std::env::temp_dir().join(format!("mach-oversized-{}.gif", uuid::Uuid::new_v4()));
1191        // Valid one-frame GIF with its logical canvas patched to 8193×1. The
1192        // strict dimension check runs before a canvas buffer can be allocated.
1193        std::fs::write(
1194            &path,
1195            [
1196                b'G', b'I', b'F', b'8', b'9', b'a', 0x01, 0x20, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00,
1197                0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c,
1198                0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
1199                0x3b,
1200            ],
1201        )
1202        .unwrap();
1203
1204        let still = load_dynamic(&path).expect_err("still decoder must enforce dimensions");
1205        let gif = match GifPlayback::load(&path) {
1206            Err(error) => error,
1207            Ok(_) => panic!("GIF decoder must enforce dimensions"),
1208        };
1209        assert!(
1210            still.to_lowercase().contains("limit") || still.to_lowercase().contains("dimension"),
1211            "{still}"
1212        );
1213        assert!(
1214            gif.to_lowercase().contains("limit") || gif.to_lowercase().contains("dimension"),
1215            "{gif}"
1216        );
1217    }
1218
1219    #[test]
1220    fn half_blocks_are_never_re_measured() {
1221        // Their font size is a stand-in, not a measurement, so acting on a
1222        // real one would drop every encoding and change nothing on screen.
1223        let mut store = store_with_an_image();
1224        assert_eq!(
1225            store.picker.as_ref().unwrap().protocol_type(),
1226            ProtocolType::Halfblocks
1227        );
1228        let was = store.picker.as_ref().unwrap().font_size();
1229        assert!(!store.recheck_cell_size());
1230        assert!(same_cell(store.picker.as_ref().unwrap().font_size(), was));
1231    }
1232}
1233
1234#[cfg(test)]
1235mod gif_tests {
1236    use super::*;
1237    use image::codecs::gif::GifEncoder;
1238    use image::{Delay, Frame, Rgba, RgbaImage};
1239    use std::fs::File;
1240
1241    fn write_test_gif(path: &Path, n: u32) {
1242        let file = File::create(path).unwrap();
1243        let mut enc = GifEncoder::new(file);
1244        enc.set_repeat(image::codecs::gif::Repeat::Infinite)
1245            .unwrap();
1246        for i in 0..n {
1247            let mut img = RgbaImage::new(8, 8);
1248            for p in img.pixels_mut() {
1249                *p = Rgba([((i * 80) % 255) as u8, 0, 255, 255]);
1250            }
1251            let delay = Delay::from_numer_denom_ms(50, 1);
1252            let frame = Frame::from_parts(img, 0, 0, delay);
1253            enc.encode_frame(frame).unwrap();
1254        }
1255    }
1256
1257    #[test]
1258    fn loads_and_advances_multiple_gif_frames() {
1259        let dir = std::env::temp_dir().join("mach-gif-test");
1260        let _ = std::fs::create_dir_all(&dir);
1261        let path = dir.join("anim.gif");
1262        write_test_gif(&path, 4);
1263        assert!(is_gif(&path));
1264        let mut gif = GifPlayback::load(&path).expect("load gif");
1265        assert!(gif.frame_count() >= 2, "got {} frames", gif.frame_count());
1266        assert!(gif.is_animated());
1267        let first = gif.index;
1268        std::thread::sleep(Duration::from_millis(120));
1269        assert!(gif.tick(), "should advance after delay");
1270        assert_ne!(gif.index, first);
1271    }
1272}