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