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}