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}