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