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}