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::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::error::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::config::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::config::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; 3] = ['▘', '▝', '▖'];
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::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
272impl retroglyph_window::presenter_builder::PresenterBuilder for WgpuBackendBuilder {
273 type Presenter = WgpuRenderer;
274 type Error = WgpuBackendError;
275
276 fn new() -> Self {
277 Self::new()
278 }
279
280 fn grid_size(self, cols: u16, rows: u16) -> Self {
281 self.grid_size(cols, rows)
282 }
283
284 fn scale(self, scale: u16) -> Self {
285 self.scale(scale)
286 }
287
288 fn font(self, fonts: impl Into<FontChain<'static>>) -> Self {
289 self.font(fonts)
290 }
291
292 #[cfg(feature = "tilesets")]
293 fn tileset(self, opts: TilesetOptions) -> Self {
294 self.tileset(opts)
295 }
296
297 fn build_presenter(self) -> Result<Self::Presenter, Self::Error> {
298 self.build()
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::{WgpuBackendBuilder, WgpuBackendError};
305
306 fn test_font() -> retroglyph_window::font::BitmapFont {
307 static DATA: [u8; 16] = [0; 16];
308 retroglyph_window::font::BitmapFont::new(&DATA, 8, 16, 1)
309 }
310
311 /// Exercises `PresenterBuilder`'s methods through the trait, not the inherent ones the rest
312 /// of this module already covers, so the forwarding impl itself (not just the methods it
313 /// forwards to) is under test.
314 #[test]
315 fn presenter_builder_impl_forwards_to_the_inherent_methods() {
316 fn build<B: retroglyph_window::presenter_builder::PresenterBuilder>(
317 font: retroglyph_window::font::BitmapFont,
318 ) -> Result<B::Presenter, B::Error> {
319 B::new()
320 .grid_size(4, 2)
321 .scale(1)
322 .font(font)
323 .build_presenter()
324 }
325 assert!(build::<WgpuBackendBuilder>(test_font()).is_ok());
326 }
327
328 #[test]
329 fn zero_scale_and_zero_grid_are_rejected_before_any_font_work() {
330 assert!(matches!(
331 WgpuBackendBuilder::new().scale(0).build(),
332 Err(WgpuBackendError::ZeroScale)
333 ));
334 assert!(matches!(
335 WgpuBackendBuilder::new().grid_size(0, 10).build(),
336 Err(WgpuBackendError::ZeroGrid)
337 ));
338 assert!(matches!(
339 WgpuBackendBuilder::new().grid_size(10, 0).build(),
340 Err(WgpuBackendError::ZeroGrid)
341 ));
342 }
343
344 #[cfg(not(feature = "default-font"))]
345 #[test]
346 fn a_fontless_build_names_the_two_ways_to_supply_one() {
347 let err = WgpuBackendBuilder::new().build().unwrap_err();
348 assert!(matches!(err, WgpuBackendError::NoFont));
349 let msg = err.to_string();
350 assert!(msg.contains("WgpuBackendBuilder::font()"));
351 assert!(msg.contains("default-font"));
352 }
353
354 #[cfg(feature = "default-font")]
355 #[test]
356 fn a_grid_and_scale_that_overflow_the_surface_size_are_rejected() {
357 // retroglyph#729: `CellGeometry::surface_size` multiplies cols/rows/scale as plain `u32`,
358 // which overflows for a `u16` scale this large.
359 let result = WgpuBackendBuilder::new()
360 .grid_size(u16::MAX, 1)
361 .scale(u16::MAX)
362 .build();
363 assert!(matches!(result, Err(WgpuBackendError::SurfaceTooLarge)));
364 }
365
366 #[cfg(feature = "default-font")]
367 #[test]
368 fn mixed_glyph_sizes_are_rejected() {
369 use retroglyph_window::font::{BitmapFont, FontChain, unscii16};
370
371 // A fallback whose glyphs are 8x8, against an 8x16 primary: a grid has one cell size.
372 static SMALL: [u8; 8] = [0; 8];
373 const CHARSET: [char; 1] = ['▘'];
374 static FALLBACKS: [BitmapFont; 1] = [BitmapFont::with_charset(&SMALL, 8, 8, 1, &CHARSET)];
375
376 let result = WgpuBackendBuilder::new()
377 .font(FontChain::new(unscii16::FONT, &FALLBACKS))
378 .build();
379 assert!(matches!(result, Err(WgpuBackendError::MixedGlyphSizes)));
380 }
381}