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