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}