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::Tint), which is per-cell and applies on top: see
19/// [`Surface::with_tint`](retroglyph_core::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 -- but 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::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::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::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::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
135/// Errors that can occur during tileset validation or decoding.
136#[derive(Debug)]
137#[non_exhaustive]
138pub enum TilesetError {
139 /// PNG decode failed.
140 PngDecode(String),
141 /// The image dimensions are not evenly divisible by the declared tile size.
142 InvalidDimensions(u32, u32, u16, u16),
143 /// The codepage mapping table has zero entries.
144 EmptyCodepage,
145 /// The pixel format is not RGBA8 or RGB8.
146 UnsupportedPixelFormat(String),
147 /// `tile_width` or `tile_height` is zero.
148 ZeroTileSize,
149}
150
151impl fmt::Display for TilesetError {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::PngDecode(e) => write!(f, "png decode failed: {e}"),
155 Self::InvalidDimensions(iw, ih, tw, th) => {
156 write!(f, "image {iw}x{ih} is not divisible by tile size {tw}x{th}")
157 }
158 Self::EmptyCodepage => write!(f, "codepage mapping has no entries"),
159 Self::UnsupportedPixelFormat(fmt_name) => {
160 write!(
161 f,
162 "unsupported pixel format: {fmt_name}; expected RGBA8 or RGB8"
163 )
164 }
165 Self::ZeroTileSize => {
166 write!(f, "tile_width and tile_height must be non-zero")
167 }
168 }
169 }
170}
171
172impl std::error::Error for TilesetError {}
173
174/// Maps row-major tile indices in a sprite sheet to Unicode codepoints.
175///
176/// `#[non_exhaustive]` allows adding new variants (e.g. `Cp1252`) without a
177/// semver break.
178#[derive(Debug, Clone, PartialEq, Eq)]
179#[non_exhaustive]
180pub enum Codepage {
181 /// Standard CP437 layout: the i-th tile maps to `CP437_TO_UNICODE[i]`.
182 ///
183 /// Only the first 256 tiles in the sheet are mapped; extras are ignored.
184 Cp437,
185 /// Starting at `start`, tile index `i` maps to `char::from_u32(start as u32 + i)`.
186 ///
187 /// Tiles that would map to a surrogate or exceed `char::MAX` are skipped.
188 Unicode {
189 /// Codepoint of the first tile; subsequent tiles increment by 1.
190 start: char,
191 },
192 /// Positional mapping: tile index `i` maps to `char::from_u32(i)`.
193 ///
194 /// This is the simplest option when you don't care about Unicode semantics
195 /// and just want to reference tiles by a zero-based index. Use
196 /// [`Tile::glyph`](retroglyph_core::Tile::glyph) values 0, 1, 2, … to address
197 /// individual sprites in sheet order.
198 ///
199 /// Tiles whose index falls in the surrogate range (0xD800–0xDFFF) are
200 /// skipped; all others are valid.
201 Identity,
202 /// Explicit mapping: tile `i` maps to `table[i]`.
203 ///
204 /// Tiles beyond `table.len()` are ignored.
205 Custom(Vec<char>),
206}
207
208impl Codepage {
209 /// Returns the codepoint for tile index `i`, or `None` if out of range
210 /// or invalid (surrogates, indices past `char::MAX`).
211 #[must_use]
212 #[allow(clippy::cast_possible_truncation)]
213 pub fn codepoint(&self, i: usize) -> Option<char> {
214 match self {
215 Self::Cp437 => CP437_TO_UNICODE.get(i).copied(),
216 Self::Unicode { start } => {
217 let scalar = (*start as u32).checked_add(i as u32)?;
218 char::from_u32(scalar)
219 }
220 Self::Identity => char::from_u32(i as u32),
221 Self::Custom(table) => table.get(i).copied(),
222 }
223 }
224
225 /// Number of tiles this codepage defines, or `None` for unbounded variants.
226 #[must_use]
227 pub const fn len(&self) -> Option<usize> {
228 match self {
229 Self::Cp437 => Some(256),
230 Self::Unicode { .. } | Self::Identity => None,
231 Self::Custom(t) => Some(t.len()),
232 }
233 }
234
235 /// Returns `true` if the codepage defines zero tiles.
236 #[must_use]
237 pub fn is_empty(&self) -> bool {
238 self.len() == Some(0)
239 }
240}
241
242/// Standard IBM CP437 to Unicode mapping, 256 entries.
243pub const CP437_TO_UNICODE: [char; 256] = [
244 '\u{0000}', '\u{263A}', '\u{263B}', '\u{2665}', '\u{2666}', '\u{2663}', '\u{2660}', '\u{2022}',
245 '\u{25D8}', '\u{25CB}', '\u{25D9}', '\u{2642}', '\u{2640}', '\u{266A}', '\u{266B}', '\u{263C}',
246 '\u{25BA}', '\u{25C4}', '\u{2195}', '\u{203C}', '\u{00B6}', '\u{00A7}', '\u{25AC}', '\u{21A8}',
247 '\u{2191}', '\u{2193}', '\u{2192}', '\u{2190}', '\u{221F}', '\u{2194}', '\u{25B2}', '\u{25BC}',
248 ' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2',
249 '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E',
250 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
251 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
252 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~',
253 '\u{2302}', '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}',
254 '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', '\u{00EE}', '\u{00EC}', '\u{00C4}',
255 '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}',
256 '\u{00F9}', '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}',
257 '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', '\u{00F1}', '\u{00D1}', '\u{00AA}',
258 '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}',
259 '\u{00BB}', '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}',
260 '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', '\u{255D}', '\u{255C}', '\u{255B}',
261 '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}',
262 '\u{255F}', '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}',
263 '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', '\u{2558}', '\u{2552}', '\u{2553}',
264 '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}',
265 '\u{2580}', '\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}',
266 '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}', '\u{221E}', '\u{03C6}', '\u{03B5}',
267 '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}',
268 '\u{2248}', '\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}',
269 '\u{00A0}',
270];
271
272/// Options for loading a single tileset (sprite sheet).
273///
274/// # Sprites carry their own color
275///
276/// By default ([`SheetColor::Art`]) a tileset's artwork is composited verbatim: the cell's
277/// [`Style::fg`](retroglyph_core::Style::fg) does not tint it, so a full-color sheet renders
278/// exactly as authored. The cell's background is still painted behind the sprite and shows
279/// through its transparent pixels.
280///
281/// A sheet authored as white-on-transparent masks declares [`SheetColor::Mask`] instead, and its
282/// sprites are colored by the cell's foreground the way a bitmap font glyph is.
283///
284/// Recoloring one piece of artwork per cell (biome variants, damage flashes) is a per-draw
285/// decision rather than a sheet-wide one, and goes through
286/// [`Surface::with_tint`](retroglyph_core::Surface::with_tint).
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct TilesetOptions {
289 /// Raw bytes of the PNG file.
290 pub bytes: Vec<u8>,
291 /// Width of a single tile in pixels.
292 pub tile_width: u16,
293 /// Height of a single tile in pixels.
294 pub tile_height: u16,
295 /// Number of tiles per row in the sprite sheet.
296 ///
297 /// If `None`, derived as `image_width / tile_width`.
298 pub columns: Option<u16>,
299 /// Codepoint mapping from tile index to Unicode character.
300 pub codepage: Codepage,
301 /// Where each sprite sits inside the multi-cell box a span reserves for it.
302 pub align: SpriteAlign,
303 /// What this sheet's pixels mean, and so whether the cell's foreground color colors them.
304 pub color: SheetColor,
305 /// If set, any pixel matching this RGB colour is made fully transparent
306 /// (alpha = 0) when decoding the tileset.
307 ///
308 /// Useful for spritesheets that use a solid colour background instead
309 /// of an alpha channel. Equivalent to bracket-lib's `with_font_bg()`
310 /// or doryen-rs's top-left-pixel key colour auto-detection.
311 pub transparent_color: Option<(u8, u8, u8)>,
312}
313
314impl TilesetOptions {
315 /// Starts building a tileset from raw PNG bytes.
316 ///
317 /// Pass `include_bytes!("...").to_vec()` to embed the asset at compile
318 /// time, or `std::fs::read(path)?` to load it at runtime.
319 #[must_use]
320 pub const fn from_bytes(bytes: Vec<u8>) -> TilesetBuilder {
321 TilesetBuilder {
322 bytes,
323 tile_width: 0,
324 tile_height: 0,
325 columns: None,
326 codepage: Codepage::Cp437,
327 align: SpriteAlign::TopLeft,
328 color: SheetColor::Art,
329 transparent_color: None,
330 }
331 }
332}
333
334/// Builder for [`TilesetOptions`].
335///
336/// Construct via [`TilesetOptions::from_bytes`].
337///
338/// [`columns`](TilesetBuilder::columns) defaults to `image_width / tile_width`,
339/// so you usually don't need to set it explicitly. [`codepage`](TilesetBuilder::codepage)
340/// defaults to [`Codepage::Cp437`].
341///
342/// # Examples
343///
344/// Standard CP437 tileset:
345///
346/// ```no_run
347/// use retroglyph_window::tileset::TilesetOptions;
348///
349/// let png: Vec<u8> = std::fs::read("assets/cp437_16x16.png").unwrap();
350/// let opts = TilesetOptions::from_bytes(png)
351/// .tile_size(16, 16) // codepage defaults to Cp437
352/// .build()
353/// .unwrap();
354/// ```
355///
356/// Private-use sprite sheet addressed by index, centred in whatever box a span reserves:
357///
358/// ```no_run
359/// use retroglyph_window::tileset::{Codepage, SpriteAlign, TilesetOptions};
360///
361/// let png: Vec<u8> = std::fs::read("assets/sprites.png").unwrap();
362/// let opts = TilesetOptions::from_bytes(png)
363/// .tile_size(32, 32)
364/// .codepage(Codepage::Identity) // tile 0 = '\0', tile 1 = '\x01', …
365/// .align(SpriteAlign::Center)
366/// .build()
367/// .unwrap();
368/// ```
369///
370/// How many cells a sprite occupies is a per-write decision, not a tileset-wide one: declare it
371/// with [`Surface::put_span`](retroglyph_core::Surface::put_span) at the draw call.
372///
373/// Unicode private-use area sprite sheet:
374///
375/// ```no_run
376/// use retroglyph_window::tileset::TilesetOptions;
377///
378/// let png: Vec<u8> = std::fs::read("assets/monsters.png").unwrap();
379/// let opts = TilesetOptions::from_bytes(png)
380/// .tile_size(16, 16)
381/// .start_codepoint('\u{E000}') // maps to Unicode PUA starting at U+E000
382/// .build()
383/// .unwrap();
384/// ```
385pub struct TilesetBuilder {
386 bytes: Vec<u8>,
387 tile_width: u16,
388 tile_height: u16,
389 columns: Option<u16>,
390 codepage: Codepage,
391 align: SpriteAlign,
392 color: SheetColor,
393 transparent_color: Option<(u8, u8, u8)>,
394}
395
396impl TilesetBuilder {
397 /// Sets the pixel dimensions of each tile.
398 #[must_use]
399 pub const fn tile_size(mut self, width: u16, height: u16) -> Self {
400 self.tile_width = width;
401 self.tile_height = height;
402 self
403 }
404
405 /// Sets the number of tiles per row in the sprite sheet.
406 ///
407 /// Useful for sheets with padding. If not set, derived from image width.
408 #[must_use]
409 pub const fn columns(mut self, cols: u16) -> Self {
410 self.columns = Some(cols);
411 self
412 }
413
414 /// Sets the codepoint mapping.
415 #[must_use]
416 pub fn codepage(mut self, codepage: Codepage) -> Self {
417 self.codepage = codepage;
418 self
419 }
420
421 /// Sets the codepoint of the first tile; subsequent tiles increment by 1.
422 ///
423 /// Shorthand for `codepage(Codepage::Unicode { start })`.
424 #[must_use]
425 pub fn start_codepoint(mut self, start: char) -> Self {
426 self.codepage = Codepage::Unicode { start };
427 self
428 }
429
430 /// Sets where each sprite sits inside the multi-cell box a span reserves for it.
431 ///
432 /// Defaults to [`SpriteAlign::TopLeft`]. Has no visible effect on a sprite whose art fills
433 /// its box exactly; see [`SpriteAlign`].
434 #[must_use]
435 pub const fn align(mut self, align: SpriteAlign) -> Self {
436 self.align = align;
437 self
438 }
439
440 /// Declares what this sheet's pixels mean, and so whether the cell's foreground color
441 /// colors them.
442 ///
443 /// Defaults to [`SheetColor::Art`]: composited verbatim. See [`SheetColor`].
444 #[must_use]
445 pub const fn color(mut self, color: SheetColor) -> Self {
446 self.color = color;
447 self
448 }
449
450 /// Shorthand for [`color(SheetColor::Mask)`](Self::color): this sheet is white-on-
451 /// transparent artwork to be colored by each cell's foreground.
452 #[must_use]
453 pub const fn mask(self) -> Self {
454 self.color(SheetColor::Mask)
455 }
456
457 /// Pixels matching `(r, g, b)` are made fully transparent (alpha = 0).
458 ///
459 /// Use this for spritesheets that use a solid colour background instead
460 /// of an alpha channel.
461 #[must_use]
462 pub const fn transparent_color(mut self, r: u8, g: u8, b: u8) -> Self {
463 self.transparent_color = Some((r, g, b));
464 self
465 }
466
467 /// Validates and builds [`TilesetOptions`].
468 ///
469 /// # Errors
470 ///
471 /// Returns [`TilesetError::ZeroTileSize`] if tile dimensions are 0, or
472 /// [`TilesetError::EmptyCodepage`] if `Custom` codepage is empty.
473 pub fn build(self) -> Result<TilesetOptions, TilesetError> {
474 if self.tile_width == 0 || self.tile_height == 0 {
475 return Err(TilesetError::ZeroTileSize);
476 }
477 if let Codepage::Custom(ref t) = self.codepage
478 && t.is_empty()
479 {
480 return Err(TilesetError::EmptyCodepage);
481 }
482 Ok(TilesetOptions {
483 bytes: self.bytes,
484 tile_width: self.tile_width,
485 tile_height: self.tile_height,
486 columns: self.columns,
487 codepage: self.codepage,
488 align: self.align,
489 color: self.color,
490 transparent_color: self.transparent_color,
491 })
492 }
493}
494
495// ── Tests ─────────────────────────────────────────────────────────────────
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn tileset_builder_rejects_zero_tile_size() {
503 let opts = TilesetOptions::from_bytes(vec![]).tile_size(0, 16).build();
504 assert!(matches!(opts, Err(TilesetError::ZeroTileSize)));
505 }
506
507 #[test]
508 fn tileset_builder_rejects_empty_custom_codepage() {
509 let opts = TilesetOptions::from_bytes(vec![])
510 .tile_size(16, 16)
511 .codepage(Codepage::Custom(vec![]))
512 .build();
513 assert!(matches!(opts, Err(TilesetError::EmptyCodepage)));
514 }
515
516 #[test]
517 fn tileset_builder_valid() {
518 let opts = TilesetOptions::from_bytes(vec![0u8; 64])
519 .tile_size(16, 16)
520 .start_codepoint('\u{E000}')
521 .align(SpriteAlign::Center)
522 .build()
523 .unwrap();
524 assert_eq!(opts.tile_width, 16);
525 assert_eq!(opts.align, SpriteAlign::Center);
526 assert!(matches!(
527 opts.codepage,
528 Codepage::Unicode { start: '\u{E000}' }
529 ));
530 }
531
532 #[test]
533 fn tileset_builder_defaults_to_top_left_alignment() {
534 let opts = TilesetOptions::from_bytes(vec![0u8; 64])
535 .tile_size(16, 16)
536 .build()
537 .unwrap();
538 assert_eq!(opts.align, SpriteAlign::TopLeft);
539 }
540
541 #[test]
542 fn sprite_align_positions_art_within_a_larger_box() {
543 // 16x16 art in a 32x32 box: 16 pixels of slack on each axis.
544 let at = |align: SpriteAlign| align.offset(16, 16, 32, 32);
545 assert_eq!(at(SpriteAlign::TopLeft), (0, 0));
546 assert_eq!(at(SpriteAlign::Top), (8, 0));
547 assert_eq!(at(SpriteAlign::TopRight), (16, 0));
548 assert_eq!(at(SpriteAlign::Left), (0, 8));
549 assert_eq!(at(SpriteAlign::Center), (8, 8));
550 assert_eq!(at(SpriteAlign::Right), (16, 8));
551 assert_eq!(at(SpriteAlign::BottomLeft), (0, 16));
552 assert_eq!(at(SpriteAlign::Bottom), (8, 16));
553 assert_eq!(at(SpriteAlign::BottomRight), (16, 16));
554 }
555
556 #[test]
557 fn sprite_align_centring_rounds_down() {
558 // 9 pixels of slack: the odd leftover pixel goes to the right/bottom.
559 assert_eq!(SpriteAlign::Center.offset(7, 7, 16, 16), (4, 4));
560 assert_eq!(SpriteAlign::Center.offset(8, 8, 15, 15), (3, 3));
561 }
562
563 #[test]
564 fn sprite_align_is_a_no_op_when_the_art_fills_the_box() {
565 for align in [
566 SpriteAlign::TopLeft,
567 SpriteAlign::Top,
568 SpriteAlign::TopRight,
569 SpriteAlign::Left,
570 SpriteAlign::Center,
571 SpriteAlign::Right,
572 SpriteAlign::BottomLeft,
573 SpriteAlign::Bottom,
574 SpriteAlign::BottomRight,
575 ] {
576 assert_eq!(align.offset(16, 16, 16, 16), (0, 0), "{align:?}");
577 }
578 }
579
580 #[test]
581 fn sprite_align_saturates_when_the_art_exceeds_the_box() {
582 // Never pull an oversized sprite off its own anchor cell.
583 assert_eq!(SpriteAlign::Center.offset(32, 32, 16, 16), (0, 0));
584 assert_eq!(SpriteAlign::BottomRight.offset(32, 32, 16, 16), (0, 0));
585 }
586
587 #[test]
588 fn cp437_codepage_spot_checks() {
589 assert_eq!(Codepage::Cp437.codepoint(32), Some(' '));
590 assert_eq!(Codepage::Cp437.codepoint(64), Some('@'));
591 assert_eq!(Codepage::Cp437.codepoint(176), Some('\u{2591}'));
592 assert_eq!(Codepage::Cp437.codepoint(256), None);
593 }
594
595 #[test]
596 fn identity_codepage_positional() {
597 assert_eq!(Codepage::Identity.codepoint(0), Some('\0'));
598 assert_eq!(Codepage::Identity.codepoint(65), Some('A'));
599 // Surrogate range must be skipped.
600 assert_eq!(Codepage::Identity.codepoint(0xD800), None);
601 assert_eq!(Codepage::Identity.codepoint(0xDFFF), None);
602 // Above surrogates is fine.
603 assert_eq!(Codepage::Identity.codepoint(0xE000), Some('\u{E000}'));
604 }
605
606 #[test]
607 fn unicode_codepage_offset() {
608 let cp = Codepage::Unicode { start: '\u{E000}' };
609 assert_eq!(cp.codepoint(0), Some('\u{E000}'));
610 assert_eq!(cp.codepoint(5), Some('\u{E005}'));
611 }
612
613 #[test]
614 fn custom_codepage_bounds() {
615 let cp = Codepage::Custom(vec!['A', 'B', 'C']);
616 assert_eq!(cp.codepoint(0), Some('A'));
617 assert_eq!(cp.codepoint(2), Some('C'));
618 assert_eq!(cp.codepoint(3), None);
619 }
620}