Skip to main content

sequoia_assets/
registry.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::rc::Rc;
5
6use crate::photo::PhotoError;
7use crate::raster::{MAX_RASTER_PX, Sprite, rasterize};
8use crate::svg::{SvgError, SvgIcon, parse_svg};
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum AssetError {
13    Unknown(String),
14
15    Photo(PhotoError),
16}
17
18impl std::fmt::Display for AssetError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            AssetError::Unknown(name) => write!(f, "unknown icon `{name}`"),
22            AssetError::Photo(e) => write!(f, "photo error: {e}"),
23        }
24    }
25}
26
27impl std::error::Error for AssetError {
28    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
29        match self {
30            AssetError::Unknown(_) => None,
31            AssetError::Photo(e) => Some(e),
32        }
33    }
34}
35
36impl From<PhotoError> for AssetError {
37    fn from(e: PhotoError) -> Self {
38        AssetError::Photo(e)
39    }
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct IconDirReport {
44    pub registered: Vec<String>,
45    pub failed: Vec<(PathBuf, SvgError)>,
46}
47
48#[derive(Default)]
49struct Inner {
50    fonts: Vec<(String, Rc<[u8]>)>,
51    default_font: Option<String>,
52    icons: HashMap<String, Rc<SvgIcon>>,
53    rasters: HashMap<(String, u32), Rc<Sprite>>,
54    raster_sizes: HashMap<String, Vec<u32>>,
55
56    photo_sizes: HashMap<String, (u32, u32)>,
57}
58
59const PHOTO_SIZES_MAX: usize = 1024;
60
61const RASTERS_MAX: usize = 128;
62
63#[derive(Clone, Default)]
64pub struct AssetRegistry {
65    inner: Rc<RefCell<Inner>>,
66}
67
68impl AssetRegistry {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn with_builtin_icons() -> Self {
74        let registry = Self::new();
75        for (name, svg) in crate::icons::builtin() {
76            if let Err(e) = registry.register_icon(*name, svg) {
77                debug_assert!(false, "bundled icon `{name}` must be valid: {e}");
78                log::error!("sequoia-assets: bundled icon `{name}` invalid: {e} — skipped");
79            }
80        }
81        registry
82    }
83
84    pub fn register_font(&self, name: impl Into<String>, data: Vec<u8>) {
85        self.inner
86            .borrow_mut()
87            .fonts
88            .push((name.into(), Rc::from(data)));
89    }
90
91    pub fn set_default_font(&self, name: impl Into<String>) {
92        self.inner.borrow_mut().default_font = Some(name.into());
93    }
94
95    pub fn default_font(&self) -> Option<String> {
96        self.inner.borrow().default_font.clone()
97    }
98
99    pub fn fonts(&self) -> Vec<(String, Rc<[u8]>)> {
100        self.inner.borrow().fonts.clone()
101    }
102
103    pub fn register_icon(&self, name: impl Into<String>, svg_src: &str) -> Result<(), SvgError> {
104        let icon = parse_svg(svg_src)?;
105        self.register_icon_data(name, icon);
106        Ok(())
107    }
108
109    pub fn register_icon_dir(&self, dir: impl AsRef<Path>) -> std::io::Result<IconDirReport> {
110        let root = dir.as_ref();
111        let mut files = Vec::new();
112        collect_svg_files(root, &mut files)?;
113        files.sort();
114
115        let mut report = IconDirReport::default();
116        for path in files {
117            let Some(name) = icon_name(root, &path) else {
118                continue;
119            };
120            let svg = std::fs::read_to_string(&path)?;
121            match self.register_icon(name.clone(), &svg) {
122                Ok(()) => report.registered.push(name),
123                Err(e) => report.failed.push((path, e)),
124            }
125        }
126        Ok(report)
127    }
128
129    pub fn register_icon_data(&self, name: impl Into<String>, icon: SvgIcon) {
130        let name = name.into();
131        let mut inner = self.inner.borrow_mut();
132        if let Some(sizes) = inner.raster_sizes.remove(&name) {
133            for px in sizes {
134                inner.rasters.remove(&(name.clone(), px));
135            }
136        }
137        inner.icons.insert(name, Rc::new(icon));
138    }
139
140    pub fn icon(&self, name: &str) -> Option<Rc<SvgIcon>> {
141        let inner = self.inner.borrow();
142        if let Some(icon) = inner.icons.get(name) {
143            return Some(Rc::clone(icon));
144        }
145        if let Some((_, short)) = name.split_once(':')
146            && let Some(icon) = inner.icons.get(short)
147        {
148            return Some(Rc::clone(icon));
149        }
150        None
151    }
152
153    pub fn icon_names(&self) -> Vec<String> {
154        let mut names: Vec<String> = self.inner.borrow().icons.keys().cloned().collect();
155        names.sort();
156        names
157    }
158
159    pub fn icons_in_namespace(&self, ns: &str) -> Vec<String> {
160        let prefix = format!("{ns}:");
161        let mut names: Vec<String> = self
162            .inner
163            .borrow()
164            .icons
165            .keys()
166            .filter(|k| k.starts_with(&prefix))
167            .cloned()
168            .collect();
169        names.sort();
170        names
171    }
172
173    pub fn rasterize_icon(&self, name: &str, px: u32) -> Result<Rc<Sprite>, AssetError> {
174        if px > MAX_RASTER_PX {
175            return Err(AssetError::Photo(PhotoError::TooLarge {
176                width: px,
177                height: px,
178            }));
179        }
180        let resolved = self
181            .resolve_icon_name(name)
182            .ok_or_else(|| AssetError::Unknown(name.into()))?;
183        let mut inner = self.inner.borrow_mut();
184        if let Some(sprite) = inner.rasters.get(&(resolved.clone(), px)) {
185            return Ok(Rc::clone(sprite));
186        }
187        let icon = inner
188            .icons
189            .get(&resolved)
190            .cloned()
191            .ok_or_else(|| AssetError::Unknown(name.into()))?;
192        let sprite = Rc::new(rasterize(&icon, px));
193        if inner.rasters.len() >= RASTERS_MAX
194            && let Some(victim) = inner.rasters.keys().next().cloned()
195        {
196            inner.rasters.remove(&victim);
197            if let Some(sizes) = inner.raster_sizes.get_mut(&victim.0) {
198                sizes.retain(|&s| s != victim.1);
199                if sizes.is_empty() {
200                    inner.raster_sizes.remove(&victim.0);
201                }
202            }
203        }
204        let sizes = inner.raster_sizes.entry(resolved.clone()).or_default();
205        if !sizes.contains(&px) {
206            sizes.push(px);
207        }
208        inner.rasters.insert((resolved, px), Rc::clone(&sprite));
209        Ok(sprite)
210    }
211
212    fn resolve_icon_name(&self, name: &str) -> Option<String> {
213        let inner = self.inner.borrow();
214        if inner.icons.contains_key(name) {
215            return Some(name.to_owned());
216        }
217        if let Some((_, short)) = name.split_once(':')
218            && inner.icons.contains_key(short)
219        {
220            return Some(short.to_owned());
221        }
222        None
223    }
224
225    pub fn photo_size(&self, path: &str) -> Result<(u32, u32), AssetError> {
226        let mut inner = self.inner.borrow_mut();
227        if let Some(size) = inner.photo_sizes.get(path) {
228            return Ok(*size);
229        }
230        let size = image::image_dimensions(path)
231            .map_err(|_| AssetError::Photo(PhotoError::Io(path.into())))?;
232        if size.0 == 0 || size.1 == 0 {
233            return Err(AssetError::Photo(PhotoError::Decode("empty image".into())));
234        }
235        if inner.photo_sizes.len() >= PHOTO_SIZES_MAX {
236            inner.photo_sizes.clear();
237        }
238        inner.photo_sizes.insert(path.to_owned(), size);
239        Ok(size)
240    }
241}
242
243fn collect_svg_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
244    for entry in std::fs::read_dir(dir)? {
245        let entry = entry?;
246        let path = entry.path();
247        let file_type = entry.file_type()?;
248        if file_type.is_dir() {
249            collect_svg_files(&path, out)?;
250        } else if file_type.is_file()
251            && path
252                .extension()
253                .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("svg"))
254        {
255            out.push(path);
256        }
257    }
258    Ok(())
259}
260
261fn icon_name(root: &Path, path: &Path) -> Option<String> {
262    let relative = path.strip_prefix(root).ok()?;
263    let mut parts = Vec::new();
264    for component in relative.components() {
265        let std::path::Component::Normal(part) = component else {
266            continue;
267        };
268        parts.push(part.to_str()?.to_owned());
269    }
270    let file = parts.pop()?;
271    let stem = Path::new(&file).file_stem()?.to_str()?.to_owned();
272    parts.push(stem);
273    Some(parts.join("/"))
274}
275
276pub fn icon_key(name: &str, px: u32) -> u64 {
277    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
278    for byte in name.as_bytes() {
279        hash ^= u64::from(*byte);
280        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
281    }
282    hash ^= u64::from(px);
283    hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
284    hash
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    const DOT: &str = r#"<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>"#;
292
293    #[test]
294    fn register_and_rasterize_with_cache() {
295        let registry = AssetRegistry::new();
296        registry.register_icon("dot", DOT).unwrap();
297        let a = registry.rasterize_icon("dot", 16).unwrap();
298        let b = registry.rasterize_icon("dot", 16).unwrap();
299        assert!(Rc::ptr_eq(&a, &b), "cache returns the same sprite");
300        assert_eq!((a.width, a.height), (16, 16));
301        assert!(registry.rasterize_icon("missing", 16).is_err());
302    }
303
304    #[test]
305    fn reregister_invalidates_cache() {
306        let registry = AssetRegistry::new();
307        registry.register_icon("dot", DOT).unwrap();
308        let first = registry.rasterize_icon("dot", 16).unwrap();
309        registry
310            .register_icon(
311                "dot",
312                r#"<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#,
313            )
314            .unwrap();
315        let second = registry.rasterize_icon("dot", 16).unwrap();
316        assert!(!Rc::ptr_eq(&first, &second));
317    }
318
319    #[test]
320    fn bad_svg_is_error() {
321        let registry = AssetRegistry::new();
322        assert!(matches!(
323            registry.register_icon("x", "<svg><path d='M1'/></svg>"),
324            Err(SvgError::BadPath(_, _))
325        ));
326        assert!(registry.icon("x").is_none());
327    }
328
329    #[test]
330    fn oversize_raster_request_is_refused() {
331        use crate::raster::MAX_RASTER_PX;
332        let registry = AssetRegistry::new();
333        registry.register_icon("dot", DOT).unwrap();
334        assert!(
335            registry.rasterize_icon("dot", MAX_RASTER_PX).is_ok(),
336            "limit przechodzi"
337        );
338        assert!(
339            registry.rasterize_icon("dot", MAX_RASTER_PX + 1).is_err(),
340            "past the limit: refused instead of px alloc²"
341        );
342        assert!(registry.rasterize_icon("dot", u32::MAX).is_err());
343    }
344
345    #[test]
346    fn fonts_roundtrip() {
347        let registry = AssetRegistry::new();
348        registry.register_font("Inter", vec![1, 2, 3]);
349        registry.set_default_font("Inter");
350        assert_eq!(registry.default_font(), Some("Inter".into()));
351        let fonts = registry.fonts();
352        assert_eq!(fonts.len(), 1);
353        assert_eq!(fonts[0].0, "Inter");
354        assert_eq!(&*fonts[0].1, &[1, 2, 3]);
355    }
356
357    #[test]
358    fn clone_shares_state() {
359        let registry = AssetRegistry::new();
360        let clone = registry.clone();
361        clone.register_icon("dot", DOT).unwrap();
362        assert!(registry.icon("dot").is_some());
363    }
364
365    #[test]
366    fn icon_key_stable_and_distinct() {
367        assert_eq!(icon_key("check", 16), icon_key("check", 16));
368        assert_ne!(icon_key("check", 16), icon_key("check", 24));
369        assert_ne!(icon_key("check", 16), icon_key("close", 16));
370    }
371
372    #[test]
373    fn builtin_icons_all_parse_and_rasterize() {
374        let registry = AssetRegistry::with_builtin_icons();
375        let names = registry.icon_names();
376        assert!(names.len() >= 12, "wbudowany zestaw: {names:?}");
377        for name in names {
378            let sprite = registry.rasterize_icon(&name, 24).expect("rasteryzacja");
379            assert!(!sprite.is_blank(), "icon {name} must not be blank");
380        }
381    }
382
383    #[test]
384    fn icon_dir_loads_recursively_by_relative_name() {
385        let root = std::env::temp_dir().join(format!("sequoia-icons-{}", std::process::id()));
386        let _ = std::fs::remove_dir_all(&root);
387        std::fs::create_dir_all(root.join("tool")).unwrap();
388        std::fs::write(root.join("tool/select.svg"), DOT).unwrap();
389        std::fs::write(root.join("bad.svg"), "<svg></svg>").unwrap();
390        std::fs::write(root.join("notes.txt"), DOT).unwrap();
391
392        let registry = AssetRegistry::new();
393        let report = registry.register_icon_dir(&root).unwrap();
394        assert_eq!(report.registered, vec!["tool/select".to_owned()]);
395        assert_eq!(report.failed.len(), 1);
396        assert_eq!(report.failed[0].0, root.join("bad.svg"));
397        assert!(registry.icon("tool/select").is_some());
398        assert!(registry.icon("notes").is_none());
399        assert!(registry.register_icon_dir(root.join("missing")).is_err());
400
401        std::fs::remove_dir_all(&root).unwrap();
402    }
403
404    #[test]
405    fn photo_size_probes_without_decoding() {
406        let registry = AssetRegistry::new();
407        assert!(registry.photo_size("no/such/file.png").is_err());
408
409        let dir = std::env::temp_dir().join("sequoia-photo-probe");
410        std::fs::create_dir_all(&dir).unwrap();
411        let path = dir.join("probe.png");
412        let pixels: Vec<u8> = (0..4 * 3).flat_map(|_| [10u8, 20, 30, 255]).collect();
413        image::save_buffer(&path, &pixels, 4, 3, image::ColorType::Rgba8).unwrap();
414        let size = registry
415            .photo_size(path.to_str().unwrap())
416            .expect("wymiary PNG");
417        assert_eq!(size, (4, 3));
418
419        std::fs::remove_file(&path).unwrap();
420        assert_eq!(
421            registry.photo_size(path.to_str().unwrap()),
422            Ok((4, 3)),
423            "dimension cache"
424        );
425    }
426
427    #[test]
428    fn raster_cache_evicts_one_entry_not_everything() {
429        let registry = AssetRegistry::new();
430        registry.register_icon("dot", DOT).unwrap();
431        for px in 1..=super::RASTERS_MAX as u32 {
432            assert!(registry.rasterize_icon("dot", px).is_ok());
433        }
434        assert_eq!(registry.inner.borrow().rasters.len(), super::RASTERS_MAX);
435
436        assert!(
437            registry
438                .rasterize_icon("dot", super::RASTERS_MAX as u32 + 1)
439                .is_ok()
440        );
441
442        let inner = registry.inner.borrow();
443        assert_eq!(inner.rasters.len(), super::RASTERS_MAX);
444        let survivors = (1..=super::RASTERS_MAX as u32)
445            .filter(|px| inner.rasters.contains_key(&("dot".to_owned(), *px)))
446            .count();
447        assert_eq!(
448            survivors,
449            super::RASTERS_MAX - 1,
450            "single-entry eviction keeps the rest"
451        );
452    }
453}