Skip to main content

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` 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::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::{SoftwareBackendBuilder, SoftwareRenderer};
118/// use retroglyph_core::color::Style;
119/// use retroglyph_core::grid::Pos;
120/// use retroglyph_core::backend::{DrawCell, Output};
121/// use retroglyph_core::color::Color;
122///
123/// let opts = SoftwareBackendBuilder::new()
124///     .grid_size(1, 1)
125///     .scale(1)
126///     .build()
127///     .unwrap();
128///
129/// let mut renderer: SoftwareRenderer = opts.into_renderer().unwrap();
130///
131/// // Draw a red cell on layer 0.
132/// use retroglyph_core::tile::Tile;
133/// let tile = Tile::new(' ', Style::new().bg(Color::Rgb { r: 255, g: 0, b: 0 }));
134/// renderer
135///     .draw_layers([DrawCell::on_layer(0, Pos::new(0, 0), &tile)].into_iter())
136///     .unwrap();
137///
138/// let pixels = renderer.pixels();
139/// assert!(pixels.iter().all(|&p| p == 0x00FF_0000));
140/// ```
141///
142/// See the `demo` example for a complete runnable program.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct SoftwareBackend {
145    /// The chain of bitmap fonts glyphs are resolved through.
146    ///
147    /// `None` only when `default-font` is disabled and no font has
148    /// been supplied via [`SoftwareBackendBuilder::font`].
149    ///
150    /// Crate-private: the only way to reach
151    /// [`into_renderer`](super::SoftwareBackend::into_renderer) with a `SoftwareBackend` is
152    /// through [`SoftwareBackendBuilder`], which validates this invariant in
153    /// [`build`](SoftwareBackendBuilder::build). Use [`fonts`](SoftwareBackend::fonts) to read it
154    /// back from outside the crate.
155    pub(crate) fonts: Option<FontChain<'static>>,
156    /// Grid width in cells.
157    pub cols: u16,
158    /// Grid height in cells.
159    pub rows: u16,
160    /// Pixel-scale factor applied to each font pixel.
161    ///
162    /// A scale of 2 renders each 1-bit font pixel as a 2×2 block, making
163    /// the Unscii 16 font display at 16×32 pixels per cell. Default is 1.
164    pub scale: u8,
165    /// Registered tileset options, loaded at
166    /// [`into_renderer`](SoftwareBackend::into_renderer) time.
167    #[cfg(feature = "tilesets")]
168    pub tilesets: Vec<TilesetOptions>,
169}
170
171impl SoftwareBackend {
172    /// Returns the configured font chain, if any.
173    ///
174    /// `None` only when `default-font` is disabled and no font was supplied
175    /// via [`SoftwareBackendBuilder::font`]; in that case
176    /// [`SoftwareBackendBuilder::build`] fails with [`SoftwareBackendError::NoFont`]
177    /// before a `SoftwareBackend` can be constructed at all.
178    #[must_use]
179    pub const fn fonts(&self) -> Option<&FontChain<'static>> {
180        self.fonts.as_ref()
181    }
182
183    /// Builder-internal defaults. Not exposed as `impl Default`: the only
184    /// supported way to obtain a `SoftwareBackend` is through
185    /// [`SoftwareBackendBuilder`], so that its `font` invariant is always
186    /// validated by [`SoftwareBackendBuilder::build`].
187    const fn defaults() -> Self {
188        Self {
189            #[cfg(feature = "default-font")]
190            fonts: Some(FontChain::new(retroglyph_window::font::unscii16::FONT, &[])),
191            #[cfg(not(feature = "default-font"))]
192            fonts: None,
193            cols: 80,
194            rows: 25,
195            scale: 1,
196            #[cfg(feature = "tilesets")]
197            tilesets: Vec::new(),
198        }
199    }
200}
201
202/// Builder for [`SoftwareBackend`].
203///
204/// # Examples
205///
206/// ```
207/// use retroglyph_software::SoftwareBackendBuilder;
208///
209/// // With the `default-font` feature the embedded Unscii 16 font is
210/// // used automatically.  To supply your own 8×16 bitmap font:
211/// //
212/// //   use retroglyph_window::font::BitmapFont;
213/// //   let my_font = BitmapFont::new(include_bytes!("my_font.bin"), 8, 16, 256);
214/// //   SoftwareBackendBuilder::new().font(my_font)...
215///
216/// let backend = SoftwareBackendBuilder::new()
217///     .grid_size(80, 25)
218///     .build()
219///     .expect("backend init failed");
220/// ```
221#[derive(Debug)]
222pub struct SoftwareBackendBuilder {
223    options: SoftwareBackend,
224}
225
226impl SoftwareBackendBuilder {
227    /// Creates a builder with default options.
228    ///
229    /// When the `default-font` feature is enabled the Unscii 16
230    /// font is pre-selected; otherwise you must call [`font`](Self::font).
231    #[must_use]
232    pub const fn new() -> Self {
233        Self {
234            options: SoftwareBackend::defaults(),
235        }
236    }
237
238    /// Sets the grid dimensions in cells.
239    #[must_use]
240    pub const fn grid_size(mut self, cols: u16, rows: u16) -> Self {
241        self.options.cols = cols;
242        self.options.rows = rows;
243        self
244    }
245
246    /// Pixel-scale factor for the font.
247    ///
248    /// Each 1-bit font pixel becomes a `scale`×`scale` block. For the VGA
249    /// 8×16 font a scale of 2 gives 16×32 pixel cells, more readable
250    /// on modern displays.
251    #[must_use]
252    pub const fn scale(mut self, scale: u8) -> Self {
253        self.options.scale = scale;
254        self
255    }
256
257    /// Overrides the fonts glyphs are resolved through.
258    ///
259    /// Takes either a single [`BitmapFont`](retroglyph_window::font::BitmapFont) or a whole
260    /// [`FontChain`], since a lone font is a chain of one. A chain is how a grid draws characters
261    /// CP437 has no mapping for (quadrants, sextants, braille): the extra coverage comes from a
262    /// fallback font built with
263    /// [`BitmapFont::with_charset`](retroglyph_window::font::BitmapFont::with_charset), and the
264    /// glyph is drawn from the bitmap font path, so it takes the cell's foreground color like any
265    /// other glyph (unlike a tileset sprite, which carries its own colors).
266    ///
267    /// The cell pixel size is derived from the chain's glyph size multiplied by
268    /// [`scale`](Self::scale); every font in a chain must therefore agree on its glyph size, or
269    /// [`build`](Self::build) fails with [`SoftwareBackendError::MixedGlyphSizes`].
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use retroglyph_software::SoftwareBackendBuilder;
275    /// use retroglyph_window::font::{BitmapFont, FontChain, unscii16};
276    ///
277    /// // A fallback font declaring the quadrant glyphs CP437 has no mapping for.
278    /// static QUADRANTS: [u8; 3 * 16] = [0; 3 * 16];
279    /// const CHARSET: [(char, u8); 3] = [('▘', 0), ('▝', 1), ('▖', 2)];
280    /// static FALLBACKS: [BitmapFont; 1] =
281    ///     [BitmapFont::with_charset(&QUADRANTS, 8, 16, 3, &CHARSET)];
282    ///
283    /// let backend = SoftwareBackendBuilder::new()
284    ///     .font(FontChain::new(unscii16::FONT, &FALLBACKS))
285    ///     .build()
286    ///     .expect("backend init failed");
287    /// ```
288    #[must_use]
289    pub fn font(mut self, fonts: impl Into<FontChain<'static>>) -> Self {
290        self.options.fonts = Some(fonts.into());
291        self
292    }
293
294    /// Registers a tileset for loading when the backend starts.
295    ///
296    /// Multiple tilesets can be registered; they are all loaded when
297    /// [`into_renderer`](SoftwareBackend::into_renderer) is called. Later
298    /// registrations win on codepoint collision.
299    ///
300    /// Available only when the `tilesets` feature is enabled.
301    #[cfg(feature = "tilesets")]
302    #[must_use]
303    pub fn tileset(mut self, opts: TilesetOptions) -> Self {
304        self.options.tilesets.push(opts);
305        self
306    }
307
308    /// Validates options and returns the backend configuration.
309    ///
310    /// Call [`into_renderer`](SoftwareBackend::into_renderer) on the result to
311    /// obtain the renderer (hand it to `retroglyph_window::winit::run_windowed`
312    /// to open a window).
313    ///
314    /// # Errors
315    ///
316    /// Returns [`SoftwareBackendError::NoFont`] if no font was set and the
317    /// `default-font` feature is not enabled.
318    ///
319    /// Returns [`SoftwareBackendError::MixedGlyphSizes`] if the configured chain's fonts disagree
320    /// on their glyph size.
321    ///
322    /// Returns [`SoftwareBackendError::ZeroScale`] if `scale` was set to `0`.
323    ///
324    /// Returns [`SoftwareBackendError::ZeroGrid`] if `cols` or `rows` was set to `0`.
325    pub fn build(self) -> Result<SoftwareBackend, SoftwareBackendError> {
326        let Some(fonts) = self.options.fonts else {
327            return Err(SoftwareBackendError::NoFont);
328        };
329        if fonts.glyph_size().is_none() {
330            return Err(SoftwareBackendError::MixedGlyphSizes);
331        }
332        if self.options.scale == 0 {
333            return Err(SoftwareBackendError::ZeroScale);
334        }
335        if self.options.cols == 0 || self.options.rows == 0 {
336            return Err(SoftwareBackendError::ZeroGrid);
337        }
338        Ok(self.options)
339    }
340}
341
342impl Default for SoftwareBackendBuilder {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use retroglyph_window::font::BitmapFont;
352
353    fn test_font() -> BitmapFont {
354        static DATA: [u8; 16] = [0; 16];
355        BitmapFont::new(&DATA, 8, 16, 1)
356    }
357
358    #[test]
359    fn build_rejects_zero_scale() {
360        let result = SoftwareBackendBuilder::new()
361            .font(test_font())
362            .scale(0)
363            .build();
364        assert!(matches!(result, Err(SoftwareBackendError::ZeroScale)));
365    }
366
367    #[test]
368    fn build_rejects_a_zero_grid_dimension() {
369        let result = SoftwareBackendBuilder::new()
370            .font(test_font())
371            .grid_size(0, 25)
372            .build();
373        assert!(matches!(result, Err(SoftwareBackendError::ZeroGrid)));
374    }
375
376    #[test]
377    fn build_accepts_nonzero_scale() {
378        let result = SoftwareBackendBuilder::new()
379            .font(test_font())
380            .scale(2)
381            .build();
382        assert!(result.is_ok());
383    }
384
385    #[test]
386    fn build_rejects_a_chain_whose_fonts_disagree_on_glyph_size() {
387        static SHORT_DATA: [u8; 8] = [0; 8];
388        static FALLBACKS: [BitmapFont; 1] = [BitmapFont::new(&SHORT_DATA, 8, 8, 1)];
389
390        let result = SoftwareBackendBuilder::new()
391            .font(FontChain::new(test_font(), &FALLBACKS))
392            .build();
393        assert!(matches!(result, Err(SoftwareBackendError::MixedGlyphSizes)));
394    }
395}