ailloli_ui_render_wgpu/icons/icon_cache.rs
1//! CPU rasterization and GPU caching for icon textures.
2
3use ailloli_ui_core::IconId;
4use ailloli_ui_devicons_font::{glyph_or_fallback, DEVICON_FONT_BYTES};
5use fontdue::Font;
6use lucide_icons::LUCIDE_FONT_BYTES;
7
8use super::devicons::rasterize_devicon;
9use super::lucide::lucide_char;
10use super::raster::pad_rgba_rows;
11use super::svg::rasterize_svg;
12use std::collections::HashMap;
13use std::hash::{Hash, Hasher};
14
15/// Cache key for a rasterized icon at a given size and DPR bucket.
16///
17/// `px_size` is the physical square side and `scale_100` is the rounded device
18/// pixel ratio multiplied by 100. Both participate in equality and hashing.
19///
20/// # Examples
21///
22/// ```
23/// use ailloli_ui_core::IconId;
24/// use ailloli_ui_render_wgpu::IconKey;
25/// let key = IconKey { icon: IconId::Plus, px_size: 24, scale_100: 150 };
26/// assert_eq!((key.px_size, key.scale_100), (24, 150));
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct IconKey {
30 /// Logical icon source identity.
31 pub icon: IconId,
32 /// Raster texture side length in physical pixels.
33 pub px_size: u16,
34 /// Device-pixel-ratio bucket, `round(dpr * 100)`, clamped to `1..=u16::MAX`.
35 pub scale_100: u16,
36}
37
38impl Hash for IconKey {
39 fn hash<H: Hasher>(&self, state: &mut H) {
40 self.icon.hash(state);
41 self.px_size.hash(state);
42 self.scale_100.hash(state);
43 }
44}
45
46/// GPU texture + bind group for one cached icon.
47///
48/// # Examples
49///
50/// ```
51/// use ailloli_ui_render_wgpu::icons::IconGpu;
52/// let _: usize = std::mem::size_of::<IconGpu>();
53/// ```
54#[derive(Debug)]
55pub struct IconGpu {
56 /// Owned RGBA8 texture keeping the view alive.
57 pub texture: wgpu::Texture,
58 /// Full texture view bound for sampling.
59 pub view: wgpu::TextureView,
60 /// Nearest-neighbor sampler used by icon rendering.
61 pub sampler: wgpu::Sampler,
62 /// Texture-and-sampler bind group for the textured pipeline.
63 pub bind_group: wgpu::BindGroup,
64 /// Actual square side length in physical pixels; at least eight.
65 pub size_px: u32,
66}
67
68/// In-memory cache of rasterized Lucide / Devicon / SVG icons.
69///
70/// Entries live for the lifetime of the cache; there is no eviction. SVG parse
71/// failure is cached as a transparent square, and unsupported Devicons use the
72/// framework's generic-file glyph.
73///
74/// # Examples
75///
76/// ```
77/// use ailloli_ui_render_wgpu::icons::IconCache;
78/// let cache = IconCache::new();
79/// let _ = cache;
80/// ```
81pub struct IconCache {
82 /// Parsed Lucide icon font retained for glyph rasterization.
83 lucide_font: Font,
84 /// Parsed developer-icon font retained for glyph rasterization.
85 devicon_font: Font,
86 /// GPU textures indexed by icon identity, size, tint, and scale.
87 cache: HashMap<IconKey, IconGpu>,
88}
89
90impl Default for IconCache {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl IconCache {
97 /// Loads the bundled Lucide and Devicon fonts and creates an empty cache.
98 ///
99 /// # Panics
100 ///
101 /// Panics only if a bundled, build-time font asset is invalid.
102 ///
103 /// # Examples
104 ///
105 /// ```
106 /// use ailloli_ui_render_wgpu::icons::IconCache;
107 /// let cache = IconCache::new();
108 /// let _ = cache;
109 /// ```
110 pub fn new() -> Self {
111 let lucide_font = Font::from_bytes(LUCIDE_FONT_BYTES, fontdue::FontSettings::default())
112 .expect("load lucide font");
113 let devicon_font = Font::from_bytes(DEVICON_FONT_BYTES, fontdue::FontSettings::default())
114 .expect("load devicon font");
115 Self {
116 lucide_font,
117 devicon_font,
118 cache: HashMap::new(),
119 }
120 }
121
122 /// Rasterizes one key to a tight square RGBA buffer on the CPU.
123 ///
124 /// Callers normalize the size before invoking this helper.
125 fn rasterize_icon_rgba(&self, icon: &IconId, px_size: u32) -> Vec<u8> {
126 match icon {
127 IconId::Devicon(ch) => {
128 rasterize_devicon(&self.devicon_font, glyph_or_fallback(*ch), px_size)
129 }
130 IconId::Svg(src) => rasterize_svg(src, px_size)
131 .unwrap_or_else(|| vec![0u8; (px_size * px_size * 4) as usize]),
132 _ => {
133 let ch = lucide_char(icon);
134 super::raster::rasterize_glyph_mask(&self.lucide_font, ch, px_size)
135 }
136 }
137 }
138
139 /// Uploads a tight RGBA square and builds its nearest-neighbor binding.
140 fn upload_rgba_texture(
141 device: &wgpu::Device,
142 queue: &wgpu::Queue,
143 bind_group_layout: &wgpu::BindGroupLayout,
144 rgba: Vec<u8>,
145 px_size: u32,
146 label: &str,
147 ) -> IconGpu {
148 let tex = device.create_texture(&wgpu::TextureDescriptor {
149 label: Some(label),
150 size: wgpu::Extent3d {
151 width: px_size,
152 height: px_size,
153 depth_or_array_layers: 1,
154 },
155 mip_level_count: 1,
156 sample_count: 1,
157 dimension: wgpu::TextureDimension::D2,
158 format: wgpu::TextureFormat::Rgba8Unorm,
159 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
160 view_formats: &[],
161 });
162
163 let (padded, padded_bpr) = pad_rgba_rows(&rgba, px_size);
164 queue.write_texture(
165 wgpu::ImageCopyTexture {
166 texture: &tex,
167 mip_level: 0,
168 origin: wgpu::Origin3d::ZERO,
169 aspect: wgpu::TextureAspect::All,
170 },
171 &padded,
172 wgpu::ImageDataLayout {
173 offset: 0,
174 bytes_per_row: Some(padded_bpr),
175 rows_per_image: Some(px_size),
176 },
177 wgpu::Extent3d {
178 width: px_size,
179 height: px_size,
180 depth_or_array_layers: 1,
181 },
182 );
183
184 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
185 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
186 label: Some("icon sampler"),
187 mag_filter: wgpu::FilterMode::Nearest,
188 min_filter: wgpu::FilterMode::Nearest,
189 mipmap_filter: wgpu::FilterMode::Nearest,
190 ..Default::default()
191 });
192
193 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
194 label: Some("icon bind group"),
195 layout: bind_group_layout,
196 entries: &[
197 wgpu::BindGroupEntry {
198 binding: 0,
199 resource: wgpu::BindingResource::TextureView(&view),
200 },
201 wgpu::BindGroupEntry {
202 binding: 1,
203 resource: wgpu::BindingResource::Sampler(&sampler),
204 },
205 ],
206 });
207
208 IconGpu {
209 texture: tex,
210 view,
211 sampler,
212 bind_group,
213 size_px: px_size,
214 }
215 }
216
217 /// Returns the cached GPU icon or rasterizes and uploads it once.
218 ///
219 /// `key.px_size` is clamped upward to eight for allocation and rasterization,
220 /// while the original key is retained. The returned reference remains valid
221 /// until the cache is mutably borrowed again.
222 ///
223 /// # Panics
224 ///
225 /// Panics on impossible cache inconsistency or GPU allocation validation
226 /// failure. Invalid SVG itself produces a transparent texture instead.
227 ///
228 /// # Examples
229 ///
230 /// ```no_run
231 /// use ailloli_ui_render_wgpu::{icons::IconCache, IconKey};
232 /// fn upload<'a>(cache: &'a mut IconCache, device: &wgpu::Device,
233 /// queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout,
234 /// key: &IconKey) -> &'a ailloli_ui_render_wgpu::icons::IconGpu {
235 /// cache.get_or_create(device, queue, layout, key)
236 /// }
237 /// ```
238 pub fn get_or_create(
239 &mut self,
240 device: &wgpu::Device,
241 queue: &wgpu::Queue,
242 bind_group_layout: &wgpu::BindGroupLayout,
243 key: &IconKey,
244 ) -> &IconGpu {
245 if self.cache.contains_key(key) {
246 return self.cache.get(key).expect("cache contains key");
247 }
248
249 let px_size = key.px_size.max(8) as u32;
250 let rgba = self.rasterize_icon_rgba(&key.icon, px_size);
251 let gpu = Self::upload_rgba_texture(
252 device,
253 queue,
254 bind_group_layout,
255 rgba,
256 px_size,
257 "icon texture",
258 );
259 self.cache.insert(key.clone(), gpu);
260 self.cache.get(key).expect("cache contains key")
261 }
262
263 /// Looks up a previously uploaded icon without mutating the cache.
264 ///
265 /// Returns `None` before [`Self::get_or_create`] has inserted the exact key.
266 ///
267 /// # Examples
268 ///
269 /// ```
270 /// use ailloli_ui_core::IconId;
271 /// use ailloli_ui_render_wgpu::{icons::IconCache, IconKey};
272 /// let cache = IconCache::new();
273 /// let key = IconKey { icon: IconId::Plus, px_size: 16, scale_100: 100 };
274 /// assert!(cache.get(&key).is_none());
275 /// ```
276 pub fn get(&self, key: &IconKey) -> Option<&IconGpu> {
277 self.cache.get(key)
278 }
279}
280
281#[cfg(test)]
282/// Verifies fallback rasterization for unsupported Devicon glyphs.
283mod tests {
284 use super::IconCache;
285 use ailloli_ui_core::IconId;
286 use ailloli_ui_devicons_font::GENERIC_FILE_GLYPH;
287
288 #[test]
289 fn unsupported_devicon_rasterizes_as_the_generic_file_glyph() {
290 let cache = IconCache::new();
291 let unsupported = cache.rasterize_icon_rgba(&IconId::Devicon('\u{f303}'), 24);
292 let fallback = cache.rasterize_icon_rgba(&IconId::Devicon(GENERIC_FILE_GLYPH), 24);
293 assert_eq!(unsupported, fallback);
294 assert!(fallback.iter().any(|channel| *channel != 0));
295 }
296}