Skip to main content

retroglyph_wgpu/
config.rs

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