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