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