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}
44
45impl FontAtlas {
46    /// Loads the default embedded font atlas.
47    pub fn load_default(gl: &web_sys::WebGl2RenderingContext) -> Result<Self, Error> {
48        let config = FontAtlasData::default();
49        Self::load(gl, config)
50    }
51
52    /// Creates a TextureAtlas from a grid of equal-sized cells
53    pub fn load(
54        gl: &web_sys::WebGl2RenderingContext,
55        config: FontAtlasData,
56    ) -> Result<Self, Error> {
57        let texture = crate::gl::texture::Texture::from_font_atlas_data(gl, GL::RGBA, &config)?;
58        let num_slices = config.texture_dimensions.2;
59
60        let texture_layers = config
61            .glyphs
62            .iter()
63            .map(|g| g.id as i32)
64            .max()
65            .unwrap_or(0)
66            + 1;
67
68        let (cell_width, cell_height) = config.cell_size;
69        let mut layers = HashMap::new();
70        let mut symbol_lookup = HashMap::new();
71
72        // we only store the normal-styled glyphs (incl emoji) in the atlas lookup,
73        // as the correct layer id can be derived from the base glyph id plus font style
74        config.glyphs.iter()
75            .filter(|g| g.style == FontStyle::Normal) // only normal style glyphs
76            .filter(|g| !g.is_ascii())                // only non-ascii glyphs
77            .for_each(|g| {
78                symbol_lookup.insert(g.id, g.symbol.clone());
79                layers.insert(g.symbol.clone(), g.id);
80            });
81
82        Ok(Self {
83            texture,
84            glyph_coords: layers,
85            symbol_lookup,
86            cell_size: (cell_width, cell_height),
87            num_slices: num_slices as u32,
88            underline: config.underline,
89            strikethrough: config.strikethrough,
90            glyph_tracker: GlyphTracker::new(),
91        })
92    }
93
94    /// Binds the atlas texture to the specified texture unit
95    pub fn bind(&self, gl: &web_sys::WebGl2RenderingContext, texture_unit: u32) {
96        self.texture.bind(gl, texture_unit);
97    }
98
99    pub fn cell_size(&self) -> (i32, i32) {
100        let (w, h) = self.cell_size;
101        (
102            w - 2 * FontAtlasData::PADDING,
103            h - 2 * FontAtlasData::PADDING,
104        )
105    }
106
107    /// Returns the underline configuration
108    pub fn underline(&self) -> beamterm_data::LineDecoration {
109        self.underline
110    }
111
112    /// Returns the strikethrough configuration
113    pub fn strikethrough(&self) -> beamterm_data::LineDecoration {
114        self.strikethrough
115    }
116
117    /// Returns the symbol for the given glyph ID, if it exists
118    pub fn get_symbol(&self, glyph_id: u16) -> Option<Cow<'_, str>> {
119        let base_glyph_id = glyph_id & (Glyph::GLYPH_ID_MASK | Glyph::EMOJI_FLAG);
120
121        if (0x20..0x80).contains(&base_glyph_id) {
122            // ASCII characters are directly mapped to their code point
123            let ch = base_glyph_id as u8 as char;
124            Some(Cow::from(ch.to_compact_string()))
125        } else {
126            self.symbol_lookup
127                .get(&base_glyph_id)
128                .map(|s| Cow::from(s.as_str()))
129        }
130    }
131
132    /// Returns the base glyph identifier for the given key
133    pub fn get_base_glyph_id(&self, key: &str) -> Option<u16> {
134        if key.len() == 1 {
135            let ch = key.chars().next().unwrap();
136            if ch.is_ascii() {
137                // 0x00..0x7f double as layer
138                let id = ch as u16;
139                return Some(id);
140            }
141        }
142
143        match self.glyph_coords.get(key) {
144            Some(id) => Some(*id),
145            None => {
146                self.glyph_tracker.record_missing(key);
147                None
148            },
149        }
150    }
151
152    /// Returns a reference to the glyph tracker for accessing missing glyphs.
153    pub fn glyph_tracker(&self) -> &GlyphTracker {
154        &self.glyph_tracker
155    }
156
157    /// Returns the total number of glyphs available in the atlas.
158    /// This includes ASCII characters (0x20..0x80) plus non-ASCII glyphs.
159    pub(crate) fn glyph_count(&self) -> u32 {
160        // ASCII printable characters: 0x20..0x80 (96 characters)
161        let ascii_count = 0x80 - 0x20;
162        // Non-ASCII glyphs stored in symbol_lookup
163        let non_ascii_count = self.symbol_lookup.len() as u32;
164        ascii_count + non_ascii_count
165    }
166}
167
168/// Tracks glyphs that were requested but not found in the font atlas.
169#[derive(Debug, Default)]
170pub struct GlyphTracker {
171    missing: RefCell<HashSet<CompactString>>,
172}
173
174impl GlyphTracker {
175    /// Creates a new empty glyph tracker.
176    pub fn new() -> Self {
177        Self { missing: RefCell::new(HashSet::new()) }
178    }
179
180    /// Records a glyph as missing.
181    pub fn record_missing(&self, glyph: &str) {
182        self.missing.borrow_mut().insert(glyph.into());
183    }
184
185    /// Returns a copy of all missing glyphs.
186    pub fn missing_glyphs(&self) -> HashSet<CompactString> {
187        self.missing.borrow().clone()
188    }
189
190    /// Clears all tracked missing glyphs.
191    pub fn clear(&self) {
192        self.missing.borrow_mut().clear();
193    }
194
195    /// Returns the number of unique missing glyphs.
196    pub fn len(&self) -> usize {
197        self.missing.borrow().len()
198    }
199
200    /// Returns true if no glyphs are missing.
201    pub fn is_empty(&self) -> bool {
202        self.missing.borrow().is_empty()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_glyph_tracker() {
212        let tracker = GlyphTracker::new();
213
214        // Initially empty
215        assert!(tracker.is_empty());
216        assert_eq!(tracker.len(), 0);
217
218        // Record some missing glyphs
219        tracker.record_missing("🎮");
220        tracker.record_missing("🎯");
221        tracker.record_missing("🎮"); // Duplicate
222
223        assert!(!tracker.is_empty());
224        assert_eq!(tracker.len(), 2); // Only unique glyphs
225
226        // Check the missing glyphs
227        let missing = tracker.missing_glyphs();
228        assert!(missing.contains(&CompactString::new("🎮")));
229        assert!(missing.contains(&CompactString::new("🎯")));
230
231        // Clear and verify
232        tracker.clear();
233        assert!(tracker.is_empty());
234        assert_eq!(tracker.len(), 0);
235    }
236}