Skip to main content

beamterm_core/gl/
atlas.rs

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