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/// What a tileset's pixels mean, which decides how its sprites respond to the cell's foreground
12/// color.
13///
14/// This is a fact about how the artwork was authored, not about any one draw call, which is why
15/// it sits on the tileset rather than at the call site. A sheet of full-color terrain and a sheet
16/// of white icon masks can be loaded side by side and each behave correctly.
17///
18/// Orthogonal to [`Tint`](retroglyph_core::color::Tint), which is per-cell and applies on top: see
19/// [`Surface::with_tint`](retroglyph_core::surface::Surface::with_tint).
20///
21/// Open question (retroglyph#559): a sheet mixing mask tiles and full-colour art tiles has no
22/// way to say so today, since this is a sheet-wide setting. The likely answer is to split such a
23/// sheet into two `TilesetOptions` loads, one per `SheetColor`, rather than adding a per-tile
24/// escape hatch here. That is untested against a real mixed asset and not resolved by this
25/// type as written.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
27#[non_exhaustive]
28pub enum SheetColor {
29    /// Full-color artwork, composited verbatim.
30    ///
31    /// The cell's [`Style::fg`](retroglyph_core::color::Style::fg) does not touch it. The default,
32    /// because a sheet that carries its own color is the common case and rendering it as
33    /// authored is the unsurprising outcome.
34    #[default]
35    Art,
36    /// A white-on-transparent mask, colored by the cell's foreground the way a font glyph is.
37    ///
38    /// Equivalent to a [`Tint::Multiply`](retroglyph_core::color::Tint::Multiply) by the resolved
39    /// foreground color, so a white pixel takes the foreground exactly and a grey one takes a
40    /// proportionally darker shade. This is how libtcod tilesets, Dwarf Fortress's classic
41    /// tileset, and `BearLibTerminal`'s bitmap fonts all behave, and it is the one case where
42    /// reading `fg` as a sprite's color is correct rather than a workaround.
43    ///
44    /// It also keeps a sprite and its text fallback in agreement: the same `fg` colors the
45    /// sprite on a pixel backend and the fallback glyph on a cell backend.
46    Mask,
47}
48
49/// Where a sprite sits inside the multi-cell box a span reserves for it.
50///
51/// Geometry only: alignment moves a sprite's pixels, it never changes their color. See
52/// [`TilesetOptions`] for how a sprite's color relates to the cell's style.
53///
54/// Only observable when the reserved box is larger than the sprite's own pixels, i.e. when
55/// [`Surface::put_span`](retroglyph_core::surface::Surface::put_span) declares more cells than the
56/// artwork fills. A sprite drawn into a box its art exactly fills (the common case) renders
57/// identically under every variant. Mirrors `BearLibTerminal`'s tileset `align=` option.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59#[non_exhaustive]
60pub enum SpriteAlign {
61    /// Flush with the box's top-left corner.
62    #[default]
63    TopLeft,
64    /// Centred horizontally, flush with the top edge.
65    Top,
66    /// Flush with the top-right corner.
67    TopRight,
68    /// Flush with the left edge, centred vertically.
69    Left,
70    /// Centred on both axes.
71    Center,
72    /// Flush with the right edge, centred vertically.
73    Right,
74    /// Flush with the bottom-left corner.
75    BottomLeft,
76    /// Centred horizontally, flush with the bottom edge.
77    Bottom,
78    /// Flush with the bottom-right corner.
79    BottomRight,
80}
81
82impl SpriteAlign {
83    /// Returns the offset of a `sprite_w` x `sprite_h` sprite placed inside a `box_w` x `box_h`
84    /// box, in unscaled pixels to match [`Tile::dx`](retroglyph_core::tile::Tile::dx).
85    ///
86    /// Centring uses integer division, so an odd leftover pixel lands on the right/bottom side.
87    /// Saturates at `0` on either axis where the sprite is at least as large as the box, so an
88    /// oversized sprite is never pulled off its own anchor cell.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use retroglyph_window::tileset::SpriteAlign;
94    ///
95    /// // 16x16 art in a 32x32 box leaves 16 pixels of slack on each axis.
96    /// assert_eq!(SpriteAlign::Center.offset(16, 16, 32, 32), (8, 8));
97    /// assert_eq!(SpriteAlign::BottomRight.offset(16, 16, 32, 32), (16, 16));
98    /// // Art that fills its box renders identically under every variant.
99    /// assert_eq!(SpriteAlign::Center.offset(16, 16, 16, 16), (0, 0));
100    /// ```
101    #[must_use]
102    pub const fn offset(self, sprite_w: u32, sprite_h: u32, box_w: u32, box_h: u32) -> (i16, i16) {
103        let slack_x = box_w.saturating_sub(sprite_w);
104        let slack_y = box_h.saturating_sub(sprite_h);
105        let (x, y) = match self {
106            Self::TopLeft => (0, 0),
107            Self::Top => (slack_x / 2, 0),
108            Self::TopRight => (slack_x, 0),
109            Self::Left => (0, slack_y / 2),
110            Self::Center => (slack_x / 2, slack_y / 2),
111            Self::Right => (slack_x, slack_y / 2),
112            Self::BottomLeft => (0, slack_y),
113            Self::Bottom => (slack_x / 2, slack_y),
114            Self::BottomRight => (slack_x, slack_y),
115        };
116        // Slack is bounded by the box, which is a cell count times a `u8` glyph size, so it
117        // cannot reach `i16::MAX` for any grid a backend can actually render. `saturating_as`
118        // isn't const, hence the explicit clamp.
119        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
120        (
121            (if x > i16::MAX as u32 {
122                i16::MAX as u32
123            } else {
124                x
125            }) as i16,
126            (if y > i16::MAX as u32 {
127                i16::MAX as u32
128            } else {
129                y
130            }) as i16,
131        )
132    }
133
134    /// Returns the offset of a `sprite_w` x `sprite_h` sprite inside the box a `span_w` x
135    /// `span_h` span of `glyph_w` x `glyph_h` cells reserves for it, in unscaled pixels.
136    ///
137    /// `span_w`/`span_h` come from [`Tile::span`](retroglyph_core::tile::Tile::span) and `glyph_w`/
138    /// `glyph_h` are the unscaled cell size, so the box is `span_w * glyph_w` x `span_h *
139    /// glyph_h` pixels. A zero cell size is treated as one pixel, leaving the sprite on its
140    /// anchor rather than offsetting it by a meaningless amount.
141    #[must_use]
142    pub const fn offset_in_span(
143        self,
144        sprite_w: u32,
145        sprite_h: u32,
146        span_w: u16,
147        span_h: u16,
148        glyph_w: u8,
149        glyph_h: u8,
150    ) -> (i16, i16) {
151        let box_w = span_w as u32 * if glyph_w == 0 { 1 } else { glyph_w as u32 };
152        let box_h = span_h as u32 * if glyph_h == 0 { 1 } else { glyph_h as u32 };
153        self.offset(sprite_w, sprite_h, box_w, box_h)
154    }
155}
156
157/// Errors that can occur during tileset validation or decoding.
158#[derive(Debug)]
159#[non_exhaustive]
160pub enum TilesetError {
161    /// Image decode failed: the bytes are not a sprite sheet in a format the `image` crate
162    /// supports.
163    ImageDecode(String),
164    /// The image dimensions are not evenly divisible by the declared tile size.
165    InvalidDimensions(u32, u32, u16, u16),
166    /// The codepage mapping table has zero entries.
167    EmptyCodepage,
168    /// `tile_width` or `tile_height` is zero.
169    ZeroTileSize,
170    /// [`TilesetBuilder::columns`] declared more columns than the image actually has at the given
171    /// `tile_width`: honoring it would read tile pixels from past the end of the decoded buffer.
172    TooManyColumns(u16, u32),
173}
174
175impl fmt::Display for TilesetError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::ImageDecode(e) => write!(f, "image decode failed: {e}"),
179            Self::InvalidDimensions(iw, ih, tw, th) => {
180                write!(f, "image {iw}x{ih} is not divisible by tile size {tw}x{th}")
181            }
182            Self::EmptyCodepage => write!(f, "codepage mapping has no entries"),
183            Self::ZeroTileSize => {
184                write!(f, "tile_width and tile_height must be non-zero")
185            }
186            Self::TooManyColumns(declared, actual) => write!(
187                f,
188                "declared {declared} columns but the image only has {actual} at this tile_width"
189            ),
190        }
191    }
192}
193
194impl std::error::Error for TilesetError {}
195
196/// Maps row-major tile indices in a sprite sheet to Unicode codepoints.
197///
198/// `#[non_exhaustive]` allows adding new variants (e.g. `Cp1252`) without a
199/// semver break.
200#[derive(Debug, Clone, PartialEq, Eq)]
201#[non_exhaustive]
202pub enum Codepage {
203    /// Standard CP437 layout: the i-th tile maps to `CP437_TO_UNICODE[i]`.
204    ///
205    /// Only the first 256 tiles in the sheet are mapped; extras are ignored.
206    Cp437,
207    /// Starting at `start`, tile index `i` maps to `char::from_u32(start as u32 + i)`.
208    ///
209    /// Tiles that would map to a surrogate or exceed `char::MAX` are skipped.
210    Unicode {
211        /// Codepoint of the first tile; subsequent tiles increment by 1.
212        start: char,
213    },
214    /// Positional mapping: tile index `i` maps to `char::from_u32(i)`.
215    ///
216    /// This is the simplest option when you don't care about Unicode semantics
217    /// and just want to reference tiles by a zero-based index. Use
218    /// [`Tile::glyph`](retroglyph_core::tile::Tile::glyph) values 0, 1, 2, … to address
219    /// individual sprites in sheet order.
220    ///
221    /// Tiles whose index falls in the surrogate range (0xD800–0xDFFF) are
222    /// skipped; all others are valid.
223    Identity,
224    /// Explicit mapping: tile `i` maps to `table[i]`.
225    ///
226    /// Tiles beyond `table.len()` are ignored.
227    Custom(Vec<char>),
228}
229
230impl Codepage {
231    /// Returns the codepoint for tile index `i`, or `None` if out of range
232    /// or invalid (surrogates, indices past `char::MAX`).
233    #[must_use]
234    pub fn codepoint(&self, i: usize) -> Option<char> {
235        match self {
236            Self::Cp437 => CP437_TO_UNICODE.get(i).copied(),
237            Self::Unicode { start } => {
238                let i = u32::try_from(i).ok()?;
239                let scalar = (*start as u32).checked_add(i)?;
240                char::from_u32(scalar)
241            }
242            Self::Identity => char::from_u32(u32::try_from(i).ok()?),
243            Self::Custom(table) => table.get(i).copied(),
244        }
245    }
246
247    /// Number of tiles this codepage defines, or `None` for unbounded variants.
248    #[must_use]
249    pub const fn len(&self) -> Option<usize> {
250        match self {
251            Self::Cp437 => Some(256),
252            Self::Unicode { .. } | Self::Identity => None,
253            Self::Custom(t) => Some(t.len()),
254        }
255    }
256
257    /// Returns `true` if the codepage defines zero tiles.
258    #[must_use]
259    pub fn is_empty(&self) -> bool {
260        self.len() == Some(0)
261    }
262}
263
264/// Standard IBM CP437 to Unicode mapping, 256 entries.
265pub const CP437_TO_UNICODE: [char; 256] = [
266    '\u{0000}', '\u{263A}', '\u{263B}', '\u{2665}', '\u{2666}', '\u{2663}', '\u{2660}', '\u{2022}',
267    '\u{25D8}', '\u{25CB}', '\u{25D9}', '\u{2642}', '\u{2640}', '\u{266A}', '\u{266B}', '\u{263C}',
268    '\u{25BA}', '\u{25C4}', '\u{2195}', '\u{203C}', '\u{00B6}', '\u{00A7}', '\u{25AC}', '\u{21A8}',
269    '\u{2191}', '\u{2193}', '\u{2192}', '\u{2190}', '\u{221F}', '\u{2194}', '\u{25B2}', '\u{25BC}',
270    ' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2',
271    '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E',
272    'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
273    'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
274    'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~',
275    '\u{2302}', '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}',
276    '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', '\u{00EE}', '\u{00EC}', '\u{00C4}',
277    '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}',
278    '\u{00F9}', '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}',
279    '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', '\u{00F1}', '\u{00D1}', '\u{00AA}',
280    '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}',
281    '\u{00BB}', '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}',
282    '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', '\u{255D}', '\u{255C}', '\u{255B}',
283    '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}',
284    '\u{255F}', '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}',
285    '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', '\u{2558}', '\u{2552}', '\u{2553}',
286    '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}',
287    '\u{2580}', '\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}',
288    '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}', '\u{221E}', '\u{03C6}', '\u{03B5}',
289    '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}',
290    '\u{2248}', '\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}',
291    '\u{00A0}',
292];
293
294/// Options for loading a single tileset (sprite sheet).
295///
296/// # Sprites carry their own color
297///
298/// By default ([`SheetColor::Art`]) a tileset's artwork is composited verbatim: the cell's
299/// [`Style::fg`](retroglyph_core::color::Style::fg) does not tint it, so a full-color sheet renders
300/// exactly as authored. The cell's background is still painted behind the sprite and shows
301/// through its transparent pixels.
302///
303/// A sheet authored as white-on-transparent masks declares [`SheetColor::Mask`] instead, and its
304/// sprites are colored by the cell's foreground the way a bitmap font glyph is.
305///
306/// Recoloring one piece of artwork per cell (biome variants, damage flashes) is a per-draw
307/// decision rather than a sheet-wide one, and goes through
308/// [`Surface::with_tint`](retroglyph_core::surface::Surface::with_tint).
309#[derive(Debug, Clone, PartialEq, Eq)]
310#[non_exhaustive]
311pub struct TilesetOptions {
312    /// Raw bytes of the sprite sheet image (any format the `image` crate supports).
313    pub bytes: Vec<u8>,
314    /// Width of a single tile in pixels.
315    pub tile_width: u16,
316    /// Height of a single tile in pixels.
317    pub tile_height: u16,
318    /// Number of tiles per row in the sprite sheet.
319    ///
320    /// If `None`, derived as `image_width / tile_width`.
321    pub columns: Option<u16>,
322    /// Codepoint mapping from tile index to Unicode character.
323    pub codepage: Codepage,
324    /// Where each sprite sits inside the multi-cell box a span reserves for it.
325    pub align: SpriteAlign,
326    /// What this sheet's pixels mean, and so whether the cell's foreground color colors them.
327    pub color: SheetColor,
328    /// If set, any pixel matching this RGB colour is made fully transparent
329    /// (alpha = 0) when decoding the tileset.
330    ///
331    /// Useful for spritesheets that use a solid colour background instead
332    /// of an alpha channel.  Equivalent to bracket-lib's `with_font_bg()`
333    /// or doryen-rs's top-left-pixel key colour auto-detection.
334    pub transparent_color: Option<(u8, u8, u8)>,
335}
336
337impl TilesetOptions {
338    /// Starts building a tileset from raw sprite sheet bytes.
339    ///
340    /// Pass `include_bytes!("...").to_vec()` to embed the asset at compile
341    /// time, or `std::fs::read(path)?` to load it at runtime.
342    #[must_use]
343    pub const fn builder(bytes: Vec<u8>) -> TilesetBuilder {
344        TilesetBuilder {
345            bytes,
346            tile_width: 0,
347            tile_height: 0,
348            columns: None,
349            codepage: Codepage::Cp437,
350            align: SpriteAlign::TopLeft,
351            color: SheetColor::Art,
352            transparent_color: None,
353        }
354    }
355}
356
357/// Builder for [`TilesetOptions`].
358///
359/// Construct via [`TilesetOptions::builder`].
360///
361/// [`columns`](TilesetBuilder::columns) defaults to `image_width / tile_width`,
362/// so you usually don't need to set it explicitly. [`codepage`](TilesetBuilder::codepage)
363/// defaults to [`Codepage::Cp437`].
364///
365/// # Examples
366///
367/// Standard CP437 tileset:
368///
369/// ```no_run
370/// use retroglyph_window::tileset::TilesetOptions;
371///
372/// let png: Vec<u8> = std::fs::read("assets/cp437_16x16.png").unwrap();
373/// let opts = TilesetOptions::builder(png)
374///     .tile_size(16, 16) // codepage defaults to Cp437
375///     .build()
376///     .unwrap();
377/// ```
378///
379/// Private-use sprite sheet addressed by index, centred in whatever box a span reserves:
380///
381/// ```no_run
382/// use retroglyph_window::tileset::{Codepage, SpriteAlign, TilesetOptions};
383///
384/// let png: Vec<u8> = std::fs::read("assets/sprites.png").unwrap();
385/// let opts = TilesetOptions::builder(png)
386///     .tile_size(32, 32)
387///     .codepage(Codepage::Identity)  // tile 0 = '\0', tile 1 = '\x01', …
388///     .align(SpriteAlign::Center)
389///     .build()
390///     .unwrap();
391/// ```
392///
393/// How many cells a sprite occupies is a per-write decision, not a tileset-wide one: declare it
394/// with [`Surface::put_span`](retroglyph_core::surface::Surface::put_span) at the draw call.
395///
396/// Unicode private-use area sprite sheet:
397///
398/// ```no_run
399/// use retroglyph_window::tileset::TilesetOptions;
400///
401/// let png: Vec<u8> = std::fs::read("assets/monsters.png").unwrap();
402/// let opts = TilesetOptions::builder(png)
403///     .tile_size(16, 16)
404///     .start_codepoint('\u{E000}') // maps to Unicode PUA starting at U+E000
405///     .build()
406///     .unwrap();
407/// ```
408pub struct TilesetBuilder {
409    bytes: Vec<u8>,
410    tile_width: u16,
411    tile_height: u16,
412    columns: Option<u16>,
413    codepage: Codepage,
414    align: SpriteAlign,
415    color: SheetColor,
416    transparent_color: Option<(u8, u8, u8)>,
417}
418
419impl TilesetBuilder {
420    /// Sets the pixel dimensions of each tile.
421    #[must_use]
422    pub const fn tile_size(mut self, width: u16, height: u16) -> Self {
423        self.tile_width = width;
424        self.tile_height = height;
425        self
426    }
427
428    /// Sets the number of tiles per row in the sprite sheet.
429    ///
430    /// Useful for sheets with padding. If not set, derived from image width.
431    #[must_use]
432    pub const fn columns(mut self, cols: u16) -> Self {
433        self.columns = Some(cols);
434        self
435    }
436
437    /// Sets the codepoint mapping.
438    #[must_use]
439    pub fn codepage(mut self, codepage: Codepage) -> Self {
440        self.codepage = codepage;
441        self
442    }
443
444    /// Sets the codepoint of the first tile; subsequent tiles increment by 1.
445    ///
446    /// Shorthand for `codepage(Codepage::Unicode { start })`.
447    #[must_use]
448    pub fn start_codepoint(mut self, start: char) -> Self {
449        self.codepage = Codepage::Unicode { start };
450        self
451    }
452
453    /// Sets where each sprite sits inside the multi-cell box a span reserves for it.
454    ///
455    /// Defaults to [`SpriteAlign::TopLeft`]. Has no visible effect on a sprite whose art fills
456    /// its box exactly; see [`SpriteAlign`].
457    #[must_use]
458    pub const fn align(mut self, align: SpriteAlign) -> Self {
459        self.align = align;
460        self
461    }
462
463    /// Declares what this sheet's pixels mean, and so whether the cell's foreground color
464    /// colors them.
465    ///
466    /// Defaults to [`SheetColor::Art`]: composited verbatim. See [`SheetColor`].
467    #[must_use]
468    pub const fn color(mut self, color: SheetColor) -> Self {
469        self.color = color;
470        self
471    }
472
473    /// Shorthand for [`color(SheetColor::Mask)`](Self::color): this sheet is white-on-
474    /// transparent artwork to be colored by each cell's foreground.
475    #[must_use]
476    pub const fn mask(self) -> Self {
477        self.color(SheetColor::Mask)
478    }
479
480    /// Pixels matching `(r, g, b)` are made fully transparent (alpha = 0).
481    ///
482    /// Use this for spritesheets that use a solid colour background instead
483    /// of an alpha channel.
484    #[must_use]
485    pub const fn transparent_color(mut self, r: u8, g: u8, b: u8) -> Self {
486        self.transparent_color = Some((r, g, b));
487        self
488    }
489
490    /// Validates and builds [`TilesetOptions`].
491    ///
492    /// # Errors
493    ///
494    /// Returns [`TilesetError::ZeroTileSize`] if tile dimensions are 0, or
495    /// [`TilesetError::EmptyCodepage`] if `Custom` codepage is empty.
496    pub fn build(self) -> Result<TilesetOptions, TilesetError> {
497        if self.tile_width == 0 || self.tile_height == 0 {
498            return Err(TilesetError::ZeroTileSize);
499        }
500        if let Codepage::Custom(ref t) = self.codepage
501            && t.is_empty()
502        {
503            return Err(TilesetError::EmptyCodepage);
504        }
505        Ok(TilesetOptions {
506            bytes: self.bytes,
507            tile_width: self.tile_width,
508            tile_height: self.tile_height,
509            columns: self.columns,
510            codepage: self.codepage,
511            align: self.align,
512            color: self.color,
513            transparent_color: self.transparent_color,
514        })
515    }
516}
517
518// ── Tests ─────────────────────────────────────────────────────────────────
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn tileset_builder_rejects_zero_tile_size() {
526        let opts = TilesetOptions::builder(vec![]).tile_size(0, 16).build();
527        assert!(matches!(opts, Err(TilesetError::ZeroTileSize)));
528    }
529
530    #[test]
531    fn tileset_builder_rejects_empty_custom_codepage() {
532        let opts = TilesetOptions::builder(vec![])
533            .tile_size(16, 16)
534            .codepage(Codepage::Custom(vec![]))
535            .build();
536        assert!(matches!(opts, Err(TilesetError::EmptyCodepage)));
537    }
538
539    #[test]
540    fn tileset_builder_valid() {
541        let opts = TilesetOptions::builder(vec![0u8; 64])
542            .tile_size(16, 16)
543            .start_codepoint('\u{E000}')
544            .align(SpriteAlign::Center)
545            .build()
546            .unwrap();
547        assert_eq!(opts.tile_width, 16);
548        assert_eq!(opts.align, SpriteAlign::Center);
549        assert!(matches!(
550            opts.codepage,
551            Codepage::Unicode { start: '\u{E000}' }
552        ));
553    }
554
555    #[test]
556    fn tileset_builder_defaults_to_top_left_alignment() {
557        let opts = TilesetOptions::builder(vec![0u8; 64])
558            .tile_size(16, 16)
559            .build()
560            .unwrap();
561        assert_eq!(opts.align, SpriteAlign::TopLeft);
562    }
563
564    #[test]
565    fn sprite_align_positions_art_within_a_larger_box() {
566        // 16x16 art in a 32x32 box: 16 pixels of slack on each axis.
567        let at = |align: SpriteAlign| align.offset(16, 16, 32, 32);
568        assert_eq!(at(SpriteAlign::TopLeft), (0, 0));
569        assert_eq!(at(SpriteAlign::Top), (8, 0));
570        assert_eq!(at(SpriteAlign::TopRight), (16, 0));
571        assert_eq!(at(SpriteAlign::Left), (0, 8));
572        assert_eq!(at(SpriteAlign::Center), (8, 8));
573        assert_eq!(at(SpriteAlign::Right), (16, 8));
574        assert_eq!(at(SpriteAlign::BottomLeft), (0, 16));
575        assert_eq!(at(SpriteAlign::Bottom), (8, 16));
576        assert_eq!(at(SpriteAlign::BottomRight), (16, 16));
577    }
578
579    #[test]
580    fn sprite_align_centring_rounds_down() {
581        // 9 pixels of slack: the odd leftover pixel goes to the right/bottom.
582        assert_eq!(SpriteAlign::Center.offset(7, 7, 16, 16), (4, 4));
583        assert_eq!(SpriteAlign::Center.offset(8, 8, 15, 15), (3, 3));
584    }
585
586    #[test]
587    fn sprite_align_is_a_no_op_when_the_art_fills_the_box() {
588        for align in [
589            SpriteAlign::TopLeft,
590            SpriteAlign::Top,
591            SpriteAlign::TopRight,
592            SpriteAlign::Left,
593            SpriteAlign::Center,
594            SpriteAlign::Right,
595            SpriteAlign::BottomLeft,
596            SpriteAlign::Bottom,
597            SpriteAlign::BottomRight,
598        ] {
599            assert_eq!(align.offset(16, 16, 16, 16), (0, 0), "{align:?}");
600        }
601    }
602
603    #[test]
604    fn sprite_align_saturates_when_the_art_exceeds_the_box() {
605        // Never pull an oversized sprite off its own anchor cell.
606        assert_eq!(SpriteAlign::Center.offset(32, 32, 16, 16), (0, 0));
607        assert_eq!(SpriteAlign::BottomRight.offset(32, 32, 16, 16), (0, 0));
608    }
609
610    #[test]
611    fn cp437_codepage_spot_checks() {
612        assert_eq!(Codepage::Cp437.codepoint(32), Some(' '));
613        assert_eq!(Codepage::Cp437.codepoint(64), Some('@'));
614        assert_eq!(Codepage::Cp437.codepoint(176), Some('\u{2591}'));
615        assert_eq!(Codepage::Cp437.codepoint(256), None);
616    }
617
618    #[test]
619    fn identity_codepage_positional() {
620        assert_eq!(Codepage::Identity.codepoint(0), Some('\0'));
621        assert_eq!(Codepage::Identity.codepoint(65), Some('A'));
622        // Surrogate range must be skipped.
623        assert_eq!(Codepage::Identity.codepoint(0xD800), None);
624        assert_eq!(Codepage::Identity.codepoint(0xDFFF), None);
625        // Above surrogates is fine.
626        assert_eq!(Codepage::Identity.codepoint(0xE000), Some('\u{E000}'));
627    }
628
629    #[test]
630    fn unicode_codepage_offset() {
631        let cp = Codepage::Unicode { start: '\u{E000}' };
632        assert_eq!(cp.codepoint(0), Some('\u{E000}'));
633        assert_eq!(cp.codepoint(5), Some('\u{E005}'));
634    }
635
636    #[test]
637    fn custom_codepage_bounds() {
638        let cp = Codepage::Custom(vec!['A', 'B', 'C']);
639        assert_eq!(cp.codepoint(0), Some('A'));
640        assert_eq!(cp.codepoint(2), Some('C'));
641        assert_eq!(cp.codepoint(3), None);
642    }
643
644    #[test]
645    fn codepage_codepoint_index_past_u32_is_not_reported_out_of_range() {
646        // Regression test for retroglyph#731: an index at or beyond u32::MAX must not
647        // wrap around and land on an unrelated valid codepoint.
648        let huge = 0x1_0000_0041usize; // 2^32 + 0x41 ('A' + 2^32)
649        assert_eq!(Codepage::Identity.codepoint(huge), None);
650        assert_eq!(
651            Codepage::Unicode { start: 'A' }.codepoint(0x1_0000_0000usize),
652            None
653        );
654    }
655}