Skip to main content

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}