retroglyph_core/grid.rs
1//! The layered tile grid: [`Grid`], plus the [`Size`], [`Pos`], and [`Rect`]
2//! coordinate types used throughout the crate.
3//!
4//! # Layers, draw order, and compositing
5//!
6//! A [`Grid`] holds up to 256 independent layers (`u8` ids `0..=255`), one
7//! [`Tile`] per cell on each. Layer 0 is always allocated; layers 1-255 are
8//! allocated lazily, on first write to that layer (see
9//! [`put_tile`](Grid::put_tile), [`cells_mut_or_alloc`](Grid::cells_mut_or_alloc)). This is the
10//! crate's most distinctive feature and the one most worth understanding
11//! before reaching for a second layer.
12//!
13//! ## Draw order
14//!
15//! Layers composite bottom-to-top, in ascending id order: 0 first, then every
16//! allocated layer up to [`max_layer`](Grid::max_layer), each painted over
17//! whatever the layers below it produced. Layer id *is* z-order: there is
18//! no separate depth or z-index to set. A common convention is layer 0 for
19//! terrain, 1 for items, 2 for actors, 3+ for UI/effects, but the crate
20//! enforces nothing; any id can hold any content.
21//!
22//! Compositing itself happens in one of two places, chosen by the backend
23//! (see [`crate::Output::composites_layers`]):
24//!
25//! - **Cell backends** (`Headless`, `retroglyph-crossterm`) do not composite
26//! layers themselves. [`crate::Terminal::present`] calls
27//! `flatten_into` (crate-private) to collapse every allocated layer
28//! into a single-layer frame *before* handing it to the backend, so
29//! layers 1+ behave identically on every cell backend.
30//! - **Pixel backends** (`retroglyph-software`) composite per pixel: they
31//! receive the raw layered stream from
32//! [`crate::Output::draw_layers`] (layer-major, ascending id) and paint
33//! each layer's cells directly onto the pixel buffer in that order.
34//!
35//! ## The `EMPTY` flag: transparency vs. opaque occlusion
36//!
37//! Every [`Tile`] carries [`TileFlags::EMPTY`], set on [`Tile::default`] and
38//! cleared by every write (`put_tile`, `write_grapheme`, indexing, ...).
39//! Compositing treats it as the transparency bit:
40//!
41//! - An **untouched cell** (`EMPTY` set) is fully transparent:
42//! [`blit`](Grid::blit) skips it, and `flatten_into` (crate-private)
43//! leaves whatever the layers below already drew.
44//! - An **explicit space** (`Tile::new(' ', style)`, `EMPTY` clear) is
45//! opaque: it overwrites the glyph and foreground below it, same as any
46//! other character. This is the one sharp edge in the model: `' '`
47//! painted on a higher layer *erases* content underneath, it does not
48//! reveal it.
49//!
50//! Background color follows its own rule, independent of `EMPTY`: a tile's
51//! background only overwrites the composited background when it is not
52//! [`Color::Default`]. A non-empty tile with a `Default` background still
53//! lets a lower layer's background show through even though its glyph is
54//! opaque. See `flatten_into` (crate-private) for the exact rule.
55//!
56//! ## Multi-cell spans
57//!
58//! [`write_span`](Grid::write_span) writes one piece of artwork across a `w x h` block of cells:
59//! the top-left cell is the **anchor** ([`TileFlags::SPAN_ANCHOR`], carrying the footprint), and
60//! every other cell is **covered** ([`TileFlags::SPAN_COVERED`], carrying its offset back to the
61//! anchor). [`span_owner`](Grid::span_owner) resolves any cell of a span to its anchor in O(1),
62//! so hit-testing a multi-cell sprite is one lookup rather than a rectangle scan.
63//!
64//! Covered cells keep **real glyphs**, and that is the point: they are the span's text fallback.
65//! One `write_span` call renders correctly on every backend without a capability check.
66//!
67//! - A **cell backend** ignores `SPAN_COVERED` and prints all `w * h` glyphs, so `["C=", "[]"]`
68//! reads as a little piece of ASCII art.
69//! - A **pixel backend** looks the anchor glyph up in its sprite cache, draws that one sprite
70//! across the whole footprint, and skips every covered cell's glyph.
71//!
72//! This is the deliberate difference from [`TileFlags::WIDE_CHAR_SPACER`], which every backend
73//! skips: a wide character's spacer has no content of its own, whereas a covered cell does.
74//!
75//! A span is written and cleared whole. Any ordinary write into one of its cells
76//! ([`put_tile`](Grid::put_tile), [`write_grapheme`](Grid::write_grapheme))
77//! clears the entire span first, so an anchor can never be left claiming cells it no longer owns.
78//! The exceptions are the escape hatches that hand out a `&mut Tile` directly
79//! ([`tile_mut`](Grid::tile_mut), [`cells_mut`](Grid::cells_mut),
80//! [`cells_mut_or_alloc`](Grid::cells_mut_or_alloc), `IndexMut`), which cannot intercept the
81//! write; use [`clear_span`](Grid::clear_span) first if you reach for one of those on a grid
82//! that uses spans.
83//!
84//! ## No short-circuiting: every allocated layer is visited, for every cell
85//!
86//! Compositing does not stop early when it hits an opaque tile on a high
87//! layer. Both `flatten_into` (crate-private) and the software
88//! backend's per-pixel compositor walk layers `0..=max_layer` in order for
89//! *every* cell, unconditionally, even if a fully opaque tile on layer 5
90//! makes layers 6-50 invisible at that position. Cost is `O(max_layer)` per
91//! cell, not `O(topmost opaque layer)`. Painting one fully opaque layer 250
92//! over the whole grid still walks (and `EMPTY`-checks) layers 1-249 on
93//! every present.
94//!
95//! ## Allocation cost: layer 1 vs. layer 200
96//!
97//! Writing to a layer for the first time allocates one `width x height`
98//! buffer of [`Tile`]s, the same cost regardless of the layer's id, plus a
99//! one-time growth of the layer table's `Vec<Option<LayerBuf>>` up to that
100//! layer's id (see [`Grid::new`]): the table starts at a single slot (layer
101//! 0) and only grows as far as the highest layer id ever written, so a
102//! single/few-layer `Grid` never pays for slots it never touches. Writing to
103//! layer 200 first grows the table to 201 slots, then allocates layer 200's
104//! buffer; the untouched slots 1-199 in between are a cheap `None`.
105//!
106//! What the layer id *does* affect is steady-state iteration cost, via
107//! [`max_layer`](Grid::max_layer): every present, diff, and full-grid
108//! iteration walks `0..=max_layer`, skipping unallocated slots with an O(1)
109//! `None` check. `max_layer` only grows. Clearing a layer
110//! ([`clear`](Grid::clear)) does not deallocate it or lower `max_layer`. So
111//! writing once to layer 200 and never touching layers 1-199 means every
112//! future frame's compositing pass walks past 199 unallocated slots to reach
113//! it. That walk is cheap (a pointer-sized `None` check per skipped layer)
114//! but not free; prefer low, contiguous layer ids for frequently-updated
115//! content and reserve high ids for rarely-touched overlays (e.g. a debug
116//! HUD pinned to layer 255).
117
118use crate::backend::DrawCell;
119use crate::color::Color;
120use crate::style::Style;
121use crate::tile::Tile;
122use crate::tile::TileFlags;
123#[cfg(feature = "egc")]
124use crate::tile::cap_grapheme;
125use crate::tint::Tint;
126use alloc::collections::BTreeMap;
127use alloc::sync::Arc;
128use alloc::vec::Vec;
129// Aliased rather than imported as `BlendMode`: this module already defines its own `BlendMode`
130// (below), and alpha-blend 0.3 renamed `blend_modes::SeparableBlendMode` to a top-level
131// `BlendMode` of its own, which would otherwise collide.
132#[cfg(feature = "color-space")]
133use alpha_blend::BlendMode as SeparableBlendMode;
134use core::fmt;
135use core::ops::{Index, IndexMut};
136use grixy::buf::GridBuf;
137use grixy::ops::layout::RowMajor;
138use grixy::ops::{ExactSizeGrid, GridRead, GridWrite};
139
140/// Blend mode for [`Grid::blit_alpha`], selecting how source and destination colors combine
141/// before the `fg_alpha`/`bg_alpha` factor is applied.
142///
143/// [`Linear`](Self::Linear) is a straight per-channel color lerp: `blit_alpha`'s original
144/// behavior. The remaining variants are the [W3C separable blend modes] libtcod also offers:
145/// each computes a fully blended color per channel via [`alpha_blend::BlendMode`] (imported
146/// here under its old name, [`SeparableBlendMode`], to avoid colliding with this module's own
147/// [`BlendMode`]), and *that* result is what gets lerped against the destination by the alpha
148/// factor, in place of the source color `Linear` would use.
149///
150/// Requires the `color-space` feature (default on): see [`Grid::blit_alpha`]'s doc comment for which
151/// crate backs each mode.
152///
153/// [W3C separable blend modes]: https://www.w3.org/TR/compositing-1/#blending
154#[cfg(feature = "color-space")]
155#[non_exhaustive]
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
157pub enum BlendMode {
158 /// Straight per-channel RGB lerp between destination and source.
159 #[default]
160 Linear,
161 /// Lightens: `dst + src - dst * src`. Always at least as light as either input.
162 Screen,
163 /// Brightens the destination to reflect the source (aka "color dodge").
164 Dodge,
165 /// Darkens the destination to reflect the source (aka "color burn").
166 Burn,
167 /// Multiplies or screens the colors, depending on the destination.
168 Overlay,
169 /// Darkens: `dst * src`. Always at least as dark as either input; the complement of
170 /// [`Screen`](Self::Screen).
171 Multiply,
172}
173
174#[cfg(feature = "color-space")]
175impl BlendMode {
176 /// The equivalent [`SeparableBlendMode`], or `None` for [`Linear`](Self::Linear) (which uses
177 /// [`gem::Mix`] instead: see [`blend_color`]).
178 const fn separable(self) -> Option<SeparableBlendMode> {
179 match self {
180 Self::Linear => None,
181 Self::Screen => Some(SeparableBlendMode::Screen),
182 Self::Dodge => Some(SeparableBlendMode::ColorDodge),
183 Self::Burn => Some(SeparableBlendMode::ColorBurn),
184 Self::Overlay => Some(SeparableBlendMode::Overlay),
185 Self::Multiply => Some(SeparableBlendMode::Multiply),
186 }
187 }
188}
189
190/// Size of the grid.
191///
192/// # Examples
193///
194/// ```
195/// use retroglyph_core::Size;
196///
197/// let size = Size {
198/// width: 80,
199/// height: 24,
200/// };
201/// assert_eq!(size.width, 80);
202/// ```
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
204pub struct Size {
205 /// Width.
206 pub width: u16,
207 /// Height.
208 pub height: u16,
209}
210
211/// Pos in the grid, in (x = column, y = row) order.
212///
213/// Implements [`Ord`] in row-major order (y primary, then x), which is the
214/// natural ordering for terminal rendering: top-to-bottom, left-to-right within
215/// each row.
216///
217/// # Examples
218///
219/// ```
220/// use retroglyph_core::Pos;
221///
222/// let pos = Pos::new(2, 1);
223/// assert_eq!(pos.x, 2);
224/// assert_eq!(pos.y, 1);
225/// ```
226pub type Pos = ixy::Pos<u16>;
227
228/// Rectangle in the grid.
229///
230/// # Examples
231///
232/// ```
233/// use retroglyph_core::Rect;
234///
235/// let rect = Rect::new(0, 0, 10, 4);
236/// assert_eq!(rect.width(), 10);
237/// assert_eq!(rect.height(), 4);
238/// ```
239pub type Rect = ixy::Rect<u16>;
240
241/// A sub-cell pixel offset `(dx, dy)`, distinct from [`Pos`] so a caller can't transpose a
242/// position and an offset in a call like [`Surface::put_offset`](crate::surface::Surface::put_offset).
243///
244/// Visual only: an offset shifts where a glyph is painted within its cell on backends that
245/// support sub-cell placement (e.g. `retroglyph-software`); it never changes which cell a glyph
246/// occupies, and cell-mode backends (e.g. `retroglyph-crossterm`) ignore it entirely.
247///
248/// # Examples
249///
250/// ```
251/// use retroglyph_core::Offset;
252///
253/// let offset = Offset::new(3, -2);
254/// assert_eq!(offset.dx, 3);
255/// assert_eq!(offset.dy, -2);
256/// ```
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
258pub struct Offset {
259 /// Horizontal pixel offset.
260 pub dx: i16,
261 /// Vertical pixel offset.
262 pub dy: i16,
263}
264
265impl Offset {
266 /// Creates a new offset from `(dx, dy)`.
267 #[must_use]
268 pub const fn new(dx: i16, dy: i16) -> Self {
269 Self { dx, dy }
270 }
271}
272
273impl From<(i16, i16)> for Offset {
274 fn from((dx, dy): (i16, i16)) -> Self {
275 Self { dx, dy }
276 }
277}
278
279impl From<Offset> for (i16, i16) {
280 fn from(offset: Offset) -> Self {
281 (offset.dx, offset.dy)
282 }
283}
284
285impl From<(u16, u16)> for Size {
286 fn from((width, height): (u16, u16)) -> Self {
287 Self { width, height }
288 }
289}
290
291impl From<Size> for (u16, u16) {
292 fn from(s: Size) -> Self {
293 (s.width, s.height)
294 }
295}
296
297// ---------------------------------------------------------------------------
298// Helpers: coordinate conversion between u16 and usize
299// ---------------------------------------------------------------------------
300
301fn to_grixy_pos(pos: Pos) -> grixy::core::Pos {
302 grixy::core::Pos::new(usize::from(pos.x), usize::from(pos.y))
303}
304
305// ---------------------------------------------------------------------------
306// Grid iterators
307// ---------------------------------------------------------------------------
308
309/// Iterator over all cells with their `(x, y)` coordinates.
310pub struct Cells<'a> {
311 iter: core::iter::Enumerate<core::slice::Iter<'a, Tile>>,
312 width: usize,
313}
314
315impl<'a> Iterator for Cells<'a> {
316 type Item = (u16, u16, &'a Tile);
317
318 fn next(&mut self) -> Option<Self::Item> {
319 self.iter.next().map(|(i, tile)| {
320 #[allow(clippy::cast_possible_truncation)]
321 let x = (i % self.width) as u16;
322 #[allow(clippy::cast_possible_truncation)]
323 let y = (i / self.width) as u16;
324 (x, y, tile)
325 })
326 }
327}
328
329/// Mutable iterator over all cells with their `(x, y)` coordinates.
330pub struct CellsMut<'a> {
331 iter: core::iter::Enumerate<core::slice::IterMut<'a, Tile>>,
332 width: usize,
333}
334
335impl<'a> Iterator for CellsMut<'a> {
336 type Item = (u16, u16, &'a mut Tile);
337
338 fn next(&mut self) -> Option<Self::Item> {
339 self.iter.next().map(|(i, tile)| {
340 #[allow(clippy::cast_possible_truncation)]
341 let x = (i % self.width) as u16;
342 #[allow(clippy::cast_possible_truncation)]
343 let y = (i / self.width) as u16;
344 (x, y, tile)
345 })
346 }
347}
348
349// ---------------------------------------------------------------------------
350// LayerBuf — a single layer's flat buffer
351// ---------------------------------------------------------------------------
352
353/// A single layer in the grid: a flat 2D buffer of one tile per cell.
354///
355/// Layer 0 is always allocated. Layers 1–255 are allocated on first write
356/// (see [`Grid::put_tile`]).
357#[derive(Clone)]
358pub(crate) struct LayerBuf {
359 pub(crate) buf: GridBuf<Tile, Vec<Tile>, RowMajor>,
360 /// Sparse side-table: flat row-major index -> the cell's out-of-line data, for tiles with
361 /// [`TileFlags::HAS_EXTRA`] set. Empty until something writes a multi-codepoint grapheme or
362 /// a tint, which is what keeps [`Tile`] itself small (see [`Grid::grapheme`] and
363 /// [`Grid::tint`]).
364 ///
365 /// The `HAS_EXTRA` flag is authoritative: readers must check it before
366 /// consulting this map, since some write paths (`put_tile`,
367 /// `IndexMut`, `cells_mut`, `cells_mut_or_alloc`) can leave a stale entry behind when they
368 /// overwrite a tile that used to carry extra data without an explicit
369 /// cleanup call. Since those paths only ever hand out or store tiles
370 /// with `HAS_EXTRA` clear, a stale entry is harmless: it is simply
371 /// never looked up until the slot is reused by `write_grapheme` or `set_tint`, which
372 /// always overwrite it.
373 extras: BTreeMap<usize, TileExtra>,
374}
375
376/// One cell's out-of-line data: everything that belongs to a tile but does not fit in one.
377///
378/// [`Tile`] is exactly 20 bytes with no padding to spare, and both members here are rare enough
379/// per cell that inlining either would grow every tile of every layer to pay for a minority of
380/// them. They share one table, one flag, and one set of rekeying paths rather than each bringing
381/// their own.
382#[derive(Clone, Debug, Default, PartialEq, Eq)]
383pub(crate) struct TileExtra {
384 /// The full grapheme cluster, when [`Tile::glyph`] holds only its first codepoint.
385 pub(crate) grapheme: Option<Arc<str>>,
386 /// How a pixel backend recolours this cell's sprite.
387 pub(crate) tint: Tint,
388}
389
390impl TileExtra {
391 /// Whether this entry carries nothing, and so should be dropped rather than stored.
392 ///
393 /// Keeping the table free of empty entries is what lets `HAS_EXTRA` be set exactly when an
394 /// entry exists, instead of the flag and the table disagreeing about an all-default value.
395 fn is_empty(&self) -> bool {
396 self.grapheme.is_none() && self.tint == Tint::None
397 }
398}
399
400impl LayerBuf {
401 fn new(width: u16, height: u16) -> Self {
402 let n = usize::from(width) * usize::from(height);
403 Self {
404 buf: GridBuf::from_buffer(alloc::vec![Tile::default(); n], usize::from(width)),
405 extras: BTreeMap::new(),
406 }
407 }
408
409 /// Returns the side-table entry for the tile at flat index `idx`, or `None` if `tile`
410 /// doesn't have [`TileFlags::HAS_EXTRA`] set.
411 fn entry_for(&self, idx: usize, tile: &Tile) -> Option<&TileExtra> {
412 if tile.flags.contains(TileFlags::HAS_EXTRA) {
413 self.extras.get(&idx)
414 } else {
415 None
416 }
417 }
418
419 /// Returns the grapheme text for the tile at flat index `idx`, or `None`
420 /// if `tile` doesn't have [`TileFlags::HAS_EXTRA`] set.
421 fn extra_for(&self, idx: usize, tile: &Tile) -> Option<&str> {
422 self.entry_for(idx, tile)?.grapheme.as_deref()
423 }
424
425 /// Returns the tint for the tile at flat index `idx`, or [`Tint::None`] if `tile` doesn't
426 /// have [`TileFlags::HAS_EXTRA`] set.
427 fn tint_for(&self, idx: usize, tile: &Tile) -> Tint {
428 self.entry_for(idx, tile).map_or(Tint::None, |e| e.tint)
429 }
430
431 /// Returns a clone of the side-table entry at flat index `idx`, or `None` if `tile` doesn't
432 /// have [`TileFlags::HAS_EXTRA`] set. Used to copy a cell's out-of-line data between grids
433 /// (e.g. [`Grid::blit`]); the grapheme rides along as an `Arc` clone rather than a fresh
434 /// allocation.
435 fn extra_entry_for(&self, idx: usize, tile: &Tile) -> Option<TileExtra> {
436 self.entry_for(idx, tile).cloned()
437 }
438}
439
440// ---------------------------------------------------------------------------
441// Grid
442// ---------------------------------------------------------------------------
443
444/// A 2D buffer of [`Tile`]s, addressable across up to 256 stacked layers.
445///
446/// Layer 0 is always allocated; higher layers are allocated on first write, growing the
447/// layer-table `Vec` up to that layer's id as needed (see [`Grid::new`]). Single-layer use pays
448/// no overhead: layers 1+ stay unallocated until used, and the layer table itself never grows
449/// past a single slot.
450///
451/// # Out-of-bounds drawing
452///
453/// Drawing off the grid is a no-op, the same convention as drawing off-screen: every write method
454/// that names a position or region (e.g. [`put_tile`](Self::put_tile), [`write_grapheme`](Self::write_grapheme),
455/// [`write_span`](Self::write_span), [`blit`](Self::blit)) silently discards any part of the
456/// write that falls outside `0..width` / `0..height`, rather than panicking. The one deliberate
457/// exception is indexing (`Index<Pos>`/`IndexMut<Pos>`, and by extension anything built on it),
458/// which panics on an out-of-bounds `Pos` the same way indexing a slice does. Read accessors
459/// that take a position (e.g. [`tile`](Self::tile)) report an out-of-bounds position as `None`,
460/// indistinguishable from an unallocated layer.
461///
462/// Requires an allocator (backed by `alloc::vec::Vec`), so it is unavailable
463/// in strictly static, no-alloc environments.
464///
465/// # Examples
466///
467/// ```
468/// use retroglyph_core::{Color, Grid, Pos, Style};
469///
470/// let mut grid = Grid::new(10, 5);
471/// grid.put_tile(0, Pos::new(2, 1), retroglyph_core::Tile::new('@', Style::new().fg(Color::GREEN)));
472/// assert_eq!(grid[Pos::new(2, 1)].glyph(), '@');
473/// ```
474#[derive(Clone)]
475pub struct Grid {
476 width: u16,
477 height: u16,
478 /// Indexed by layer ID (0–255), but only as long as the highest layer id ever written to
479 /// (see [`layer_or_alloc`](Self::layer_or_alloc)), not always all 256 slots. Index 0 is
480 /// always `Some`. Unwritten layers within the current length are `None`; ids past the end
481 /// are treated identically to a `None` slot (see [`layer`](Self::layer)).
482 layers: Vec<Option<LayerBuf>>,
483 /// Highest layer ID that has been allocated. Always at least 0.
484 max_layer: u8,
485 /// Whether any multi-cell span has ever been written to this grid (see
486 /// [`write_span`](Self::write_span)).
487 ///
488 /// Conservative and one-way: set on the first `write_span`, never cleared. Every ordinary
489 /// write has to clear a span it would partially overwrite
490 /// (`clear_span_overlap`), and this flag is what keeps that check from
491 /// costing a buffer read per `put` in the overwhelmingly common grid that never uses a span
492 /// at all: it degrades to one `bool` test. Clearing it again on the last span's removal
493 /// would need span refcounting for no observable gain.
494 has_spans: bool,
495}
496
497// ---------------------------------------------------------------------------
498// Internal helpers
499// ---------------------------------------------------------------------------
500
501impl Grid {
502 /// Borrow a specific layer, or `None` if unallocated.
503 ///
504 /// `id` may be beyond the current layer-table `Vec`'s length: the table only grows as far
505 /// as the highest layer id ever written (see [`layer_or_alloc`](Self::layer_or_alloc)), so an
506 /// id past the end simply means "never written", same as an in-bounds `None` slot.
507 fn layer(&self, id: u8) -> Option<&LayerBuf> {
508 self.layers.get(usize::from(id))?.as_ref()
509 }
510
511 /// Borrow a specific layer mutably, allocating it if necessary.
512 ///
513 /// Grows the layer-table `Vec` up to `id + 1` slots on demand, rather than the table always
514 /// holding all 256 possible slots (see retroglyph#264): a `Grid` that only ever writes to
515 /// layer 0, or a handful of low ids, never pays for the 250+ slots it never touches.
516 fn layer_or_alloc(&mut self, id: u8) -> &mut LayerBuf {
517 let idx = usize::from(id);
518 if idx >= self.layers.len() {
519 self.layers.resize_with(idx + 1, || None);
520 }
521 if self.layers[idx].is_none() {
522 self.layers[idx] = Some(LayerBuf::new(self.width, self.height));
523 }
524 if id > self.max_layer {
525 self.max_layer = id;
526 }
527 self.layers[idx].as_mut().unwrap()
528 }
529
530 /// Borrow layer 0 (always allocated).
531 fn layer0(&self) -> &LayerBuf {
532 // SAFETY: layer 0 is always `Some` (set in `new`).
533 self.layers[0].as_ref().unwrap()
534 }
535
536 /// Borrow layer 0 mutably (always allocated).
537 fn layer0_mut(&mut self) -> &mut LayerBuf {
538 self.layers[0].as_mut().unwrap()
539 }
540}
541
542// ---------------------------------------------------------------------------
543// Grid — public API (all forward to layer 0)
544// ---------------------------------------------------------------------------
545
546impl Grid {
547 /// Creates a new grid of the given dimensions.
548 ///
549 /// Layer 0 is allocated immediately. Layers 1–255 are `None` until first
550 /// write via [`put_tile`](Self::put_tile); the layer table itself only
551 /// grows as far as the highest layer id ever written, not all 256 slots
552 /// up front.
553 #[must_use]
554 pub fn new(width: u16, height: u16) -> Self {
555 Self {
556 width,
557 height,
558 layers: alloc::vec![Some(LayerBuf::new(width, height))],
559 max_layer: 0,
560 has_spans: false,
561 }
562 }
563
564 /// Build a grid from a rectangular character map, one [`Tile`] per cell.
565 ///
566 /// `map` is split on `\n`; the grid width is the longest line's character
567 /// count and the height is the number of lines. Lines shorter than the
568 /// widest are padded with the default tile. `f` maps each character to its
569 /// tile, called once per character in reading order.
570 ///
571 /// Characters are counted as Unicode scalar values (one column each), which
572 /// matches ASCII / CP437 maps and level/prefab strings. Wide characters are
573 /// not width-adjusted.
574 ///
575 /// # Examples
576 ///
577 /// ```
578 /// use retroglyph_core::{Grid, Pos, Style, Tile};
579 ///
580 /// // A ragged map: the second line is shorter than the first.
581 /// let grid = Grid::from_charmap("###\n#.", |c| match c {
582 /// '#' => Tile::new('#', Style::default()),
583 /// _ => Tile::default(),
584 /// });
585 ///
586 /// // Width comes from the longest line; the shorter line is padded with the default
587 /// // tile rather than truncating the grid to the shortest line.
588 /// assert_eq!((grid.width(), grid.height()), (3, 2));
589 /// assert_eq!(grid[Pos::new(0, 0)].glyph(), '#');
590 /// assert_eq!(grid[Pos::new(1, 1)].glyph(), ' '); // '.' maps to the default tile
591 /// assert_eq!(grid[Pos::new(2, 1)].glyph(), ' '); // padding past the short line's end
592 /// ```
593 #[must_use]
594 pub fn from_charmap<F>(map: &str, mut f: F) -> Self
595 where
596 F: FnMut(char) -> Tile,
597 {
598 let mut width: u16 = 0;
599 let mut height: u16 = 0;
600 for line in map.lines() {
601 let len = u16::try_from(line.chars().count()).unwrap_or(u16::MAX);
602 width = width.max(len);
603 height = height.saturating_add(1);
604 }
605 let mut grid = Self::new(width, height);
606 for (y, line) in map.lines().enumerate() {
607 #[allow(clippy::cast_possible_truncation)]
608 let y = y as u16;
609 for (x, ch) in line.chars().enumerate() {
610 #[allow(clippy::cast_possible_truncation)]
611 let x = x as u16;
612 grid.put_tile(0, Pos::new(x, y), f(ch));
613 }
614 }
615 grid
616 }
617
618 /// Returns the width of the grid.
619 #[must_use]
620 pub const fn width(&self) -> u16 {
621 self.width
622 }
623
624 /// Returns the height of the grid.
625 #[must_use]
626 pub const fn height(&self) -> u16 {
627 self.height
628 }
629
630 /// Returns the highest layer id that has ever been allocated.
631 ///
632 /// Always at least 0 (layer 0 is always allocated). This only grows:
633 /// clearing a layer does not deallocate it, so the value does not shrink
634 /// once a higher layer has been written.
635 #[must_use]
636 pub const fn max_layer(&self) -> u8 {
637 self.max_layer
638 }
639
640 /// Returns the full grapheme cluster stored for the tile at `(x, y)` on
641 /// `layer`, if any.
642 ///
643 /// `Some` only when the tile has [`TileFlags::HAS_EXTRA`] set, i.e. it
644 /// was written via [`write_grapheme`](Self::write_grapheme) with a
645 /// multi-codepoint EGC (combining marks, ZWJ sequences, etc.). For the
646 /// common single-codepoint case, or without the `egc` feature, this is
647 /// always `None`; use [`tile`](Self::tile)'s
648 /// [`Tile::glyph`](crate::tile::Tile::glyph) and
649 /// [`encode_utf8`](char::encode_utf8) to reconstruct the string instead.
650 ///
651 /// Returns `None` if the layer is unallocated or the coordinates are out
652 /// of bounds.
653 #[must_use]
654 pub fn grapheme(&self, layer: u8, x: u16, y: u16) -> Option<&str> {
655 let lb = self.layer(layer)?;
656 let pos = to_grixy_pos(Pos::new(x, y));
657 let tile = lb.buf.get(pos)?;
658 let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
659 lb.extra_for(idx, tile)
660 }
661
662 /// Iterates all tiles on `layer` with their `(x, y)` coordinates.
663 ///
664 /// Returns `None` if the layer is unallocated.
665 #[must_use]
666 pub fn cells(&self, layer: u8) -> Option<Cells<'_>> {
667 let lb = self.layer(layer)?;
668 Some(Cells {
669 iter: lb.buf.as_ref().iter().enumerate(),
670 width: usize::from(self.width),
671 })
672 }
673
674 /// Iterates all tiles on `layer` mutably with their `(x, y)` coordinates.
675 ///
676 /// Returns `None` if the layer is unallocated, mirroring [`cells`](Self::cells)'s
677 /// fallibility. Use [`cells_mut_or_alloc`](Self::cells_mut_or_alloc) to allocate the layer
678 /// first instead of failing.
679 pub fn cells_mut(&mut self, layer: u8) -> Option<CellsMut<'_>> {
680 let width = usize::from(self.width);
681 let lb = self.layers.get_mut(usize::from(layer))?.as_mut()?;
682 Some(CellsMut {
683 iter: lb.buf.as_mut().iter_mut().enumerate(),
684 width,
685 })
686 }
687
688 /// Iterates all tiles on `layer` mutably with their `(x, y)` coordinates, allocating the
689 /// layer first if it has not been written to yet.
690 ///
691 /// Prefer [`cells_mut`](Self::cells_mut) unless an empty layer legitimately needs to exist
692 /// after this call returns; unlike that method, this one never fails, at the cost of always
693 /// allocating.
694 pub fn cells_mut_or_alloc(&mut self, layer: u8) -> CellsMut<'_> {
695 let width = usize::from(self.width);
696 let lb = self.layer_or_alloc(layer);
697 CellsMut {
698 iter: lb.buf.as_mut().iter_mut().enumerate(),
699 width,
700 }
701 }
702
703 /// Clears a specific layer, resetting all tiles to the default.
704 ///
705 /// Does nothing if the layer is unallocated.
706 pub fn clear(&mut self, layer: u8) {
707 if let Some(lb) = self
708 .layers
709 .get_mut(usize::from(layer))
710 .and_then(Option::as_mut)
711 {
712 lb.buf.clear();
713 lb.extras.clear();
714 }
715 }
716
717 /// Resize the grid to `width` × `height` tiles.
718 ///
719 /// Content within the overlapping region is preserved on all allocated
720 /// layers. New cells are initialised to the default tile. Shrinking
721 /// discards tiles outside the new bounds.
722 pub fn resize(&mut self, width: u16, height: u16) {
723 let old_width = usize::from(self.width);
724 let new_width = usize::from(width);
725 let new_height = usize::from(height);
726 self.width = width;
727 self.height = height;
728 for layer in self.layers.iter_mut().flatten() {
729 // The extras side-table is keyed by flat row-major index, which
730 // shifts whenever the width changes: remap it in lockstep with
731 // `buf.resize` (below) rather than leaving it pointing at stale
732 // (or now out-of-bounds) cells.
733 if !layer.extras.is_empty() {
734 layer.extras = layer
735 .extras
736 .iter()
737 .filter_map(|(&old_idx, s)| {
738 let x = old_idx % old_width;
739 let y = old_idx / old_width;
740 (x < new_width && y < new_height).then(|| (y * new_width + x, s.clone()))
741 })
742 .collect();
743 }
744 layer.buf.resize(new_width, new_height);
745 }
746 }
747
748 // ------------------------------------------------------------------
749 // Write grapheme — layer 0 only
750 // ------------------------------------------------------------------
751
752 /// Write a grapheme cluster at `(x, y)` on layer 0, enforcing wide-
753 /// character invariants.
754 ///
755 /// This is the canonical way to place content into the grid when the `egc`
756 /// feature is enabled. It:
757 ///
758 /// - Clears any wide character whose primary or spacer cell would be
759 /// overwritten.
760 /// - Sets [`TileFlags::WIDE_CHAR`] on the primary cell and places a
761 /// [`TileFlags::WIDE_CHAR_SPACER`] in the adjacent cell for 2-column
762 /// characters.
763 /// - Stores multi-codepoint EGCs (combining marks, ZWJ sequences) in the
764 /// layer's EGC side-table (see [`grapheme`](Self::grapheme)), capped at
765 /// 8 codepoints total.
766 ///
767 /// Also does nothing if the grapheme has zero display width, or if a 2-column wide character
768 /// would overflow the grid (the last column needs both its own cell and a spacer).
769 ///
770 /// # Panics
771 ///
772 /// Panics if the grapheme's display width exceeds [`u16::MAX`]. In
773 /// practice this cannot happen: the maximum Unicode grapheme width is 2.
774 ///
775 /// Only present when the `egc` feature is enabled.
776 #[cfg(feature = "egc")]
777 pub fn write_grapheme(&mut self, layer: u8, x: u16, y: u16, grapheme: &str, style: Style) {
778 use unicode_width::UnicodeWidthStr;
779
780 let width = u16::try_from(grapheme.width()).expect("grapheme width exceeds u16");
781 if width == 0 {
782 return;
783 }
784
785 // Capture dimensions as plain values to avoid borrow conflicts.
786 let w = usize::from(self.width);
787 let cap = w * usize::from(self.height);
788 let idx = usize::from(y) * w + usize::from(x);
789 if idx >= cap {
790 return;
791 }
792
793 // A 2-column char needs a spacer at x+1. If that's out of bounds,
794 // silently refuse rather than leaving an orphaned primary cell.
795 if width == 2 && x.saturating_add(1) as usize >= w {
796 return;
797 }
798
799 // Clear any wide-char cell, or any multi-cell span, that would be partially overwritten.
800 self.clear_span_overlap(layer, x, y, width);
801 self.clear_overlap(layer, x, y, width);
802
803 // Capture width before borrowing self mutably.
804 let grid_w = usize::from(self.width);
805 let idx = usize::from(y) * grid_w + usize::from(x);
806
807 let lb = self.layer_or_alloc(layer);
808 // Build cell content.
809 let mut chars = grapheme.chars();
810 let first = chars.next().unwrap_or(' ');
811 let has_extra = chars.next().is_some();
812 let flags = if width == 2 {
813 TileFlags::WIDE_CHAR
814 } else {
815 TileFlags::empty()
816 };
817 let flags = if has_extra {
818 flags | TileFlags::HAS_EXTRA
819 } else {
820 flags
821 };
822
823 lb.buf.as_mut()[idx].glyph = first;
824 lb.buf.as_mut()[idx].style = style;
825 lb.buf.as_mut()[idx].flags = flags;
826 // `width` here is the full grapheme's display width (1 or 2), not just `first`'s: more
827 // accurate than recomputing from the primary codepoint alone, and exactly what the
828 // terminal renderer needs to advance the cursor after printing this cell.
829 #[allow(clippy::cast_possible_truncation)]
830 {
831 lb.buf.as_mut()[idx].width = width as u8;
832 }
833 // A fresh glyph write replaces the cell's out-of-line data outright rather than merging
834 // with it: a tint belongs to the artwork that was drawn here, not to the cell, so
835 // overwriting the glyph drops it. `Grid::set_tint` is the follow-up that puts one back.
836 if has_extra {
837 lb.extras.insert(
838 idx,
839 TileExtra {
840 grapheme: Some(Arc::from(cap_grapheme(grapheme))),
841 tint: Tint::None,
842 },
843 );
844 } else {
845 lb.extras.remove(&idx);
846 }
847
848 // Place spacer for wide characters.
849 if width == 2 {
850 let spacer_idx = usize::from(y) * grid_w + usize::from(x + 1);
851 if spacer_idx < cap {
852 let spacer = &mut lb.buf.as_mut()[spacer_idx];
853 spacer.glyph = ' ';
854 spacer.style = style;
855 spacer.width = 0;
856 spacer.flags = TileFlags::WIDE_CHAR_SPACER;
857 lb.extras.remove(&spacer_idx);
858 }
859 }
860 }
861
862 /// Clears wide-character cells that would be partially overwritten by a
863 /// write starting at `(x, y)` spanning `width` columns.
864 ///
865 /// `clear_span_overlap` is the multi-cell-span analogue. It is not `egc`-gated, because a
866 /// span is not a Unicode concept and exists on every feature combination, so a write that
867 /// can land inside either kind of multi-cell structure calls both.
868 #[cfg(feature = "egc")]
869 fn clear_overlap(&mut self, layer: u8, x: u16, y: u16, width: u16) {
870 let w = usize::from(self.width);
871 let cap = w * usize::from(self.height);
872 let lb = self.layer_or_alloc(layer);
873 for cx in x..x.saturating_add(width) {
874 let idx = usize::from(y) * w + usize::from(cx);
875 if idx >= cap {
876 continue;
877 }
878 // flags is Copy, so reading through the shared ref is fine.
879 let flags = lb.buf.as_ref()[idx].flags;
880
881 if flags.contains(TileFlags::WIDE_CHAR_SPACER) && cx > 0 {
882 let pidx = usize::from(y) * w + usize::from(cx - 1);
883 if pidx < cap {
884 lb.buf.as_mut()[pidx].reset();
885 lb.extras.remove(&pidx);
886 }
887 }
888
889 if flags.contains(TileFlags::WIDE_CHAR) {
890 let sidx = usize::from(y) * w + usize::from(cx + 1);
891 if sidx < cap {
892 lb.buf.as_mut()[sidx].reset();
893 lb.extras.remove(&sidx);
894 }
895 }
896 }
897 }
898}
899
900// ---------------------------------------------------------------------------
901// Grid — multi-cell spans
902// ---------------------------------------------------------------------------
903
904impl Grid {
905 /// Writes a multi-cell span at `(x, y)` on `layer`: one piece of artwork occupying a block of
906 /// cells rather than one.
907 ///
908 /// `rows` holds one string per row of the footprint, so the span is `rows.len()` cells tall
909 /// and `rows[0]`'s character count wide, and every row must be that same width. Any
910 /// `AsRef<str>` row works, so a literal footprint (`&["[==]", "|__|"]`) and a computed one
911 /// (`&Vec<String>`) both pass without a borrowing pass over the rows. The first
912 /// character goes to the **anchor** cell at `(x, y)` with [`TileFlags::SPAN_ANCHOR`]; each
913 /// remaining character goes to its own cell with [`TileFlags::SPAN_COVERED`]. `style` applies
914 /// to every cell.
915 ///
916 /// # Text fallback
917 ///
918 /// The covered cells keep real glyphs, which is what lets one call render correctly on every
919 /// backend with no capability check:
920 ///
921 /// - A **cell backend** (`Headless`, `retroglyph-crossterm`, `retroglyph-terminal`) ignores
922 /// [`TileFlags::SPAN_COVERED`] and prints all of them, so `["[==]", "|__|"]` reads as a
923 /// small piece of ASCII art.
924 /// - A **pixel backend** (`retroglyph-software`, `retroglyph-gl`) looks the anchor glyph up in
925 /// its sprite cache, draws that one sprite across the whole footprint, and skips every
926 /// covered cell's glyph.
927 ///
928 /// This is the deliberate difference from [`TileFlags::WIDE_CHAR_SPACER`], which every
929 /// backend skips.
930 ///
931 /// Any existing span or wide character the footprint would partially overwrite is cleared
932 /// first, in full, as [`write_grapheme`](Self::write_grapheme) does for its own 1- or 2-cell
933 /// write.
934 ///
935 /// For the common sprite case (one runtime-chosen anchor glyph, blanks in every covered
936 /// cell), [`write_span_uniform`](Self::write_span_uniform) says the same thing without
937 /// building the rows.
938 ///
939 /// # Returns
940 ///
941 /// `Some(())` once the whole span is written, or `None` having written nothing at all when
942 /// `rows` is empty, its first row is empty, its rows differ in width, either axis exceeds 255
943 /// cells, or the footprint would not fit in the grid at `(x, y)`.
944 ///
945 /// # Examples
946 ///
947 /// ```
948 /// # fn main() {
949 /// # fn run() -> Option<()> {
950 /// use retroglyph_core::{Grid, Pos, Style};
951 ///
952 /// let mut grid = Grid::new(8, 4);
953 /// grid.write_span(0, 1, 1, &["[==]", "|__|"], Style::default())?;
954 ///
955 /// assert_eq!(grid.tile(0, Pos::new(1, 1))?.span(), (4, 2));
956 /// // Covered cells keep their fallback glyphs, and name their anchor.
957 /// assert_eq!(grid.tile(0, Pos::new(4, 2))?.glyph(), '|');
958 /// assert_eq!(grid.span_owner(0, 4, 2), Some(Pos::new(1, 1)));
959 /// # Some(())
960 /// # }
961 /// # run().unwrap();
962 /// # }
963 /// ```
964 pub fn write_span<S: AsRef<str>>(
965 &mut self,
966 layer: u8,
967 x: u16,
968 y: u16,
969 rows: &[S],
970 style: Style,
971 ) -> Option<()> {
972 let cols = rows.first()?.as_ref().chars().count();
973 if cols == 0 || rows.iter().any(|r| r.as_ref().chars().count() != cols) {
974 return None;
975 }
976 // `Tile` stores a span's dimensions in one byte each (see `Tile::span_w`), so a span
977 // wider or taller than 255 cells is not representable.
978 let footprint = (u8::try_from(cols).ok()?, u8::try_from(rows.len()).ok()?);
979
980 self.write_span_cells(
981 layer,
982 Pos::new(x, y),
983 footprint,
984 style,
985 rows.iter().map(|row| row.as_ref().chars()),
986 )
987 }
988
989 /// Writes a `size` multi-cell span at `pos` on `layer`: `anchor` in the anchor cell, `fill`
990 /// in every other cell of the footprint.
991 ///
992 /// The uniform case of [`write_span`](Self::write_span), and the shape a sheet-driven
993 /// renderer usually wants: one sprite, chosen at runtime, with the cells it covers blanked so
994 /// nothing shows through its transparent pixels. Spelling that as an array of blank rows
995 /// carries no information and, for a computed anchor, has to be allocated per draw.
996 ///
997 /// `fill` is what a *cell* backend prints for the covered cells (a pixel backend skips them
998 /// and draws the sprite instead), so it is the span's text fallback: `' '` blanks them, and a
999 /// visible character keeps the footprint legible in a terminal. See
1000 /// [`write_span`](Self::write_span) for the full write semantics.
1001 ///
1002 /// # Returns
1003 ///
1004 /// `Some(())` once the whole span is written, or `None` having written nothing at all when
1005 /// either axis of `size` is `0` or exceeds 255 cells, or the footprint would not fit in the
1006 /// grid at `pos`.
1007 ///
1008 /// # Examples
1009 ///
1010 /// ```
1011 /// # fn main() {
1012 /// # fn run() -> Option<()> {
1013 /// use retroglyph_core::{Grid, Pos, Style};
1014 ///
1015 /// let mut grid = Grid::new(8, 4);
1016 /// let anchor = '\u{E000}'; // chosen at runtime from a tilesheet
1017 /// grid.write_span_uniform(0, (1, 1), (2, 2), anchor, ' ', Style::default())?;
1018 ///
1019 /// assert_eq!(grid.tile(0, Pos::new(1, 1))?.span(), (2, 2));
1020 /// assert_eq!(grid.span_owner(0, 2, 2), Some(Pos::new(1, 1)));
1021 /// # Some(())
1022 /// # }
1023 /// # run().unwrap();
1024 /// # }
1025 /// ```
1026 pub fn write_span_uniform(
1027 &mut self,
1028 layer: u8,
1029 pos: impl Into<Pos>,
1030 size: impl Into<Size>,
1031 anchor: char,
1032 fill: char,
1033 style: Style,
1034 ) -> Option<()> {
1035 let size = size.into();
1036 // `Tile` stores a span's dimensions in one byte each (see `Tile::span_w`), so a span
1037 // wider or taller than 255 cells is not representable.
1038 let footprint = (
1039 u8::try_from(size.width).ok()?,
1040 u8::try_from(size.height).ok()?,
1041 );
1042 if footprint.0 == 0 || footprint.1 == 0 {
1043 return None;
1044 }
1045
1046 let rows = (0..footprint.1).map(move |row| {
1047 (0..footprint.0).map(move |col| if (row, col) == (0, 0) { anchor } else { fill })
1048 });
1049 self.write_span_cells(layer, pos.into(), footprint, style, rows)
1050 }
1051
1052 /// Writes a `footprint` (`w`, `h`) span at `pos` on `layer`, taking its glyphs row by row.
1053 ///
1054 /// The shared body of [`write_span`](Self::write_span) and
1055 /// [`write_span_uniform`](Self::write_span_uniform): both have already narrowed the footprint
1056 /// to a `u8` per axis, so all that is left is the grid-fit check and the write itself.
1057 /// `rows` must yield exactly `footprint.1` rows of exactly `footprint.0` glyphs.
1058 fn write_span_cells<R: Iterator<Item = char>>(
1059 &mut self,
1060 layer: u8,
1061 pos: Pos,
1062 footprint: (u8, u8),
1063 style: Style,
1064 rows: impl Iterator<Item = R>,
1065 ) -> Option<()> {
1066 let (footprint_w, footprint_h) = footprint;
1067 let (x, y) = (pos.x, pos.y);
1068
1069 let grid_w = usize::from(self.width);
1070 if usize::from(x) + usize::from(footprint_w) > grid_w
1071 || usize::from(y) + usize::from(footprint_h) > usize::from(self.height)
1072 {
1073 return None;
1074 }
1075
1076 // Clear anything the footprint would partially overwrite. Every rejection above happens
1077 // first, so a refused write can never have already destroyed the caller's content.
1078 for row in 0..footprint_h {
1079 let cy = y + u16::from(row);
1080 self.clear_span_overlap(layer, x, cy, u16::from(footprint_w));
1081 #[cfg(feature = "egc")]
1082 self.clear_overlap(layer, x, cy, u16::from(footprint_w));
1083 }
1084
1085 self.has_spans = true;
1086 let lb = self.layer_or_alloc(layer);
1087 for (row, line) in rows.enumerate() {
1088 for (col, ch) in line.enumerate() {
1089 let idx = (usize::from(y) + row) * grid_w + usize::from(x) + col;
1090 let mut tile = Tile::new(ch, style);
1091 if row == 0 && col == 0 {
1092 tile.flags = TileFlags::SPAN_ANCHOR;
1093 tile.span_w = footprint_w;
1094 tile.span_h = footprint_h;
1095 } else {
1096 // Both fit in a `u8`: they are strictly less than the footprint, which was
1097 // already narrowed to one above.
1098 #[allow(clippy::cast_possible_truncation)]
1099 {
1100 tile.flags = TileFlags::SPAN_COVERED;
1101 tile.span_w = col as u8;
1102 tile.span_h = row as u8;
1103 }
1104 }
1105 lb.buf.as_mut()[idx] = tile;
1106 lb.extras.remove(&idx);
1107 }
1108 }
1109 Some(())
1110 }
1111
1112 /// The anchor of the multi-cell span occupying `(x, y)` on `layer`, or `None` when the cell
1113 /// belongs to no span or is out of bounds.
1114 ///
1115 /// An anchor cell reports itself, so every cell of one span answers with the same position
1116 /// and hit-testing multi-cell artwork is a single comparison:
1117 ///
1118 /// ```
1119 /// # fn main() {
1120 /// # fn run() -> Option<()> {
1121 /// # use retroglyph_core::{Grid, Pos, Style};
1122 /// # let mut grid = Grid::new(8, 4);
1123 /// grid.write_span(0, 2, 1, &["[==]", "|__|"], Style::default())?;
1124 /// let chest = Pos::new(2, 1);
1125 /// // Any of the eight cells counts as standing on the chest.
1126 /// assert_eq!(grid.span_owner(0, 2, 1), Some(chest));
1127 /// assert_eq!(grid.span_owner(0, 5, 2), Some(chest));
1128 /// assert_eq!(grid.span_owner(0, 6, 2), None);
1129 /// # Some(())
1130 /// # }
1131 /// # run().unwrap();
1132 /// # }
1133 /// ```
1134 ///
1135 /// O(1): a covered tile stores its offset back to the anchor (see [`Tile::span_offset`]), so
1136 /// this is a lookup and a subtraction, not a scan.
1137 #[must_use]
1138 pub fn span_owner(&self, layer: u8, x: u16, y: u16) -> Option<Pos> {
1139 self.span_anchor_at(layer, x, y)
1140 }
1141
1142 /// Clears the whole multi-cell span that `(x, y)` on `layer` belongs to, anchor included,
1143 /// resetting every one of its cells to the default (empty) tile.
1144 ///
1145 /// Works from any cell of the span, so it pairs with [`span_owner`](Self::span_owner): hit-test
1146 /// a cell, then clear the artwork it belongs to. Does nothing if the cell is not part of a
1147 /// span, is out of bounds, or the layer is unallocated.
1148 pub fn clear_span(&mut self, layer: u8, x: u16, y: u16) {
1149 if let Some(anchor) = self.span_anchor_at(layer, x, y) {
1150 self.reset_span_at(layer, anchor);
1151 }
1152 }
1153
1154 /// The anchor of the span `(x, y)` belongs to, treating an anchor cell as its own anchor.
1155 fn span_anchor_at(&self, layer: u8, x: u16, y: u16) -> Option<Pos> {
1156 let tile = self.layer(layer)?.buf.get(to_grixy_pos(Pos::new(x, y)))?;
1157 if tile.flags.contains(TileFlags::SPAN_ANCHOR) {
1158 return Some(Pos::new(x, y));
1159 }
1160 let (dx, dy) = tile.span_offset()?;
1161 Some(Pos::new(x.checked_sub(dx)?, y.checked_sub(dy)?))
1162 }
1163
1164 /// Resets every cell of the span anchored at `anchor` on `layer`. No-op if that cell is not
1165 /// a [`TileFlags::SPAN_ANCHOR`], or the layer is unallocated.
1166 fn reset_span_at(&mut self, layer: u8, anchor: Pos) {
1167 let w = usize::from(self.width);
1168 let h = usize::from(self.height);
1169 let Some(lb) = self
1170 .layers
1171 .get_mut(usize::from(layer))
1172 .and_then(Option::as_mut)
1173 else {
1174 return;
1175 };
1176 let anchor_idx = usize::from(anchor.y) * w + usize::from(anchor.x);
1177 let Some(anchor_tile) = lb.buf.as_ref().get(anchor_idx).copied() else {
1178 return;
1179 };
1180 if !anchor_tile.flags.contains(TileFlags::SPAN_ANCHOR) {
1181 return;
1182 }
1183 for row in 0..usize::from(anchor_tile.span_h) {
1184 let cy = usize::from(anchor.y) + row;
1185 if cy >= h {
1186 break;
1187 }
1188 for col in 0..usize::from(anchor_tile.span_w) {
1189 let cx = usize::from(anchor.x) + col;
1190 if cx >= w {
1191 break;
1192 }
1193 let idx = cy * w + cx;
1194 lb.buf.as_mut()[idx].reset();
1195 lb.extras.remove(&idx);
1196 }
1197 }
1198 }
1199
1200 /// Clears every multi-cell span that a `width`-cell write starting at `(x, y)` on `layer`
1201 /// would partially overwrite.
1202 ///
1203 /// The span analogue of [`clear_overlap`](Self::clear_overlap), and the reason every ordinary
1204 /// write path calls it: overwriting one cell of a span would otherwise leave an anchor
1205 /// claiming cells it no longer owns, or a covered cell pointing at an anchor that is gone.
1206 ///
1207 /// Returns immediately on a grid that has never had a span written to it, which is what keeps
1208 /// this off the cost of an ordinary [`put_tile`](Self::put_tile) (see
1209 /// [`has_spans`](Self::has_spans)).
1210 fn clear_span_overlap(&mut self, layer: u8, x: u16, y: u16, width: u16) {
1211 if !self.has_spans {
1212 return;
1213 }
1214 // Collect first: resetting a span mutates cells this scan is still reading. Overlapping
1215 // writes touch at most a handful of spans, so the linear `contains` beats a set.
1216 let mut anchors: Vec<Pos> = Vec::new();
1217 let Some(lb) = self.layer(layer) else {
1218 return;
1219 };
1220 for cx in x..x.saturating_add(width) {
1221 let Some(tile) = lb.buf.get(to_grixy_pos(Pos::new(cx, y))) else {
1222 continue;
1223 };
1224 let anchor = if tile.flags.contains(TileFlags::SPAN_ANCHOR) {
1225 Pos::new(cx, y)
1226 } else if let Some((dx, dy)) = tile.span_offset() {
1227 match (cx.checked_sub(dx), y.checked_sub(dy)) {
1228 (Some(ax), Some(ay)) => Pos::new(ax, ay),
1229 _ => continue,
1230 }
1231 } else {
1232 continue;
1233 };
1234 if !anchors.contains(&anchor) {
1235 anchors.push(anchor);
1236 }
1237 }
1238 for anchor in anchors {
1239 self.reset_span_at(layer, anchor);
1240 }
1241 }
1242}
1243
1244// ---------------------------------------------------------------------------
1245// Grid — multi-layer API
1246// ---------------------------------------------------------------------------
1247
1248impl Grid {
1249 /// Write a tile to `layer` at `pos`.
1250 ///
1251 /// Allocates the layer if it has not been written to yet. Returns `None`
1252 /// if `pos` is out of bounds.
1253 ///
1254 /// To read back, use [`tile`](Self::tile).
1255 ///
1256 /// Any tile written this way has its extra grapheme text cleared, since a
1257 /// caller-constructed [`Tile`] can never legitimately carry
1258 /// [`TileFlags::HAS_EXTRA`] (the flag is crate-private). Internal callers
1259 /// that need to preserve EGC text across a copy (e.g. [`blit`](Self::blit))
1260 /// follow up with a direct extras-table write. Any multi-cell span the
1261 /// cell belongs to is cleared first, so a write can never leave an anchor
1262 /// pointing at cells it no longer owns.
1263 pub fn put_tile(&mut self, layer: u8, pos: impl Into<Pos>, mut tile: Tile) -> Option<()> {
1264 let pos = pos.into();
1265 self.clear_span_overlap(layer, pos.x, pos.y, 1);
1266 let gpos = to_grixy_pos(pos);
1267 let idx = usize::from(pos.y) * usize::from(self.width) + usize::from(pos.x);
1268 let lb = self.layer_or_alloc(layer);
1269 if !lb.buf.contains(gpos) {
1270 return None;
1271 }
1272 lb.extras.remove(&idx);
1273 tile.flags.remove(TileFlags::HAS_EXTRA);
1274 lb.buf[gpos] = tile;
1275 Some(())
1276 }
1277
1278 /// Sets the whole side-table entry for an already-written tile at `(x, y)` on `layer`,
1279 /// setting [`TileFlags::HAS_EXTRA`] to match. Does nothing if out of bounds. Crate-private:
1280 /// the external ways in are [`write_grapheme`](Self::write_grapheme) and
1281 /// [`set_tint`](Self::set_tint).
1282 ///
1283 /// An empty entry is removed rather than stored, so the flag means exactly "an entry
1284 /// exists".
1285 pub(crate) fn set_extra(&mut self, layer: u8, x: u16, y: u16, extra: TileExtra) {
1286 let pos = to_grixy_pos(Pos::new(x, y));
1287 let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
1288 let lb = self.layer_or_alloc(layer);
1289 if lb.buf.contains(pos) {
1290 if extra.is_empty() {
1291 lb.buf[pos].flags.remove(TileFlags::HAS_EXTRA);
1292 lb.extras.remove(&idx);
1293 } else {
1294 lb.buf[pos].flags.insert(TileFlags::HAS_EXTRA);
1295 lb.extras.insert(idx, extra);
1296 }
1297 }
1298 }
1299
1300 /// How a pixel backend recolours the sprite drawn for the cell at `(x, y)` on `layer`.
1301 ///
1302 /// [`Tint::None`] for a cell that has never been tinted, for a cell whose glyph was
1303 /// overwritten since (a glyph write drops the tint with the artwork it belonged to), and for
1304 /// coordinates outside the grid or on an unallocated layer.
1305 ///
1306 /// A tint is grid state rather than [`Tile`] state, for the same reason a multi-codepoint
1307 /// grapheme is (see [`grapheme`](Self::grapheme)): it is rare per cell and `Tile` has no room
1308 /// left. So it is read here, not through [`Tile::style`].
1309 ///
1310 /// Cell backends have no sprite to recolour and ignore this entirely.
1311 #[must_use]
1312 pub fn tint(&self, layer: u8, x: u16, y: u16) -> Tint {
1313 let Some(lb) = self.layer(layer) else {
1314 return Tint::None;
1315 };
1316 let Some(tile) = lb.buf.get(to_grixy_pos(Pos::new(x, y))) else {
1317 return Tint::None;
1318 };
1319 let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
1320 lb.tint_for(idx, tile)
1321 }
1322
1323 /// Sets how a pixel backend recolours the sprite drawn for the cell at `(x, y)` on `layer`.
1324 ///
1325 /// Applies to the cell as it stands, so it belongs *after* the write that put the glyph
1326 /// there: writing a glyph over a tinted cell drops the tint, on the grounds that a tint
1327 /// describes the artwork rather than the position. For a multi-cell span, tint the anchor;
1328 /// that is the cell a pixel backend draws the sprite from.
1329 ///
1330 /// Setting [`Tint::None`] clears the tint, and drops the cell's side-table entry entirely if
1331 /// it held nothing else. Does nothing if `(x, y)` is out of bounds.
1332 pub fn set_tint(&mut self, layer: u8, x: u16, y: u16, tint: Tint) {
1333 let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
1334 let pos = to_grixy_pos(Pos::new(x, y));
1335 let lb = self.layer_or_alloc(layer);
1336 if !lb.buf.contains(pos) {
1337 return;
1338 }
1339 // Preserve any grapheme already stored for this cell: the two members of the entry are
1340 // written by separate calls and neither should clobber the other.
1341 let grapheme = if lb.buf[pos].flags.contains(TileFlags::HAS_EXTRA) {
1342 lb.extras.get(&idx).and_then(|e| e.grapheme.clone())
1343 } else {
1344 None
1345 };
1346 let entry = TileExtra { grapheme, tint };
1347 if entry.is_empty() {
1348 lb.buf[pos].flags.remove(TileFlags::HAS_EXTRA);
1349 lb.extras.remove(&idx);
1350 } else {
1351 lb.buf[pos].flags.insert(TileFlags::HAS_EXTRA);
1352 lb.extras.insert(idx, entry);
1353 }
1354 }
1355
1356 /// Read a tile on `layer` at `pos`, or `None` if the layer is
1357 /// unallocated or `pos` is out of bounds.
1358 #[must_use]
1359 pub fn tile(&self, layer: u8, pos: impl Into<Pos>) -> Option<&Tile> {
1360 let pos = to_grixy_pos(pos.into());
1361 self.layer(layer)?.buf.get(pos)
1362 }
1363
1364 /// Mutably borrow a tile on `layer` at `pos`, or `None` if the layer is
1365 /// unallocated or `pos` is out of bounds.
1366 ///
1367 /// This hands out a direct `&mut Tile`, so it cannot intercept a write the way
1368 /// [`put_tile`](Self::put_tile) does: it does not clear a multi-cell span `pos` belongs to,
1369 /// and it does not clear grapheme extras stored for the tile. Call
1370 /// [`clear_span`](Self::clear_span) first if `pos` may belong to a span.
1371 pub fn tile_mut(&mut self, layer: u8, pos: impl Into<Pos>) -> Option<&mut Tile> {
1372 let pos = to_grixy_pos(pos.into());
1373 self.layers
1374 .get_mut(usize::from(layer))?
1375 .as_mut()?
1376 .buf
1377 .get_mut(pos)
1378 }
1379
1380 /// Copy tiles from `src` within `src_rect` to `self` at `(dst_x, dst_y)`
1381 /// on `layer`. Empty tiles (nothing written; see [`Tile::is_empty`]) are
1382 /// treated as transparent and skipped. An explicit space is copied and
1383 /// overwrites the destination.
1384 ///
1385 /// Multi-cell spans (see [`write_span`](Self::write_span)) do **not** survive a blit: copied
1386 /// tiles keep their glyphs but lose [`TileFlags::SPAN_ANCHOR`]/[`TileFlags::SPAN_COVERED`],
1387 /// so a span degrades to exactly its text fallback. `src_rect` can clip a span in half, and
1388 /// half a span is not a thing the grid can represent; degrading to the fallback glyphs is
1389 /// both representable and the same content a cell backend would have drawn anyway.
1390 ///
1391 /// Walks `src`'s and `self`'s layer buffers directly by flat index instead of going through
1392 /// [`tile`](Self::tile)/[`put_tile`](Self::put_tile) per cell (see retroglyph#263):
1393 /// each of those recomputes a coordinate conversion and a bounds check per cell, which this
1394 /// does once per row instead. The destination layer is allocated once, up front, rather than
1395 /// as a side effect of the first written cell, but only if `src_rect` (clamped to `src`'s
1396 /// bounds) contains at least one non-empty tile, matching `put_tile`'s original
1397 /// allocate-on-first-write behavior for a `src_rect` that is entirely transparent.
1398 pub fn blit(&mut self, layer: u8, src: &Self, src_rect: Rect, dst_x: u16, dst_y: u16) {
1399 let Some(src_lb) = src.layer(layer) else {
1400 return;
1401 };
1402 let src_width = usize::from(src.width);
1403 let sx0 = src_rect.left().min(src.width);
1404 let sx1 = src_rect.right().min(src.width);
1405 let sy0 = src_rect.top().min(src.height);
1406 let sy1 = src_rect.bottom().min(src.height);
1407 if sx0 >= sx1 || sy0 >= sy1 {
1408 return;
1409 }
1410
1411 // Matches the original's implicit allocate-on-first-write: only touch the destination
1412 // layer at all if there's at least one visible (non-empty) source tile to copy.
1413 let has_visible = (sy0..sy1).any(|sy| {
1414 let start = usize::from(sy) * src_width + usize::from(sx0);
1415 let end = usize::from(sy) * src_width + usize::from(sx1);
1416 src_lb.buf.as_ref()[start..end]
1417 .iter()
1418 .any(|t| !t.flags.contains(TileFlags::EMPTY))
1419 });
1420 if !has_visible {
1421 return;
1422 }
1423
1424 let dst_width = usize::from(self.width);
1425 let dst_height = usize::from(self.height);
1426 let dst_lb = self.layer_or_alloc(layer);
1427 let mut pending_extras: Vec<(usize, TileExtra)> = Vec::new();
1428
1429 // `dst_x`/`dst_y` saturate on overflow (retroglyph#268): a `u16::MAX`-adjacent origin
1430 // combined with a `src_rect` offset would otherwise wrap silently and either write to
1431 // the wrong cell or get rejected by luck rather than by design. Saturating to `u16::MAX`
1432 // is always caught by the `>= dst_width`/`>= dst_height` bounds check below, since a
1433 // valid index must be strictly less than a `u16`-derived dimension.
1434 for sy in sy0..sy1 {
1435 let dy = dst_y.saturating_add(sy - src_rect.top());
1436 if usize::from(dy) >= dst_height {
1437 continue;
1438 }
1439 for sx in sx0..sx1 {
1440 let dx = dst_x.saturating_add(sx - src_rect.left());
1441 if usize::from(dx) >= dst_width {
1442 continue;
1443 }
1444 let src_idx = usize::from(sy) * src_width + usize::from(sx);
1445 let tile = &src_lb.buf.as_ref()[src_idx];
1446 if tile.flags.contains(TileFlags::EMPTY) {
1447 continue;
1448 }
1449 let dst_idx = usize::from(dy) * dst_width + usize::from(dx);
1450 let mut out_tile = *tile;
1451 out_tile.flags.remove(TileFlags::HAS_EXTRA);
1452 out_tile.clear_span();
1453 dst_lb.buf.as_mut()[dst_idx] = out_tile;
1454 if tile.flags.contains(TileFlags::HAS_EXTRA) {
1455 if let Some(extra) = src_lb.extra_entry_for(src_idx, tile) {
1456 pending_extras.push((dst_idx, extra));
1457 }
1458 } else {
1459 dst_lb.extras.remove(&dst_idx);
1460 }
1461 }
1462 }
1463
1464 for (idx, extra) in pending_extras {
1465 dst_lb.buf.as_mut()[idx].flags.insert(TileFlags::HAS_EXTRA);
1466 dst_lb.extras.insert(idx, extra);
1467 }
1468 }
1469
1470 /// Same as [`blit`](Self::blit) but blends foreground and background
1471 /// colors with the given alpha factors, using `mode` to compute the
1472 /// blended color. `fg_alpha` and `bg_alpha` are in 0.0-1.0 range where
1473 /// 0.0 = keep destination, 1.0 = replace with src; for a non-
1474 /// [`Linear`](BlendMode::Linear) `mode`, "replace with src" instead means
1475 /// "replace with `mode`'s fully blended color" (see [`BlendMode`]).
1476 ///
1477 /// Blending operates on packed RGB values; [`Color::Default`] preserves
1478 /// the destination. Non-RGB color variants (Ansi/Indexed) are passed
1479 /// through unblended, regardless of `mode`.
1480 ///
1481 /// Requires the `color-space` feature (default on): [`BlendMode::Linear`]'s
1482 /// per-channel color lerp is delegated to [`gem::Mix`]; the other
1483 /// modes delegate to [`alpha_blend::BlendMode`] (imported in this module as
1484 /// `SeparableBlendMode` to avoid colliding with this crate's own [`BlendMode`]).
1485 ///
1486 /// Like [`blit`](Self::blit) (see retroglyph#262/#263), walks `src`'s and `self`'s layer
1487 /// buffers directly by flat index instead of per-cell [`tile`](Self::tile)/
1488 /// [`put_tile`](Self::put_tile), and allocates the destination layer once, up front, rather
1489 /// than as a side effect of the first written cell.
1490 #[cfg(feature = "color-space")]
1491 #[allow(clippy::too_many_arguments, clippy::float_cmp)]
1492 pub fn blit_alpha(
1493 &mut self,
1494 layer: u8,
1495 src: &Self,
1496 src_rect: Rect,
1497 dst_x: u16,
1498 dst_y: u16,
1499 mode: BlendMode,
1500 fg_alpha: f32,
1501 bg_alpha: f32,
1502 ) {
1503 let Some(src_lb) = src.layer(layer) else {
1504 return;
1505 };
1506 let src_width = usize::from(src.width);
1507 let sx0 = src_rect.left().min(src.width);
1508 let sx1 = src_rect.right().min(src.width);
1509 let sy0 = src_rect.top().min(src.height);
1510 let sy1 = src_rect.bottom().min(src.height);
1511 if sx0 >= sx1 || sy0 >= sy1 {
1512 return;
1513 }
1514
1515 // Matches the original's implicit allocate-on-first-write: only touch the destination
1516 // layer at all if there's at least one visible (non-empty) source tile to copy.
1517 let has_visible = (sy0..sy1).any(|sy| {
1518 let start = usize::from(sy) * src_width + usize::from(sx0);
1519 let end = usize::from(sy) * src_width + usize::from(sx1);
1520 src_lb.buf.as_ref()[start..end]
1521 .iter()
1522 .any(|t| !t.flags.contains(TileFlags::EMPTY))
1523 });
1524 if !has_visible {
1525 return;
1526 }
1527
1528 let dst_width = usize::from(self.width);
1529 let dst_height = usize::from(self.height);
1530 let dst_lb = self.layer_or_alloc(layer);
1531 let mut pending_extras: Vec<(usize, TileExtra)> = Vec::new();
1532
1533 // See `blit`'s matching comment (retroglyph#268): `saturating_add` here, paired with the
1534 // bounds checks below, prevents a `u16::MAX`-adjacent destination origin from wrapping.
1535 for sy in sy0..sy1 {
1536 let dy = dst_y.saturating_add(sy - src_rect.top());
1537 if usize::from(dy) >= dst_height {
1538 continue;
1539 }
1540 for sx in sx0..sx1 {
1541 let dx = dst_x.saturating_add(sx - src_rect.left());
1542 if usize::from(dx) >= dst_width {
1543 continue;
1544 }
1545 let src_idx = usize::from(sy) * src_width + usize::from(sx);
1546 let tile = &src_lb.buf.as_ref()[src_idx];
1547 if tile.flags.contains(TileFlags::EMPTY) {
1548 continue;
1549 }
1550 let dst_idx = usize::from(dy) * dst_width + usize::from(dx);
1551 let mut blended = *tile;
1552 {
1553 let dst_tile = &dst_lb.buf.as_ref()[dst_idx];
1554 // `fg_alpha == 1.0` only lets `Linear` skip the call: `Linear` at `t ==
1555 // 1.0` is `src` by definition, but a `Screen`/`Dodge`/`Burn`/`Overlay`
1556 // mix at full alpha still needs to run the mode's formula: it isn't
1557 // equivalent to the raw source color (see `blend_color`'s matching guard).
1558 if mode != BlendMode::Linear || fg_alpha != 1.0 {
1559 blended.style.fg =
1560 blend_fg(mode, tile.style.fg, dst_tile.style.fg, fg_alpha);
1561 }
1562 if mode != BlendMode::Linear || bg_alpha != 1.0 {
1563 blended.style.bg =
1564 blend_bg(mode, tile.style.bg, dst_tile.style.bg, bg_alpha);
1565 }
1566 }
1567 blended.flags.remove(TileFlags::HAS_EXTRA);
1568 blended.clear_span();
1569 dst_lb.buf.as_mut()[dst_idx] = blended;
1570 if tile.flags.contains(TileFlags::HAS_EXTRA) {
1571 if let Some(extra) = src_lb.extra_entry_for(src_idx, tile) {
1572 pending_extras.push((dst_idx, extra));
1573 }
1574 } else {
1575 dst_lb.extras.remove(&dst_idx);
1576 }
1577 }
1578 }
1579
1580 for (idx, extra) in pending_extras {
1581 dst_lb.buf.as_mut()[idx].flags.insert(TileFlags::HAS_EXTRA);
1582 dst_lb.extras.insert(idx, extra);
1583 }
1584 }
1585
1586 /// Yield `(layer_id, Pos, &Tile, Option<&str>)` for every allocated cell
1587 /// across all layers, in layer-major (0 → `max_layer`) then row-major
1588 /// order. The last element is the tile's grapheme text (see
1589 /// [`grapheme`](Self::grapheme)), `Some` only when
1590 /// [`TileFlags::HAS_EXTRA`] is set.
1591 ///
1592 /// Unallocated layers are skipped. This is used by backends that need
1593 /// the full frame on every draw (see [`crate::Output::needs_full_frame`]).
1594 ///
1595 /// This iterator is zero-allocation: it walks the layer buffers inline.
1596 pub fn layers(&self) -> impl Iterator<Item = DrawCell<'_>> + '_ {
1597 let width = usize::from(self.width);
1598 (0..=self.max_layer)
1599 .filter_map(move |id| self.layer(id).map(|lb| (id, lb)))
1600 .flat_map(move |(id, lb)| {
1601 lb.buf.as_ref().iter().enumerate().map(move |(i, tile)| {
1602 #[allow(clippy::cast_possible_truncation)]
1603 let x = (i % width) as u16;
1604 #[allow(clippy::cast_possible_truncation)]
1605 let y = (i / width) as u16;
1606 DrawCell {
1607 layer: id,
1608 pos: Pos::new(x, y),
1609 tile,
1610 grapheme: lb.extra_for(i, tile),
1611 tint: lb.tint_for(i, tile),
1612 }
1613 })
1614 })
1615 }
1616
1617 /// Clear every allocated layer.
1618 pub fn clear_all(&mut self) {
1619 for layer in self.layers.iter_mut().flatten() {
1620 layer.buf.clear();
1621 layer.extras.clear();
1622 }
1623 }
1624
1625 /// Composite every allocated layer into `dst`'s layer 0, one tile per cell.
1626 ///
1627 /// Used by [`crate::Terminal::present`] for backends that do not composite
1628 /// layers themselves (see [`crate::Output::composites_layers`]). The rule
1629 /// matches the software renderer's pixel semantics and the [`blit`](Self::blit)
1630 /// transparency convention:
1631 ///
1632 /// - Start from layer 0's tile (its `bg` fills the cell).
1633 /// - For each higher allocated layer, in ascending order: if the tile is
1634 /// not empty (see [`Tile::is_empty`]) replace the glyph, foreground,
1635 /// offsets, flags, span, and extra; if its background is not
1636 /// [`Color::Default`], replace the background.
1637 ///
1638 /// The span fields travel with the flags they are keyed by (see [`Tile::span`]): a
1639 /// multi-cell span on a higher layer must arrive at a cell backend intact, or its covered
1640 /// cells lose the anchor they name.
1641 ///
1642 /// Because an explicit space is not empty, drawing one on a higher layer
1643 /// overwrites (erases) the glyph beneath it.
1644 ///
1645 /// `dst` must have the same dimensions as `self`.
1646 ///
1647 /// Walks layer buffers directly by flat index instead of calling
1648 /// [`tile`](Self::tile) per cell (see retroglyph#262): that recomputes a coordinate
1649 /// conversion and a bounds check per cell, which a flat scan over each layer's backing
1650 /// buffer (the same style [`layers`](Self::layers) and [`diff`](Self::diff) already use)
1651 /// avoids entirely.
1652 pub(crate) fn flatten_into(&self, dst: &mut Self) {
1653 dst.has_spans |= self.has_spans;
1654 let layer0 = self.layer0();
1655 let cell_count = layer0.buf.as_ref().len();
1656
1657 // Seed every destination cell from layer 0: its tile verbatim, and its extra text
1658 // filtered through `HAS_EXTRA` (the flag is authoritative, see `LayerBuf::extras`'
1659 // doc comment, so a stale, unflagged entry in `layer0.extras` is not carried over).
1660 let dst_layer0 = dst.layer0_mut();
1661 dst_layer0.buf.as_mut().copy_from_slice(layer0.buf.as_ref());
1662 dst_layer0.extras.clear();
1663 for (&idx, extra) in &layer0.extras {
1664 if layer0.buf.as_ref()[idx]
1665 .flags
1666 .contains(TileFlags::HAS_EXTRA)
1667 {
1668 dst_layer0.extras.insert(idx, extra.clone());
1669 }
1670 }
1671
1672 // Overlay every higher allocated layer, in ascending order, index-for-index.
1673 for id in 1..=self.max_layer {
1674 let Some(lb) = self.layer(id) else {
1675 continue;
1676 };
1677 let src_buf = lb.buf.as_ref();
1678 debug_assert_eq!(src_buf.len(), cell_count);
1679 let dst_layer0 = dst.layer0_mut();
1680 for (idx, tile) in src_buf.iter().enumerate() {
1681 if !tile.flags.contains(TileFlags::EMPTY) {
1682 {
1683 let out = &mut dst_layer0.buf.as_mut()[idx];
1684 out.glyph = tile.glyph;
1685 out.width = tile.width;
1686 out.style.fg = tile.style.fg;
1687 out.dx = tile.dx;
1688 out.dy = tile.dy;
1689 out.flags = tile.flags;
1690 out.span_w = tile.span_w;
1691 out.span_h = tile.span_h;
1692 }
1693 if tile.flags.contains(TileFlags::HAS_EXTRA) {
1694 if let Some(extra) = lb.extra_entry_for(idx, tile) {
1695 dst_layer0.extras.insert(idx, extra);
1696 }
1697 } else {
1698 dst_layer0.extras.remove(&idx);
1699 }
1700 }
1701 if tile.style.bg != Color::Default {
1702 dst_layer0.buf.as_mut()[idx].style.bg = tile.style.bg;
1703 }
1704 }
1705 }
1706 }
1707
1708 /// Yield `(layer_id, Pos, &Tile, Option<&str>)` for every changed
1709 /// position across all layers, in layer-major (0 → `max_layer`) then
1710 /// row-major order. The last element is the changed tile's grapheme text
1711 /// (see [`grapheme`](Self::grapheme)).
1712 ///
1713 /// Three cases per layer:
1714 /// - Layer absent in `self`: nothing yielded.
1715 /// - Layer in `self`, absent in `other` (newly allocated): all
1716 /// `width × height` tiles yielded.
1717 /// - Layer in both: only positions where the `Tile` or its grapheme text
1718 /// differs are yielded. `self` and `other` must have matching
1719 /// dimensions for this case; the crate never calls `diff` otherwise.
1720 ///
1721 /// This iterator is zero-allocation: it walks the layer buffers inline.
1722 pub fn diff<'a>(&'a self, other: &'a Self) -> impl Iterator<Item = DrawCell<'a>> + 'a {
1723 let width = usize::from(self.width);
1724 let max = self.max_layer;
1725 (0..=max).flat_map(move |id| {
1726 match (self.layer(id), other.layer(id)) {
1727 // Layer absent in `self`: nothing changed.
1728 (None, _) => LayerDiff::Empty,
1729 // Newly allocated layer: all cells are "changed".
1730 (Some(cur_lb), None) => LayerDiff::Full(
1731 cur_lb
1732 .buf
1733 .as_ref()
1734 .iter()
1735 .enumerate()
1736 .map(move |(i, tile)| {
1737 #[allow(clippy::cast_possible_truncation)]
1738 let x = (i % width) as u16;
1739 #[allow(clippy::cast_possible_truncation)]
1740 let y = (i / width) as u16;
1741 DrawCell {
1742 layer: id,
1743 pos: Pos::new(x, y),
1744 tile,
1745 grapheme: cur_lb.extra_for(i, tile),
1746 tint: cur_lb.tint_for(i, tile),
1747 }
1748 }),
1749 ),
1750 // Layer in both: only the differing cells. Compared by hand
1751 // (rather than delegating to grixy's `GridDiff`) because a
1752 // `Tile`-only comparison can't see grapheme-text changes: two
1753 // multi-codepoint EGCs sharing a primary codepoint but
1754 // different combining marks (e.g. `e\u{0301}` vs `e\u{0300}`)
1755 // compare equal on every `Tile` field.
1756 (Some(cur_lb), Some(prev_lb)) => {
1757 LayerDiff::Diff(cur_lb.buf.as_ref().iter().enumerate().filter_map(
1758 move |(i, tile)| {
1759 let prev_tile = &prev_lb.buf.as_ref()[i];
1760 // The whole entry, not just its grapheme: a `Tile`-only comparison
1761 // cannot see a change to either member of the side table, and a
1762 // tint-only change is as real a redraw as a combining-mark change.
1763 let cur_extra = cur_lb.entry_for(i, tile);
1764 let prev_extra = prev_lb.entry_for(i, prev_tile);
1765 if tile == prev_tile && cur_extra == prev_extra {
1766 return None;
1767 }
1768 #[allow(clippy::cast_possible_truncation)]
1769 let x = (i % width) as u16;
1770 #[allow(clippy::cast_possible_truncation)]
1771 let y = (i / width) as u16;
1772 Some(DrawCell {
1773 layer: id,
1774 pos: Pos::new(x, y),
1775 tile,
1776 grapheme: cur_extra.and_then(|e| e.grapheme.as_deref()),
1777 tint: cur_extra.map_or(Tint::None, |e| e.tint),
1778 })
1779 },
1780 ))
1781 }
1782 }
1783 })
1784 }
1785}
1786
1787/// Per-layer diff iterator, replacing a boxed trait object so `diff` performs
1788/// no per-layer heap allocation.
1789enum LayerDiff<F, D> {
1790 Empty,
1791 Full(F),
1792 Diff(D),
1793}
1794
1795impl<'a, F, D> Iterator for LayerDiff<F, D>
1796where
1797 F: Iterator<Item = DrawCell<'a>>,
1798 D: Iterator<Item = DrawCell<'a>>,
1799{
1800 type Item = DrawCell<'a>;
1801
1802 fn next(&mut self) -> Option<Self::Item> {
1803 match self {
1804 Self::Empty => None,
1805 Self::Full(iter) => iter.next(),
1806 Self::Diff(iter) => iter.next(),
1807 }
1808 }
1809}
1810
1811/// Blend two [`Color`] values using `mode`. [`Color::Default`] preserves the
1812/// destination. Non-RGB source colors are returned as-is (no resolution).
1813///
1814/// [`BlendMode::Linear`] is a per-channel sRGB-domain lerp (dst -> src by
1815/// `t`) delegated to [`gem::Mix`], which is `no_std`-safe (round-half-
1816/// away via `floor(x + 0.5)`, no `std`/`libm` float intrinsics). The other
1817/// modes evaluate [`SeparableBlendMode::mix`] per channel in `0.0..=1.0`
1818/// (converting u8 <-> f32 at the boundary; see [`blend_separable_channel`]),
1819/// then lerp that fully mixed color against the destination by `t`, same as
1820/// `Linear`.
1821#[cfg(feature = "color-space")]
1822#[allow(clippy::float_cmp)]
1823fn blend_color(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
1824 use gem::Mix as _;
1825 use gem::rgb::{HasBlue as _, HasGreen as _, HasRed as _, Rgb888};
1826 match (src, dst) {
1827 (Color::Default, _) => Color::Default,
1828 (
1829 Color::Rgb {
1830 r: sr,
1831 g: sg,
1832 b: sb,
1833 },
1834 Color::Rgb {
1835 r: dr,
1836 g: dg,
1837 b: db,
1838 },
1839 ) if mode != BlendMode::Linear || t != 1.0 => {
1840 // `Linear` at `t == 1.0` is `src` by definition (skip to the catch-all arm below);
1841 // the other modes must still run their mix formula at `t == 1.0`: see `blit_alpha`.
1842 let (r, g, b) = mode.separable().map_or_else(
1843 || {
1844 // `dst.mix(src, t)`, not `src.mix(dst, t)`: at `t == 0.0` this must return
1845 // `dst` ("keep destination", per `blit_alpha`'s doc comment) and only reach
1846 // `src` at `t == 1.0`: the same `0.0 == dst, 1.0 == fully blended` contract
1847 // every other `BlendMode` follows (see `blend_separable_channel`).
1848 let out = Rgb888::from_rgb(dr, dg, db).mix(Rgb888::from_rgb(sr, sg, sb), t);
1849 (out.red(), out.green(), out.blue())
1850 },
1851 |sep| {
1852 (
1853 blend_separable_channel(sep, sr, dr, t),
1854 blend_separable_channel(sep, sg, dg, t),
1855 blend_separable_channel(sep, sb, db, t),
1856 )
1857 },
1858 );
1859 Color::Rgb { r, g, b }
1860 }
1861 (src, _) => src,
1862 }
1863}
1864
1865/// Evaluates `sep`'s per-channel mixing function for one RGB channel (`src`/`dst` are u8, `sep`
1866/// operates in `0.0..=1.0` f32), then lerps that mixed value against `dst` by `t`: `0.0` keeps
1867/// `dst`, `1.0` uses the fully mixed color. Rounds with `libm::roundf` rather than `f32::round`
1868/// (a `std`-only method not available in `core`, same reasoning as `libm::fmaf` in
1869/// `animate::easing`) and clamps before converting back to u8, since `ColorDodge`/`ColorBurn`'s
1870/// `min(1.0, ...)` branches can round a hair outside `0.0..=1.0` at the float boundary.
1871#[cfg(feature = "color-space")]
1872fn blend_separable_channel(sep: SeparableBlendMode, src: u8, dst: u8, t: f32) -> u8 {
1873 let cs = f32::from(src) / 255.0;
1874 let cb = f32::from(dst) / 255.0;
1875 let mixed = sep.mix(cb, cs);
1876 // Not `f32::mul_add`: it's a std-only inherent method, not in `core`. `libm::fmaf` is the
1877 // no_std-safe equivalent (see `animate::easing` for the same reasoning).
1878 let blended = libm::fmaf(mixed - cb, t, cb);
1879 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1880 let out = libm::roundf(blended.clamp(0.0, 1.0) * 255.0) as u8;
1881 out
1882}
1883
1884#[cfg(feature = "color-space")]
1885fn blend_fg(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
1886 blend_color(mode, src, dst, t)
1887}
1888
1889#[cfg(feature = "color-space")]
1890fn blend_bg(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
1891 blend_color(mode, src, dst, t)
1892}
1893
1894// ---------------------------------------------------------------------------
1895// Index / IndexMut — layer 0
1896// ---------------------------------------------------------------------------
1897
1898impl Index<Pos> for Grid {
1899 type Output = Tile;
1900
1901 /// Reads the tile on layer 0 at `pos`.
1902 ///
1903 /// # Panics
1904 ///
1905 /// Panics if `pos` is outside the grid's `0..width` x `0..height` bounds. This is the
1906 /// unchecked, layer-0-only counterpart to [`tile`](Self::tile), which instead returns `None`
1907 /// on either an out-of-bounds `pos` or an unallocated layer; reach for `tile` when `pos`
1908 /// isn't already known to be in bounds.
1909 fn index(&self, pos: Pos) -> &Tile {
1910 &self.layer0().buf[to_grixy_pos(pos)]
1911 }
1912}
1913
1914impl IndexMut<Pos> for Grid {
1915 /// Mutably borrows the tile on layer 0 at `pos`.
1916 ///
1917 /// # Panics
1918 ///
1919 /// Panics if `pos` is outside the grid's `0..width` x `0..height` bounds, the same bound as
1920 /// [`Index`]'s `index`. Reach for [`tile_mut`](Self::tile_mut) when `pos` isn't already known
1921 /// to be in bounds; it returns `None` instead of panicking.
1922 fn index_mut(&mut self, pos: Pos) -> &mut Tile {
1923 let pos = to_grixy_pos(pos);
1924 &mut self.layer0_mut().buf[pos]
1925 }
1926}
1927
1928// ---------------------------------------------------------------------------
1929// Display / Debug — layer 0
1930// ---------------------------------------------------------------------------
1931
1932impl fmt::Display for Grid {
1933 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1934 for y in 0..self.height() {
1935 for x in 0..self.width() {
1936 let tile = &self[Pos::new(x, y)];
1937 #[cfg(feature = "egc")]
1938 let is_spacer = tile.flags.contains(TileFlags::WIDE_CHAR_SPACER);
1939 #[cfg(not(feature = "egc"))]
1940 let is_spacer = tile.glyph == '\0';
1941 let c = if is_spacer {
1942 ' ' // right half of a wide char — don't print twice
1943 } else if tile.glyph == ' ' {
1944 '·' // empty cell marker
1945 } else {
1946 tile.glyph
1947 };
1948 write!(f, "{c}")?;
1949 }
1950 writeln!(f)?;
1951 }
1952 Ok(())
1953 }
1954}
1955
1956impl fmt::Debug for Grid {
1957 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1958 f.debug_struct("Grid")
1959 .field("width", &self.width)
1960 .field("height", &self.height)
1961 .finish_non_exhaustive()
1962 }
1963}
1964
1965// ---------------------------------------------------------------------------
1966// Tests
1967// ---------------------------------------------------------------------------
1968
1969#[cfg(test)]
1970mod tests {
1971 use super::*;
1972
1973 // --- Existing tests (must pass unchanged) ---
1974
1975 #[test]
1976 fn test_grid_new() {
1977 let grid = Grid::new(80, 25);
1978 assert_eq!(grid.width(), 80);
1979 assert_eq!(grid.height(), 25);
1980 }
1981
1982 #[test]
1983 fn test_grid_put_get() {
1984 let mut grid = Grid::new(10, 10);
1985 let tile = Tile::default().with_glyph('X');
1986
1987 grid.put_tile(0, (5, 5), tile);
1988 assert_eq!(grid[Pos::new(5, 5)].glyph(), 'X');
1989 }
1990
1991 #[test]
1992 fn test_grid_checked_put_get() {
1993 let mut grid = Grid::new(10, 10);
1994 let tile = Tile::default().with_glyph('Y');
1995
1996 assert!(grid.put_tile(0, (5, 5), tile).is_some());
1997 assert_eq!(grid.tile(0, (5, 5)).unwrap().glyph(), 'Y');
1998
1999 assert!(grid.tile(0, (10, 0)).is_none());
2000 assert!(grid.put_tile(0, (0, 10), Tile::default()).is_none());
2001 }
2002
2003 #[test]
2004 fn test_grid_put_tile_out_of_bounds_returns_none() {
2005 let mut grid = Grid::new(10, 10);
2006 assert!(grid.put_tile(0, (10, 0), Tile::default()).is_none());
2007 }
2008
2009 #[test]
2010 #[should_panic(expected = "index out of bounds")]
2011 fn test_grid_index_panics_out_of_bounds() {
2012 let grid = Grid::new(10, 10);
2013 let _ = &grid[Pos::new(0, 10)];
2014 }
2015
2016 #[test]
2017 fn test_grid_diff() {
2018 let mut g1 = Grid::new(2, 2);
2019 let g2 = Grid::new(2, 2);
2020
2021 g1.put_tile(0, (0, 0), Tile::default().with_glyph('A'));
2022
2023 let diffs: Vec<_> = g1.diff(&g2).collect();
2024 assert_eq!(diffs.len(), 1);
2025 assert_eq!(
2026 diffs[0],
2027 DrawCell::on_layer(0, Pos::new(0, 0), &g1[Pos::new(0, 0)])
2028 );
2029 }
2030
2031 #[test]
2032 fn test_grid_resize_expand() {
2033 let mut grid = Grid::new(3, 3);
2034 grid.put_tile(0, (1, 1), Tile::default().with_glyph('X'));
2035 grid.resize(6, 6);
2036 assert_eq!(grid.width(), 6);
2037 assert_eq!(grid.height(), 6);
2038 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'X'); // preserved
2039 assert_eq!(grid[Pos::new(5, 5)].glyph(), ' '); // new cells default
2040 }
2041
2042 #[test]
2043 fn test_grid_resize_shrink() {
2044 let mut grid = Grid::new(10, 10);
2045 grid.put_tile(0, (1, 1), Tile::default().with_glyph('A'));
2046 grid.resize(5, 5);
2047 assert_eq!(grid.width(), 5);
2048 assert_eq!(grid.height(), 5);
2049 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'A'); // still in bounds, preserved
2050 }
2051
2052 #[test]
2053 fn test_grid_resize_preserves_overlap() {
2054 let mut grid = Grid::new(4, 4);
2055 grid.put_tile(0, (0, 0), Tile::default().with_glyph('@'));
2056 grid.put_tile(0, (3, 3), Tile::default().with_glyph('X'));
2057 grid.resize(3, 3); // shrink: (3,3) falls outside
2058 assert_eq!(grid[Pos::new(0, 0)].glyph(), '@');
2059 assert_eq!(grid[Pos::new(2, 2)].glyph(), ' '); // was default, still default
2060 }
2061
2062 #[test]
2063 fn test_grid_display() {
2064 let mut grid = Grid::new(3, 2);
2065 grid.put_tile(0, (0, 0), Tile::default().with_glyph('A'));
2066
2067 let s = alloc::format!("{grid}");
2068 assert_eq!(s, "A··\n···\n");
2069 }
2070
2071 #[test]
2072 fn test_grid_cells_count() {
2073 let grid = Grid::new(4, 3);
2074 assert_eq!(grid.cells(0).unwrap().count(), 12);
2075 }
2076
2077 #[test]
2078 fn test_grid_cells_coordinates() {
2079 let grid = Grid::new(3, 2);
2080 let coords: Vec<(u16, u16)> = grid.cells(0).unwrap().map(|(x, y, _)| (x, y)).collect();
2081 assert_eq!(
2082 coords,
2083 vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1),]
2084 );
2085 }
2086
2087 #[test]
2088 fn test_grid_cells_mut() {
2089 use crate::style::Style;
2090 let mut grid = Grid::new(2, 2);
2091 for (x, y, tile) in grid.cells_mut(0).unwrap() {
2092 #[allow(clippy::cast_possible_truncation)]
2093 let idx = (y * 2 + x) as u8;
2094 *tile = Tile::new(char::from(b'A' + idx), Style::default());
2095 }
2096 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
2097 assert_eq!(grid[Pos::new(1, 0)].glyph(), 'B');
2098 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C');
2099 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'D');
2100 }
2101
2102 #[test]
2103 fn test_grid_cells_mut_unallocated_layer_is_none() {
2104 // Mirrors `cells`: an unwritten layer is `None`, not an empty iterator, and critically
2105 // it must not allocate the layer as a side effect of checking.
2106 let mut grid = Grid::new(2, 2);
2107 assert!(grid.cells_mut(3).is_none());
2108 assert!(grid.tile(3, (0, 0)).is_none());
2109 }
2110
2111 #[test]
2112 fn test_grid_cells_mut_or_alloc_allocates_an_unwritten_layer() {
2113 use crate::style::Style;
2114 let mut grid = Grid::new(2, 2);
2115 assert!(grid.cells_mut(2).is_none());
2116 for (_, _, tile) in grid.cells_mut_or_alloc(2) {
2117 *tile = Tile::new('x', Style::default());
2118 }
2119 // Now allocated: the non-allocating accessor finds it too.
2120 assert!(grid.cells_mut(2).is_some());
2121 assert_eq!(grid.tile(2, (0, 0)).unwrap().glyph(), 'x');
2122 }
2123
2124 #[test]
2125 fn test_rect_contains() {
2126 let r = Rect::new(2, 3, 4, 5);
2127 assert!(r.contains_pos(Pos::new(2, 3)));
2128 assert!(r.contains_pos(Pos::new(5, 7)));
2129 assert!(!r.contains_pos(Pos::new(6, 3))); // x == x+width, exclusive
2130 assert!(!r.contains_pos(Pos::new(2, 8))); // y == y+height, exclusive
2131 assert!(!r.contains_pos(Pos::new(1, 3)));
2132 }
2133
2134 #[test]
2135 fn test_rect_area() {
2136 assert_eq!(Rect::new(0, 0, 5, 3).area(), 15);
2137 assert_eq!(Rect::default().area(), 0);
2138 }
2139
2140 #[test]
2141 fn test_rect_top_left_bottom_right() {
2142 let r = Rect::new(1, 2, 3, 4);
2143 assert_eq!(r.top_left(), Pos::new(1, 2));
2144 assert_eq!(r.bottom_right(), Pos::new(4, 6));
2145 }
2146
2147 #[test]
2148 fn test_rect_intersects() {
2149 let a = Rect::new(0, 0, 4, 4);
2150 let b = Rect::new(2, 2, 4, 4);
2151 let c = Rect::new(4, 0, 4, 4); // touches edge, no overlap
2152 assert!(!a.intersect(b).is_empty());
2153 assert!(a.intersect(c).is_empty());
2154 }
2155
2156 #[test]
2157 fn test_rect_positions() {
2158 let r = Rect::new(1, 2, 2, 2);
2159 let pts: Vec<Pos> = r.pos_iter().collect();
2160 assert_eq!(
2161 pts,
2162 vec![
2163 Pos::new(1, 2),
2164 Pos::new(2, 2),
2165 Pos::new(1, 3),
2166 Pos::new(2, 3),
2167 ]
2168 );
2169 }
2170
2171 #[test]
2172 fn test_index_position() {
2173 let mut grid = Grid::new(5, 5);
2174 let pos = Pos::new(2, 3);
2175 grid[pos] = Tile::default().with_glyph('Z');
2176 assert_eq!(grid[pos].glyph(), 'Z');
2177 }
2178
2179 #[test]
2180 fn test_position_from_tuple() {
2181 let p: Pos = (3u16, 7u16).into();
2182 assert_eq!(p, Pos::new(3, 7));
2183 let t: (u16, u16) = p.into();
2184 assert_eq!(t, (3, 7));
2185 }
2186
2187 #[test]
2188 fn test_size_from_tuple() {
2189 let s: Size = (80u16, 25u16).into();
2190 assert_eq!(
2191 s,
2192 Size {
2193 width: 80,
2194 height: 25
2195 }
2196 );
2197 let t: (u16, u16) = s.into();
2198 assert_eq!(t, (80, 25));
2199 }
2200
2201 #[test]
2202 fn test_offset_from_tuple() {
2203 let o: Offset = (-3i16, 7i16).into();
2204 assert_eq!(o, Offset::new(-3, 7));
2205 let t: (i16, i16) = o.into();
2206 assert_eq!(t, (-3, 7));
2207 }
2208
2209 #[test]
2210 fn test_offset_default_is_zero() {
2211 assert_eq!(Offset::default(), Offset::new(0, 0));
2212 }
2213
2214 #[test]
2215 fn test_position_ord_row_major() {
2216 let mut positions = vec![Pos::new(5, 0), Pos::new(0, 1), Pos::new(3, 0)];
2217 positions.sort();
2218 assert_eq!(
2219 positions,
2220 vec![Pos::new(3, 0), Pos::new(5, 0), Pos::new(0, 1),]
2221 );
2222 }
2223
2224 #[test]
2225 fn test_size_ord() {
2226 assert!(
2227 Size {
2228 width: 1,
2229 height: 2
2230 } < Size {
2231 width: 2,
2232 height: 1
2233 }
2234 );
2235 }
2236
2237 // --- New tests for multi-layer API ---
2238
2239 #[test]
2240 fn test_grid_layer_zero_always_allocated() {
2241 let g = Grid::new(5, 5);
2242 assert!(g.layer(0).is_some());
2243 for id in 1u8..=5 {
2244 assert!(g.layer(id).is_none(), "layer {id} should be None");
2245 }
2246 }
2247
2248 #[test]
2249 fn test_grid_put_tile_allocates_layer() {
2250 let mut g = Grid::new(5, 5);
2251 g.put_tile(3, (0, 0), Tile::new('@', Style::default()));
2252 assert!(g.layer(3).is_some());
2253 assert!(g.layer(4).is_none());
2254 }
2255
2256 #[test]
2257 fn test_grid_new_layer_table_starts_at_a_single_slot() {
2258 // retroglyph#264: the layer-table `Vec` itself should start small (a single slot for
2259 // layer 0), not pre-allocate all 256 possible slots up front.
2260 let g = Grid::new(5, 5);
2261 assert_eq!(g.layers.len(), 1);
2262 assert_eq!(g.max_layer(), 0);
2263 }
2264
2265 #[test]
2266 fn test_grid_layer_or_alloc_grows_table_lazily_to_the_written_id() {
2267 let mut g = Grid::new(5, 5);
2268 g.put_tile(10, (0, 0), Tile::new('@', Style::default()));
2269 // The table grows to exactly `id + 1` slots, not all 256.
2270 assert_eq!(g.layers.len(), 11);
2271 assert_eq!(g.max_layer(), 10);
2272 assert!(g.layer(10).is_some());
2273 for id in 1u8..10 {
2274 assert!(g.layer(id).is_none(), "layer {id} should be None");
2275 }
2276 }
2277
2278 #[test]
2279 fn test_grid_layer_beyond_table_length_reads_as_none() {
2280 // A layer id past the current table length (never written) must read identically to an
2281 // in-bounds `None` slot, not panic or error.
2282 let g = Grid::new(5, 5);
2283 assert_eq!(g.layers.len(), 1);
2284 assert!(g.layer(255).is_none());
2285 assert!(g.tile(255, (0, 0)).is_none());
2286 assert!(g.grapheme(255, 0, 0).is_none());
2287 }
2288
2289 #[test]
2290 fn test_grid_clear_beyond_table_length_is_a_no_op() {
2291 // Clearing an id past the current table length must not panic: it's equivalent to
2292 // clearing an unallocated in-bounds layer (does nothing).
2293 let mut g = Grid::new(5, 5);
2294 g.clear(255);
2295 assert_eq!(g.layers.len(), 1);
2296 }
2297
2298 #[test]
2299 fn test_grid_layer_table_growth_is_monotonic_across_writes() {
2300 // Writing to a lower layer id after a higher one must not shrink the table, and must
2301 // preserve the higher layer's content.
2302 let mut g = Grid::new(5, 5);
2303 g.put_tile(20, (1, 1), Tile::new('H', Style::default()));
2304 assert_eq!(g.layers.len(), 21);
2305 g.put_tile(2, (0, 0), Tile::new('L', Style::default()));
2306 assert_eq!(
2307 g.layers.len(),
2308 21,
2309 "writing a lower id must not shrink the table"
2310 );
2311 assert_eq!(g.max_layer(), 20);
2312 assert_eq!(g.tile(20, (1, 1)).unwrap().glyph, 'H');
2313 assert_eq!(g.tile(2, (0, 0)).unwrap().glyph, 'L');
2314 }
2315
2316 #[test]
2317 fn test_grid_diff_empty_when_identical() {
2318 let g = Grid::new(5, 5);
2319 let prev = Grid::new(5, 5);
2320 assert_eq!(g.diff(&prev).count(), 0);
2321 }
2322
2323 #[test]
2324 fn test_grid_diff_reports_changed_cell() {
2325 let mut cur = Grid::new(5, 5);
2326 let prev = Grid::new(5, 5);
2327 cur.put_tile(0, (2, 3), Tile::new('X', Style::default()));
2328 let diffs: Vec<_> = cur.diff(&prev).collect();
2329 assert_eq!(diffs.len(), 1);
2330 assert_eq!(diffs[0].layer, 0);
2331 assert_eq!(diffs[0].pos, Pos::new(2, 3));
2332 assert_eq!(diffs[0].tile.glyph, 'X');
2333 }
2334
2335 #[test]
2336 fn test_grid_diff_new_layer_yields_all_cells() {
2337 let mut cur = Grid::new(3, 4);
2338 let prev = Grid::new(3, 4);
2339 cur.put_tile(1, (0, 0), Tile::new('A', Style::default()));
2340 let diffs: Vec<_> = cur.diff(&prev).collect();
2341 // All 12 cells of the newly allocated layer 1 are yielded.
2342 assert_eq!(diffs.len(), 12);
2343 assert!(diffs.iter().all(|c| c.layer == 1));
2344 }
2345
2346 #[test]
2347 fn test_grid_diff_layer_major_order() {
2348 let mut cur = Grid::new(3, 3);
2349 let prev = Grid::new(3, 3);
2350 cur.put_tile(2, (0, 0), Tile::new('B', Style::default()));
2351 cur.put_tile(0, (1, 0), Tile::new('A', Style::default()));
2352 let layers: Vec<u8> = cur.diff(&prev).map(|c| c.layer).collect();
2353 // Layer 0's change appears first, then all of layer 2.
2354 assert_eq!(layers[0], 0);
2355 assert!(layers[1..].iter().all(|&l| l == 2));
2356 }
2357
2358 #[test]
2359 fn test_grid_put_and_get_on_layer_2() {
2360 use crate::style::Style;
2361 let mut g = Grid::new(5, 5);
2362 g.put_tile(2, (1, 1), Tile::new('Z', Style::default()));
2363 assert_eq!(g.tile(2, (1, 1)).unwrap().glyph, 'Z');
2364 // Layer 0 at same position should still be default.
2365 assert_eq!(g[Pos::new(1, 1)].glyph, ' ');
2366 // Unallocated layer returns None.
2367 assert!(g.tile(3, (0, 0)).is_none());
2368 }
2369
2370 #[test]
2371 fn test_grid_tile_mut_writes_in_place_without_clearing_spans() {
2372 let mut g = Grid::new(4, 4);
2373 g.write_span(0, 0, 0, &["C=", "[]"], Style::default())
2374 .unwrap();
2375
2376 // Unlike `put_tile`, `tile_mut` hands out a direct `&mut Tile` and does not intercept
2377 // the write, so the span's other cells are left dangling on purpose here.
2378 g.tile_mut(0, (0, 0)).unwrap().glyph = 'x';
2379 assert_eq!(g[Pos::new(0, 0)].glyph(), 'x');
2380
2381 // Unallocated layer and out-of-bounds position both report `None`, not a panic.
2382 assert!(g.tile_mut(1, (0, 0)).is_none());
2383 assert!(g.tile_mut(0, (10, 10)).is_none());
2384 }
2385
2386 #[test]
2387 fn test_grid_clear_layer() {
2388 let mut g = Grid::new(5, 5);
2389 g.put_tile(1, (0, 0), Tile::new('Z', Style::default()));
2390 g.put_tile(0, (0, 0), Tile::new('A', Style::default()));
2391 g.clear(1);
2392 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph, 'A');
2393 assert!(g.tile(1, (0, 0)).is_some());
2394 assert_eq!(g.tile(1, (0, 0)).unwrap().glyph, ' '); // cleared
2395 }
2396
2397 #[test]
2398 fn test_grid_clear_all() {
2399 let mut g = Grid::new(5, 5);
2400 g.put_tile(1, (0, 0), Tile::new('Z', Style::default()));
2401 g.put_tile(0, (0, 0), Tile::new('A', Style::default()));
2402 g.clear_all();
2403 // Both layers reset to default (space).
2404 assert_eq!(g[Pos::new(0, 0)].glyph, ' ');
2405 assert_eq!(g.tile(1, (0, 0)).unwrap().glyph, ' ');
2406 }
2407
2408 #[test]
2409 fn test_grid_clone_is_independent() {
2410 let mut g = Grid::new(3, 3);
2411 g.put_tile(0, (0, 0), Tile::new('A', Style::default()));
2412 g.put_tile(2, (1, 1), Tile::new('B', Style::default()));
2413
2414 let mut cloned = g.clone();
2415 assert_eq!(cloned[Pos::new(0, 0)].glyph, 'A');
2416 assert_eq!(cloned.tile(2, (1, 1)).unwrap().glyph, 'B');
2417 assert_eq!(cloned.max_layer(), g.max_layer());
2418
2419 // Mutating the clone must not affect the original (deep copy).
2420 cloned.put_tile(0, (0, 0), Tile::new('Z', Style::default()));
2421 assert_eq!(cloned[Pos::new(0, 0)].glyph, 'Z');
2422 assert_eq!(g[Pos::new(0, 0)].glyph, 'A');
2423 }
2424
2425 // --- Extra grapheme text (EGC side-table) ---
2426
2427 #[cfg(feature = "egc")]
2428 #[test]
2429 fn test_grid_write_grapheme_stores_and_reads_extra() {
2430 let mut g = Grid::new(5, 5);
2431 g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
2432 assert_eq!(g[Pos::new(1, 1)].glyph, 'e');
2433 assert_eq!(g.grapheme(0, 1, 1), Some("e\u{0301}"));
2434
2435 // Single-codepoint writes never populate the side-table.
2436 g.write_grapheme(0, 2, 2, "a", Style::default());
2437 assert_eq!(g.grapheme(0, 2, 2), None);
2438 }
2439
2440 #[cfg(feature = "egc")]
2441 #[test]
2442 fn test_grid_overwrite_clears_extra() {
2443 let mut g = Grid::new(5, 5);
2444 g.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
2445 assert_eq!(g.grapheme(0, 0, 0), Some("e\u{0301}"));
2446
2447 // A plain `put` (or a later single-codepoint `write_grapheme`) must
2448 // drop the stale side-table entry, not just leave it unreachable.
2449 g.put_tile(0, (0, 0), Tile::new('X', Style::default()));
2450 assert_eq!(g.grapheme(0, 0, 0), None);
2451 assert!(!g[Pos::new(0, 0)].flags().contains(TileFlags::HAS_EXTRA));
2452 }
2453
2454 #[cfg(feature = "egc")]
2455 #[test]
2456 fn test_grid_resize_remaps_extras_to_new_stride() {
2457 let mut g = Grid::new(4, 4);
2458 g.write_grapheme(0, 3, 1, "e\u{0301}", Style::default());
2459 assert_eq!(g.grapheme(0, 3, 1), Some("e\u{0301}"));
2460
2461 // Widening changes the row stride, so the flat index for (3, 1)
2462 // changes even though the cell itself is preserved.
2463 g.resize(8, 4);
2464 assert_eq!(g[Pos::new(3, 1)].glyph, 'e');
2465 assert_eq!(g.grapheme(0, 3, 1), Some("e\u{0301}"));
2466 // No ghost entry landed on some other cell at the old flat index.
2467 assert_eq!(g.grapheme(0, 7, 0), None);
2468
2469 // Shrinking past the cell drops its extras entry along with the tile.
2470 g.resize(2, 4);
2471 assert_eq!(g.grapheme(0, 3, 1), None);
2472 }
2473
2474 // ── Tint storage ──────────────────────────────────────────────────────
2475 //
2476 // A tint lives in the same sparse side table as a grapheme, so it inherits every path that
2477 // table already has to get right: rekeying on resize, copying on blit, and being dropped
2478 // when the cell it belongs to is overwritten or cleared. These cover each of those, plus the
2479 // interaction between the two members now sharing one entry and one flag.
2480
2481 #[test]
2482 fn tint_round_trips_and_defaults_to_none() {
2483 let mut g = Grid::new(4, 4);
2484 assert_eq!(g.tint(0, 1, 1), Tint::None);
2485
2486 g.write_grapheme(0, 1, 1, "@", Style::default());
2487 g.set_tint(0, 1, 1, Tint::multiply(128, 64, 32));
2488 assert_eq!(g.tint(0, 1, 1), Tint::multiply(128, 64, 32));
2489
2490 // Setting None clears it again.
2491 g.set_tint(0, 1, 1, Tint::None);
2492 assert_eq!(g.tint(0, 1, 1), Tint::None);
2493 }
2494
2495 #[test]
2496 fn tint_is_per_layer_and_per_cell() {
2497 let mut g = Grid::new(4, 4);
2498 g.set_tint(0, 1, 1, Tint::multiply(10, 20, 30));
2499 g.set_tint(3, 1, 1, Tint::mix(1, 2, 3, 4));
2500
2501 assert_eq!(g.tint(0, 1, 1), Tint::multiply(10, 20, 30));
2502 assert_eq!(g.tint(3, 1, 1), Tint::mix(1, 2, 3, 4));
2503 assert_eq!(g.tint(0, 1, 2), Tint::None);
2504 assert_eq!(g.tint(1, 1, 1), Tint::None);
2505 }
2506
2507 #[test]
2508 fn tint_out_of_bounds_reads_none_and_writes_nothing() {
2509 let mut g = Grid::new(2, 2);
2510 g.set_tint(0, 9, 9, Tint::multiply(1, 2, 3));
2511 assert_eq!(g.tint(0, 9, 9), Tint::None);
2512 assert_eq!(g.tint(0, 0, 0), Tint::None);
2513 }
2514
2515 #[test]
2516 fn writing_a_glyph_over_a_tinted_cell_drops_the_tint() {
2517 let mut g = Grid::new(4, 4);
2518 g.write_grapheme(0, 1, 1, "@", Style::default());
2519 g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
2520
2521 // A tint describes the artwork that was drawn, not the position, so replacing the
2522 // artwork drops it rather than silently recolouring whatever lands there next.
2523 g.write_grapheme(0, 1, 1, "#", Style::default());
2524 assert_eq!(g.tint(0, 1, 1), Tint::None);
2525 }
2526
2527 #[test]
2528 fn put_tile_drops_the_tint() {
2529 let mut g = Grid::new(4, 4);
2530 g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
2531 g.put_tile(0, Pos::new(1, 1), Tile::new('x', Style::default()));
2532 assert_eq!(g.tint(0, 1, 1), Tint::None);
2533 }
2534
2535 #[test]
2536 fn clear_drops_every_tint_on_the_layer() {
2537 let mut g = Grid::new(4, 4);
2538 g.set_tint(0, 1, 1, Tint::multiply(1, 2, 3));
2539 g.set_tint(1, 1, 1, Tint::multiply(4, 5, 6));
2540
2541 g.clear(0);
2542 assert_eq!(g.tint(0, 1, 1), Tint::None);
2543 assert_eq!(g.tint(1, 1, 1), Tint::multiply(4, 5, 6));
2544 }
2545
2546 #[test]
2547 fn resize_remaps_a_tint_to_the_new_stride() {
2548 let mut g = Grid::new(4, 4);
2549 g.write_grapheme(0, 3, 1, "@", Style::default());
2550 g.set_tint(0, 3, 1, Tint::mix(200, 100, 50, 128));
2551
2552 // Widening changes the row stride, so (3, 1)'s flat index moves.
2553 g.resize(8, 4);
2554 assert_eq!(g.tint(0, 3, 1), Tint::mix(200, 100, 50, 128));
2555 // No ghost entry landed on whatever cell now holds the old flat index.
2556 assert_eq!(g.tint(0, 7, 0), Tint::None);
2557
2558 // Shrinking past the cell drops its entry with the tile.
2559 g.resize(2, 4);
2560 assert_eq!(g.tint(0, 3, 1), Tint::None);
2561 }
2562
2563 #[test]
2564 fn blit_carries_a_tint_across_grids() {
2565 let mut src = Grid::new(4, 4);
2566 src.write_grapheme(0, 1, 1, "@", Style::default());
2567 src.set_tint(0, 1, 1, Tint::multiply(64, 128, 192));
2568
2569 let mut dst = Grid::new(4, 4);
2570 // Pre-existing tint on the destination cell, to prove the copy replaces rather than
2571 // merges with whatever was there.
2572 dst.set_tint(0, 1, 1, Tint::mix(9, 9, 9, 9));
2573 dst.blit(0, &src, Rect::new(0, 0, 4, 4), 0, 0);
2574
2575 assert_eq!(dst.tint(0, 1, 1), Tint::multiply(64, 128, 192));
2576 assert_eq!(dst.tint(0, 0, 0), Tint::None);
2577 }
2578
2579 #[test]
2580 fn blit_clears_a_destination_tint_where_the_source_has_none() {
2581 let mut src = Grid::new(2, 2);
2582 src.write_grapheme(0, 0, 0, "@", Style::default());
2583
2584 let mut dst = Grid::new(2, 2);
2585 dst.set_tint(0, 0, 0, Tint::multiply(1, 2, 3));
2586 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
2587
2588 assert_eq!(dst.tint(0, 0, 0), Tint::None);
2589 }
2590
2591 #[cfg(feature = "egc")]
2592 #[test]
2593 fn a_tint_and_a_grapheme_share_one_entry_without_clobbering_each_other() {
2594 let mut g = Grid::new(4, 4);
2595 g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
2596 g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
2597
2598 // Both members survive: `set_tint` preserves the grapheme already stored.
2599 assert_eq!(g.grapheme(0, 1, 1), Some("e\u{0301}"));
2600 assert_eq!(g.tint(0, 1, 1), Tint::multiply(128, 128, 128));
2601
2602 // Clearing the tint leaves the grapheme, and so leaves the entry in place.
2603 g.set_tint(0, 1, 1, Tint::None);
2604 assert_eq!(g.grapheme(0, 1, 1), Some("e\u{0301}"));
2605 assert_eq!(g.tint(0, 1, 1), Tint::None);
2606 }
2607
2608 #[cfg(feature = "egc")]
2609 #[test]
2610 fn a_tint_alone_keeps_grapheme_reads_answering_none() {
2611 let mut g = Grid::new(4, 4);
2612 g.write_grapheme(0, 1, 1, "@", Style::default());
2613 g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
2614
2615 // HAS_EXTRA is now set for a cell with no grapheme text. `grapheme` must still say None
2616 // rather than reaching into the entry and finding an empty slot.
2617 assert_eq!(g.grapheme(0, 1, 1), None);
2618 assert_eq!(g.tint(0, 1, 1), Tint::multiply(128, 128, 128));
2619 }
2620
2621 #[cfg(feature = "egc")]
2622 #[test]
2623 fn test_grid_diff_detects_grapheme_only_change() {
2624 // Same glyph, style, and flags on both sides: only the combining
2625 // mark differs. A `Tile`-only diff would miss this.
2626 let mut cur = Grid::new(2, 2);
2627 let mut prev = Grid::new(2, 2);
2628 cur.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
2629 prev.write_grapheme(0, 0, 0, "e\u{0300}", Style::default());
2630
2631 let diffs: Vec<_> = cur.diff(&prev).collect();
2632 assert_eq!(diffs.len(), 1);
2633 assert_eq!(diffs[0].pos, Pos::new(0, 0));
2634 assert_eq!(diffs[0].grapheme, Some("e\u{0301}"));
2635
2636 // Identical grapheme text on both sides: no diff.
2637 let mut prev2 = Grid::new(2, 2);
2638 prev2.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
2639 assert_eq!(cur.diff(&prev2).count(), 0);
2640 }
2641
2642 #[cfg(feature = "egc")]
2643 #[test]
2644 fn test_grid_blit_preserves_extra() {
2645 let mut src = Grid::new(2, 2);
2646 src.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
2647
2648 let mut dst = Grid::new(2, 2);
2649 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
2650 assert_eq!(dst[Pos::new(0, 0)].glyph, 'e');
2651 assert_eq!(dst.grapheme(0, 0, 0), Some("e\u{0301}"));
2652 }
2653
2654 #[test]
2655 fn test_grid_blit_empty_rect_is_a_no_op() {
2656 // A zero-area `src_rect` has no cells at all: `sx0 >= sx1` should short-circuit before
2657 // touching the destination.
2658 let src = Grid::new(2, 2);
2659 let mut dst = Grid::new(2, 2);
2660 dst.put_tile(0, (0, 0), Tile::new('x', Style::default()));
2661 dst.blit(0, &src, Rect::new(0, 0, 0, 0), 0, 0);
2662 assert_eq!(dst[Pos::new(0, 0)].glyph(), 'x');
2663 assert_eq!(dst.max_layer(), 0);
2664 }
2665
2666 #[test]
2667 fn test_grid_blit_fully_transparent_source_does_not_allocate_dst_layer() {
2668 // Perf refactor (#263): the destination layer is allocated up front, but only after
2669 // confirming the (clamped) source region has at least one non-empty tile, matching
2670 // `put_tile`'s original allocate-on-first-write behavior for an all-transparent blit.
2671 let src = Grid::new(2, 2);
2672 let mut dst = Grid::new(2, 2);
2673 dst.blit(3, &src, Rect::new(0, 0, 2, 2), 0, 0);
2674 assert_eq!(dst.max_layer(), 0);
2675 }
2676
2677 #[test]
2678 fn test_grid_blit_skips_out_of_bounds_source_and_dest_regions() {
2679 let mut src = Grid::new(4, 4);
2680 for y in 0..4 {
2681 for x in 0..4 {
2682 src.put_tile(0, (x, y), Tile::new('#', Style::default()));
2683 }
2684 }
2685
2686 let mut dst = Grid::new(2, 2);
2687 // `src_rect` extends past `src`'s bounds and the destination offset pushes part of the
2688 // copied region past `dst`'s bounds too; both should be silently clamped, not panic.
2689 dst.blit(0, &src, Rect::new(2, 2, 10, 10), 1, 1);
2690 assert_eq!(dst[Pos::new(1, 1)].glyph(), '#');
2691 assert_eq!(dst[Pos::new(0, 0)].glyph(), ' ');
2692 assert_eq!(dst[Pos::new(0, 1)].glyph(), ' ');
2693 assert_eq!(dst[Pos::new(1, 0)].glyph(), ' ');
2694 }
2695
2696 #[test]
2697 fn test_grid_blit_sub_cell_offset_and_transparency() {
2698 let mut src = Grid::new(2, 2);
2699 src.put_tile(0, (0, 0), Tile::new('A', Style::default()));
2700 // (1, 0) and (1, 1) stay at their default (empty) tile: transparent, should not
2701 // overwrite the destination.
2702 src.put_tile(0, (0, 1), Tile::new('B', Style::default()));
2703
2704 let mut dst = Grid::new(3, 3);
2705 dst.put_tile(0, (2, 2), Tile::new('Z', Style::default()));
2706 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 1, 1);
2707
2708 assert_eq!(dst[Pos::new(1, 1)].glyph(), 'A');
2709 assert_eq!(dst[Pos::new(1, 2)].glyph(), 'B');
2710 // Untouched by the (transparent) source cells at (1, 0) and (1, 1).
2711 assert_eq!(dst[Pos::new(2, 1)].glyph(), ' ');
2712 assert_eq!(dst[Pos::new(2, 2)].glyph(), 'Z');
2713 }
2714
2715 #[test]
2716 fn test_grid_blit_multi_layer_independent() {
2717 let mut src = Grid::new(2, 2);
2718 src.put_tile(0, (0, 0), Tile::new('a', Style::default()));
2719 src.put_tile(2, (0, 0), Tile::new('b', Style::default()));
2720
2721 let mut dst = Grid::new(2, 2);
2722 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
2723 dst.blit(2, &src, Rect::new(0, 0, 2, 2), 0, 0);
2724
2725 assert_eq!(dst.tile(0, (0, 0)).map(Tile::glyph), Some('a'));
2726 assert_eq!(dst.tile(2, (0, 0)).map(Tile::glyph), Some('b'));
2727 // Layer 1 was never written by either blit call.
2728 assert!(dst.tile(1, (0, 0)).is_none());
2729 }
2730
2731 #[test]
2732 fn test_grid_blit_dest_origin_near_u16_max_does_not_wrap() {
2733 // retroglyph#268: with a plain (non-saturating) `dst_x + (sx - src_rect.left())`, an
2734 // origin this close to `u16::MAX` overflows and wraps back into a small, in-bounds
2735 // value: silently corrupting an unrelated cell instead of being clamped out. Picked so
2736 // that `dst_x + 3` overflows `u16` and wraps to `1`, which *is* in-bounds for this small
2737 // `dst` grid: `65534u16.wrapping_add(3) == 1`.
2738 let mut src = Grid::new(4, 1);
2739 src.put_tile(0, (3, 0), Tile::new('Q', Style::default()));
2740
2741 let mut dst = Grid::new(4, 1);
2742 dst.blit(0, &src, Rect::new(0, 0, 4, 1), u16::MAX - 1, 0);
2743
2744 // The would-be-wrapped cell (index 1) must not have been touched.
2745 assert_eq!(dst[Pos::new(1, 0)].glyph(), ' ');
2746 // No other cell was touched either: the whole row's writes overflowed and were
2747 // skipped (dst_x saturates to u16::MAX for every column in this row).
2748 for x in 0..4 {
2749 assert_eq!(
2750 dst[Pos::new(x, 0)].glyph(),
2751 ' ',
2752 "cell ({x}, 0) unexpectedly written"
2753 );
2754 }
2755 }
2756
2757 #[test]
2758 fn test_grid_blit_normal_offset_unaffected_by_overflow_fix() {
2759 // A typical, non-overflowing blit must still work exactly as before.
2760 let mut src = Grid::new(2, 2);
2761 src.put_tile(0, (0, 0), Tile::new('A', Style::default()));
2762 src.put_tile(0, (1, 1), Tile::new('B', Style::default()));
2763
2764 let mut dst = Grid::new(4, 4);
2765 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 1, 1);
2766
2767 assert_eq!(dst[Pos::new(1, 1)].glyph(), 'A');
2768 assert_eq!(dst[Pos::new(2, 2)].glyph(), 'B');
2769 }
2770
2771 // --- `BlendMode` / `blit_alpha` ---
2772
2773 #[cfg(feature = "color-space")]
2774 #[test]
2775 fn test_blend_separable_channel_screen() {
2776 // cb = 102 (0.4), cs = 204 (0.8): screen = cb + cs - cb*cs = 0.88.
2777 assert_eq!(
2778 blend_separable_channel(SeparableBlendMode::Screen, 204, 102, 1.0),
2779 224
2780 );
2781 // t = 0.5 lerps the destination halfway to that fully mixed color.
2782 assert_eq!(
2783 blend_separable_channel(SeparableBlendMode::Screen, 204, 102, 0.5),
2784 163
2785 );
2786 }
2787
2788 #[cfg(feature = "color-space")]
2789 #[test]
2790 fn test_blend_separable_channel_dodge() {
2791 // cb = 51 (0.2), cs = 204 (0.8): min(1, 0.2 / 0.2) saturates to 1.0.
2792 assert_eq!(
2793 blend_separable_channel(SeparableBlendMode::ColorDodge, 204, 51, 1.0),
2794 255
2795 );
2796 assert_eq!(
2797 blend_separable_channel(SeparableBlendMode::ColorDodge, 204, 51, 0.5),
2798 153
2799 );
2800 }
2801
2802 #[cfg(feature = "color-space")]
2803 #[test]
2804 fn test_blend_separable_channel_burn() {
2805 // cb = 204 (0.8), cs = 51 (0.2): 1 - min(1, 0.2 / 0.2) bottoms out at 0.0.
2806 assert_eq!(
2807 blend_separable_channel(SeparableBlendMode::ColorBurn, 51, 204, 1.0),
2808 0
2809 );
2810 assert_eq!(
2811 blend_separable_channel(SeparableBlendMode::ColorBurn, 51, 204, 0.5),
2812 102
2813 );
2814 }
2815
2816 #[cfg(feature = "color-space")]
2817 #[test]
2818 fn test_blend_separable_channel_overlay() {
2819 // cb = 51 (0.2, the <= 0.5 branch): 2 * cb * cs.
2820 assert_eq!(
2821 blend_separable_channel(SeparableBlendMode::Overlay, 204, 51, 1.0),
2822 82
2823 );
2824 assert_eq!(
2825 blend_separable_channel(SeparableBlendMode::Overlay, 204, 51, 0.5),
2826 66
2827 );
2828 // cb = 204 (0.8, the > 0.5 branch): 1 - 2 * (1 - cb) * (1 - cs).
2829 assert_eq!(
2830 blend_separable_channel(SeparableBlendMode::Overlay, 51, 204, 1.0),
2831 173
2832 );
2833 }
2834
2835 /// End-to-end through `blit_alpha`, not just the per-channel helper: proves `BlendMode`
2836 /// actually reaches `blend_fg`/`blend_bg` and lands on the destination tile's style.
2837 #[cfg(feature = "color-space")]
2838 #[test]
2839 fn test_grid_blit_alpha_screen_blends_fg() {
2840 let mut src = Grid::new(1, 1);
2841 src.put_tile(
2842 0,
2843 (0, 0),
2844 Tile::default()
2845 .with_glyph('X')
2846 .with_style(Style::new().fg(Color::Rgb {
2847 r: 204,
2848 g: 204,
2849 b: 204,
2850 })),
2851 );
2852
2853 let mut dst = Grid::new(1, 1);
2854 dst.put_tile(
2855 0,
2856 (0, 0),
2857 Tile::default()
2858 .with_glyph('_')
2859 .with_style(Style::new().fg(Color::Rgb {
2860 r: 102,
2861 g: 102,
2862 b: 102,
2863 })),
2864 );
2865
2866 dst.blit_alpha(
2867 0,
2868 &src,
2869 Rect::new(0, 0, 1, 1),
2870 0,
2871 0,
2872 BlendMode::Screen,
2873 1.0,
2874 1.0,
2875 );
2876 assert_eq!(
2877 dst[Pos::new(0, 0)].style.fg,
2878 Color::Rgb {
2879 r: 224,
2880 g: 224,
2881 b: 224
2882 }
2883 );
2884 }
2885
2886 /// retroglyph#268: same wraparound guard as `blit`'s
2887 /// `test_grid_blit_dest_origin_near_u16_max_does_not_wrap`, but through `blit_alpha`'s
2888 /// separate `dst_x`/`dst_y` computation.
2889 #[cfg(feature = "color-space")]
2890 #[test]
2891 fn test_grid_blit_alpha_dest_origin_near_u16_max_does_not_wrap() {
2892 let mut src = Grid::new(4, 1);
2893 src.put_tile(0, (3, 0), Tile::new('Q', Style::default()));
2894
2895 let mut dst = Grid::new(4, 1);
2896 dst.blit_alpha(
2897 0,
2898 &src,
2899 Rect::new(0, 0, 4, 1),
2900 u16::MAX - 1,
2901 0,
2902 BlendMode::Linear,
2903 1.0,
2904 1.0,
2905 );
2906
2907 for x in 0..4 {
2908 assert_eq!(
2909 dst[Pos::new(x, 0)].glyph(),
2910 ' ',
2911 "cell ({x}, 0) unexpectedly written"
2912 );
2913 }
2914 }
2915
2916 /// `BlendMode::Linear` at `t == 0.0` keeps the destination and at `t == 1.0` uses the source,
2917 /// matching `blit_alpha`'s doc comment (this direction was actually inverted before this
2918 /// change: the underlying `gem::Mix` call had `src`/`dst` swapped, so `t == 0.0` used
2919 /// to return `src` and `t == 1.0` returned `dst`. No prior tests covered `blit_alpha`, so
2920 /// this had shipped unnoticed).
2921 #[cfg(feature = "color-space")]
2922 #[test]
2923 fn test_grid_blit_alpha_linear_direction() {
2924 let mut src = Grid::new(1, 1);
2925 src.put_tile(
2926 0,
2927 (0, 0),
2928 Tile::default()
2929 .with_glyph('X')
2930 .with_style(Style::new().fg(Color::Rgb {
2931 r: 255,
2932 g: 255,
2933 b: 255,
2934 })),
2935 );
2936
2937 let dst_color = Color::Rgb { r: 0, g: 0, b: 0 };
2938 let at = |t: f32| {
2939 let mut dst = Grid::new(1, 1);
2940 dst.put_tile(
2941 0,
2942 (0, 0),
2943 Tile::default()
2944 .with_glyph('_')
2945 .with_style(Style::new().fg(dst_color)),
2946 );
2947 dst.blit_alpha(
2948 0,
2949 &src,
2950 Rect::new(0, 0, 1, 1),
2951 0,
2952 0,
2953 BlendMode::Linear,
2954 t,
2955 1.0,
2956 );
2957 dst[Pos::new(0, 0)].style.fg
2958 };
2959
2960 assert_eq!(at(0.0), dst_color);
2961 assert_eq!(
2962 at(1.0),
2963 Color::Rgb {
2964 r: 255,
2965 g: 255,
2966 b: 255
2967 }
2968 );
2969 let Color::Rgb { r, g, b } = at(0.5) else {
2970 panic!("expected Color::Rgb");
2971 };
2972 assert!(r > 0 && r < 255, "expected a mid-gray, got {r}");
2973 assert_eq!(r, g);
2974 assert_eq!(g, b);
2975 }
2976
2977 /// Every `BlendMode` preserves `Color::Default` and passes non-RGB colors through unblended,
2978 /// same as the pre-existing `Linear` behavior.
2979 #[cfg(feature = "color-space")]
2980 #[test]
2981 fn test_blend_color_non_rgb_passthrough_all_modes() {
2982 for mode in [
2983 BlendMode::Linear,
2984 BlendMode::Screen,
2985 BlendMode::Dodge,
2986 BlendMode::Burn,
2987 BlendMode::Overlay,
2988 BlendMode::Multiply,
2989 ] {
2990 assert_eq!(
2991 blend_color(mode, Color::Default, Color::Rgb { r: 1, g: 2, b: 3 }, 0.5),
2992 Color::Default
2993 );
2994 assert_eq!(
2995 blend_color(mode, Color::BLACK, Color::WHITE, 0.5),
2996 Color::BLACK
2997 );
2998 }
2999 }
3000
3001 #[cfg(feature = "egc")]
3002 #[test]
3003 fn test_grid_clone_preserves_extra() {
3004 let mut g = Grid::new(2, 2);
3005 g.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
3006 let cloned = g.clone();
3007 assert_eq!(cloned.grapheme(0, 0, 0), Some("e\u{0301}"));
3008 }
3009
3010 #[cfg(feature = "egc")]
3011 #[test]
3012 fn test_grid_flatten_into_carries_extra_from_higher_layer() {
3013 let mut g = Grid::new(2, 2);
3014 g.write_grapheme(1, 0, 0, "e\u{0301}", Style::default());
3015 let mut flattened = Grid::new(2, 2);
3016 g.flatten_into(&mut flattened);
3017 assert_eq!(flattened[Pos::new(0, 0)].glyph, 'e');
3018 assert_eq!(flattened.grapheme(0, 0, 0), Some("e\u{0301}"));
3019 }
3020
3021 #[test]
3022 fn test_grid_flatten_into_single_layer_is_a_plain_copy() {
3023 let mut g = Grid::new(2, 2);
3024 g.put_tile(0, (0, 0), Tile::new('a', Style::default()));
3025 g.put_tile(0, (1, 1), Tile::new('b', Style::default()));
3026 let mut flattened = Grid::new(2, 2);
3027 g.flatten_into(&mut flattened);
3028 assert_eq!(flattened[Pos::new(0, 0)].glyph(), 'a');
3029 assert_eq!(flattened[Pos::new(1, 1)].glyph(), 'b');
3030 assert_eq!(flattened[Pos::new(1, 0)].glyph(), ' ');
3031 }
3032
3033 #[test]
3034 fn test_grid_flatten_into_higher_layer_overwrites_glyph_and_fg_but_not_default_bg() {
3035 let mut g = Grid::new(1, 1);
3036 g.put_tile(
3037 0,
3038 (0, 0),
3039 Tile::new('a', Style::new().fg(Color::BLACK).bg(Color::WHITE)),
3040 );
3041 g.put_tile(1, (0, 0), Tile::new('b', Style::new().fg(Color::WHITE)));
3042
3043 let mut flattened = Grid::new(1, 1);
3044 g.flatten_into(&mut flattened);
3045 let out = flattened[Pos::new(0, 0)];
3046 assert_eq!(out.glyph(), 'b');
3047 assert_eq!(out.style().fg, Color::WHITE);
3048 // Layer 1's tile has a `Default` background, so layer 0's background shows through.
3049 assert_eq!(out.style().bg, Color::WHITE);
3050 }
3051
3052 #[test]
3053 fn test_grid_flatten_into_empty_higher_layer_cell_is_transparent() {
3054 let mut g = Grid::new(2, 1);
3055 g.put_tile(0, (0, 0), Tile::new('a', Style::default()));
3056 g.put_tile(0, (1, 0), Tile::new('b', Style::default()));
3057 // Only touch (0, 0) on layer 1; (1, 0) on layer 1 stays at its default (EMPTY) tile.
3058 g.put_tile(1, (0, 0), Tile::new('c', Style::default()));
3059
3060 let mut flattened = Grid::new(2, 1);
3061 g.flatten_into(&mut flattened);
3062 assert_eq!(flattened[Pos::new(0, 0)].glyph(), 'c');
3063 // Untouched by the transparent layer-1 cell: layer 0's glyph shows through.
3064 assert_eq!(flattened[Pos::new(1, 0)].glyph(), 'b');
3065 }
3066
3067 #[test]
3068 fn test_grid_flatten_into_multi_layer_stale_dst_extra_is_cleared() {
3069 // `dst` may be a reused scratch buffer with stale content from a previous frame (see
3070 // `Terminal::present`): `flatten_into` must fully overwrite it, not merge with it.
3071 let mut flattened = Grid::new(1, 1);
3072 flattened.put_tile(0, (0, 0), Tile::new('z', Style::default()));
3073
3074 let g = Grid::new(1, 1);
3075 g.flatten_into(&mut flattened);
3076 assert_eq!(flattened[Pos::new(0, 0)].glyph(), ' ');
3077 }
3078
3079 // ── Multi-cell spans (retroglyph#412) ────────────────────────────────
3080
3081 /// The anchor owns the footprint; every other cell names the anchor and keeps its own glyph.
3082 #[test]
3083 fn write_span_marks_anchor_and_covered_cells() {
3084 let mut grid = Grid::new(4, 4);
3085 grid.write_span(0, 1, 1, &["C=", "[]"], Style::default())
3086 .expect("2x2 span fits in a 4x4 grid");
3087
3088 let anchor = grid.tile(0, (1, 1)).unwrap();
3089 assert!(anchor.flags().contains(TileFlags::SPAN_ANCHOR));
3090 assert_eq!(anchor.span(), (2, 2));
3091 assert_eq!(anchor.span_offset(), None);
3092 assert_eq!(anchor.glyph(), 'C');
3093
3094 for (x, y, glyph, offset) in [
3095 (2, 1, '=', (1, 0)),
3096 (1, 2, '[', (0, 1)),
3097 (2, 2, ']', (1, 1)),
3098 ] {
3099 let tile = grid.tile(0, (x, y)).unwrap();
3100 assert!(
3101 tile.flags().contains(TileFlags::SPAN_COVERED),
3102 "({x}, {y}) should be covered"
3103 );
3104 assert_eq!(tile.glyph(), glyph, "({x}, {y}) keeps its fallback glyph");
3105 assert_eq!(tile.span_offset(), Some(offset));
3106 // A covered cell is inside a footprint, it does not own one.
3107 assert_eq!(tile.span(), (1, 1));
3108 }
3109 }
3110
3111 /// The whole point of `SPAN_COVERED` differing from `WIDE_CHAR_SPACER`: cell backends read
3112 /// these glyphs, so they must survive the write intact.
3113 #[test]
3114 fn write_span_keeps_the_fallback_glyphs_readable() {
3115 let mut grid = Grid::new(4, 4);
3116 grid.write_span(0, 0, 0, &["C=", "[]"], Style::default())
3117 .unwrap();
3118 let read = |x, y| grid.tile(0, (x, y)).unwrap().glyph();
3119 assert_eq!(
3120 [read(0, 0), read(1, 0), read(0, 1), read(1, 1)],
3121 ['C', '=', '[', ']']
3122 );
3123 }
3124
3125 #[test]
3126 fn span_owner_reports_the_anchor_from_every_cell_of_the_span() {
3127 let mut grid = Grid::new(6, 6);
3128 grid.write_span(0, 2, 3, &["AB", "CD", "EF"], Style::default())
3129 .unwrap();
3130
3131 // Every cell of the footprint, the anchor included, answers with the same position, so
3132 // hit-testing is one comparison.
3133 for (x, y) in [(2, 3), (3, 3), (2, 4), (3, 4), (2, 5), (3, 5)] {
3134 assert_eq!(grid.span_owner(0, x, y), Some(Pos::new(2, 3)), "({x}, {y})");
3135 }
3136 // A free cell, an out-of-bounds one, and one on an unallocated layer belong to no span.
3137 assert_eq!(grid.span_owner(0, 0, 0), None);
3138 assert_eq!(grid.span_owner(0, 99, 99), None);
3139 assert_eq!(grid.span_owner(3, 3, 3), None);
3140 }
3141
3142 #[test]
3143 fn write_span_rejects_malformed_input_without_writing() {
3144 let mut grid = Grid::new(4, 4);
3145 assert_eq!(
3146 grid.write_span(0, 0, 0, &[] as &[&str], Style::default()),
3147 None
3148 );
3149 assert_eq!(grid.write_span(0, 0, 0, &[""], Style::default()), None);
3150 // Ragged rows.
3151 assert_eq!(
3152 grid.write_span(0, 0, 0, &["ab", "c"], Style::default()),
3153 None
3154 );
3155 // Too wide / too tall for the grid at this origin.
3156 assert_eq!(grid.write_span(0, 3, 0, &["ab"], Style::default()), None);
3157 assert_eq!(
3158 grid.write_span(0, 0, 3, &["a", "b"], Style::default()),
3159 None
3160 );
3161 // Nothing was written by any of the above.
3162 for y in 0..4 {
3163 for x in 0..4 {
3164 assert!(
3165 grid[Pos::new(x, y)].is_empty(),
3166 "({x}, {y}) should be untouched"
3167 );
3168 }
3169 }
3170 }
3171
3172 #[test]
3173 fn write_span_takes_any_as_ref_str_row() {
3174 let mut grid = Grid::new(4, 4);
3175 // A footprint computed at runtime: owned rows, no borrowing pass over them.
3176 let rows: Vec<String> = (0..2)
3177 .map(|row| {
3178 (0..2)
3179 .map(|col| if (row, col) == (0, 0) { 'C' } else { ' ' })
3180 .collect()
3181 })
3182 .collect();
3183
3184 assert_eq!(grid.write_span(0, 0, 0, &rows, Style::default()), Some(()));
3185 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'C');
3186 assert_eq!(grid[Pos::new(0, 0)].span(), (2, 2));
3187 }
3188
3189 #[test]
3190 fn write_span_uniform_writes_the_anchor_once_and_fills_the_rest() {
3191 let mut grid = Grid::new(4, 4);
3192 assert_eq!(
3193 grid.write_span_uniform(0, (1, 1), (2, 2), 'C', '.', Style::default()),
3194 Some(())
3195 );
3196
3197 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'C');
3198 assert_eq!(grid[Pos::new(1, 1)].span(), (2, 2));
3199 for (x, y) in [(2, 1), (1, 2), (2, 2)] {
3200 assert_eq!(grid[Pos::new(x, y)].glyph(), '.', "({x}, {y})");
3201 assert_eq!(grid.span_owner(0, x, y), Some(Pos::new(1, 1)));
3202 }
3203 }
3204
3205 #[test]
3206 fn write_span_uniform_matches_the_equivalent_write_span() {
3207 let mut uniform = Grid::new(4, 4);
3208 uniform
3209 .write_span_uniform(0, (0, 0), (3, 2), 'C', ' ', Style::default())
3210 .unwrap();
3211
3212 let mut rows = Grid::new(4, 4);
3213 rows.write_span(0, 0, 0, &["C ", " "], Style::default())
3214 .unwrap();
3215
3216 for y in 0..4 {
3217 for x in 0..4 {
3218 assert_eq!(
3219 uniform[Pos::new(x, y)],
3220 rows[Pos::new(x, y)],
3221 "({x}, {y}) differs"
3222 );
3223 }
3224 }
3225 }
3226
3227 #[test]
3228 fn write_span_uniform_rejects_a_degenerate_or_oversized_footprint() {
3229 let mut grid = Grid::new(4, 4);
3230 let style = Style::default();
3231
3232 assert_eq!(
3233 grid.write_span_uniform(0, (0, 0), (0, 2), 'C', ' ', style),
3234 None
3235 );
3236 assert_eq!(
3237 grid.write_span_uniform(0, (0, 0), (2, 0), 'C', ' ', style),
3238 None
3239 );
3240 // A span's dimensions are one byte each.
3241 assert_eq!(
3242 grid.write_span_uniform(0, (0, 0), (256, 1), 'C', ' ', style),
3243 None
3244 );
3245 // Does not fit the grid at this origin.
3246 assert_eq!(
3247 grid.write_span_uniform(0, (3, 0), (2, 1), 'C', ' ', style),
3248 None
3249 );
3250
3251 for y in 0..4 {
3252 for x in 0..4 {
3253 assert!(
3254 grid[Pos::new(x, y)].is_empty(),
3255 "({x}, {y}) should be untouched"
3256 );
3257 }
3258 }
3259 }
3260
3261 #[test]
3262 fn writing_into_a_covered_cell_clears_the_whole_span() {
3263 let mut grid = Grid::new(4, 4);
3264 grid.write_span(0, 0, 0, &["C=", "[]"], Style::default())
3265 .unwrap();
3266 grid.put_tile(0, (1, 1), Tile::new('x', Style::default()));
3267
3268 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'x');
3269 for (x, y) in [(0, 0), (1, 0), (0, 1)] {
3270 let tile = grid[Pos::new(x, y)];
3271 assert!(tile.is_empty(), "({x}, {y}) should have been cleared");
3272 assert_eq!(tile.flags(), TileFlags::EMPTY);
3273 }
3274 }
3275
3276 #[test]
3277 fn writing_over_the_anchor_clears_the_whole_span() {
3278 let mut grid = Grid::new(4, 4);
3279 grid.write_span(0, 0, 0, &["C=", "[]"], Style::default())
3280 .unwrap();
3281 grid.put_tile(0, (0, 0), Tile::new('x', Style::default()));
3282
3283 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'x');
3284 assert_eq!(grid[Pos::new(0, 0)].span(), (1, 1));
3285 for (x, y) in [(1, 0), (0, 1), (1, 1)] {
3286 assert!(
3287 grid[Pos::new(x, y)].is_empty(),
3288 "({x}, {y}) should be cleared"
3289 );
3290 }
3291 }
3292
3293 #[test]
3294 fn overlapping_spans_erase_the_old_one_entirely() {
3295 let mut grid = Grid::new(4, 4);
3296 grid.write_span(0, 0, 0, &["AB", "CD"], Style::default())
3297 .unwrap();
3298 // Overlaps the first span's bottom-right cell only; all four of its cells must go.
3299 grid.write_span(0, 1, 1, &["EF", "GH"], Style::default())
3300 .unwrap();
3301
3302 assert!(grid[Pos::new(0, 0)].is_empty());
3303 assert!(grid[Pos::new(1, 0)].is_empty());
3304 assert!(grid[Pos::new(0, 1)].is_empty());
3305 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'E');
3306 assert_eq!(grid.span_owner(0, 2, 2), Some(Pos::new(1, 1)));
3307 }
3308
3309 #[test]
3310 fn clear_span_works_from_any_cell_of_the_span() {
3311 let mut grid = Grid::new(4, 4);
3312 for from in [(0, 0), (1, 0), (0, 1), (1, 1)] {
3313 grid.write_span(0, 0, 0, &["C=", "[]"], Style::default())
3314 .unwrap();
3315 grid.clear_span(0, from.0, from.1);
3316 for y in 0..2 {
3317 for x in 0..2 {
3318 assert!(
3319 grid[Pos::new(x, y)].is_empty(),
3320 "clearing from {from:?}: ({x}, {y})"
3321 );
3322 }
3323 }
3324 }
3325 // A cell that is not part of a span is left alone.
3326 grid.put_tile(0, (3, 3), Tile::new('z', Style::default()));
3327 grid.clear_span(0, 3, 3);
3328 assert_eq!(grid[Pos::new(3, 3)].glyph(), 'z');
3329 }
3330
3331 #[test]
3332 fn spans_are_layer_scoped() {
3333 let mut grid = Grid::new(4, 4);
3334 grid.write_span(1, 0, 0, &["C=", "[]"], Style::default())
3335 .unwrap();
3336 assert_eq!(grid.span_owner(1, 1, 1), Some(Pos::new(0, 0)));
3337 // Layer 0 knows nothing about layer 1's span, and writing there leaves it intact.
3338 assert_eq!(grid.span_owner(0, 1, 1), None);
3339 assert_eq!(grid.span_owner(0, 0, 0), None);
3340 grid.put_tile(0, (1, 1), Tile::new('x', Style::default()));
3341 assert_eq!(grid.span_owner(1, 1, 1), Some(Pos::new(0, 0)));
3342 }
3343
3344 #[test]
3345 fn flatten_into_carries_span() {
3346 // Cell backends receive the flattened grid, so a span on a higher layer has to survive
3347 // flattening with both its flags *and* its span fields, or every covered cell ends up
3348 // naming an anchor that isn't there.
3349 let mut grid = Grid::new(4, 4);
3350 grid.write_span(2, 1, 1, &["C=", "[]"], Style::default())
3351 .unwrap();
3352
3353 let mut flat = Grid::new(4, 4);
3354 grid.flatten_into(&mut flat);
3355
3356 assert_eq!(flat[Pos::new(1, 1)].span(), (2, 2));
3357 assert!(
3358 flat[Pos::new(1, 1)]
3359 .flags()
3360 .contains(TileFlags::SPAN_ANCHOR)
3361 );
3362 assert_eq!(flat.span_owner(0, 2, 2), Some(Pos::new(1, 1)));
3363 assert_eq!(flat[Pos::new(2, 2)].glyph(), ']');
3364 }
3365
3366 #[test]
3367 fn blit_degrades_a_span_to_its_fallback_glyphs() {
3368 // `src_rect` can clip a footprint in half, and half a span is not representable, so
3369 // `blit` drops the span role and keeps the glyphs (which are the text fallback anyway).
3370 let mut src = Grid::new(4, 4);
3371 src.write_span(0, 0, 0, &["C=", "[]"], Style::default())
3372 .unwrap();
3373
3374 let mut dst = Grid::new(4, 4);
3375 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
3376
3377 assert_eq!(dst[Pos::new(0, 0)].glyph(), 'C');
3378 assert_eq!(dst[Pos::new(1, 1)].glyph(), ']');
3379 assert_eq!(dst[Pos::new(0, 0)].span(), (1, 1));
3380 assert_eq!(dst.span_owner(0, 1, 1), None);
3381 for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
3382 let flags = dst[Pos::new(x, y)].flags();
3383 assert!(!flags.contains(TileFlags::SPAN_ANCHOR), "({x}, {y})");
3384 assert!(!flags.contains(TileFlags::SPAN_COVERED), "({x}, {y})");
3385 }
3386 }
3387
3388 #[test]
3389 fn clear_region_clears_a_span_it_only_partly_covers() {
3390 let mut grid = Grid::new(4, 4);
3391 grid.write_span(0, 0, 0, &["C=", "[]"], Style::default())
3392 .unwrap();
3393 // Only the anchor cell is inside the region, but the whole span must go.
3394 grid.put_tile(0, (0, 0), Tile::default());
3395 for y in 0..2 {
3396 for x in 0..2 {
3397 assert!(grid[Pos::new(x, y)].is_empty(), "({x}, {y})");
3398 }
3399 }
3400 }
3401}
3402
3403/// Property tests for the wide-character (EGC) grid invariants.
3404///
3405/// These exercise the trickiest code in the crate — `write_grapheme` and its
3406/// `clear_overlap` helper — by hammering a small grid with random sequences of
3407/// narrow, wide, combining, and emoji graphemes and checking that the
3408/// wide-character bookkeeping never desyncs.
3409#[cfg(all(test, feature = "egc"))]
3410mod egc_proptests {
3411 use super::*;
3412 use crate::style::Style;
3413 use proptest::prelude::*;
3414
3415 const W: u16 = 8;
3416 const H: u16 = 4;
3417
3418 /// Narrow, wide (CJK), combining-mark, and wide-emoji graphemes.
3419 const GRAPHEMES: &[&str] = &["a", "\u{4e2d}", "e\u{0301}", "\u{1f600}"];
3420
3421 /// Every `WIDE_CHAR` has its spacer to the right, every `WIDE_CHAR_SPACER`
3422 /// has its lead to the left, and no cell is both.
3423 fn assert_wide_invariants(grid: &Grid) {
3424 for y in 0..grid.height() {
3425 for x in 0..grid.width() {
3426 let flags = grid[Pos::new(x, y)].flags();
3427 let lead = flags.contains(TileFlags::WIDE_CHAR);
3428 let spacer = flags.contains(TileFlags::WIDE_CHAR_SPACER);
3429
3430 assert!(
3431 !(lead && spacer),
3432 "cell ({x}, {y}) is both wide lead and spacer"
3433 );
3434
3435 if lead {
3436 assert!(x + 1 < grid.width(), "wide lead at ({x}, {y}) has no room");
3437 assert!(
3438 grid[Pos::new(x + 1, y)]
3439 .flags()
3440 .contains(TileFlags::WIDE_CHAR_SPACER),
3441 "wide lead at ({x}, {y}) is missing its spacer"
3442 );
3443 }
3444
3445 if spacer {
3446 assert!(x > 0, "orphan spacer at ({x}, {y}) (no cell to the left)");
3447 assert!(
3448 grid[Pos::new(x - 1, y)]
3449 .flags()
3450 .contains(TileFlags::WIDE_CHAR),
3451 "orphan spacer at ({x}, {y}) (left cell is not a wide lead)"
3452 );
3453 }
3454 }
3455 }
3456 }
3457
3458 proptest! {
3459 #[test]
3460 fn wide_char_bookkeeping_never_desyncs(
3461 ops in prop::collection::vec(
3462 (0u16..W, 0u16..H, 0usize..GRAPHEMES.len()),
3463 0..64,
3464 ),
3465 ) {
3466 let mut grid = Grid::new(W, H);
3467 for (x, y, gi) in ops {
3468 grid.write_grapheme(0, x, y, GRAPHEMES[gi], Style::default());
3469 // The invariant must hold after every single write, not just
3470 // at the end — an intermediate orphan would be a real bug.
3471 assert_wide_invariants(&grid);
3472 }
3473 }
3474 }
3475}