Skip to main content

beamterm_core/gl/
static_atlas.rs

1use std::collections::HashMap;
2
3use beamterm_data::{FontAtlasData, FontStyle, Glyph};
4use compact_str::{CompactString, ToCompactString};
5
6use super::{
7    atlas,
8    atlas::{Atlas, GlyphSlot, GlyphTracker},
9};
10use crate::error::Error;
11
12/// A texture atlas containing font glyphs for efficient GL text rendering.
13///
14/// `StaticFontAtlas` manages a GL 2D texture array where each layer contains a single
15/// character glyph. This design enables efficient instanced rendering of text by
16/// allowing the GPU to select the appropriate character layer for each rendered cell.
17///
18/// # Architecture
19/// The atlas uses a **GL 2D texture array** where:
20/// - Each layer contains one character glyph
21/// - ASCII characters use their ASCII value as the layer index
22/// - Non-ASCII characters are stored in a hash map for layer lookup
23/// - All glyphs have uniform cell dimensions for consistent spacing
24#[derive(Debug)]
25pub struct StaticFontAtlas {
26    /// The underlying texture
27    texture: crate::gl::texture::Texture,
28    /// Symbol to 3d texture index
29    glyph_coords: HashMap<CompactString, u16>,
30    /// Base glyph identifier to symbol mapping
31    symbol_lookup: HashMap<u16, CompactString>,
32    /// The size of each character cell in pixels
33    cell_size: (i32, i32),
34    /// Underline configuration
35    underline: beamterm_data::LineDecoration,
36    /// Strikethrough configuration
37    strikethrough: beamterm_data::LineDecoration,
38    /// Tracks glyphs that were requested but not found in the atlas
39    glyph_tracker: GlyphTracker,
40    /// The last assigned halfwidth base glyph ID, before fullwidth
41    last_halfwidth_base_glyph_id: u16,
42    /// Retained atlas data for context loss recovery
43    atlas_data: FontAtlasData,
44}
45
46impl StaticFontAtlas {
47    /// Creates a TextureAtlas from a grid of equal-sized cells
48    pub fn load(gl: &glow::Context, config: FontAtlasData) -> Result<Self, Error> {
49        let texture = crate::gl::texture::Texture::from_font_atlas_data(gl, &config)?;
50
51        let (cell_width, cell_height) = config.cell_size;
52        let mut layers = HashMap::new();
53        let mut symbol_lookup = HashMap::new();
54
55        // we only store the normal-styled glyphs (incl emoji) in the atlas lookup,
56        // as the correct layer id can be derived from the base glyph id plus font style.
57        //
58        // emoji are (currently all) double-width and occupy two consecutive glyph ids,
59        // but we only store the first id in the lookup.
60        config.glyphs.iter()
61            .filter(|g| g.style == FontStyle::Normal) // only normal style glyphs
62            .filter(|g| !g.is_ascii())                // only non-ascii glyphs
63            .for_each(|g| {
64                symbol_lookup.insert(g.id, g.symbol.clone());
65                layers.insert(g.symbol.clone(), g.id);
66            });
67
68        Ok(Self {
69            texture,
70            glyph_coords: layers,
71            last_halfwidth_base_glyph_id: config.max_halfwidth_base_glyph_id,
72            symbol_lookup,
73            cell_size: (cell_width, cell_height),
74            underline: config.underline,
75            strikethrough: config.strikethrough,
76            glyph_tracker: GlyphTracker::new(),
77            atlas_data: config,
78        })
79    }
80}
81
82impl atlas::sealed::Sealed for StaticFontAtlas {}
83
84impl Atlas for StaticFontAtlas {
85    fn get_glyph_id(&self, key: &str, style_bits: u16) -> Option<u16> {
86        let base_id = self.get_base_glyph_id(key)?;
87        Some(base_id | style_bits)
88    }
89
90    /// Returns the base glyph identifier for the given key
91    fn get_base_glyph_id(&self, key: &str) -> Option<u16> {
92        if key.len() == 1 {
93            let ch = key.chars().next().unwrap();
94            if ch.is_ascii() {
95                // 0x00..0x7f double as layer
96                let id = ch as u16;
97                return Some(id);
98            }
99        }
100
101        match self.glyph_coords.get(key) {
102            Some(id) => Some(*id),
103            None => {
104                self.glyph_tracker.record_missing(key);
105                None
106            },
107        }
108    }
109
110    fn cell_size(&self) -> (i32, i32) {
111        let (w, h) = self.cell_size;
112        (
113            w - 2 * FontAtlasData::PADDING,
114            h - 2 * FontAtlasData::PADDING,
115        )
116    }
117
118    fn bind(&self, gl: &glow::Context) {
119        self.texture.bind(gl);
120    }
121
122    /// Returns the underline configuration
123    fn underline(&self) -> beamterm_data::LineDecoration {
124        self.underline
125    }
126
127    /// Returns the strikethrough configuration
128    fn strikethrough(&self) -> beamterm_data::LineDecoration {
129        self.strikethrough
130    }
131
132    /// Returns the symbol for the given glyph ID, if it exists
133    fn get_symbol(&self, glyph_id: u16) -> Option<CompactString> {
134        let base_glyph_id = if glyph_id & Glyph::EMOJI_FLAG != 0 {
135            glyph_id & Glyph::GLYPH_ID_EMOJI_MASK
136        } else {
137            glyph_id & Glyph::GLYPH_ID_MASK
138        };
139
140        if (0x20..0x80).contains(&base_glyph_id) {
141            // ASCII characters are directly mapped to their code point
142            let ch = base_glyph_id as u8 as char;
143            Some(ch.to_compact_string())
144        } else {
145            self.symbol_lookup.get(&base_glyph_id).cloned()
146        }
147    }
148
149    fn get_ascii_char(&self, glyph_id: u16) -> Option<char> {
150        // Static atlas: ASCII chars 0x20-0x7F have glyph_id == char code
151        if (0x20..0x80).contains(&glyph_id) {
152            Some(glyph_id as u8 as char)
153        } else {
154            None
155        }
156    }
157
158    fn glyph_tracker(&self) -> &GlyphTracker {
159        &self.glyph_tracker
160    }
161
162    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    fn flush(&self, _gl: &glow::Context) -> Result<(), Error> {
171        Ok(()) // static atlas has no pending glyphs
172    }
173
174    /// Recreates the GPU texture after a context loss.
175    ///
176    /// This method rebuilds the texture from the retained atlas data. All glyph
177    /// mappings and other CPU-side state are preserved; only the GPU texture
178    /// handle is recreated.
179    fn recreate_texture(&mut self, gl: &glow::Context) -> Result<(), Error> {
180        // Delete old texture if it exists (may be invalid after context loss)
181        self.texture.delete(gl);
182
183        // Recreate texture from retained atlas data
184        self.texture = crate::gl::texture::Texture::from_font_atlas_data(gl, &self.atlas_data)?;
185
186        Ok(())
187    }
188
189    fn for_each_symbol(&self, f: &mut dyn FnMut(u16, &str)) {
190        // ASCII printable characters (0x20..0x80)
191        for code in 0x20u16..0x80 {
192            let ch = code as u8 as char;
193            let mut buf = [0u8; 4];
194            let s = ch.encode_utf8(&mut buf);
195            f(code, s);
196        }
197        // Non-ASCII glyphs from symbol lookup
198        for (glyph_id, symbol) in &self.symbol_lookup {
199            f(*glyph_id, symbol.as_str());
200        }
201    }
202
203    fn resolve_glyph_slot(&self, key: &str, style_bits: u16) -> Option<GlyphSlot> {
204        if key.len() == 1 {
205            let ch = key.chars().next().unwrap();
206            if ch.is_ascii() {
207                // 0x00..0x7f double as layer
208                let id = ch as u16;
209                return Some(GlyphSlot::Normal(id | style_bits));
210            }
211        }
212
213        match self.glyph_coords.get(key) {
214            Some(base_glyph_id) => {
215                let id = base_glyph_id | style_bits;
216                if *base_glyph_id >= self.last_halfwidth_base_glyph_id {
217                    Some(GlyphSlot::Wide(id))
218                } else if id & Glyph::EMOJI_FLAG != 0 {
219                    Some(GlyphSlot::Emoji(id))
220                } else {
221                    Some(GlyphSlot::Normal(id))
222                }
223            },
224            None => {
225                self.glyph_tracker.record_missing(key);
226                None
227            },
228        }
229    }
230
231    /// Returns `0x1FFF` to support the full glyph encoding from `beamterm-atlas`.
232    ///
233    /// This 13-bit mask includes the emoji flag (bit 12) so that emoji base IDs
234    /// can be extracted correctly for symbol lookup and texture coordinate calculation.
235    fn base_lookup_mask(&self) -> u32 {
236        atlas::STATIC_ATLAS_LOOKUP_MASK
237    }
238
239    fn delete(&self, gl: &glow::Context) {
240        self.texture.delete(gl);
241    }
242
243    fn update_pixel_ratio(&mut self, _gl: &glow::Context, pixel_ratio: f32) -> Result<f32, Error> {
244        // Static atlas doesn't need to do anything - cell scaling is handled by the grid
245        Ok(pixel_ratio)
246    }
247
248    fn cell_scale_for_dpr(&self, pixel_ratio: f32) -> f32 {
249        // snap to specific scale values to avoid arbitrary fractional scaling
250        if pixel_ratio <= 0.5 { 0.5 } else { pixel_ratio.round().max(1.0) }
251    }
252
253    fn texture_cell_size(&self) -> (i32, i32) {
254        // Static atlas texture size equals cell_size (fixed resolution)
255        self.cell_size()
256    }
257}