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, glow::RGBA, &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 for StaticFontAtlas {
83    fn get_glyph_id(&self, key: &str, style_bits: u16) -> Option<u16> {
84        let base_id = self.get_base_glyph_id(key)?;
85        Some(base_id | style_bits)
86    }
87
88    /// Returns the base glyph identifier for the given key
89    fn get_base_glyph_id(&self, key: &str) -> Option<u16> {
90        if key.len() == 1 {
91            let ch = key.chars().next().unwrap();
92            if ch.is_ascii() {
93                // 0x00..0x7f double as layer
94                let id = ch as u16;
95                return Some(id);
96            }
97        }
98
99        match self.glyph_coords.get(key) {
100            Some(id) => Some(*id),
101            None => {
102                self.glyph_tracker.record_missing(key);
103                None
104            },
105        }
106    }
107
108    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    fn bind(&self, gl: &glow::Context) {
117        self.texture.bind(gl);
118    }
119
120    /// Returns the underline configuration
121    fn underline(&self) -> beamterm_data::LineDecoration {
122        self.underline
123    }
124
125    /// Returns the strikethrough configuration
126    fn strikethrough(&self) -> beamterm_data::LineDecoration {
127        self.strikethrough
128    }
129
130    /// Returns the symbol for the given glyph ID, if it exists
131    fn get_symbol(&self, glyph_id: u16) -> Option<CompactString> {
132        let base_glyph_id = if glyph_id & Glyph::EMOJI_FLAG != 0 {
133            glyph_id & Glyph::GLYPH_ID_EMOJI_MASK
134        } else {
135            glyph_id & Glyph::GLYPH_ID_MASK
136        };
137
138        if (0x20..0x80).contains(&base_glyph_id) {
139            // ASCII characters are directly mapped to their code point
140            let ch = base_glyph_id as u8 as char;
141            Some(ch.to_compact_string())
142        } else {
143            self.symbol_lookup.get(&base_glyph_id).cloned()
144        }
145    }
146
147    fn get_ascii_char(&self, glyph_id: u16) -> Option<char> {
148        // Static atlas: ASCII chars 0x20-0x7F have glyph_id == char code
149        if (0x20..0x80).contains(&glyph_id) {
150            Some(glyph_id as u8 as char)
151        } else {
152            None
153        }
154    }
155
156    fn glyph_tracker(&self) -> &GlyphTracker {
157        &self.glyph_tracker
158    }
159
160    fn glyph_count(&self) -> u32 {
161        // ASCII printable characters: 0x20..0x80 (96 characters)
162        let ascii_count = 0x80 - 0x20;
163        // Non-ASCII glyphs stored in symbol_lookup
164        let non_ascii_count = self.symbol_lookup.len() as u32;
165        ascii_count + non_ascii_count
166    }
167
168    fn flush(&self, _gl: &glow::Context) -> Result<(), Error> {
169        Ok(()) // static atlas has no pending glyphs
170    }
171
172    /// Recreates the GPU texture after a context loss.
173    ///
174    /// This method rebuilds the texture from the retained atlas data. All glyph
175    /// mappings and other CPU-side state are preserved; only the GPU texture
176    /// handle is recreated.
177    fn recreate_texture(&mut self, gl: &glow::Context) -> Result<(), Error> {
178        // Delete old texture if it exists (may be invalid after context loss)
179        self.texture.delete(gl);
180
181        // Recreate texture from retained atlas data
182        self.texture =
183            crate::gl::texture::Texture::from_font_atlas_data(gl, glow::RGBA, &self.atlas_data)?;
184
185        Ok(())
186    }
187
188    fn for_each_symbol(&self, f: &mut dyn FnMut(u16, &str)) {
189        // ASCII printable characters (0x20..0x80)
190        for code in 0x20u16..0x80 {
191            let ch = code as u8 as char;
192            let mut buf = [0u8; 4];
193            let s = ch.encode_utf8(&mut buf);
194            f(code, s);
195        }
196        // Non-ASCII glyphs from symbol lookup
197        for (glyph_id, symbol) in &self.symbol_lookup {
198            f(*glyph_id, symbol.as_str());
199        }
200    }
201
202    fn resolve_glyph_slot(&self, key: &str, style_bits: u16) -> Option<GlyphSlot> {
203        if key.len() == 1 {
204            let ch = key.chars().next().unwrap();
205            if ch.is_ascii() {
206                // 0x00..0x7f double as layer
207                let id = ch as u16;
208                return Some(GlyphSlot::Normal(id | style_bits));
209            }
210        }
211
212        match self.glyph_coords.get(key) {
213            Some(base_glyph_id) => {
214                let id = base_glyph_id | style_bits;
215                if *base_glyph_id >= self.last_halfwidth_base_glyph_id {
216                    Some(GlyphSlot::Wide(id))
217                } else if id & Glyph::EMOJI_FLAG != 0 {
218                    Some(GlyphSlot::Emoji(id))
219                } else {
220                    Some(GlyphSlot::Normal(id))
221                }
222            },
223            None => {
224                self.glyph_tracker.record_missing(key);
225                None
226            },
227        }
228    }
229
230    /// Returns `0x1FFF` to support the full glyph encoding from `beamterm-atlas`.
231    ///
232    /// This 13-bit mask includes the emoji flag (bit 12) so that emoji base IDs
233    /// can be extracted correctly for symbol lookup and texture coordinate calculation.
234    fn base_lookup_mask(&self) -> u32 {
235        atlas::STATIC_ATLAS_LOOKUP_MASK
236    }
237
238    fn delete(&self, gl: &glow::Context) {
239        self.texture.delete(gl);
240    }
241
242    fn update_pixel_ratio(&mut self, _gl: &glow::Context, pixel_ratio: f32) -> Result<f32, Error> {
243        // Static atlas doesn't need to do anything - cell scaling is handled by the grid
244        Ok(pixel_ratio)
245    }
246
247    fn cell_scale_for_dpr(&self, pixel_ratio: f32) -> f32 {
248        // snap to specific scale values to avoid arbitrary fractional scaling
249        if pixel_ratio <= 0.5 { 0.5 } else { pixel_ratio.round().max(1.0) }
250    }
251
252    fn texture_cell_size(&self) -> (i32, i32) {
253        // Static atlas texture size equals cell_size (fixed resolution)
254        self.cell_size()
255    }
256}