falcon_library/
library_icon.rs1use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use image::imageops::FilterType;
8
9use crate::{Error, Result};
10
11pub const ICON_FILE: &str = "icon.png";
13
14const SHORT_SIDE: u32 = 64;
16
17pub fn icon_path(root: &Path) -> Option<PathBuf> {
19 let p = root.join(ICON_FILE);
20 p.is_file().then_some(p)
21}
22
23fn scaled(w: u32, h: u32) -> (u32, u32) {
25 let k = f64::from(SHORT_SIDE) / f64::from(w.min(h).max(1));
26 let side = |n: u32| ((f64::from(n) * k).round() as u32).max(1);
27 (side(w), side(h))
28}
29
30pub fn set_icon(root: &Path, source: &Path) -> Result<()> {
35 let refuse = |reason: String| Error::Import { path: source.to_path_buf(), reason };
36 let img = image::open(source).map_err(|e| refuse(format!("not an image falcon can read ({e})")))?;
37 let (w, h) = scaled(img.width(), img.height());
38 let small = image::DynamicImage::ImageRgba8(img.resize_exact(w, h, FilterType::Lanczos3).to_rgba8());
39 let mut png = std::io::Cursor::new(Vec::new());
40 small.write_to(&mut png, image::ImageFormat::Png).map_err(|e| refuse(format!("the icon could not be encoded ({e})")))?;
41
42 let target = root.join(ICON_FILE);
43 let mut tmp = tempfile::Builder::new().prefix(".icon-").suffix(".tmp").tempfile_in(root).map_err(|e| Error::io(root, e))?;
44 tmp.write_all(png.get_ref()).and_then(|()| tmp.flush()).map_err(|e| Error::io(tmp.path(), e))?;
45 tmp.persist(&target).map_err(|e| Error::io(&target, e.error))?;
46 Ok(())
47}
48
49pub fn remove_icon(root: &Path) -> Result<()> {
51 let target = root.join(ICON_FILE);
52 match std::fs::remove_file(&target) {
53 Ok(()) => Ok(()),
54 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
55 Err(e) => Err(Error::io(target, e)),
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 fn make(dir: &Path, name: &str, w: u32, h: u32) -> PathBuf {
64 let p = dir.join(name);
65 image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])).save(&p).unwrap();
66 p
67 }
68
69 fn size(p: &Path) -> (u32, u32) {
70 image::image_dimensions(p).unwrap()
71 }
72
73 #[test]
74 fn wide_image_scales_down() {
75 let d = tempfile::tempdir().unwrap();
76 let src = make(d.path(), "a.png", 200, 100);
77 assert_eq!(icon_path(d.path()), None);
78 set_icon(d.path(), &src).unwrap();
79 assert_eq!(size(&icon_path(d.path()).unwrap()), (128, 64));
80 }
81
82 #[test]
83 fn small_image_scales_up() {
84 let d = tempfile::tempdir().unwrap();
85 let src = make(d.path(), "a.png", 32, 48);
86 set_icon(d.path(), &src).unwrap();
87 assert_eq!(size(&d.path().join(ICON_FILE)), (64, 96));
88 }
89
90 #[test]
91 fn extreme_ratio_keeps_one_pixel() {
92 assert_eq!(scaled(1000, 1), (64000, 64));
93 assert_eq!(scaled(1, 1000), (64, 64000));
94 assert_eq!(scaled(64, 64), (64, 64));
95 }
96
97 #[test]
98 fn replaces_the_old_icon_and_leaves_no_temp_file() {
99 let d = tempfile::tempdir().unwrap();
100 set_icon(d.path(), &make(d.path(), "a.png", 64, 64)).unwrap();
101 set_icon(d.path(), &make(d.path(), "b.png", 100, 200)).unwrap();
102 assert_eq!(size(&d.path().join(ICON_FILE)), (64, 128));
103 let stray = std::fs::read_dir(d.path()).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with(".icon-")).count();
104 assert_eq!(stray, 0);
105 }
106
107 #[test]
108 fn remove_is_idempotent() {
109 let d = tempfile::tempdir().unwrap();
110 remove_icon(d.path()).unwrap();
111 set_icon(d.path(), &make(d.path(), "a.png", 10, 10)).unwrap();
112 remove_icon(d.path()).unwrap();
113 assert_eq!(icon_path(d.path()), None);
114 remove_icon(d.path()).unwrap();
115 }
116
117 #[test]
118 fn a_bad_source_keeps_the_existing_icon() {
119 let d = tempfile::tempdir().unwrap();
120 set_icon(d.path(), &make(d.path(), "a.png", 64, 64)).unwrap();
121 let before = std::fs::read(d.path().join(ICON_FILE)).unwrap();
122 let bad = d.path().join("notes.png");
123 std::fs::write(&bad, b"this is text").unwrap();
124 assert!(set_icon(d.path(), &bad).is_err());
125 assert!(set_icon(d.path(), &d.path().join("missing.png")).is_err());
126 assert_eq!(std::fs::read(d.path().join(ICON_FILE)).unwrap(), before);
127 }
128}