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}