sequoia-assets 0.1.1

Sequoia GUI framework — asset registry (icons, fonts)
Documentation
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;

use crate::photo::PhotoError;
use crate::raster::{MAX_RASTER_PX, Sprite, rasterize};
use crate::svg::{SvgError, SvgIcon, parse_svg};

#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AssetError {
    Unknown(String),

    Photo(PhotoError),
}

impl std::fmt::Display for AssetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AssetError::Unknown(name) => write!(f, "unknown icon `{name}`"),
            AssetError::Photo(e) => write!(f, "photo error: {e}"),
        }
    }
}

impl std::error::Error for AssetError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AssetError::Unknown(_) => None,
            AssetError::Photo(e) => Some(e),
        }
    }
}

impl From<PhotoError> for AssetError {
    fn from(e: PhotoError) -> Self {
        AssetError::Photo(e)
    }
}

#[derive(Default)]
struct Inner {
    fonts: Vec<(String, Rc<[u8]>)>,
    default_font: Option<String>,
    icons: HashMap<String, Rc<SvgIcon>>,
    rasters: HashMap<(String, u32), Rc<Sprite>>,
    raster_sizes: HashMap<String, Vec<u32>>,

    photo_sizes: HashMap<String, (u32, u32)>,
}

const PHOTO_SIZES_MAX: usize = 1024;

const RASTERS_MAX: usize = 128;

#[derive(Clone, Default)]
pub struct AssetRegistry {
    inner: Rc<RefCell<Inner>>,
}

impl AssetRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_builtin_icons() -> Self {
        let registry = Self::new();
        for (name, svg) in crate::icons::builtin() {
            if let Err(e) = registry.register_icon(*name, svg) {
                debug_assert!(false, "bundled icon `{name}` must be valid: {e}");
                log::error!("sequoia-assets: bundled icon `{name}` invalid: {e} — skipped");
            }
        }
        registry
    }

    pub fn register_font(&self, name: impl Into<String>, data: Vec<u8>) {
        self.inner
            .borrow_mut()
            .fonts
            .push((name.into(), Rc::from(data)));
    }

    pub fn set_default_font(&self, name: impl Into<String>) {
        self.inner.borrow_mut().default_font = Some(name.into());
    }

    pub fn default_font(&self) -> Option<String> {
        self.inner.borrow().default_font.clone()
    }

    pub fn fonts(&self) -> Vec<(String, Rc<[u8]>)> {
        self.inner.borrow().fonts.clone()
    }

    pub fn register_icon(&self, name: impl Into<String>, svg_src: &str) -> Result<(), SvgError> {
        let icon = parse_svg(svg_src)?;
        self.register_icon_data(name, icon);
        Ok(())
    }

    pub fn register_icon_data(&self, name: impl Into<String>, icon: SvgIcon) {
        let name = name.into();
        let mut inner = self.inner.borrow_mut();
        if let Some(sizes) = inner.raster_sizes.remove(&name) {
            for px in sizes {
                inner.rasters.remove(&(name.clone(), px));
            }
        }
        inner.icons.insert(name, Rc::new(icon));
    }

    pub fn icon(&self, name: &str) -> Option<Rc<SvgIcon>> {
        let inner = self.inner.borrow();
        if let Some(icon) = inner.icons.get(name) {
            return Some(Rc::clone(icon));
        }
        if let Some((_, short)) = name.split_once(':')
            && let Some(icon) = inner.icons.get(short)
        {
            return Some(Rc::clone(icon));
        }
        None
    }

    pub fn icon_names(&self) -> Vec<String> {
        let mut names: Vec<String> = self.inner.borrow().icons.keys().cloned().collect();
        names.sort();
        names
    }

    pub fn icons_in_namespace(&self, ns: &str) -> Vec<String> {
        let prefix = format!("{ns}:");
        let mut names: Vec<String> = self
            .inner
            .borrow()
            .icons
            .keys()
            .filter(|k| k.starts_with(&prefix))
            .cloned()
            .collect();
        names.sort();
        names
    }

    pub fn rasterize_icon(&self, name: &str, px: u32) -> Result<Rc<Sprite>, AssetError> {
        if px > MAX_RASTER_PX {
            return Err(AssetError::Photo(PhotoError::TooLarge {
                width: px,
                height: px,
            }));
        }
        let resolved = self
            .resolve_icon_name(name)
            .ok_or_else(|| AssetError::Unknown(name.into()))?;
        let mut inner = self.inner.borrow_mut();
        if let Some(sprite) = inner.rasters.get(&(resolved.clone(), px)) {
            return Ok(Rc::clone(sprite));
        }
        let icon = inner
            .icons
            .get(&resolved)
            .cloned()
            .ok_or_else(|| AssetError::Unknown(name.into()))?;
        let sprite = Rc::new(rasterize(&icon, px));
        if inner.rasters.len() >= RASTERS_MAX
            && let Some(victim) = inner.rasters.keys().next().cloned()
        {
            inner.rasters.remove(&victim);
            if let Some(sizes) = inner.raster_sizes.get_mut(&victim.0) {
                sizes.retain(|&s| s != victim.1);
                if sizes.is_empty() {
                    inner.raster_sizes.remove(&victim.0);
                }
            }
        }
        let sizes = inner.raster_sizes.entry(resolved.clone()).or_default();
        if !sizes.contains(&px) {
            sizes.push(px);
        }
        inner.rasters.insert((resolved, px), Rc::clone(&sprite));
        Ok(sprite)
    }

    fn resolve_icon_name(&self, name: &str) -> Option<String> {
        let inner = self.inner.borrow();
        if inner.icons.contains_key(name) {
            return Some(name.to_owned());
        }
        if let Some((_, short)) = name.split_once(':')
            && inner.icons.contains_key(short)
        {
            return Some(short.to_owned());
        }
        None
    }

    pub fn photo_size(&self, path: &str) -> Result<(u32, u32), AssetError> {
        let mut inner = self.inner.borrow_mut();
        if let Some(size) = inner.photo_sizes.get(path) {
            return Ok(*size);
        }
        let size = image::image_dimensions(path)
            .map_err(|_| AssetError::Photo(PhotoError::Io(path.into())))?;
        if size.0 == 0 || size.1 == 0 {
            return Err(AssetError::Photo(PhotoError::Decode("empty image".into())));
        }
        if inner.photo_sizes.len() >= PHOTO_SIZES_MAX {
            inner.photo_sizes.clear();
        }
        inner.photo_sizes.insert(path.to_owned(), size);
        Ok(size)
    }
}

pub fn icon_key(name: &str, px: u32) -> u64 {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for byte in name.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    hash ^= u64::from(px);
    hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    hash
}

#[cfg(test)]
mod tests {
    use super::*;

    const DOT: &str = r#"<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>"#;

    #[test]
    fn register_and_rasterize_with_cache() {
        let registry = AssetRegistry::new();
        registry.register_icon("dot", DOT).unwrap();
        let a = registry.rasterize_icon("dot", 16).unwrap();
        let b = registry.rasterize_icon("dot", 16).unwrap();
        assert!(Rc::ptr_eq(&a, &b), "cache returns the same sprite");
        assert_eq!((a.width, a.height), (16, 16));
        assert!(registry.rasterize_icon("missing", 16).is_err());
    }

    #[test]
    fn reregister_invalidates_cache() {
        let registry = AssetRegistry::new();
        registry.register_icon("dot", DOT).unwrap();
        let first = registry.rasterize_icon("dot", 16).unwrap();
        registry
            .register_icon(
                "dot",
                r#"<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#,
            )
            .unwrap();
        let second = registry.rasterize_icon("dot", 16).unwrap();
        assert!(!Rc::ptr_eq(&first, &second));
    }

    #[test]
    fn bad_svg_is_error() {
        let registry = AssetRegistry::new();
        assert!(matches!(
            registry.register_icon("x", "<svg><path d='M1'/></svg>"),
            Err(SvgError::BadPath(_, _))
        ));
        assert!(registry.icon("x").is_none());
    }

    #[test]
    fn oversize_raster_request_is_refused() {
        use crate::raster::MAX_RASTER_PX;
        let registry = AssetRegistry::new();
        registry.register_icon("dot", DOT).unwrap();
        assert!(
            registry.rasterize_icon("dot", MAX_RASTER_PX).is_ok(),
            "limit przechodzi"
        );
        assert!(
            registry.rasterize_icon("dot", MAX_RASTER_PX + 1).is_err(),
            "past the limit: refused instead of px alloc²"
        );
        assert!(registry.rasterize_icon("dot", u32::MAX).is_err());
    }

    #[test]
    fn fonts_roundtrip() {
        let registry = AssetRegistry::new();
        registry.register_font("Inter", vec![1, 2, 3]);
        registry.set_default_font("Inter");
        assert_eq!(registry.default_font(), Some("Inter".into()));
        let fonts = registry.fonts();
        assert_eq!(fonts.len(), 1);
        assert_eq!(fonts[0].0, "Inter");
        assert_eq!(&*fonts[0].1, &[1, 2, 3]);
    }

    #[test]
    fn clone_shares_state() {
        let registry = AssetRegistry::new();
        let clone = registry.clone();
        clone.register_icon("dot", DOT).unwrap();
        assert!(registry.icon("dot").is_some());
    }

    #[test]
    fn icon_key_stable_and_distinct() {
        assert_eq!(icon_key("check", 16), icon_key("check", 16));
        assert_ne!(icon_key("check", 16), icon_key("check", 24));
        assert_ne!(icon_key("check", 16), icon_key("close", 16));
    }

    #[test]
    fn builtin_icons_all_parse_and_rasterize() {
        let registry = AssetRegistry::with_builtin_icons();
        let names = registry.icon_names();
        assert!(names.len() >= 12, "wbudowany zestaw: {names:?}");
        for name in names {
            let sprite = registry.rasterize_icon(&name, 24).expect("rasteryzacja");
            assert!(!sprite.is_blank(), "icon {name} must not be blank");
        }
    }

    #[test]
    fn photo_size_probes_without_decoding() {
        let registry = AssetRegistry::new();
        assert!(registry.photo_size("no/such/file.png").is_err());

        let dir = std::env::temp_dir().join("sequoia-photo-probe");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("probe.png");
        let pixels: Vec<u8> = (0..4 * 3).flat_map(|_| [10u8, 20, 30, 255]).collect();
        image::save_buffer(&path, &pixels, 4, 3, image::ColorType::Rgba8).unwrap();
        let size = registry
            .photo_size(path.to_str().unwrap())
            .expect("wymiary PNG");
        assert_eq!(size, (4, 3));

        std::fs::remove_file(&path).unwrap();
        assert_eq!(
            registry.photo_size(path.to_str().unwrap()),
            Ok((4, 3)),
            "dimension cache"
        );
    }

    #[test]
    fn raster_cache_evicts_one_entry_not_everything() {
        let registry = AssetRegistry::new();
        registry.register_icon("dot", DOT).unwrap();
        for px in 1..=super::RASTERS_MAX as u32 {
            assert!(registry.rasterize_icon("dot", px).is_ok());
        }
        assert_eq!(registry.inner.borrow().rasters.len(), super::RASTERS_MAX);

        assert!(
            registry
                .rasterize_icon("dot", super::RASTERS_MAX as u32 + 1)
                .is_ok()
        );

        let inner = registry.inner.borrow();
        assert_eq!(inner.rasters.len(), super::RASTERS_MAX);
        let survivors = (1..=super::RASTERS_MAX as u32)
            .filter(|px| inner.rasters.contains_key(&("dot".to_owned(), *px)))
            .count();
        assert_eq!(
            survivors,
            super::RASTERS_MAX - 1,
            "single-entry eviction keeps the rest"
        );
    }
}