retroglyph_gl/config.rs
1//! Configuration, builder, and error types for the GL backend.
2//!
3//! [`GlBackendBuilder`] gathers grid size, integer scale, a [`FontChain`], and (with the
4//! `tilesets` feature) any PNG sprite tilesets, then [`build`](GlBackendBuilder::build) produces a
5//! [`GlRenderer`]. The renderer is created without a GL context; the context and GPU resources are
6//! created lazily when the windowing loop calls
7//! [`Presenter::init_surface`](retroglyph_window::Presenter::init_surface).
8
9use crate::GlRenderer;
10use retroglyph_window::atlas::GlyphAtlas;
11use retroglyph_window::font::FontChain;
12#[cfg(feature = "tilesets")]
13use retroglyph_window::tileset::TilesetOptions;
14use std::fmt;
15
16/// Errors from configuring the GL backend.
17#[derive(Debug)]
18#[non_exhaustive]
19pub enum GlBackendError {
20 /// No font was provided and the `default-font` feature is not enabled.
21 NoFont,
22 /// The fonts in the configured [`FontChain`] disagree on their glyph size.
23 MixedGlyphSizes,
24 /// The configured [`FontChain`] has more glyphs in total than the atlas can address.
25 FontChainTooLarge,
26 /// `scale` was set to `0`, which would produce a zero-size surface.
27 ZeroScale,
28 /// The grid was configured with a zero column or row count.
29 ZeroGrid,
30 /// `cols`, `rows`, and `scale` combine to a surface wider or taller than `u32::MAX` physical
31 /// pixels.
32 SurfaceTooLarge,
33 /// A registered tileset failed to decode (issue #366).
34 #[cfg(feature = "tilesets")]
35 Tileset(retroglyph_window::tileset::TilesetError),
36}
37
38impl fmt::Display for GlBackendError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::NoFont => write!(
42 f,
43 "no bitmap font provided; supply one via GlBackendBuilder::font() or enable the \
44 `default-font` feature"
45 ),
46 Self::MixedGlyphSizes => write!(
47 f,
48 "every font in a chain must have the same glyph width and height; a grid has one \
49 cell size"
50 ),
51 Self::FontChainTooLarge => write!(
52 f,
53 "a font chain may hold at most {} glyphs in total; the atlas addresses a glyph by \
54 a 16-bit slot",
55 u32::from(u16::MAX) + 1
56 ),
57 Self::ZeroScale => write!(f, "scale must be non-zero"),
58 Self::ZeroGrid => write!(f, "grid columns and rows must both be non-zero"),
59 Self::SurfaceTooLarge => {
60 write!(
61 f,
62 "grid_size and scale combine to a surface over u32::MAX pixels wide or tall"
63 )
64 }
65 #[cfg(feature = "tilesets")]
66 Self::Tileset(e) => write!(f, "tileset error: {e}"),
67 }
68 }
69}
70
71impl std::error::Error for GlBackendError {
72 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
73 match self {
74 #[cfg(feature = "tilesets")]
75 Self::Tileset(e) => Some(e),
76 _ => None,
77 }
78 }
79}
80
81/// Builder for the GL backend.
82///
83/// # Examples
84///
85/// ```no_run
86/// # #[cfg(not(target_arch = "wasm32"))]
87/// # fn main() {
88/// use retroglyph_core::color::Style;
89/// use retroglyph_gl::GlBackendBuilder;
90/// use retroglyph_window::winit::{WindowConfig, run_windowed};
91///
92/// let renderer = GlBackendBuilder::new()
93/// .grid_size(80, 25)
94/// .scale(2)
95/// .build()
96/// .expect("gl backend init failed");
97/// let config = WindowConfig::fit(&renderer, "My Game", None, true);
98/// run_windowed(config, renderer, move |term| {
99/// term.draw(|s| s.print((0, 0), "Hello from retroglyph-gl!", Style::default()))
100/// .ok();
101/// })
102/// .expect("event loop failed");
103/// # }
104/// # #[cfg(target_arch = "wasm32")]
105/// # fn main() {}
106/// ```
107#[derive(Debug, Clone)]
108pub struct GlBackendBuilder {
109 fonts: Option<FontChain<'static>>,
110 /// Registered tilesets, decoded into a sprite atlas at [`build`](Self::build) time (issue #366).
111 #[cfg(feature = "tilesets")]
112 tilesets: Vec<TilesetOptions>,
113 cols: u16,
114 rows: u16,
115 scale: u16,
116}
117
118impl Default for GlBackendBuilder {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124impl GlBackendBuilder {
125 /// A new builder with an 80x25 grid at scale 1 and no font yet (the `default-font` feature
126 /// supplies one at [`build`](Self::build) time if none is set).
127 #[must_use]
128 pub const fn new() -> Self {
129 Self {
130 fonts: None,
131 #[cfg(feature = "tilesets")]
132 tilesets: Vec::new(),
133 cols: 80,
134 rows: 25,
135 scale: 1,
136 }
137 }
138
139 /// Sets the grid size in cells.
140 #[must_use]
141 pub const fn grid_size(mut self, cols: u16, rows: u16) -> Self {
142 self.cols = cols;
143 self.rows = rows;
144 self
145 }
146
147 /// Sets the integer pixel scale (each glyph pixel becomes `scale`x`scale` physical pixels).
148 #[must_use]
149 pub const fn scale(mut self, scale: u16) -> Self {
150 self.scale = scale;
151 self
152 }
153
154 /// Sets the fonts glyphs are resolved through, overriding the `default-font` embedded font.
155 ///
156 /// Takes either a single [`BitmapFont`](retroglyph_window::font::BitmapFont) or a whole
157 /// [`FontChain`], since a lone font is a chain of one. A chain is how a grid draws characters
158 /// CP437 has no mapping for (quadrants, sextants, braille): the extra coverage comes from a
159 /// fallback font built with
160 /// [`BitmapFont::with_charset`](retroglyph_window::font::BitmapFont::with_charset), every font
161 /// in the chain is packed into the same glyph atlas, and the glyph is drawn from that atlas,
162 /// so it takes the cell's foreground color like any other glyph (unlike a tileset sprite,
163 /// which carries its own colors).
164 ///
165 /// Every font in a chain must agree on its glyph size, since that is the grid's cell size, and
166 /// the chain's glyphs must fit the atlas's 16-bit slot space; otherwise
167 /// [`build`](Self::build) fails.
168 ///
169 /// # Examples
170 ///
171 /// ```no_run
172 /// use retroglyph_gl::GlBackendBuilder;
173 /// use retroglyph_window::font::{BitmapFont, FontChain, unscii16};
174 ///
175 /// // A fallback font declaring the quadrant glyphs CP437 has no mapping for.
176 /// static QUADRANTS: [u8; 3 * 16] = [0; 3 * 16];
177 /// const CHARSET: [(char, u8); 3] = [('▘', 0), ('▝', 1), ('▖', 2)];
178 /// static FALLBACKS: [BitmapFont; 1] =
179 /// [BitmapFont::with_charset(&QUADRANTS, 8, 16, 3, &CHARSET)];
180 ///
181 /// let renderer = GlBackendBuilder::new()
182 /// .font(FontChain::new(unscii16::FONT, &FALLBACKS))
183 /// .build()
184 /// .expect("gl backend init failed");
185 /// ```
186 #[must_use]
187 pub fn font(mut self, fonts: impl Into<FontChain<'static>>) -> Self {
188 self.fonts = Some(fonts.into());
189 self
190 }
191
192 /// Registers a PNG sprite tileset (issue #366). Glyphs a tileset maps override the bitmap font
193 /// for those codepoints; register multiple and later ones win on codepoint collision. Build
194 /// the options with [`TilesetOptions::builder`](retroglyph_window::tileset::TilesetOptions::builder).
195 ///
196 /// Available only with the `tilesets` feature.
197 #[cfg(feature = "tilesets")]
198 #[must_use]
199 pub fn tileset(mut self, opts: TilesetOptions) -> Self {
200 self.tilesets.push(opts);
201 self
202 }
203
204 /// Builds the [`GlRenderer`].
205 ///
206 /// The renderer holds no GL context yet; the context is created when the windowing loop calls
207 /// [`Presenter::init_surface`](retroglyph_window::Presenter::init_surface).
208 ///
209 /// # Errors
210 ///
211 /// Returns [`GlBackendError::NoFont`] if no font was set and the `default-font` feature is
212 /// disabled, [`GlBackendError::MixedGlyphSizes`] if the configured chain's fonts disagree on
213 /// their glyph size, [`GlBackendError::FontChainTooLarge`] if the chain's glyphs overflow the
214 /// atlas's slot space, [`GlBackendError::ZeroScale`] if `scale` is 0,
215 /// [`GlBackendError::ZeroGrid`] if either grid dimension is 0, or
216 /// [`GlBackendError::SurfaceTooLarge`] if `cols`/`rows`/`scale` combine to a surface wider or
217 /// taller than `u32::MAX` physical pixels.
218 pub fn build(self) -> Result<GlRenderer, GlBackendError> {
219 if self.scale == 0 {
220 return Err(GlBackendError::ZeroScale);
221 }
222 if self.cols == 0 || self.rows == 0 {
223 return Err(GlBackendError::ZeroGrid);
224 }
225 let fonts = self.resolve_fonts()?;
226 let Some(glyph_size) = fonts.glyph_size() else {
227 return Err(GlBackendError::MixedGlyphSizes);
228 };
229 // `CellGeometry::surface_size` multiplies as plain `u32`; check for overflow here, in
230 // `u64`, before it can happen there (`scale` is `u16` on this backend, unlike the
231 // software backend's `u8`, so the product is not overflow-free by construction).
232 let cell_w = u64::from(glyph_size.0) * u64::from(self.scale);
233 let cell_h = u64::from(glyph_size.1) * u64::from(self.scale);
234 let surface_w = u64::from(self.cols) * cell_w;
235 let surface_h = u64::from(self.rows) * cell_h;
236 if surface_w > u64::from(u32::MAX) || surface_h > u64::from(u32::MAX) {
237 return Err(GlBackendError::SurfaceTooLarge);
238 }
239 let glyphs = GlyphAtlas::new(fonts, glyph_size);
240 if glyphs.slot_count() > retroglyph_window::atlas::MAX_SLOTS {
241 return Err(GlBackendError::FontChainTooLarge);
242 }
243 #[cfg_attr(not(feature = "tilesets"), allow(unused_mut))]
244 let mut renderer = GlRenderer::new(glyphs, self.cols, self.rows, self.scale);
245 #[cfg(feature = "tilesets")]
246 {
247 let cache = retroglyph_window::sprite_cache::SpriteCache::from_tilesets(&self.tilesets)
248 .map_err(GlBackendError::Tileset)?;
249 if let Some(set) = crate::sprites::SpriteSet::from_cache(&cache) {
250 renderer.set_sprites(set);
251 }
252 }
253 Ok(renderer)
254 }
255
256 /// Resolves the font chain: the explicitly set one, else the embedded default (if the feature
257 /// is on), else [`GlBackendError::NoFont`].
258 // The `Result` is not always-`Ok`: without `default-font` the fallback arm returns `Err`.
259 // clippy only sees one feature configuration at a time, so silence its feature-blind
260 // `unnecessary_wraps`/`const` suggestions here.
261 #[allow(clippy::unnecessary_wraps, clippy::missing_const_for_fn)]
262 fn resolve_fonts(&self) -> Result<FontChain<'static>, GlBackendError> {
263 if let Some(fonts) = self.fonts {
264 return Ok(fonts);
265 }
266 #[cfg(feature = "default-font")]
267 {
268 Ok(FontChain::from(retroglyph_window::font::unscii16::FONT))
269 }
270 #[cfg(not(feature = "default-font"))]
271 {
272 Err(GlBackendError::NoFont)
273 }
274 }
275}