Skip to main content

beamterm_core/gl/
atlas.rs

1use std::{cell::RefCell, collections::HashSet, fmt::Debug};
2
3use compact_str::CompactString;
4
5use crate::Error;
6
7pub type SlotId = u16;
8pub(crate) const STATIC_ATLAS_LOOKUP_MASK: u32 = 0x1FFF;
9pub const DYNAMIC_ATLAS_LOOKUP_MASK: u32 = 0x0FFF;
10
11/// Trait defining the interface for font atlases.
12pub trait Atlas {
13    /// Returns the glyph identifier for the given key and style bits
14    fn get_glyph_id(&self, key: &str, style_bits: u16) -> Option<u16>;
15
16    /// Returns the base glyph identifier for the given key
17    fn get_base_glyph_id(&self, key: &str) -> Option<u16>;
18
19    /// Returns the height of the atlas in pixels.
20    fn cell_size(&self) -> (i32, i32);
21
22    /// Binds the font atlas texture to the currently active texture unit.
23    fn bind(&self, gl: &glow::Context);
24
25    /// Returns the underline configuration
26    fn underline(&self) -> beamterm_data::LineDecoration;
27
28    /// Returns the strikethrough configuration
29    fn strikethrough(&self) -> beamterm_data::LineDecoration;
30
31    /// Returns the symbol for the given glyph ID, if it exists
32    fn get_symbol(&self, glyph_id: u16) -> Option<CompactString>;
33
34    /// Returns the ASCII character for the given glyph ID, if it represents an ASCII char.
35    ///
36    /// This is an optimized path for URL detection that avoids string allocation.
37    fn get_ascii_char(&self, glyph_id: u16) -> Option<char>;
38
39    /// Returns a reference to the glyph tracker for accessing missing glyphs.
40    fn glyph_tracker(&self) -> &GlyphTracker;
41
42    /// Returns the number of glyphs currently in the atlas.
43    fn glyph_count(&self) -> u32;
44
45    /// Flushes any pending glyph data to the GPU texture.
46    ///
47    /// For dynamic atlases, this rasterizes and uploads queued glyphs that were
48    /// allocated during [`resolve_glyph_slot`] calls. Must be called after the
49    /// atlas texture is bound and before rendering.
50    ///
51    /// For static atlases, this is a no-op since all glyphs are pre-loaded.
52    ///
53    /// # Errors
54    /// Returns an error if texture upload fails.
55    fn flush(&self, gl: &glow::Context) -> Result<(), Error>;
56
57    /// Recreates the GPU texture after a context loss.
58    ///
59    /// This clears the cache - glyphs will be re-rasterized on next access.
60    fn recreate_texture(&mut self, gl: &glow::Context) -> Result<(), Error>;
61
62    /// Iterates over all glyph ID to symbol mappings.
63    ///
64    /// Calls the provided closure for each (glyph_id, symbol) pair in the atlas.
65    /// This is used for debugging and exposing the atlas contents to JavaScript.
66    fn for_each_symbol(&self, f: &mut dyn FnMut(u16, &str));
67
68    /// Resolves a glyph to its texture slot.
69    ///
70    /// For static atlases, performs a lookup and returns `None` if not found.
71    ///
72    /// For dynamic atlases, allocates a slot if missing and queues for upload.
73    /// The slot is immediately valid, but [`flush`] must be called before
74    /// rendering to populate the texture.
75    fn resolve_glyph_slot(&self, key: &str, style_bits: u16) -> Option<GlyphSlot>;
76
77    /// Returns the bitmask for extracting the base glyph ID from a styled glyph ID.
78    ///
79    /// The glyph ID encodes both the base glyph index and style/effect flags. This mask
80    /// isolates the base ID portion, which is used for texture coordinate calculation
81    /// and symbol reverse-lookup.
82    ///
83    /// # Implementation Differences
84    ///
85    /// - **`StaticFontAtlas`** returns `0x1FFF` (13 bits):
86    ///   - Bits 0-9: Base glyph ID (1024 values for regular glyphs)
87    ///   - Bits 10-11: Reserved for font style derivation
88    ///   - Bit 12: Emoji flag (included in mask for emoji base ID extraction)
89    ///   - Supports the full glyph ID encoding scheme from `beamterm-atlas`
90    ///
91    /// - **`DynamicFontAtlas`** returns `0x0FFF` (12 bits):
92    ///   - Uses a flat slot-based addressing (4096 total slots)
93    ///   - Emoji are tracked via `GlyphSlot::Emoji` variant rather than a flag bit
94    ///   - Simpler addressing since glyphs are assigned sequentially at runtime
95    fn base_lookup_mask(&self) -> u32;
96
97    /// Deletes the GPU texture resources associated with this atlas.
98    ///
99    /// This method must be called before dropping the atlas to properly clean up
100    /// GPU resources. Failing to call this will leak GPU memory.
101    fn delete(&self, gl: &glow::Context);
102
103    /// Updates the pixel ratio for HiDPI rendering.
104    ///
105    /// Returns the effective pixel ratio that should be used for viewport scaling.
106    /// Each atlas implementation decides how to handle the ratio:
107    ///
108    /// - **Static atlas**: Returns exact ratio, no internal work needed
109    /// - **Dynamic atlas**: Returns exact ratio, reinitializes with scaled font size
110    fn update_pixel_ratio(&mut self, gl: &glow::Context, pixel_ratio: f32) -> Result<f32, Error>;
111
112    /// Returns the cell scale factor for layout calculations at the given DPR.
113    ///
114    /// This determines how cells from `cell_size()` should be scaled for layout:
115    ///
116    /// - **Static atlas**: Returns snapped scale values (0.5, 1.0, 2.0, 3.0, etc.)
117    ///   to avoid arbitrary fractional scaling of pre-rasterized glyphs.
118    ///   DPR <= 0.5 snaps to 0.5, otherwise rounds to nearest integer (minimum 1.0).
119    /// - **Dynamic atlas**: Returns `1.0` - glyphs are re-rasterized at the exact DPR,
120    ///   so `cell_size()` already returns the correctly-scaled physical size
121    ///
122    /// # Contract
123    ///
124    /// - Return value is always >= 0.5
125    /// - The effective cell size for layout is `cell_size() * cell_scale_for_dpr(dpr)`
126    /// - Static atlases use snapped scaling to preserve glyph sharpness
127    /// - Dynamic atlases handle DPR internally via re-rasterization
128    fn cell_scale_for_dpr(&self, pixel_ratio: f32) -> f32;
129
130    /// Returns the texture cell size in physical pixels (for fragment shader calculations).
131    ///
132    /// This is used for computing padding fractions in the shader, which need to be
133    /// based on the actual texture dimensions rather than logical layout dimensions.
134    ///
135    /// - **Static atlas**: Same as `cell_size()` (texture is at fixed resolution)
136    /// - **Dynamic atlas**: Physical cell size (before dividing by pixel_ratio)
137    fn texture_cell_size(&self) -> (i32, i32);
138}
139
140pub struct FontAtlas {
141    inner: Box<dyn Atlas>,
142}
143
144impl<A: Atlas + 'static> From<A> for FontAtlas {
145    fn from(atlas: A) -> Self {
146        FontAtlas::new(atlas)
147    }
148}
149
150impl Debug for FontAtlas {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("FontAtlas")
153            .finish_non_exhaustive()
154    }
155}
156
157impl FontAtlas {
158    pub fn new(inner: impl Atlas + 'static) -> Self {
159        Self { inner: Box::new(inner) }
160    }
161
162    pub fn get_glyph_id(&self, key: &str, style_bits: u16) -> Option<u16> {
163        self.inner.get_glyph_id(key, style_bits)
164    }
165
166    pub fn get_base_glyph_id(&self, key: &str) -> Option<u16> {
167        self.inner.get_base_glyph_id(key)
168    }
169
170    pub fn cell_size(&self) -> (i32, i32) {
171        self.inner.cell_size()
172    }
173
174    pub fn bind(&self, gl: &glow::Context) {
175        self.inner.bind(gl)
176    }
177
178    pub fn underline(&self) -> beamterm_data::LineDecoration {
179        self.inner.underline()
180    }
181
182    pub fn strikethrough(&self) -> beamterm_data::LineDecoration {
183        self.inner.strikethrough()
184    }
185
186    pub fn get_symbol(&self, glyph_id: u16) -> Option<CompactString> {
187        self.inner.get_symbol(glyph_id)
188    }
189
190    pub fn get_ascii_char(&self, glyph_id: u16) -> Option<char> {
191        self.inner.get_ascii_char(glyph_id)
192    }
193
194    pub fn glyph_tracker(&self) -> &GlyphTracker {
195        self.inner.glyph_tracker()
196    }
197
198    pub fn glyph_count(&self) -> u32 {
199        self.inner.glyph_count()
200    }
201
202    pub fn recreate_texture(&mut self, gl: &glow::Context) -> Result<(), Error> {
203        self.inner.recreate_texture(gl)
204    }
205
206    pub fn for_each_symbol(&self, f: &mut dyn FnMut(u16, &str)) {
207        self.inner.for_each_symbol(f)
208    }
209
210    pub fn resolve_glyph_slot(&self, key: &str, style_bits: u16) -> Option<GlyphSlot> {
211        self.inner.resolve_glyph_slot(key, style_bits)
212    }
213
214    pub fn flush(&self, gl: &glow::Context) -> Result<(), Error> {
215        self.inner.flush(gl)
216    }
217
218    pub(crate) fn base_lookup_mask(&self) -> u32 {
219        self.inner.base_lookup_mask()
220    }
221
222    pub(crate) fn space_glyph_id(&self) -> u16 {
223        self.get_glyph_id(" ", 0x0)
224            .expect("space glyph exists in every font atlas")
225    }
226
227    /// Deletes the GPU texture resources associated with this atlas.
228    pub fn delete(&self, gl: &glow::Context) {
229        self.inner.delete(gl)
230    }
231
232    /// Updates the pixel ratio for HiDPI rendering.
233    ///
234    /// Returns the effective pixel ratio to use for viewport scaling.
235    pub fn update_pixel_ratio(
236        &mut self,
237        gl: &glow::Context,
238        pixel_ratio: f32,
239    ) -> Result<f32, Error> {
240        self.inner.update_pixel_ratio(gl, pixel_ratio)
241    }
242
243    /// Returns the cell scale factor for layout calculations.
244    pub fn cell_scale_for_dpr(&self, pixel_ratio: f32) -> f32 {
245        self.inner.cell_scale_for_dpr(pixel_ratio)
246    }
247
248    /// Returns the texture cell size in physical pixels.
249    pub fn texture_cell_size(&self) -> (i32, i32) {
250        self.inner.texture_cell_size()
251    }
252}
253
254#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
255pub enum GlyphSlot {
256    Normal(SlotId),
257    Wide(SlotId),
258    Emoji(SlotId),
259}
260
261impl GlyphSlot {
262    pub fn slot_id(&self) -> SlotId {
263        match *self {
264            GlyphSlot::Normal(id) | GlyphSlot::Wide(id) | GlyphSlot::Emoji(id) => id,
265        }
266    }
267
268    pub fn with_styling(self, style_bits: u16) -> Self {
269        use GlyphSlot::*;
270        match self {
271            Normal(id) => Normal(id | style_bits),
272            Wide(id) => Wide(id | style_bits),
273            Emoji(id) => Emoji(id | style_bits),
274        }
275    }
276
277    /// Returns true if this is a double-width glyph (emoji or wide CJK).
278    pub fn is_double_width(&self) -> bool {
279        matches!(self, GlyphSlot::Wide(_) | GlyphSlot::Emoji(_))
280    }
281}
282
283/// Tracks glyphs that were requested but not found in the font atlas.
284#[derive(Debug, Default)]
285pub struct GlyphTracker {
286    missing: RefCell<HashSet<CompactString>>,
287}
288
289impl GlyphTracker {
290    /// Creates a new empty glyph tracker.
291    pub fn new() -> Self {
292        Self { missing: RefCell::new(HashSet::new()) }
293    }
294
295    /// Records a glyph as missing.
296    pub fn record_missing(&self, glyph: &str) {
297        self.missing.borrow_mut().insert(glyph.into());
298    }
299
300    /// Returns a copy of all missing glyphs.
301    pub fn missing_glyphs(&self) -> HashSet<CompactString> {
302        self.missing.borrow().clone()
303    }
304
305    /// Clears all tracked missing glyphs.
306    pub fn clear(&self) {
307        self.missing.borrow_mut().clear();
308    }
309
310    /// Returns the number of unique missing glyphs.
311    pub fn len(&self) -> usize {
312        self.missing.borrow().len()
313    }
314
315    /// Returns true if no glyphs are missing.
316    pub fn is_empty(&self) -> bool {
317        self.missing.borrow().is_empty()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_glyph_tracker() {
327        let tracker = GlyphTracker::new();
328
329        // Initially empty
330        assert!(tracker.is_empty());
331        assert_eq!(tracker.len(), 0);
332
333        // Record some missing glyphs
334        tracker.record_missing("\u{1F3AE}");
335        tracker.record_missing("\u{1F3AF}");
336        tracker.record_missing("\u{1F3AE}"); // Duplicate
337
338        assert!(!tracker.is_empty());
339        assert_eq!(tracker.len(), 2); // Only unique glyphs
340
341        // Check the missing glyphs
342        let missing = tracker.missing_glyphs();
343        assert!(missing.contains(&CompactString::new("\u{1F3AE}")));
344        assert!(missing.contains(&CompactString::new("\u{1F3AF}")));
345
346        // Clear and verify
347        tracker.clear();
348        assert!(tracker.is_empty());
349        assert_eq!(tracker.len(), 0);
350    }
351}