Skip to main content

retroglyph_window/
tileset.rs

1//! Tileset configuration: codepage mappings, options, builder, and error types.
2//!
3//! This module defines the public API for configuring PNG sprite sheet tilesets
4//! that overlay or replace [`BitmapFont`](crate::font::BitmapFont) glyphs.
5//! A tileset is a sprite sheet PNG sliced into equally sized tiles, each
6//! mapped to a Unicode codepoint via a [`Codepage`]; [`SpriteCache`](crate::sprite_cache::SpriteCache)
7//! decodes and indexes those tiles for lookup by glyph at draw time.
8
9use core::fmt;
10
11/// Errors that can occur during tileset validation or decoding.
12#[derive(Debug)]
13pub enum TilesetError {
14    /// PNG decode failed.
15    PngDecode(String),
16    /// The image dimensions are not evenly divisible by the declared tile size.
17    InvalidDimensions(u32, u32, u16, u16),
18    /// The codepage mapping table has zero entries.
19    EmptyCodepage,
20    /// The pixel format is not RGBA8 or RGB8.
21    UnsupportedPixelFormat(String),
22    /// `tile_width` or `tile_height` is zero.
23    ZeroTileSize,
24    /// `spacing_cells_x` or `spacing_cells_y` is zero.
25    ZeroSpacing,
26}
27
28impl fmt::Display for TilesetError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::PngDecode(e) => write!(f, "png decode failed: {e}"),
32            Self::InvalidDimensions(iw, ih, tw, th) => {
33                write!(f, "image {iw}x{ih} is not divisible by tile size {tw}x{th}")
34            }
35            Self::EmptyCodepage => write!(f, "codepage mapping has no entries"),
36            Self::UnsupportedPixelFormat(fmt_name) => {
37                write!(
38                    f,
39                    "unsupported pixel format: {fmt_name}; expected RGBA8 or RGB8"
40                )
41            }
42            Self::ZeroTileSize => {
43                write!(f, "tile_width and tile_height must be non-zero")
44            }
45            Self::ZeroSpacing => {
46                write!(f, "spacing_cells_x and spacing_cells_y must be non-zero")
47            }
48        }
49    }
50}
51
52impl std::error::Error for TilesetError {}
53
54/// Maps row-major tile indices in a sprite sheet to Unicode codepoints.
55///
56/// `#[non_exhaustive]` allows adding new variants (e.g. `Cp1252`) without a
57/// semver break.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum Codepage {
61    /// Standard CP437 layout: the i-th tile maps to `CP437_TO_UNICODE[i]`.
62    ///
63    /// Only the first 256 tiles in the sheet are mapped; extras are ignored.
64    Cp437,
65    /// Starting at `start`, tile index `i` maps to `char::from_u32(start as u32 + i)`.
66    ///
67    /// Tiles that would map to a surrogate or exceed `char::MAX` are skipped.
68    Unicode {
69        /// Codepoint of the first tile; subsequent tiles increment by 1.
70        start: char,
71    },
72    /// Positional mapping: tile index `i` maps to `char::from_u32(i)`.
73    ///
74    /// This is the simplest option when you don't care about Unicode semantics
75    /// and just want to reference tiles by a zero-based index. Use
76    /// [`Tile::glyph`](retroglyph_core::Tile::glyph) values 0, 1, 2, … to address
77    /// individual sprites in sheet order.
78    ///
79    /// Tiles whose index falls in the surrogate range (0xD800–0xDFFF) are
80    /// skipped; all others are valid.
81    Identity,
82    /// Explicit mapping: tile `i` maps to `table[i]`.
83    ///
84    /// Tiles beyond `table.len()` are ignored.
85    Custom(Vec<char>),
86}
87
88impl Codepage {
89    /// Returns the codepoint for tile index `i`, or `None` if out of range
90    /// or invalid (surrogates, indices past `char::MAX`).
91    #[must_use]
92    #[allow(clippy::cast_possible_truncation)]
93    pub fn codepoint(&self, i: usize) -> Option<char> {
94        match self {
95            Self::Cp437 => CP437_TO_UNICODE.get(i).copied(),
96            Self::Unicode { start } => {
97                let scalar = (*start as u32).checked_add(i as u32)?;
98                char::from_u32(scalar)
99            }
100            Self::Identity => char::from_u32(i as u32),
101            Self::Custom(table) => table.get(i).copied(),
102        }
103    }
104
105    /// Number of tiles this codepage defines, or `None` for unbounded variants.
106    #[must_use]
107    pub const fn len(&self) -> Option<usize> {
108        match self {
109            Self::Cp437 => Some(256),
110            Self::Unicode { .. } | Self::Identity => None,
111            Self::Custom(t) => Some(t.len()),
112        }
113    }
114
115    /// Returns `true` if the codepage defines zero tiles.
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.len() == Some(0)
119    }
120}
121
122/// Standard IBM CP437 to Unicode mapping, 256 entries.
123pub const CP437_TO_UNICODE: [char; 256] = [
124    '\u{0000}', '\u{263A}', '\u{263B}', '\u{2665}', '\u{2666}', '\u{2663}', '\u{2660}', '\u{2022}',
125    '\u{25D8}', '\u{25CB}', '\u{25D9}', '\u{2642}', '\u{2640}', '\u{266A}', '\u{266B}', '\u{263C}',
126    '\u{25BA}', '\u{25C4}', '\u{2195}', '\u{203C}', '\u{00B6}', '\u{00A7}', '\u{25AC}', '\u{21A8}',
127    '\u{2191}', '\u{2193}', '\u{2192}', '\u{2190}', '\u{221F}', '\u{2194}', '\u{25B2}', '\u{25BC}',
128    ' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2',
129    '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E',
130    'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
131    'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
132    'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~',
133    '\u{2302}', '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}',
134    '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', '\u{00EE}', '\u{00EC}', '\u{00C4}',
135    '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}',
136    '\u{00F9}', '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}',
137    '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', '\u{00F1}', '\u{00D1}', '\u{00AA}',
138    '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}',
139    '\u{00BB}', '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}',
140    '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', '\u{255D}', '\u{255C}', '\u{255B}',
141    '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}',
142    '\u{255F}', '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}',
143    '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', '\u{2558}', '\u{2552}', '\u{2553}',
144    '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}',
145    '\u{2580}', '\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}',
146    '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}', '\u{221E}', '\u{03C6}', '\u{03B5}',
147    '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}',
148    '\u{2248}', '\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}',
149    '\u{00A0}',
150];
151
152/// Options for loading a single tileset (sprite sheet).
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct TilesetOptions {
155    /// Raw bytes of the PNG file.
156    pub bytes: Vec<u8>,
157    /// Width of a single tile in pixels.
158    pub tile_width: u16,
159    /// Height of a single tile in pixels.
160    pub tile_height: u16,
161    /// Number of tiles per row in the sprite sheet.
162    ///
163    /// If `None`, derived as `image_width / tile_width`.
164    pub columns: Option<u16>,
165    /// Codepoint mapping from tile index to Unicode character.
166    pub codepage: Codepage,
167    /// Number of grid cells this sprite spans horizontally. Must be >= 1.
168    pub spacing_cells_x: u16,
169    /// Number of grid cells this sprite spans vertically. Must be >= 1.
170    pub spacing_cells_y: u16,
171    /// If set, any pixel matching this RGB colour is made fully transparent
172    /// (alpha = 0) when decoding the tileset.
173    ///
174    /// Useful for spritesheets that use a solid colour background instead
175    /// of an alpha channel.  Equivalent to bracket-lib's `with_font_bg()`
176    /// or doryen-rs's top-left-pixel key colour auto-detection.
177    pub transparent_color: Option<(u8, u8, u8)>,
178}
179
180impl TilesetOptions {
181    /// Starts building a tileset from raw PNG bytes.
182    ///
183    /// Pass `include_bytes!("...").to_vec()` to embed the asset at compile
184    /// time, or `std::fs::read(path)?` to load it at runtime.
185    #[must_use]
186    pub const fn from_bytes(bytes: Vec<u8>) -> TilesetBuilder {
187        TilesetBuilder {
188            bytes,
189            tile_width: 0,
190            tile_height: 0,
191            columns: None,
192            codepage: Codepage::Cp437,
193            spacing_cells_x: 1,
194            spacing_cells_y: 1,
195            transparent_color: None,
196        }
197    }
198}
199
200/// Builder for [`TilesetOptions`].
201///
202/// Construct via [`TilesetOptions::from_bytes`].
203///
204/// [`columns`](TilesetBuilder::columns) defaults to `image_width / tile_width`,
205/// so you usually don't need to set it explicitly. [`codepage`](TilesetBuilder::codepage)
206/// defaults to [`Codepage::Cp437`].
207///
208/// # Examples
209///
210/// Standard CP437 tileset:
211///
212/// ```ignore
213/// use retroglyph_window::tileset::TilesetOptions;
214///
215/// let png: Vec<u8> = std::fs::read("assets/cp437_16x16.png").unwrap();
216/// let opts = TilesetOptions::from_bytes(png)
217///     .tile_size(16, 16) // codepage defaults to Cp437
218///     .build()
219///     .unwrap();
220/// ```
221///
222/// Private-use sprite sheet addressed by index:
223///
224/// ```ignore
225/// use retroglyph_window::tileset::{Codepage, TilesetOptions};
226///
227/// let png: Vec<u8> = std::fs::read("assets/sprites.png").unwrap();
228/// let opts = TilesetOptions::from_bytes(png)
229///     .tile_size(32, 32)
230///     .codepage(Codepage::Identity) // tile 0 = '\0', tile 1 = '\x01', …
231///     .spacing(2, 2)                // each sprite occupies 2×2 grid cells
232///     .build()
233///     .unwrap();
234/// ```
235///
236/// Unicode private-use area sprite sheet:
237///
238/// ```ignore
239/// use retroglyph_window::tileset::TilesetOptions;
240///
241/// let png: Vec<u8> = std::fs::read("assets/monsters.png").unwrap();
242/// let opts = TilesetOptions::from_bytes(png)
243///     .tile_size(16, 16)
244///     .start_codepoint('\u{E000}') // maps to Unicode PUA starting at U+E000
245///     .build()
246///     .unwrap();
247/// ```
248pub struct TilesetBuilder {
249    bytes: Vec<u8>,
250    tile_width: u16,
251    tile_height: u16,
252    columns: Option<u16>,
253    codepage: Codepage,
254    spacing_cells_x: u16,
255    spacing_cells_y: u16,
256    transparent_color: Option<(u8, u8, u8)>,
257}
258
259impl TilesetBuilder {
260    /// Sets the pixel dimensions of each tile.
261    #[must_use]
262    pub const fn tile_size(mut self, width: u16, height: u16) -> Self {
263        self.tile_width = width;
264        self.tile_height = height;
265        self
266    }
267
268    /// Sets the number of tiles per row in the sprite sheet.
269    ///
270    /// Useful for sheets with padding. If not set, derived from image width.
271    #[must_use]
272    pub const fn columns(mut self, cols: u16) -> Self {
273        self.columns = Some(cols);
274        self
275    }
276
277    /// Sets the codepoint mapping.
278    #[must_use]
279    pub fn codepage(mut self, codepage: Codepage) -> Self {
280        self.codepage = codepage;
281        self
282    }
283
284    /// Sets the codepoint of the first tile; subsequent tiles increment by 1.
285    ///
286    /// Shorthand for `codepage(Codepage::Unicode { start })`.
287    #[must_use]
288    pub fn start_codepoint(mut self, start: char) -> Self {
289        self.codepage = Codepage::Unicode { start };
290        self
291    }
292
293    /// Number of grid cells each sprite occupies (width x height).
294    ///
295    /// Defaults to (1, 1). A value of (2, 2) means the sprite spans 2x2 cells.
296    #[must_use]
297    pub const fn spacing(mut self, x: u16, y: u16) -> Self {
298        self.spacing_cells_x = x;
299        self.spacing_cells_y = y;
300        self
301    }
302
303    /// Pixels matching `(r, g, b)` are made fully transparent (alpha = 0).
304    ///
305    /// Use this for spritesheets that use a solid colour background instead
306    /// of an alpha channel.
307    #[must_use]
308    pub const fn transparent_color(mut self, r: u8, g: u8, b: u8) -> Self {
309        self.transparent_color = Some((r, g, b));
310        self
311    }
312
313    /// Validates and builds [`TilesetOptions`].
314    ///
315    /// # Errors
316    ///
317    /// Returns [`TilesetError::ZeroTileSize`] if tile dimensions are 0,
318    /// [`TilesetError::ZeroSpacing`] if spacing is 0, or
319    /// [`TilesetError::EmptyCodepage`] if `Custom` codepage is empty.
320    pub fn build(self) -> Result<TilesetOptions, TilesetError> {
321        if self.tile_width == 0 || self.tile_height == 0 {
322            return Err(TilesetError::ZeroTileSize);
323        }
324        if self.spacing_cells_x == 0 || self.spacing_cells_y == 0 {
325            return Err(TilesetError::ZeroSpacing);
326        }
327        if let Codepage::Custom(ref t) = self.codepage
328            && t.is_empty()
329        {
330            return Err(TilesetError::EmptyCodepage);
331        }
332        Ok(TilesetOptions {
333            bytes: self.bytes,
334            tile_width: self.tile_width,
335            tile_height: self.tile_height,
336            columns: self.columns,
337            codepage: self.codepage,
338            spacing_cells_x: self.spacing_cells_x,
339            spacing_cells_y: self.spacing_cells_y,
340            transparent_color: self.transparent_color,
341        })
342    }
343}
344
345// ── Tests ─────────────────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn tileset_builder_rejects_zero_tile_size() {
353        let opts = TilesetOptions::from_bytes(vec![]).tile_size(0, 16).build();
354        assert!(matches!(opts, Err(TilesetError::ZeroTileSize)));
355    }
356
357    #[test]
358    fn tileset_builder_rejects_zero_spacing() {
359        let opts = TilesetOptions::from_bytes(vec![])
360            .tile_size(16, 16)
361            .spacing(0, 1)
362            .build();
363        assert!(matches!(opts, Err(TilesetError::ZeroSpacing)));
364    }
365
366    #[test]
367    fn tileset_builder_rejects_empty_custom_codepage() {
368        let opts = TilesetOptions::from_bytes(vec![])
369            .tile_size(16, 16)
370            .codepage(Codepage::Custom(vec![]))
371            .build();
372        assert!(matches!(opts, Err(TilesetError::EmptyCodepage)));
373    }
374
375    #[test]
376    fn tileset_builder_valid() {
377        let opts = TilesetOptions::from_bytes(vec![0u8; 64])
378            .tile_size(16, 16)
379            .start_codepoint('\u{E000}')
380            .spacing(2, 2)
381            .build()
382            .unwrap();
383        assert_eq!(opts.tile_width, 16);
384        assert_eq!(opts.spacing_cells_x, 2);
385        assert!(matches!(
386            opts.codepage,
387            Codepage::Unicode { start: '\u{E000}' }
388        ));
389    }
390
391    #[test]
392    fn cp437_codepage_spot_checks() {
393        assert_eq!(Codepage::Cp437.codepoint(32), Some(' '));
394        assert_eq!(Codepage::Cp437.codepoint(64), Some('@'));
395        assert_eq!(Codepage::Cp437.codepoint(176), Some('\u{2591}'));
396        assert_eq!(Codepage::Cp437.codepoint(256), None);
397    }
398
399    #[test]
400    fn identity_codepage_positional() {
401        assert_eq!(Codepage::Identity.codepoint(0), Some('\0'));
402        assert_eq!(Codepage::Identity.codepoint(65), Some('A'));
403        // Surrogate range must be skipped.
404        assert_eq!(Codepage::Identity.codepoint(0xD800), None);
405        assert_eq!(Codepage::Identity.codepoint(0xDFFF), None);
406        // Above surrogates is fine.
407        assert_eq!(Codepage::Identity.codepoint(0xE000), Some('\u{E000}'));
408    }
409
410    #[test]
411    fn unicode_codepage_offset() {
412        let cp = Codepage::Unicode { start: '\u{E000}' };
413        assert_eq!(cp.codepoint(0), Some('\u{E000}'));
414        assert_eq!(cp.codepoint(5), Some('\u{E005}'));
415    }
416
417    #[test]
418    fn custom_codepage_bounds() {
419        let cp = Codepage::Custom(vec!['A', 'B', 'C']);
420        assert_eq!(cp.codepoint(0), Some('A'));
421        assert_eq!(cp.codepoint(2), Some('C'));
422        assert_eq!(cp.codepoint(3), None);
423    }
424}