beamterm_renderer/gl/
atlas.rs

1use std::{
2    borrow::Cow,
3    cell::RefCell,
4    collections::{HashMap, HashSet},
5};
6
7use beamterm_data::{FontAtlasData, FontStyle, Glyph};
8use compact_str::{CompactString, ToCompactString};
9use web_sys::console;
10
11use crate::{error::Error, gl::GL};
12
13/// A texture atlas containing font glyphs for efficient WebGL text rendering.
14///
15/// `FontAtlas` manages a WebGL 2D texture array where each layer contains a single
16/// character glyph. This design enables efficient instanced rendering of text by
17/// allowing the GPU to select the appropriate character layer for each rendered cell.
18///
19/// # Architecture
20/// The atlas uses a **WebGL 2D texture array** where:
21/// - Each layer contains one character glyph
22/// - ASCII characters use their ASCII value as the layer index
23/// - Non-ASCII characters are stored in a hash map for layer lookup
24/// - All glyphs have uniform cell dimensions for consistent spacing
25#[derive(Debug)]
26pub struct FontAtlas {
27    /// The underlying texture
28    texture: crate::gl::texture::Texture,
29    /// Symbol to 3d texture index
30    glyph_coords: HashMap<CompactString, u16>,
31    /// Base glyph identifier to symbol mapping
32    symbol_lookup: HashMap<u16, CompactString>,
33    /// The size of each character cell in pixels
34    cell_size: (i32, i32),
35    /// The number of slices in the atlas texture
36    num_slices: u32,
37    /// Underline configuration
38    underline: beamterm_data::LineDecoration,
39    /// Strikethrough configuration
40    strikethrough: beamterm_data::LineDecoration,
41    /// Tracks glyphs that were requested but not found in the atlas
42    glyph_tracker: GlyphTracker,
43    /// The last assigned halfwidth base glyph ID, before fullwidth
44    last_halfwidth_base_glyph_id: u16,
45    /// Retained atlas data for context loss recovery
46    atlas_data: FontAtlasData,
47}
48
49impl FontAtlas {
50    /// Loads the default embedded font atlas.
51    pub fn load_default(gl: &web_sys::WebGl2RenderingContext) -> Result<Self, Error> {
52        let config = FontAtlasData::default();
53        Self::load(gl, config)
54    }
55
56    /// Creates a TextureAtlas from a grid of equal-sized cells
57    pub fn load(
58        gl: &web_sys::WebGl2RenderingContext,
59        config: FontAtlasData,
60    ) -> Result<Self, Error> {
61        let texture = crate::gl::texture::Texture::from_font_atlas_data(gl, GL::RGBA, &config)?;
62        let num_slices = config.texture_dimensions.2;
63
64        let texture_layers = config
65            .glyphs
66            .iter()
67            .map(|g| g.id as i32)
68            .max()
69            .unwrap_or(0)
70            + 1;
71
72        let (cell_width, cell_height) = config.cell_size;
73        let mut layers = HashMap::new();
74        let mut symbol_lookup = HashMap::new();
75
76        // we only store the normal-styled glyphs (incl emoji) in the atlas lookup,
77        // as the correct layer id can be derived from the base glyph id plus font style.
78        //
79        // emoji are (currently all) double-width and occupy two consecutive glyph ids,
80        // but we only store the first id in the lookup.
81        config.glyphs.iter()
82            .filter(|g| g.style == FontStyle::Normal) // only normal style glyphs
83            .filter(|g| !g.is_ascii())                // only non-ascii glyphs
84            .for_each(|g| {
85                symbol_lookup.insert(g.id, g.symbol.clone());
86                layers.insert(g.symbol.clone(), g.id);
87            });
88
89        Ok(Self {
90            texture,
91            glyph_coords: layers,
92            last_halfwidth_base_glyph_id: config.max_halfwidth_base_glyph_id,
93            symbol_lookup,
94            cell_size: (cell_width, cell_height),
95            num_slices: num_slices as u32,
96            underline: config.underline,
97            strikethrough: config.strikethrough,
98            glyph_tracker: GlyphTracker::new(),
99            atlas_data: config,
100        })
101    }
102
103    /// Binds the atlas texture to the specified texture unit
104    pub fn bind(&self, gl: &web_sys::WebGl2RenderingContext, texture_unit: u32) {
105        self.texture.bind(gl, texture_unit);
106    }
107
108    pub fn cell_size(&self) -> (i32, i32) {
109        let (w, h) = self.cell_size;
110        (
111            w - 2 * FontAtlasData::PADDING,
112            h - 2 * FontAtlasData::PADDING,
113        )
114    }
115
116    /// Returns the underline configuration
117    pub fn underline(&self) -> beamterm_data::LineDecoration {
118        self.underline
119    }
120
121    /// Returns the strikethrough configuration
122    pub fn strikethrough(&self) -> beamterm_data::LineDecoration {
123        self.strikethrough
124    }
125
126    /// Returns the symbol for the given glyph ID, if it exists
127    pub fn get_symbol(&self, glyph_id: u16) -> Option<Cow<'_, str>> {
128        let base_glyph_id = if glyph_id & Glyph::EMOJI_FLAG != 0 {
129            glyph_id & Glyph::GLYPH_ID_EMOJI_MASK
130        } else {
131            glyph_id & Glyph::GLYPH_ID_MASK
132        };
133
134        if (0x20..0x80).contains(&base_glyph_id) {
135            // ASCII characters are directly mapped to their code point
136            let ch = base_glyph_id as u8 as char;
137            Some(Cow::from(ch.to_compact_string()))
138        } else {
139            self.symbol_lookup
140                .get(&base_glyph_id)
141                .map(|s| Cow::from(s.as_str()))
142        }
143    }
144
145    /// Returns the base glyph identifier for the given key
146    pub fn get_base_glyph_id(&self, key: &str) -> Option<u16> {
147        if key.len() == 1 {
148            let ch = key.chars().next().unwrap();
149            if ch.is_ascii() {
150                // 0x00..0x7f double as layer
151                let id = ch as u16;
152                return Some(id);
153            }
154        }
155
156        match self.glyph_coords.get(key) {
157            Some(id) => Some(*id),
158            None => {
159                self.glyph_tracker.record_missing(key);
160                None
161            },
162        }
163    }
164
165    /// Returns the maximum assigned halfwidth base glyph ID.
166    pub fn get_max_halfwidth_base_glyph_id(&self) -> u16 {
167        self.last_halfwidth_base_glyph_id
168    }
169
170    /// Returns a reference to the glyph tracker for accessing missing glyphs.
171    pub fn glyph_tracker(&self) -> &GlyphTracker {
172        &self.glyph_tracker
173    }
174
175    /// Returns the total number of glyphs available in the atlas.
176    /// This includes ASCII characters (0x20..0x80) plus non-ASCII glyphs.
177    pub(crate) fn glyph_count(&self) -> u32 {
178        // ASCII printable characters: 0x20..0x80 (96 characters)
179        let ascii_count = 0x80 - 0x20;
180        // Non-ASCII glyphs stored in symbol_lookup
181        let non_ascii_count = self.symbol_lookup.len() as u32;
182        ascii_count + non_ascii_count
183    }
184
185    pub(crate) fn get_symbol_lookup(&self) -> &HashMap<u16, CompactString> {
186        &self.symbol_lookup
187    }
188
189    /// Recreates the GPU texture after a WebGL context loss.
190    ///
191    /// This method rebuilds the texture from the retained atlas data. All glyph
192    /// mappings and other CPU-side state are preserved; only the GPU texture
193    /// handle is recreated.
194    ///
195    /// # Parameters
196    /// * `gl` - The new WebGL2 rendering context
197    ///
198    /// # Returns
199    /// * `Ok(())` - Texture successfully recreated
200    /// * `Err(Error)` - Failed to create texture
201    pub(crate) fn recreate_texture(
202        &mut self,
203        gl: &web_sys::WebGl2RenderingContext,
204    ) -> Result<(), Error> {
205        // Delete old texture if it exists (may be invalid after context loss)
206        self.texture.delete(gl);
207
208        // Recreate texture from retained atlas data
209        self.texture =
210            crate::gl::texture::Texture::from_font_atlas_data(gl, GL::RGBA, &self.atlas_data)?;
211
212        Ok(())
213    }
214}
215
216/// Tracks glyphs that were requested but not found in the font atlas.
217#[derive(Debug, Default)]
218pub struct GlyphTracker {
219    missing: RefCell<HashSet<CompactString>>,
220}
221
222impl GlyphTracker {
223    /// Creates a new empty glyph tracker.
224    pub fn new() -> Self {
225        Self { missing: RefCell::new(HashSet::new()) }
226    }
227
228    /// Records a glyph as missing.
229    pub fn record_missing(&self, glyph: &str) {
230        self.missing.borrow_mut().insert(glyph.into());
231    }
232
233    /// Returns a copy of all missing glyphs.
234    pub fn missing_glyphs(&self) -> HashSet<CompactString> {
235        self.missing.borrow().clone()
236    }
237
238    /// Clears all tracked missing glyphs.
239    pub fn clear(&self) {
240        self.missing.borrow_mut().clear();
241    }
242
243    /// Returns the number of unique missing glyphs.
244    pub fn len(&self) -> usize {
245        self.missing.borrow().len()
246    }
247
248    /// Returns true if no glyphs are missing.
249    pub fn is_empty(&self) -> bool {
250        self.missing.borrow().is_empty()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_glyph_tracker() {
260        let tracker = GlyphTracker::new();
261
262        // Initially empty
263        assert!(tracker.is_empty());
264        assert_eq!(tracker.len(), 0);
265
266        // Record some missing glyphs
267        tracker.record_missing("🎮");
268        tracker.record_missing("🎯");
269        tracker.record_missing("🎮"); // Duplicate
270
271        assert!(!tracker.is_empty());
272        assert_eq!(tracker.len(), 2); // Only unique glyphs
273
274        // Check the missing glyphs
275        let missing = tracker.missing_glyphs();
276        assert!(missing.contains(&CompactString::new("🎮")));
277        assert!(missing.contains(&CompactString::new("🎯")));
278
279        // Clear and verify
280        tracker.clear();
281        assert!(tracker.is_empty());
282        assert_eq!(tracker.len(), 0);
283    }
284}