retroglyph_software/config.rs
1//! Configuration, builder, and error types for the software rendering backend.
2//!
3//! The main type is [`SoftwareBackend`], which holds grid and font
4//! configuration. Construct it via [`SoftwareBackendBuilder`], then call
5//! [`into_renderer`](SoftwareBackend::into_renderer) to produce a
6//! [`SoftwareRenderer`](crate::SoftwareRenderer). Hand that renderer to
7//! `retroglyph_window::winit::run_windowed` to open a window, or use it
8//! directly for in-memory rendering.
9
10use retroglyph_window::font::FontChain;
11#[cfg(feature = "tilesets")]
12use retroglyph_window::tileset::TilesetOptions;
13use std::fmt;
14
15/// Errors that can occur when configuring the software backend.
16///
17/// Windowing errors (window creation, event loop) are not represented here:
18/// this crate builds renderers, and the loop (`retroglyph-window` or
19/// another windowing integration) reports its own errors.
20#[derive(Debug)]
21#[non_exhaustive]
22pub enum SoftwareBackendError {
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 /// `scale` was set to `0`, which would produce a zero-size pixel buffer.
28 ZeroScale,
29 /// The grid was configured with a zero column or row count, which would produce a zero-size
30 /// pixel buffer.
31 ZeroGrid,
32 /// Tileset loading failed.
33 #[cfg(feature = "tilesets")]
34 Tileset(retroglyph_window::tileset::TilesetError),
35}
36
37impl fmt::Display for SoftwareBackendError {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 Self::NoFont => write!(
41 f,
42 "no bitmap font provided; supply one via \
43 SoftwareBackendBuilder::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::ZeroScale => write!(
52 f,
53 "scale must be non-zero; a scale of 0 would produce a zero-size pixel buffer"
54 ),
55 Self::ZeroGrid => write!(f, "grid columns and rows must both be non-zero"),
56 #[cfg(feature = "tilesets")]
57 Self::Tileset(_) => write!(f, "tileset load failed"),
58 }
59 }
60}
61
62impl std::error::Error for SoftwareBackendError {
63 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64 match self {
65 Self::NoFont | Self::MixedGlyphSizes | Self::ZeroScale | Self::ZeroGrid => None,
66 #[cfg(feature = "tilesets")]
67 Self::Tileset(e) => Some(e),
68 }
69 }
70}
71
72/// Configuration and entry point for the software rendering backend.
73///
74/// Construct this via [`SoftwareBackendBuilder`], then call
75/// [`into_renderer`](SoftwareBackend::into_renderer) to obtain a
76/// [`SoftwareRenderer`](crate::SoftwareRenderer) (which implements
77/// [`Backend`](retroglyph_core::backend::Backend) for in-memory use, and
78/// `retroglyph_window::presenter::Presenter` for windowed use).
79///
80/// # Examples
81///
82/// Windowed mode (requires `default-font` feature; the loop comes
83/// from `retroglyph-window`):
84///
85/// ```no_run
86/// use retroglyph_software::config::SoftwareBackendBuilder;
87/// use retroglyph_window::winit::{WindowConfig, run_windowed};
88/// use retroglyph_core::event::{Event, KeyCode};
89/// use retroglyph_core::color::Style;
90/// use std::time::Duration;
91///
92/// let renderer = SoftwareBackendBuilder::new()
93/// .grid_size(80, 25)
94/// .scale(2)
95/// .build()
96/// .expect("backend init failed")
97/// .into_renderer()
98/// .expect("renderer init failed");
99///
100/// let config = WindowConfig::fit(&renderer, "My Game", None, true);
101/// run_windowed(config, renderer, move |term| {
102/// term.draw(|s| s.print((0, 0), "Hello from rg!", Style::default())).ok();
103///
104/// if let Some(event) = term.poll(Duration::from_millis(16)) {
105/// match event {
106/// Event::Key(k) if k.code == KeyCode::Escape => std::process::exit(0),
107/// Event::Close => std::process::exit(0),
108/// _ => {}
109/// }
110/// }
111/// }).expect("event loop failed");
112/// ```
113///
114/// Headless mode (useful for testing):
115///
116/// ```
117/// use retroglyph_software::SoftwareRenderer;
118/// use retroglyph_software::config::SoftwareBackendBuilder;
119/// use retroglyph_core::color::Style;
120/// use retroglyph_core::grid::Pos;
121/// use retroglyph_core::backend::{DrawCell, Output};
122/// use retroglyph_core::color::Color;
123///
124/// let opts = SoftwareBackendBuilder::new()
125/// .grid_size(1, 1)
126/// .scale(1)
127/// .build()
128/// .unwrap();
129///
130/// let mut renderer: SoftwareRenderer = opts.into_renderer().unwrap();
131///
132/// // Draw a red cell on layer 0.
133/// use retroglyph_core::tile::Tile;
134/// let tile = Tile::new(' ', Style::new().bg(Color::rgb(255, 0, 0)));
135/// renderer
136/// .draw_layers([DrawCell::on_layer(0, Pos::new(0, 0), &tile)].into_iter())
137/// .unwrap();
138///
139/// let pixels = renderer.pixels();
140/// assert!(pixels.iter().all(|&p| p == 0x00FF_0000));
141/// ```
142///
143/// See the `demo` example for a complete runnable program.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct SoftwareBackend {
146 /// The chain of bitmap fonts glyphs are resolved through.
147 ///
148 /// `None` only when `default-font` is disabled and no font has
149 /// been supplied via [`SoftwareBackendBuilder::font`].
150 ///
151 /// Crate-private: the only way to reach
152 /// [`into_renderer`](super::SoftwareBackend::into_renderer) with a `SoftwareBackend` is
153 /// through [`SoftwareBackendBuilder`], which validates this invariant in
154 /// [`build`](SoftwareBackendBuilder::build). Use [`fonts`](SoftwareBackend::fonts) to read it
155 /// back from outside the crate.
156 pub(crate) fonts: Option<FontChain<'static>>,
157 /// Grid width in cells.
158 pub cols: u16,
159 /// Grid height in cells.
160 pub rows: u16,
161 /// Pixel-scale factor applied to each font pixel.
162 ///
163 /// A scale of 2 renders each 1-bit font pixel as a 2×2 block, making
164 /// the Unscii 16 font display at 16×32 pixels per cell. Default is 1.
165 pub scale: u16,
166 /// Registered tileset options, loaded at
167 /// [`into_renderer`](SoftwareBackend::into_renderer) time.
168 #[cfg(feature = "tilesets")]
169 pub tilesets: Vec<TilesetOptions>,
170}
171
172impl SoftwareBackend {
173 /// Returns the configured font chain, if any.
174 ///
175 /// `None` only when `default-font` is disabled and no font was supplied
176 /// via [`SoftwareBackendBuilder::font`]; in that case
177 /// [`SoftwareBackendBuilder::build`] fails with [`SoftwareBackendError::NoFont`]
178 /// before a `SoftwareBackend` can be constructed at all.
179 #[must_use]
180 pub const fn fonts(&self) -> Option<&FontChain<'static>> {
181 self.fonts.as_ref()
182 }
183
184 /// Builder-internal defaults. Not exposed as `impl Default`: the only
185 /// supported way to obtain a `SoftwareBackend` is through
186 /// [`SoftwareBackendBuilder`], so that its `font` invariant is always
187 /// validated by [`SoftwareBackendBuilder::build`].
188 const fn defaults() -> Self {
189 Self {
190 #[cfg(feature = "default-font")]
191 fonts: Some(FontChain::new(retroglyph_window::font::unscii16::FONT, &[])),
192 #[cfg(not(feature = "default-font"))]
193 fonts: None,
194 cols: 80,
195 rows: 25,
196 scale: 1,
197 #[cfg(feature = "tilesets")]
198 tilesets: Vec::new(),
199 }
200 }
201}
202
203/// Builder for [`SoftwareBackend`].
204///
205/// # Examples
206///
207/// ```
208/// use retroglyph_software::config::SoftwareBackendBuilder;
209///
210/// // With the `default-font` feature the embedded Unscii 16 font is
211/// // used automatically. To supply your own 8×16 bitmap font:
212/// //
213/// // use retroglyph_window::font::BitmapFont;
214/// // let my_font = BitmapFont::new(include_bytes!("my_font.bin"), 8, 16, 256);
215/// // SoftwareBackendBuilder::new().font(my_font)...
216///
217/// let backend = SoftwareBackendBuilder::new()
218/// .grid_size(80, 25)
219/// .build()
220/// .expect("backend init failed");
221/// ```
222#[derive(Debug)]
223pub struct SoftwareBackendBuilder {
224 options: SoftwareBackend,
225}
226
227impl SoftwareBackendBuilder {
228 /// Creates a builder with default options.
229 ///
230 /// When the `default-font` feature is enabled the Unscii 16
231 /// font is pre-selected; otherwise you must call [`font`](Self::font).
232 #[must_use]
233 pub const fn new() -> Self {
234 Self {
235 options: SoftwareBackend::defaults(),
236 }
237 }
238
239 /// Sets the grid dimensions in cells.
240 #[must_use]
241 pub const fn grid_size(mut self, cols: u16, rows: u16) -> Self {
242 self.options.cols = cols;
243 self.options.rows = rows;
244 self
245 }
246
247 /// Pixel-scale factor for the font.
248 ///
249 /// Each 1-bit font pixel becomes a `scale`×`scale` block. For the VGA
250 /// 8×16 font a scale of 2 gives 16×32 pixel cells, more readable
251 /// on modern displays.
252 #[must_use]
253 pub const fn scale(mut self, scale: u16) -> Self {
254 self.options.scale = scale;
255 self
256 }
257
258 /// Overrides the fonts glyphs are resolved through.
259 ///
260 /// Takes either a single [`BitmapFont`](retroglyph_window::font::BitmapFont) or a whole
261 /// [`FontChain`], since a lone font is a chain of one. A chain is how a grid draws characters
262 /// CP437 has no mapping for (quadrants, sextants, braille): the extra coverage comes from a
263 /// fallback font built with
264 /// [`BitmapFont::with_charset`](retroglyph_window::font::BitmapFont::with_charset), and the
265 /// glyph is drawn from the bitmap font path, so it takes the cell's foreground color like any
266 /// other glyph (unlike a tileset sprite, which carries its own colors).
267 ///
268 /// The cell pixel size is derived from the chain's glyph size multiplied by
269 /// [`scale`](Self::scale); every font in a chain must therefore agree on its glyph size, or
270 /// [`build`](Self::build) fails with [`SoftwareBackendError::MixedGlyphSizes`].
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use retroglyph_software::config::SoftwareBackendBuilder;
276 /// use retroglyph_window::font::{BitmapFont, FontChain, unscii16};
277 ///
278 /// // A fallback font declaring the quadrant glyphs CP437 has no mapping for.
279 /// static QUADRANTS: [u8; 3 * 16] = [0; 3 * 16];
280 /// const CHARSET: [char; 3] = ['▘', '▝', '▖'];
281 /// static FALLBACKS: [BitmapFont; 1] =
282 /// [BitmapFont::with_charset(&QUADRANTS, 8, 16, 3, &CHARSET)];
283 ///
284 /// let backend = SoftwareBackendBuilder::new()
285 /// .font(FontChain::new(unscii16::FONT, &FALLBACKS))
286 /// .build()
287 /// .expect("backend init failed");
288 /// ```
289 #[must_use]
290 pub fn font(mut self, fonts: impl Into<FontChain<'static>>) -> Self {
291 self.options.fonts = Some(fonts.into());
292 self
293 }
294
295 /// Registers a tileset for loading when the backend starts.
296 ///
297 /// Multiple tilesets can be registered; they are all loaded when
298 /// [`into_renderer`](SoftwareBackend::into_renderer) is called. Later
299 /// registrations win on codepoint collision.
300 ///
301 /// Available only when the `tilesets` feature is enabled.
302 #[cfg(feature = "tilesets")]
303 #[must_use]
304 pub fn tileset(mut self, opts: TilesetOptions) -> Self {
305 self.options.tilesets.push(opts);
306 self
307 }
308
309 /// Validates options and returns the backend configuration.
310 ///
311 /// Call [`into_renderer`](SoftwareBackend::into_renderer) on the result to
312 /// obtain the renderer (hand it to `retroglyph_window::winit::run_windowed`
313 /// to open a window).
314 ///
315 /// # Errors
316 ///
317 /// Returns [`SoftwareBackendError::NoFont`] if no font was set and the
318 /// `default-font` feature is not enabled.
319 ///
320 /// Returns [`SoftwareBackendError::MixedGlyphSizes`] if the configured chain's fonts disagree
321 /// on their glyph size.
322 ///
323 /// Returns [`SoftwareBackendError::ZeroScale`] if `scale` was set to `0`.
324 ///
325 /// Returns [`SoftwareBackendError::ZeroGrid`] if `cols` or `rows` was set to `0`.
326 pub fn build(self) -> Result<SoftwareBackend, SoftwareBackendError> {
327 let Some(fonts) = self.options.fonts else {
328 return Err(SoftwareBackendError::NoFont);
329 };
330 if fonts.glyph_size().is_none() {
331 return Err(SoftwareBackendError::MixedGlyphSizes);
332 }
333 if self.options.scale == 0 {
334 return Err(SoftwareBackendError::ZeroScale);
335 }
336 if self.options.cols == 0 || self.options.rows == 0 {
337 return Err(SoftwareBackendError::ZeroGrid);
338 }
339 Ok(self.options)
340 }
341}
342
343impl retroglyph_window::presenter_builder::PresenterBuilder for SoftwareBackendBuilder {
344 type Presenter = crate::SoftwareRenderer;
345 type Error = SoftwareBackendError;
346
347 fn new() -> Self {
348 Self::new()
349 }
350
351 fn grid_size(self, cols: u16, rows: u16) -> Self {
352 self.grid_size(cols, rows)
353 }
354
355 fn scale(self, scale: u16) -> Self {
356 self.scale(scale)
357 }
358
359 fn font(self, fonts: impl Into<FontChain<'static>>) -> Self {
360 self.font(fonts)
361 }
362
363 #[cfg(feature = "tilesets")]
364 fn tileset(self, opts: TilesetOptions) -> Self {
365 self.tileset(opts)
366 }
367
368 fn build_presenter(self) -> Result<Self::Presenter, Self::Error> {
369 self.build()?.into_renderer()
370 }
371}
372
373impl Default for SoftwareBackendBuilder {
374 fn default() -> Self {
375 Self::new()
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use retroglyph_window::font::BitmapFont;
383
384 fn test_font() -> BitmapFont {
385 static DATA: [u8; 16] = [0; 16];
386 BitmapFont::new(&DATA, 8, 16, 1)
387 }
388
389 #[test]
390 fn build_rejects_zero_scale() {
391 let result = SoftwareBackendBuilder::new()
392 .font(test_font())
393 .scale(0)
394 .build();
395 assert!(matches!(result, Err(SoftwareBackendError::ZeroScale)));
396 }
397
398 #[test]
399 fn build_rejects_a_zero_grid_dimension() {
400 let result = SoftwareBackendBuilder::new()
401 .font(test_font())
402 .grid_size(0, 25)
403 .build();
404 assert!(matches!(result, Err(SoftwareBackendError::ZeroGrid)));
405 }
406
407 #[test]
408 fn build_accepts_nonzero_scale() {
409 let result = SoftwareBackendBuilder::new()
410 .font(test_font())
411 .scale(2)
412 .build();
413 assert!(result.is_ok());
414 }
415
416 #[test]
417 fn build_rejects_a_chain_whose_fonts_disagree_on_glyph_size() {
418 static SHORT_DATA: [u8; 8] = [0; 8];
419 static FALLBACKS: [BitmapFont; 1] = [BitmapFont::new(&SHORT_DATA, 8, 8, 1)];
420
421 let result = SoftwareBackendBuilder::new()
422 .font(FontChain::new(test_font(), &FALLBACKS))
423 .build();
424 assert!(matches!(result, Err(SoftwareBackendError::MixedGlyphSizes)));
425 }
426
427 /// Exercises `PresenterBuilder`'s methods through the trait, not the inherent ones
428 /// [`build_accepts_nonzero_scale`] already covers, so the forwarding impl itself (not just the
429 /// methods it forwards to) is under test.
430 #[test]
431 fn presenter_builder_impl_forwards_to_the_inherent_methods() {
432 fn build<B: retroglyph_window::presenter_builder::PresenterBuilder>(
433 font: BitmapFont,
434 ) -> Result<B::Presenter, B::Error> {
435 B::new()
436 .grid_size(4, 2)
437 .scale(1)
438 .font(font)
439 .build_presenter()
440 }
441 assert!(build::<SoftwareBackendBuilder>(test_font()).is_ok());
442 }
443}