Skip to main content

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}