1use 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
82const MAX_STILL_EDGE: u32 = 1920;
84const MAX_GIF_EDGE: u32 = 720;
86const MAX_GIF_FRAMES: usize = 48;
88const MAX_CACHE_ENTRIES: usize = 48;
90const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024;
93const MAX_DECODE_DIMENSION: u32 = 8192;
95const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024;
96const MAX_DECODE_WORKERS: usize = 2;
99
100#[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
120pub(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
229fn 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
242pub 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
256pub fn path_if_image(text: &str) -> Option<PathBuf> {
258 path_if_image_in(text, &default_images_root())
259}
260
261pub 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
272pub 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
295pub(crate) fn reference_path(text: &str) -> Option<&str> {
297 let text = text.trim();
298 if !text.starts_with("?;
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
308fn terminal_cell_size() -> Option<FontSize> {
314 let ws = ratatui::crossterm::terminal::window_size().ok()?;
315 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
335pub 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 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
361pub 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 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 let mut delay = Duration::from(frame.delay());
389 if delay.is_zero() {
391 delay = Duration::from_millis(100);
392 }
393 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 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 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 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 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 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
493pub 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
536struct CachedImage {
540 image: Arc<DynamicImage>,
541 protocol: Option<StatefulProtocol>,
542 preview_protocol: Option<StatefulProtocol>,
543}
544
545pub enum ImageReady<'a> {
547 Ready(&'a mut StatefulProtocol),
548 Loading,
550 Failed(String),
551}
552
553pub 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: VecDeque<PathBuf>,
563 pending: HashMap<PathBuf, Receiver<Result<Arc<DynamicImage>, String>>>,
565 queued: VecDeque<PathBuf>,
567 queued_paths: HashSet<PathBuf>,
568 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
590fn 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 pub fn detect() -> Self {
631 let picker = if tmux_without_passthrough() {
632 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 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 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 pub fn recheck_cell_size(&mut self) -> bool {
772 if self.cache.is_empty() && self.gif_protocols.is_empty() {
773 return false;
774 }
775 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 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 pub fn get(&mut self, path: &Path) -> ImageReady<'_> {
811 self.protocol_for(path, false)
812 }
813
814 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 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 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 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 pub fn clear_cache(&mut self) {
886 self.release(false);
887 }
888
889 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
905pub 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
917pub fn short(path: &Path) -> String {
919 short_in(path, &default_images_root())
920}
921
922pub 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(""));
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("", &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 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 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 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 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}