ailloli_ui_render_wgpu/text/
text_atlas.rs

1//! Multi-page GPU glyph atlas with per-frame pinning and LRU page eviction.
2
3use std::collections::{HashMap, VecDeque};
4
5use super::glyph_upload::write_subtexture_rgba;
6use swash::zeno::Format;
7use swash::{
8    scale::{Render, ScaleContext, Source, StrikeWith},
9    FontRef, GlyphId,
10};
11
12/// Maximum atlas pages: eight 1024² RGBA8 textures (about 32 MiB total).
13///
14/// Each RGBA8 page occupies about 4 MiB, so the absolute texture storage ceiling
15/// is approximately 32 MiB across all DPR buckets represented in the keys.
16///
17/// # Examples
18///
19/// ```
20/// use ailloli_ui_render_wgpu::text::MAX_ATLAS_PAGES;
21/// assert_eq!(MAX_ATLAS_PAGES, 8);
22/// ```
23pub const MAX_ATLAS_PAGES: u8 = 8;
24
25/// Cache key for one rasterized glyph in the text atlas.
26///
27/// # Examples
28///
29/// ```
30/// use ailloli_ui_render_wgpu::text::GlyphKey;
31/// let key = GlyphKey { face_id: 3, font_index: 0, px_size: 16,
32///     glyph_id: 42, scale_100: 200 };
33/// assert_eq!((key.face_id, key.scale_100), (3, 200));
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub struct GlyphKey {
37    /// Stable face identity supplied by the text system.
38    pub face_id: u64,
39    /// Face index inside a font collection.
40    pub font_index: u32,
41    /// Raster size in physical pixels, clamped to `8..=128` when used.
42    pub px_size: u16,
43    /// Font-specific glyph identifier; rasterization narrows it to `u16`.
44    pub glyph_id: u32,
45    /// `round(dpr * 100)` to separate HiDPI cache buckets.
46    pub scale_100: u16,
47}
48
49/// UV layout and metrics for a glyph in the atlas.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_render_wgpu::text::Glyph;
55/// let glyph = Glyph { uv_min: [0.0, 0.0], uv_max: [0.5, 0.5],
56///     size_px: [8.0, 10.0], offset_px: [1.0, -2.0], advance_px: 0.0 };
57/// assert_eq!(glyph.size_px, [8.0, 10.0]);
58/// ```
59#[derive(Debug, Clone, Copy)]
60pub struct Glyph {
61    /// Inclusive lower UV corner in normalized atlas coordinates.
62    pub uv_min: [f32; 2],
63    /// Exclusive upper UV corner in normalized atlas coordinates.
64    pub uv_max: [f32; 2],
65    /// Raster bitmap width and height in physical pixels.
66    pub size_px: [f32; 2],
67    /// Bitmap offset from the physical glyph pen position.
68    pub offset_px: [f32; 2],
69    /// Reserved advance in physical pixels; currently zero because layout owns advance.
70    pub advance_px: f32,
71}
72
73/// Per-frame atlas cache statistics.
74///
75/// Counters saturate at `u32::MAX`; `pages_active` reflects the current atlas,
76/// including pages allocated in earlier frames.
77///
78/// # Examples
79///
80/// ```
81/// use ailloli_ui_render_wgpu::text::TextAtlasStats;
82/// let stats = TextAtlasStats::default();
83/// assert_eq!((stats.hits, stats.misses, stats.pages_active), (0, 0, 0));
84/// ```
85#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
86pub struct TextAtlasStats {
87    /// Cache hits in the current frame.
88    pub hits: u32,
89    /// Cache misses in the current frame.
90    pub misses: u32,
91    /// Glyph bitmaps uploaded in the current frame.
92    pub rasterized: u32,
93    /// Atlas page resets caused by eviction in the current frame.
94    pub resets: u32,
95    /// Allocations skipped because every candidate page was frame-pinned.
96    pub evictions_blocked: u32,
97    /// Glyphs skipped for missing faces, raster failures, or allocation failure.
98    pub glyphs_skipped: u32,
99    /// Total allocated atlas pages after the latest operation.
100    pub pages_active: u32,
101}
102
103#[derive(Debug)]
104/// Shelf allocator cursor for one atlas page.
105struct Shelf {
106    /// Next free horizontal texel coordinate on the current shelf.
107    x: u32,
108    /// Top texel coordinate of the current shelf.
109    y: u32,
110    /// Maximum glyph height in texels on the current shelf.
111    h: u32,
112}
113
114/// Texture, binding, and allocator state for one atlas page.
115struct AtlasPage {
116    /// RGBA8 glyph texture owned by this page.
117    texture: wgpu::Texture,
118    /// Bind group exposing `texture` and the shared sampler to shaders.
119    bind_group: wgpu::BindGroup,
120    /// Monotonic shelf allocator state for new glyph rectangles.
121    shelf: Shelf,
122}
123
124/// Multi-page GPU glyph atlas with LRU eviction.
125///
126/// The atlas allocates one 1024-square RGBA8 page eagerly and grows to
127/// [`MAX_ATLAS_PAGES`]. Pinned pages cannot be reset during a frame. Unpinned
128/// eviction clears an entire least-recently-used page, not an individual glyph.
129///
130/// # Examples
131///
132/// ```
133/// use ailloli_ui_render_wgpu::text::TextAtlas;
134/// let _: usize = std::mem::size_of::<TextAtlas>();
135/// ```
136pub struct TextAtlas {
137    /// Width and height in physical texels of every square page.
138    tex_size: u32,
139    /// Maximum number of pages, representable by the `u8` glyph page index.
140    max_pages: u8,
141    /// Shared sampler used by every atlas page bind group.
142    sampler: wgpu::Sampler,
143
144    /// Allocated pages in stable `u8` index order.
145    pages: Vec<AtlasPage>,
146
147    /// Cached glyph metadata paired with its page index.
148    glyphs: HashMap<GlyphKey, (u8, Glyph)>,
149    /// Least-recently-used glyph keys, oldest at the front.
150    lru: VecDeque<GlyphKey>,
151    /// Per-page pin flags preventing reset during the active frame.
152    frame_pinned_pages: Vec<bool>,
153    /// Counters accumulated during the active frame.
154    frame_stats: TextAtlasStats,
155
156    /// Swash scale context reused for CPU glyph rasterization.
157    scale_cx: ScaleContext,
158}
159
160/// Scoped atlas access for one frame (pins pages until drop).
161///
162/// Dropping the guard calls `finish_frame`, unpinning every page. The guard's
163/// mutable borrow prevents other atlas access during the frame.
164///
165/// # Examples
166///
167/// ```
168/// use ailloli_ui_render_wgpu::text::TextAtlasFrame;
169/// let _: usize = std::mem::size_of::<TextAtlasFrame<'static>>();
170/// ```
171pub struct TextAtlasFrame<'a> {
172    /// Exclusively borrowed atlas whose pages remain pinned for this guard.
173    atlas: &'a mut TextAtlas,
174}
175
176impl<'a> TextAtlasFrame<'a> {
177    /// Looks up or rasterizes a glyph and pins its page until this guard drops.
178    ///
179    /// Returns `None` for invalid font data, a missing glyph image, an oversized
180    /// allocation, or when all pages are pinned and full.
181    ///
182    /// # Examples
183    ///
184    /// ```no_run
185    /// use ailloli_ui_render_wgpu::text::{GlyphKey, TextAtlasFrame};
186    /// fn lookup(frame: &mut TextAtlasFrame<'_>, device: &wgpu::Device,
187    ///     queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout, font: &[u8], key: GlyphKey) {
188    ///     let _glyph: Option<(u8, ailloli_ui_render_wgpu::text::Glyph)> =
189    ///         frame.get_or_rasterize(device, queue, layout, key, font);
190    /// }
191    /// ```
192    pub fn get_or_rasterize(
193        &mut self,
194        device: &wgpu::Device,
195        queue: &wgpu::Queue,
196        bind_group_layout: &wgpu::BindGroupLayout,
197        key: GlyphKey,
198        font_data: &[u8],
199    ) -> Option<(u8, Glyph)> {
200        self.atlas
201            .get_or_rasterize_pinned(device, queue, bind_group_layout, key, font_data)
202    }
203
204    /// Records one glyph skipped because its face bytes were unavailable.
205    ///
206    /// # Examples
207    ///
208    /// ```no_run
209    /// use ailloli_ui_render_wgpu::text::TextAtlasFrame;
210    /// fn missing(frame: &mut TextAtlasFrame<'_>) { frame.record_missing_face(); }
211    /// ```
212    pub fn record_missing_face(&mut self) {
213        self.atlas.record_missing_face();
214    }
215
216    /// Returns current per-frame counters and allocated page count.
217    ///
218    /// # Examples
219    ///
220    /// ```no_run
221    /// use ailloli_ui_render_wgpu::text::{TextAtlasFrame, TextAtlasStats};
222    /// fn stats(frame: &TextAtlasFrame<'_>) -> TextAtlasStats { frame.stats() }
223    /// ```
224    pub fn stats(&self) -> TextAtlasStats {
225        self.atlas.stats()
226    }
227}
228
229impl Drop for TextAtlasFrame<'_> {
230    fn drop(&mut self) {
231        self.atlas.finish_frame();
232    }
233}
234
235impl TextAtlas {
236    /// Creates an atlas, sampler, and the mandatory first 1024-square page.
237    ///
238    /// The supplied bind-group layout must contain a filterable 2D texture at
239    /// binding 0 and a filtering sampler at binding 1.
240    ///
241    /// # Examples
242    ///
243    /// ```no_run
244    /// use ailloli_ui_render_wgpu::text::TextAtlas;
245    /// fn create(device: &wgpu::Device, queue: &wgpu::Queue,
246    ///     layout: &wgpu::BindGroupLayout) -> TextAtlas {
247    ///     TextAtlas::new(device, queue, layout)
248    /// }
249    /// ```
250    pub fn new(
251        device: &wgpu::Device,
252        queue: &wgpu::Queue,
253        tex_bind_group_layout: &wgpu::BindGroupLayout,
254    ) -> Self {
255        let tex_size = 1024;
256        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
257            label: Some("text atlas sampler"),
258            address_mode_u: wgpu::AddressMode::ClampToEdge,
259            address_mode_v: wgpu::AddressMode::ClampToEdge,
260            address_mode_w: wgpu::AddressMode::ClampToEdge,
261            mag_filter: wgpu::FilterMode::Linear,
262            min_filter: wgpu::FilterMode::Linear,
263            mipmap_filter: wgpu::FilterMode::Nearest,
264            ..Default::default()
265        });
266        let mut atlas = Self {
267            tex_size,
268            max_pages: MAX_ATLAS_PAGES,
269            sampler,
270            pages: Vec::new(),
271            glyphs: HashMap::new(),
272            lru: VecDeque::new(),
273            frame_pinned_pages: Vec::new(),
274            frame_stats: TextAtlasStats::default(),
275            scale_cx: ScaleContext::new(),
276        };
277        atlas.allocate_page(device, queue, tex_bind_group_layout);
278        atlas
279    }
280
281    /// Page 0 bind group (always allocated; legacy renderer path).
282    ///
283    /// # Examples
284    ///
285    /// ```no_run
286    /// use ailloli_ui_render_wgpu::text::TextAtlas;
287    /// fn binding(atlas: &TextAtlas) -> &wgpu::BindGroup { atlas.bind_group() }
288    /// ```
289    pub fn bind_group(&self) -> &wgpu::BindGroup {
290        &self.pages[0].bind_group
291    }
292
293    /// Bind group for the given atlas page index.
294    ///
295    /// # Panics
296    ///
297    /// Panics if `page_idx >= page_count()`.
298    ///
299    /// # Examples
300    ///
301    /// ```no_run
302    /// use ailloli_ui_render_wgpu::text::TextAtlas;
303    /// fn first(atlas: &TextAtlas) -> &wgpu::BindGroup { atlas.page_bind_group(0) }
304    /// ```
305    pub fn page_bind_group(&self, page_idx: u8) -> &wgpu::BindGroup {
306        &self.pages[page_idx as usize].bind_group
307    }
308
309    /// Returns the number of allocated pages, always in `1..=MAX_ATLAS_PAGES`.
310    ///
311    /// # Examples
312    ///
313    /// ```no_run
314    /// use ailloli_ui_render_wgpu::text::TextAtlas;
315    /// fn pages(atlas: &TextAtlas) -> usize { atlas.page_count() }
316    /// ```
317    pub fn page_count(&self) -> usize {
318        self.pages.len()
319    }
320
321    /// Starts a frame and returns a guard that unpins pages on drop.
322    ///
323    /// Calling this discards the previous frame's counters and pins, so guards
324    /// must not conceptually overlap.
325    ///
326    /// # Examples
327    ///
328    /// ```no_run
329    /// use ailloli_ui_render_wgpu::text::TextAtlas;
330    /// fn frame(atlas: &mut TextAtlas) { let _guard = atlas.begin_frame(); }
331    /// ```
332    pub fn begin_frame(&mut self) -> TextAtlasFrame<'_> {
333        self.start_frame();
334        TextAtlasFrame { atlas: self }
335    }
336
337    /// Resets per-frame counters and clears all page pins.
338    ///
339    /// Prefer [`Self::begin_frame`] when scoped access is practical.
340    ///
341    /// # Examples
342    ///
343    /// ```no_run
344    /// use ailloli_ui_render_wgpu::text::TextAtlas;
345    /// fn start(atlas: &mut TextAtlas) { atlas.start_frame(); }
346    /// ```
347    pub fn start_frame(&mut self) {
348        self.frame_pinned_pages.clear();
349        self.frame_pinned_pages.resize(self.pages.len(), false);
350        self.frame_stats = TextAtlasStats {
351            pages_active: self.pages.len() as u32,
352            ..TextAtlasStats::default()
353        };
354    }
355
356    /// Clears page pins without changing counters, glyphs, or allocations.
357    ///
358    /// # Examples
359    ///
360    /// ```no_run
361    /// use ailloli_ui_render_wgpu::text::TextAtlas;
362    /// fn finish(atlas: &mut TextAtlas) { atlas.finish_frame(); }
363    /// ```
364    pub fn finish_frame(&mut self) {
365        self.frame_pinned_pages.fill(false);
366    }
367
368    /// Returns current frame counters with a live page-count snapshot.
369    ///
370    /// # Examples
371    ///
372    /// ```no_run
373    /// use ailloli_ui_render_wgpu::text::{TextAtlas, TextAtlasStats};
374    /// fn stats(atlas: &TextAtlas) -> TextAtlasStats { atlas.stats() }
375    /// ```
376    pub fn stats(&self) -> TextAtlasStats {
377        TextAtlasStats {
378            pages_active: self.pages.len() as u32,
379            ..self.frame_stats
380        }
381    }
382
383    /// Allocates and zero-fills one atlas page, then creates its sample binding.
384    fn allocate_page(
385        &mut self,
386        device: &wgpu::Device,
387        queue: &wgpu::Queue,
388        bind_group_layout: &wgpu::BindGroupLayout,
389    ) {
390        let format = wgpu::TextureFormat::Rgba8Unorm;
391        let texture = device.create_texture(&wgpu::TextureDescriptor {
392            label: Some("text atlas page"),
393            size: wgpu::Extent3d {
394                width: self.tex_size,
395                height: self.tex_size,
396                depth_or_array_layers: 1,
397            },
398            mip_level_count: 1,
399            sample_count: 1,
400            dimension: wgpu::TextureDimension::D2,
401            format,
402            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
403            view_formats: &[],
404        });
405        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
406        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
407            label: Some("text atlas page bind group"),
408            layout: bind_group_layout,
409            entries: &[
410                wgpu::BindGroupEntry {
411                    binding: 0,
412                    resource: wgpu::BindingResource::TextureView(&view),
413                },
414                wgpu::BindGroupEntry {
415                    binding: 1,
416                    resource: wgpu::BindingResource::Sampler(&self.sampler),
417                },
418            ],
419        });
420
421        let zero = vec![0u8; (self.tex_size * self.tex_size * 4) as usize];
422        write_subtexture_rgba(
423            queue,
424            &texture,
425            wgpu::Origin3d::ZERO,
426            self.tex_size,
427            self.tex_size,
428            &zero,
429        );
430
431        self.pages.push(AtlasPage {
432            texture,
433            bind_group,
434            shelf: Shelf { x: 0, y: 0, h: 0 },
435        });
436    }
437
438    /// Clears a page and removes every glyph and LRU entry resident on it.
439    fn reset_page(&mut self, page_idx: u8, queue: &wgpu::Queue) {
440        self.frame_stats.resets = self.frame_stats.resets.saturating_add(1);
441        let zero = vec![0u8; (self.tex_size * self.tex_size * 4) as usize];
442        write_subtexture_rgba(
443            queue,
444            &self.pages[page_idx as usize].texture,
445            wgpu::Origin3d::ZERO,
446            self.tex_size,
447            self.tex_size,
448            &zero,
449        );
450        self.pages[page_idx as usize].shelf = Shelf { x: 0, y: 0, h: 0 };
451
452        let evicted: Vec<GlyphKey> = self
453            .glyphs
454            .iter()
455            .filter_map(|(k, (p, _))| if *p == page_idx { Some(*k) } else { None })
456            .collect();
457        for k in evicted {
458            self.glyphs.remove(&k);
459            self.lru.retain(|key| key != &k);
460        }
461    }
462
463    /// Looks up or rasterizes a glyph without protecting its page from eviction.
464    ///
465    /// Returns `(page_index, metrics)` or `None` on raster/allocation failure.
466    /// Use [`Self::get_or_rasterize_pinned`] during frame preparation.
467    ///
468    /// # Examples
469    ///
470    /// ```no_run
471    /// use ailloli_ui_render_wgpu::text::{GlyphKey, TextAtlas};
472    /// fn lookup(atlas: &mut TextAtlas, device: &wgpu::Device, queue: &wgpu::Queue,
473    ///     layout: &wgpu::BindGroupLayout, key: GlyphKey, font: &[u8]) {
474    ///     let _ = atlas.get_or_rasterize(device, queue, layout, key, font);
475    /// }
476    /// ```
477    pub fn get_or_rasterize(
478        &mut self,
479        device: &wgpu::Device,
480        queue: &wgpu::Queue,
481        bind_group_layout: &wgpu::BindGroupLayout,
482        key: GlyphKey,
483        font_data: &[u8],
484    ) -> Option<(u8, Glyph)> {
485        self.get_or_rasterize_impl(device, queue, bind_group_layout, key, font_data, false)
486    }
487
488    /// Looks up or rasterizes a glyph and pins its page for the current frame.
489    ///
490    /// If every page is pinned and full, returns `None` and increments both
491    /// `evictions_blocked` and `glyphs_skipped`.
492    ///
493    /// # Examples
494    ///
495    /// ```no_run
496    /// use ailloli_ui_render_wgpu::text::{GlyphKey, TextAtlas};
497    /// fn lookup(atlas: &mut TextAtlas, device: &wgpu::Device, queue: &wgpu::Queue,
498    ///     layout: &wgpu::BindGroupLayout, key: GlyphKey, font: &[u8]) {
499    ///     let _ = atlas.get_or_rasterize_pinned(device, queue, layout, key, font);
500    /// }
501    /// ```
502    pub fn get_or_rasterize_pinned(
503        &mut self,
504        device: &wgpu::Device,
505        queue: &wgpu::Queue,
506        bind_group_layout: &wgpu::BindGroupLayout,
507        key: GlyphKey,
508        font_data: &[u8],
509    ) -> Option<(u8, Glyph)> {
510        self.get_or_rasterize_impl(device, queue, bind_group_layout, key, font_data, true)
511    }
512
513    /// Saturating-increments the missing-face/skip counter.
514    ///
515    /// # Examples
516    ///
517    /// ```no_run
518    /// use ailloli_ui_render_wgpu::text::TextAtlas;
519    /// fn missing(atlas: &mut TextAtlas) { atlas.record_missing_face(); }
520    /// ```
521    pub fn record_missing_face(&mut self) {
522        self.frame_stats.glyphs_skipped = self.frame_stats.glyphs_skipped.saturating_add(1);
523    }
524
525    /// Shared lookup, raster, page allocation, upload, and optional pin path.
526    fn get_or_rasterize_impl(
527        &mut self,
528        device: &wgpu::Device,
529        queue: &wgpu::Queue,
530        bind_group_layout: &wgpu::BindGroupLayout,
531        key: GlyphKey,
532        font_data: &[u8],
533        pin_for_frame: bool,
534    ) -> Option<(u8, Glyph)> {
535        if let Some((page, g)) = self.glyphs.get(&key).copied() {
536            self.touch_lru(key);
537            self.frame_stats.hits = self.frame_stats.hits.saturating_add(1);
538            if pin_for_frame {
539                self.pin_page(page);
540            }
541            return Some((page, g));
542        }
543
544        self.frame_stats.misses = self.frame_stats.misses.saturating_add(1);
545        let px = key.px_size.clamp(8, 128) as f32;
546        let Some((bmp, w, h, offset_x, offset_y)) =
547            self.rasterize(font_data, key.font_index, key.glyph_id, px)
548        else {
549            self.frame_stats.glyphs_skipped = self.frame_stats.glyphs_skipped.saturating_add(1);
550            return None;
551        };
552        if w == 0 || h == 0 || bmp.is_empty() {
553            let g = Glyph {
554                uv_min: [0.0, 0.0],
555                uv_max: [0.0, 0.0],
556                size_px: [0.0, 0.0],
557                offset_px: [0.0, 0.0],
558                advance_px: 0.0,
559            };
560            self.glyphs.insert(key, (0, g));
561            self.touch_lru(key);
562            if pin_for_frame {
563                self.pin_page(0);
564            }
565            return Some((0, g));
566        }
567        let w = w.max(1);
568        let h = h.max(1);
569
570        let pad = 1u32;
571        let alloc_w = w + pad * 2;
572        let alloc_h = h + pad * 2;
573
574        let Some((page_idx, x, y)) = self.alloc_or_grow(
575            device,
576            queue,
577            bind_group_layout,
578            alloc_w,
579            alloc_h,
580            pin_for_frame,
581        ) else {
582            self.frame_stats.glyphs_skipped = self.frame_stats.glyphs_skipped.saturating_add(1);
583            return None;
584        };
585
586        let mut rgba = vec![0u8; (alloc_w * alloc_h * 4) as usize];
587        for yy in 0..h {
588            for xx in 0..w {
589                let src_i = (yy * w + xx) as usize;
590                let a = bmp.get(src_i).copied().unwrap_or(0);
591                let dst_x = xx + pad;
592                let dst_y = yy + pad;
593                let dst_i = ((dst_y * alloc_w + dst_x) * 4) as usize;
594                rgba[dst_i] = 255;
595                rgba[dst_i + 1] = 255;
596                rgba[dst_i + 2] = 255;
597                rgba[dst_i + 3] = a;
598            }
599        }
600
601        write_subtexture_rgba(
602            queue,
603            &self.pages[page_idx as usize].texture,
604            wgpu::Origin3d { x, y, z: 0 },
605            alloc_w,
606            alloc_h,
607            &rgba,
608        );
609
610        let uv_min = [
611            (x + pad) as f32 / self.tex_size as f32,
612            (y + pad) as f32 / self.tex_size as f32,
613        ];
614        let uv_max = [
615            (x + pad + w) as f32 / self.tex_size as f32,
616            (y + pad + h) as f32 / self.tex_size as f32,
617        ];
618
619        let g = Glyph {
620            uv_min,
621            uv_max,
622            size_px: [w as f32, h as f32],
623            offset_px: [offset_x, offset_y],
624            advance_px: 0.0,
625        };
626        self.glyphs.insert(key, (page_idx, g));
627        self.touch_lru(key);
628        self.frame_stats.rasterized = self.frame_stats.rasterized.saturating_add(1);
629        if pin_for_frame {
630            self.pin_page(page_idx);
631        }
632        Some((page_idx, g))
633    }
634
635    /// Marks one page unavailable to eviction for the current frame.
636    fn pin_page(&mut self, page_idx: u8) {
637        let idx = page_idx as usize;
638        if idx >= self.frame_pinned_pages.len() {
639            self.frame_pinned_pages.resize(idx + 1, false);
640        }
641        self.frame_pinned_pages[idx] = true;
642    }
643
644    /// Moves a glyph key to the most-recently-used end of the queue.
645    fn touch_lru(&mut self, key: GlyphKey) {
646        self.lru.retain(|k| k != &key);
647        self.lru.push_back(key);
648    }
649
650    /// Rasterizes one glyph through Swash, returning mask and screen-space placement.
651    fn rasterize(
652        &mut self,
653        font_data: &[u8],
654        font_index: u32,
655        glyph_id: u32,
656        px: f32,
657    ) -> Option<(Vec<u8>, u32, u32, f32, f32)> {
658        let font = FontRef::from_index(font_data, font_index as usize)?;
659        let mut scaler = self.scale_cx.builder(font).size(px).hint(true).build();
660
661        let gid: GlyphId = glyph_id as u16;
662        let image = Render::new(&[
663            Source::ColorOutline(0),
664            Source::ColorBitmap(StrikeWith::BestFit),
665            Source::Outline,
666        ])
667        .format(Format::Alpha)
668        .render(&mut scaler, gid)?;
669
670        let w = image.placement.width as u32;
671        let h = image.placement.height as u32;
672        let offset_x = image.placement.left as f32;
673        // Swash `Placement::top` uses font coordinates; quads use screen pixels (Y down).
674        // Same convention as cosmic-text (`y = -placement.top` when blitting the mask).
675        let offset_y = -(image.placement.top as f32);
676        Some((image.data, w, h, offset_x, offset_y))
677    }
678
679    /// Allocates a shelf region, grows a page, or resets the oldest eligible page.
680    fn alloc_or_grow(
681        &mut self,
682        device: &wgpu::Device,
683        queue: &wgpu::Queue,
684        bind_group_layout: &wgpu::BindGroupLayout,
685        w: u32,
686        h: u32,
687        respect_frame_pins: bool,
688    ) -> Option<(u8, u32, u32)> {
689        if w > self.tex_size || h > self.tex_size {
690            return None;
691        }
692        if let Some((x, y)) = self.try_alloc_in(self.pages.len() - 1, w, h) {
693            return Some(((self.pages.len() - 1) as u8, x, y));
694        }
695        if (self.pages.len() as u8) < self.max_pages {
696            self.allocate_page(device, queue, bind_group_layout);
697            if let Some((x, y)) = self.try_alloc_in(self.pages.len() - 1, w, h) {
698                return Some(((self.pages.len() - 1) as u8, x, y));
699            }
700        }
701        let oldest_page = if respect_frame_pins {
702            match self.oldest_unpinned_page_idx() {
703                Some(page) => page,
704                None => {
705                    self.frame_stats.evictions_blocked =
706                        self.frame_stats.evictions_blocked.saturating_add(1);
707                    return None;
708                }
709            }
710        } else {
711            self.oldest_page_idx().unwrap_or(0)
712        };
713        self.reset_page(oldest_page, queue);
714        let (x, y) = self.try_alloc_in(oldest_page as usize, w, h)?;
715        Some((oldest_page, x, y))
716    }
717
718    /// Returns the page containing the globally oldest cached glyph.
719    fn oldest_page_idx(&self) -> Option<u8> {
720        let key = self.lru.front()?;
721        let (page, _) = self.glyphs.get(key)?;
722        Some(*page)
723    }
724
725    /// Returns the oldest page not pinned by the current frame.
726    fn oldest_unpinned_page_idx(&self) -> Option<u8> {
727        oldest_unpinned_page_idx_from(&self.lru, &self.glyphs, &self.frame_pinned_pages)
728    }
729
730    /// Allocates one rectangle in a page's row-shelf cursor.
731    fn try_alloc_in(&mut self, page_idx: usize, w: u32, h: u32) -> Option<(u32, u32)> {
732        let page = self.pages.get_mut(page_idx)?;
733        if page.shelf.h == 0 {
734            page.shelf.h = h;
735        }
736        if h > page.shelf.h {
737            page.shelf.x = 0;
738            page.shelf.y += page.shelf.h;
739            page.shelf.h = h;
740        }
741        if page.shelf.x + w > self.tex_size {
742            page.shelf.x = 0;
743            page.shelf.y += page.shelf.h;
744            page.shelf.h = h;
745        }
746        if page.shelf.y + page.shelf.h > self.tex_size {
747            return None;
748        }
749        let x = page.shelf.x;
750        let y = page.shelf.y;
751        page.shelf.x += w;
752        Some((x, y))
753    }
754}
755
756/// Finds the first LRU glyph whose page is not marked pinned.
757fn oldest_unpinned_page_idx_from(
758    lru: &VecDeque<GlyphKey>,
759    glyphs: &HashMap<GlyphKey, (u8, Glyph)>,
760    pinned_pages: &[bool],
761) -> Option<u8> {
762    for key in lru {
763        let Some((page, _)) = glyphs.get(key) else {
764            continue;
765        };
766        if !pinned_pages.get(*page as usize).copied().unwrap_or(false) {
767            return Some(*page);
768        }
769    }
770    None
771}
772
773#[cfg(test)]
774/// Verifies LRU victim selection while current-frame atlas pages are pinned.
775mod tests {
776    use super::*;
777
778    /// Creates a stable key that varies only by glyph identifier.
779    fn key(glyph_id: u32) -> GlyphKey {
780        GlyphKey {
781            face_id: 1,
782            font_index: 0,
783            px_size: 14,
784            glyph_id,
785            scale_100: 100,
786        }
787    }
788
789    /// Creates a minimal one-pixel glyph entry for page-victim scenarios.
790    fn glyph() -> Glyph {
791        Glyph {
792            uv_min: [0.0, 0.0],
793            uv_max: [1.0, 1.0],
794            size_px: [1.0, 1.0],
795            offset_px: [0.0, 0.0],
796            advance_px: 0.0,
797        }
798    }
799
800    #[test]
801    fn oldest_unpinned_page_skips_pages_used_by_current_frame() {
802        let mut lru = VecDeque::new();
803        let a = key(1);
804        let b = key(2);
805        lru.push_back(a);
806        lru.push_back(b);
807
808        let mut glyphs = HashMap::new();
809        glyphs.insert(a, (0, glyph()));
810        glyphs.insert(b, (1, glyph()));
811
812        assert_eq!(
813            oldest_unpinned_page_idx_from(&lru, &glyphs, &[true, false]),
814            Some(1)
815        );
816    }
817
818    #[test]
819    fn oldest_unpinned_page_returns_none_when_all_pages_are_pinned() {
820        let mut lru = VecDeque::new();
821        let a = key(1);
822        let b = key(2);
823        lru.push_back(a);
824        lru.push_back(b);
825
826        let mut glyphs = HashMap::new();
827        glyphs.insert(a, (0, glyph()));
828        glyphs.insert(b, (1, glyph()));
829
830        assert_eq!(
831            oldest_unpinned_page_idx_from(&lru, &glyphs, &[true, true]),
832            None
833        );
834    }
835}