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