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::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}