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 ///
84 /// # Errors
85 /// Returns an error if GPU texture creation fails.
86 fn recreate_texture(&mut self, gl: &glow::Context) -> Result<(), Error>;
87
88 /// Iterates over all glyph ID to symbol mappings.
89 ///
90 /// Calls the provided closure for each (glyph_id, symbol) pair in the atlas.
91 /// This is used for debugging and exposing the atlas contents to JavaScript.
92 fn for_each_symbol(&self, f: &mut dyn FnMut(u16, &str));
93
94 /// Resolves a glyph to its texture slot.
95 ///
96 /// For static atlases, performs a lookup and returns `None` if not found.
97 ///
98 /// For dynamic atlases, allocates a slot if missing and queues for upload.
99 /// The slot is immediately valid, but [`flush`] must be called before
100 /// rendering to populate the texture.
101 fn resolve_glyph_slot(&mut self, key: &str, style_bits: u16) -> Option<GlyphSlot>;
102
103 /// Returns the bit position used for emoji detection in the fragment shader.
104 ///
105 /// The glyph ID encodes the base slot index (bits 0-12, masked by `0x1FFF`)
106 /// plus effect/flag bits above that. The emoji bit tells the shader to use
107 /// texture color (emoji) vs foreground color (regular text).
108 ///
109 /// - **`StaticFontAtlas`** returns `12`: emoji are at slots >= 4096, so bit 12
110 /// is naturally set in their slot address.
111 /// - **`DynamicFontAtlas`** returns `15`: emoji flag is stored in bit 15,
112 /// outside the 13-bit slot mask, leaving bits 13-14 for underline/strikethrough.
113 fn emoji_bit(&self) -> u32;
114
115 /// Deletes the GPU texture resources associated with this atlas.
116 ///
117 /// This method must be called before dropping the atlas to properly clean up
118 /// GPU resources. Failing to call this will leak GPU memory.
119 fn delete(&self, gl: &glow::Context);
120
121 /// Updates the pixel ratio for HiDPI rendering.
122 ///
123 /// Returns the effective pixel ratio that should be used for viewport scaling.
124 /// Each atlas implementation decides how to handle the ratio:
125 ///
126 /// - **Static atlas**: Returns exact ratio, no internal work needed
127 /// - **Dynamic atlas**: Returns exact ratio, reinitializes with scaled font size
128 ///
129 /// # Errors
130 /// Returns an error if GPU texture recreation fails during reinitialization.
131 fn update_pixel_ratio(&mut self, gl: &glow::Context, pixel_ratio: f32) -> Result<f32, Error>;
132
133 /// Returns the cell scale factor for layout calculations at the given DPR.
134 ///
135 /// This determines how cells from `cell_size()` should be scaled for layout:
136 ///
137 /// - **Static atlas**: Returns snapped scale values (0.5, 1.0, 2.0, 3.0, etc.)
138 /// to avoid arbitrary fractional scaling of pre-rasterized glyphs.
139 /// DPR <= 0.5 snaps to 0.5, otherwise rounds to nearest integer (minimum 1.0).
140 /// - **Dynamic atlas**: Returns `1.0` - glyphs are re-rasterized at the exact DPR,
141 /// so `cell_size()` already returns the correctly-scaled physical size
142 ///
143 /// # Contract
144 ///
145 /// - Return value is always >= 0.5
146 /// - The effective cell size for layout is `cell_size() * cell_scale_for_dpr(dpr)`
147 /// - Static atlases use snapped scaling to preserve glyph sharpness
148 /// - Dynamic atlases handle DPR internally via re-rasterization
149 fn cell_scale_for_dpr(&self, pixel_ratio: f32) -> f32;
150
151 /// Returns the texture cell size in physical pixels (for fragment shader calculations).
152 ///
153 /// This is used for computing padding fractions in the shader, which need to be
154 /// based on the actual texture dimensions rather than logical layout dimensions.
155 ///
156 /// - **Static atlas**: Same as `cell_size()` (texture is at fixed resolution)
157 /// - **Dynamic atlas**: Physical cell size (before dividing by pixel_ratio)
158 fn texture_cell_size(&self) -> beamterm_data::CellSize;
159}
160
161/// Type-erased wrapper around any [`Atlas`] implementation.
162pub struct FontAtlas {
163 inner: Box<dyn Atlas>,
164}
165
166impl<A: Atlas + 'static> From<A> for FontAtlas {
167 fn from(atlas: A) -> Self {
168 FontAtlas::new(atlas)
169 }
170}
171
172impl Debug for FontAtlas {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("FontAtlas")
175 .finish_non_exhaustive()
176 }
177}
178
179impl FontAtlas {
180 /// Wraps an atlas implementation in a type-erased container.
181 pub fn new(inner: impl Atlas + 'static) -> Self {
182 Self { inner: Box::new(inner) }
183 }
184
185 /// Returns the styled glyph ID for the given symbol.
186 pub fn get_glyph_id(&mut self, key: &str, style_bits: u16) -> Option<u16> {
187 self.inner.get_glyph_id(key, style_bits)
188 }
189
190 /// Returns the unstyled base glyph ID for the given symbol.
191 pub fn get_base_glyph_id(&mut self, key: &str) -> Option<u16> {
192 self.inner.get_base_glyph_id(key)
193 }
194
195 /// Returns the cell dimensions used for grid layout.
196 #[must_use]
197 pub fn cell_size(&self) -> beamterm_data::CellSize {
198 self.inner.cell_size()
199 }
200
201 /// Binds the atlas texture for rendering.
202 pub fn bind(&self, gl: &glow::Context) {
203 self.inner.bind(gl);
204 }
205
206 /// Returns underline position and thickness metadata.
207 #[must_use]
208 pub fn underline(&self) -> beamterm_data::LineDecoration {
209 self.inner.underline()
210 }
211
212 /// Returns strikethrough position and thickness metadata.
213 #[must_use]
214 pub fn strikethrough(&self) -> beamterm_data::LineDecoration {
215 self.inner.strikethrough()
216 }
217
218 /// Returns the symbol string for the given glyph ID.
219 #[must_use]
220 pub fn get_symbol(&self, glyph_id: u16) -> Option<CompactString> {
221 self.inner.get_symbol(glyph_id)
222 }
223
224 /// Returns the ASCII character for the given glyph ID, if applicable.
225 #[must_use]
226 pub fn get_ascii_char(&self, glyph_id: u16) -> Option<char> {
227 self.inner.get_ascii_char(glyph_id)
228 }
229
230 /// Returns a reference to the glyph usage tracker.
231 #[must_use]
232 pub fn glyph_tracker(&self) -> &GlyphTracker {
233 self.inner.glyph_tracker()
234 }
235
236 /// Returns the total number of allocated glyphs.
237 #[must_use]
238 pub fn glyph_count(&self) -> u32 {
239 self.inner.glyph_count()
240 }
241
242 /// Recreates the GPU texture after a context loss.
243 ///
244 /// # Errors
245 /// Returns an error if GPU texture creation fails.
246 pub fn recreate_texture(&mut self, gl: &glow::Context) -> Result<(), Error> {
247 self.inner.recreate_texture(gl)
248 }
249
250 /// Iterates over all glyph ID to symbol mappings.
251 pub fn for_each_symbol(&self, f: &mut dyn FnMut(u16, &str)) {
252 self.inner.for_each_symbol(f);
253 }
254
255 /// Resolves a symbol to its glyph slot classification.
256 pub fn resolve_glyph_slot(&mut self, key: &str, style_bits: u16) -> Option<GlyphSlot> {
257 self.inner.resolve_glyph_slot(key, style_bits)
258 }
259
260 /// Flushes pending glyph data to the GPU texture.
261 ///
262 /// # Errors
263 /// Returns an error if texture upload fails.
264 pub fn flush(&mut self, gl: &glow::Context) -> Result<(), Error> {
265 self.inner.flush(gl)
266 }
267
268 pub(crate) fn emoji_bit(&self) -> u32 {
269 self.inner.emoji_bit()
270 }
271
272 pub(crate) fn space_glyph_id(&mut self) -> u16 {
273 self.get_glyph_id(" ", 0x0)
274 .expect("space glyph exists in every font atlas")
275 }
276
277 /// Deletes the GPU texture resources associated with this atlas.
278 pub fn delete(&self, gl: &glow::Context) {
279 self.inner.delete(gl);
280 }
281
282 /// Updates the pixel ratio for HiDPI rendering.
283 ///
284 /// Returns the effective pixel ratio to use for viewport scaling.
285 ///
286 /// # Errors
287 /// Returns an error if GPU texture recreation fails during reinitialization.
288 pub fn update_pixel_ratio(
289 &mut self,
290 gl: &glow::Context,
291 pixel_ratio: f32,
292 ) -> Result<f32, Error> {
293 self.inner.update_pixel_ratio(gl, pixel_ratio)
294 }
295
296 /// Returns the cell scale factor for layout calculations.
297 #[must_use]
298 pub fn cell_scale_for_dpr(&self, pixel_ratio: f32) -> f32 {
299 self.inner.cell_scale_for_dpr(pixel_ratio)
300 }
301
302 /// Returns the texture cell size in physical pixels.
303 #[must_use]
304 pub fn texture_cell_size(&self) -> beamterm_data::CellSize {
305 self.inner.texture_cell_size()
306 }
307}
308
309#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
310#[non_exhaustive]
311/// Classifies a glyph's texture slot by width category.
312pub enum GlyphSlot {
313 /// Single-width glyph slot.
314 Normal(SlotId),
315 /// Double-width glyph slot (CJK characters).
316 Wide(SlotId),
317 /// Emoji glyph slot (occupies two consecutive texture slots).
318 Emoji(SlotId),
319}
320
321impl GlyphSlot {
322 /// Returns the underlying slot ID.
323 #[must_use]
324 pub fn slot_id(&self) -> SlotId {
325 match *self {
326 GlyphSlot::Normal(id) | GlyphSlot::Wide(id) | GlyphSlot::Emoji(id) => id,
327 }
328 }
329
330 /// Returns a new slot with the given style bits applied.
331 #[must_use]
332 pub fn with_styling(self, style_bits: u16) -> Self {
333 use GlyphSlot::*;
334 match self {
335 Normal(id) => Normal(id | style_bits),
336 Wide(id) => Wide(id | style_bits),
337 Emoji(id) => Emoji(id | style_bits),
338 }
339 }
340
341 /// Returns true if this is a double-width glyph (emoji or wide CJK).
342 #[must_use]
343 pub fn is_double_width(&self) -> bool {
344 matches!(self, GlyphSlot::Wide(_) | GlyphSlot::Emoji(_))
345 }
346}
347
348/// Tracks glyphs that were requested but not found in the font atlas.
349#[derive(Debug, Default)]
350pub struct GlyphTracker {
351 missing: HashSet<CompactString>,
352}
353
354impl GlyphTracker {
355 /// Creates a new empty glyph tracker.
356 #[must_use]
357 pub fn new() -> Self {
358 Self { missing: HashSet::new() }
359 }
360
361 /// Records a glyph as missing.
362 pub fn record_missing(&mut self, glyph: &str) {
363 self.missing.insert(glyph.into());
364 }
365
366 /// Returns a copy of all missing glyphs.
367 #[must_use]
368 pub fn missing_glyphs(&self) -> HashSet<CompactString> {
369 self.missing.clone()
370 }
371
372 /// Clears all tracked missing glyphs.
373 pub fn clear(&mut self) {
374 self.missing.clear();
375 }
376
377 /// Returns the number of unique missing glyphs.
378 #[must_use]
379 pub fn len(&self) -> usize {
380 self.missing.len()
381 }
382
383 /// Returns true if no glyphs are missing.
384 #[must_use]
385 pub fn is_empty(&self) -> bool {
386 self.missing.is_empty()
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn test_glyph_tracker() {
396 let mut tracker = GlyphTracker::new();
397
398 // Initially empty
399 assert!(tracker.is_empty());
400 assert_eq!(tracker.len(), 0);
401
402 // Record some missing glyphs
403 tracker.record_missing("\u{1F3AE}");
404 tracker.record_missing("\u{1F3AF}");
405 tracker.record_missing("\u{1F3AE}"); // Duplicate
406
407 assert!(!tracker.is_empty());
408 assert_eq!(tracker.len(), 2); // Only unique glyphs
409
410 // Check the missing glyphs
411 let missing = tracker.missing_glyphs();
412 assert!(missing.contains(&CompactString::new("\u{1F3AE}")));
413 assert!(missing.contains(&CompactString::new("\u{1F3AF}")));
414
415 // Clear and verify
416 tracker.clear();
417 assert!(tracker.is_empty());
418 assert_eq!(tracker.len(), 0);
419 }
420}