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}