1use 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
25const MAX_STILL_EDGE: u32 = 1920;
27const MAX_GIF_EDGE: u32 = 720;
29const MAX_GIF_FRAMES: usize = 48;
31const MAX_CACHE_ENTRIES: usize = 48;
33const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024;
36const MAX_DECODE_DIMENSION: u32 = 8192;
38const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024;
39const 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
72fn 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
85pub 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
99pub fn path_if_image(text: &str) -> Option<PathBuf> {
101 path_if_image_in(text, &default_images_root())
102}
103
104pub 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
115pub 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
138fn reference_path(text: &str) -> Option<&str> {
140 let text = text.trim();
141 if !text.starts_with("?;
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
151fn terminal_cell_size() -> Option<FontSize> {
157 let ws = ratatui::crossterm::terminal::window_size().ok()?;
158 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
178pub 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 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
204pub 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 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 let mut delay = Duration::from(frame.delay());
232 if delay.is_zero() {
234 delay = Duration::from_millis(100);
235 }
236 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 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 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 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 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 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
336pub 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
379struct CachedImage {
383 image: Arc<DynamicImage>,
384 protocol: Option<StatefulProtocol>,
385 preview_protocol: Option<StatefulProtocol>,
386}
387
388pub enum ImageReady<'a> {
390 Ready(&'a mut StatefulProtocol),
391 Loading,
393 Failed(String),
394}
395
396pub 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: VecDeque<PathBuf>,
406 pending: HashMap<PathBuf, Receiver<Result<Arc<DynamicImage>, String>>>,
408 queued: VecDeque<PathBuf>,
410 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
431fn 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 pub fn detect() -> Self {
471 let picker = if tmux_without_passthrough() {
472 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 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 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 pub fn recheck_cell_size(&mut self) -> bool {
623 if self.cache.is_empty() && self.gif_protocols.is_empty() {
624 return false;
625 }
626 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 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 pub fn get(&mut self, path: &Path) -> ImageReady<'_> {
662 self.protocol_for(path, false)
663 }
664
665 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 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 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 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 pub fn clear_cache(&mut self) {
736 self.release(false);
737 }
738
739 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
755pub 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
767pub fn short(path: &Path) -> String {
769 short_in(path, &default_images_root())
770}
771
772pub 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(""));
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("", &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 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 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 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 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}