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`](Grid::cells_mut)). 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//! ## No short-circuiting: every allocated layer is visited, for every cell
57//!
58//! Compositing does not stop early when it hits an opaque tile on a high
59//! layer. Both `flatten_into` (crate-private) and the software
60//! backend's per-pixel compositor walk layers `0..=max_layer` in order for
61//! *every* cell, unconditionally -- even if a fully opaque tile on layer 5
62//! makes layers 6-50 invisible at that position. Cost is `O(max_layer)` per
63//! cell, not `O(topmost opaque layer)`. Painting one fully opaque layer 250
64//! over the whole grid still walks (and `EMPTY`-checks) layers 1-249 on
65//! every present.
66//!
67//! ## Allocation cost: layer 1 vs. layer 200
68//!
69//! Writing to a layer for the first time allocates one `width x height`
70//! buffer of [`Tile`]s -- the same cost regardless of the layer's id, plus a
71//! one-time growth of the layer table's `Vec<Option<LayerBuf>>` up to that
72//! layer's id (see [`Grid::new`]): the table starts at a single slot (layer
73//! 0) and only grows as far as the highest layer id ever written, so a
74//! single/few-layer `Grid` never pays for slots it never touches. Writing to
75//! layer 200 first grows the table to 201 slots, then allocates layer 200's
76//! buffer; the untouched slots 1-199 in between are a cheap `None`.
77//!
78//! What the layer id *does* affect is steady-state iteration cost, via
79//! [`max_layer`](Grid::max_layer): every present, diff, and full-grid
80//! iteration walks `0..=max_layer`, skipping unallocated slots with an O(1)
81//! `None` check. `max_layer` only grows -- clearing a layer
82//! ([`clear`](Grid::clear)) does not deallocate it or lower `max_layer`. So
83//! writing once to layer 200 and never touching layers 1-199 means every
84//! future frame's compositing pass walks past 199 unallocated slots to reach
85//! it. That walk is cheap (a pointer-sized `None` check per skipped layer)
86//! but not free; prefer low, contiguous layer ids for frequently-updated
87//! content and reserve high ids for rarely-touched overlays (e.g. a debug
88//! HUD pinned to layer 255).
89
90use crate::color::Color;
91#[cfg(feature = "egc")]
92use crate::style::Style;
93use crate::tile::Tile;
94use crate::tile::TileFlags;
95#[cfg(feature = "egc")]
96use crate::tile::cap_grapheme;
97use alloc::collections::BTreeMap;
98use alloc::sync::Arc;
99use alloc::vec::Vec;
100#[cfg(feature = "gem")]
101use alpha_blend::blend_modes::SeparableBlendMode;
102use core::fmt;
103use core::ops::{Index, IndexMut};
104use grixy::buf::GridBuf;
105use grixy::ops::layout::RowMajor;
106use grixy::ops::{ExactSizeGrid, GridRead, GridWrite};
107
108/// Blend mode for [`Grid::blit_alpha`], selecting how source and destination colors combine
109/// before the `fg_alpha`/`bg_alpha` factor is applied.
110///
111/// [`Linear`](Self::Linear) is a straight per-channel color lerp -- `blit_alpha`'s original
112/// behavior. The remaining variants are the [W3C separable blend modes] libtcod also offers:
113/// each computes a fully blended color per channel via
114/// [`alpha_blend::blend_modes::SeparableBlendMode`], and *that* result is what gets lerped
115/// against the destination by the alpha factor, in place of the source color `Linear` would use.
116///
117/// Requires the `gem` feature (default on): see [`Grid::blit_alpha`]'s doc comment for which
118/// crate backs each mode.
119///
120/// [W3C separable blend modes]: https://www.w3.org/TR/compositing-1/#blending
121#[cfg(feature = "gem")]
122#[non_exhaustive]
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
124pub enum BlendMode {
125    /// Straight per-channel RGB lerp between destination and source.
126    #[default]
127    Linear,
128    /// Lightens: `dst + src - dst * src`. Always at least as light as either input.
129    Screen,
130    /// Brightens the destination to reflect the source (aka "color dodge").
131    Dodge,
132    /// Darkens the destination to reflect the source (aka "color burn").
133    Burn,
134    /// Multiplies or screens the colors, depending on the destination.
135    Overlay,
136}
137
138#[cfg(feature = "gem")]
139impl BlendMode {
140    /// The equivalent [`SeparableBlendMode`], or `None` for [`Linear`](Self::Linear) (which uses
141    /// [`gem::rgb::Lerp`] instead -- see [`blend_color`]).
142    const fn separable(self) -> Option<SeparableBlendMode> {
143        match self {
144            Self::Linear => None,
145            Self::Screen => Some(SeparableBlendMode::Screen),
146            Self::Dodge => Some(SeparableBlendMode::ColorDodge),
147            Self::Burn => Some(SeparableBlendMode::ColorBurn),
148            Self::Overlay => Some(SeparableBlendMode::Overlay),
149        }
150    }
151}
152
153/// Size of the grid.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
155pub struct Size {
156    /// Width.
157    pub width: u16,
158    /// Height.
159    pub height: u16,
160}
161
162/// Pos in the grid, in (x = column, y = row) order.
163///
164/// Implements [`Ord`] in row-major order (y primary, then x), which is the
165/// natural ordering for terminal rendering: top-to-bottom, left-to-right within
166/// each row.
167pub type Pos = ixy::Pos<u16>;
168
169/// Rectangle in the grid.
170pub type Rect = ixy::Rect<u16>;
171
172impl From<(u16, u16)> for Size {
173    fn from((width, height): (u16, u16)) -> Self {
174        Self { width, height }
175    }
176}
177
178impl From<Size> for (u16, u16) {
179    fn from(s: Size) -> Self {
180        (s.width, s.height)
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Helpers: coordinate conversion between u16 and usize
186// ---------------------------------------------------------------------------
187
188fn to_grixy_pos(pos: Pos) -> grixy::core::Pos {
189    grixy::core::Pos::new(usize::from(pos.x), usize::from(pos.y))
190}
191
192// ---------------------------------------------------------------------------
193// Grid iterators
194// ---------------------------------------------------------------------------
195
196/// Iterator over all cells with their `(x, y)` coordinates.
197pub struct Cells<'a> {
198    iter: core::iter::Enumerate<core::slice::Iter<'a, Tile>>,
199    width: usize,
200}
201
202impl<'a> Iterator for Cells<'a> {
203    type Item = (u16, u16, &'a Tile);
204
205    fn next(&mut self) -> Option<Self::Item> {
206        self.iter.next().map(|(i, tile)| {
207            #[allow(clippy::cast_possible_truncation)]
208            let x = (i % self.width) as u16;
209            #[allow(clippy::cast_possible_truncation)]
210            let y = (i / self.width) as u16;
211            (x, y, tile)
212        })
213    }
214}
215
216/// Mutable iterator over all cells with their `(x, y)` coordinates.
217pub struct CellsMut<'a> {
218    iter: core::iter::Enumerate<core::slice::IterMut<'a, Tile>>,
219    width: usize,
220}
221
222impl<'a> Iterator for CellsMut<'a> {
223    type Item = (u16, u16, &'a mut Tile);
224
225    fn next(&mut self) -> Option<Self::Item> {
226        self.iter.next().map(|(i, tile)| {
227            #[allow(clippy::cast_possible_truncation)]
228            let x = (i % self.width) as u16;
229            #[allow(clippy::cast_possible_truncation)]
230            let y = (i / self.width) as u16;
231            (x, y, tile)
232        })
233    }
234}
235
236// ---------------------------------------------------------------------------
237// LayerBuf — a single layer's flat buffer
238// ---------------------------------------------------------------------------
239
240/// A single layer in the grid: a flat 2D buffer of one tile per cell.
241///
242/// Layer 0 is always allocated. Layers 1–255 are allocated on first write
243/// (see [`Grid::put_tile`]).
244#[derive(Clone)]
245pub(crate) struct LayerBuf {
246    pub(crate) buf: GridBuf<Tile, Vec<Tile>, RowMajor>,
247    /// Sparse EGC side-table: flat row-major index -> full grapheme text, for
248    /// tiles with [`TileFlags::HAS_EXTRA`] set. Empty unless the `egc`
249    /// feature is used to write a multi-codepoint grapheme, which is what
250    /// keeps [`Tile`] itself small (see [`Grid::grapheme`]).
251    ///
252    /// The `HAS_EXTRA` flag is authoritative: readers must check it before
253    /// consulting this map, since some write paths (`put`, `put_tile`,
254    /// `IndexMut`, `cells_mut`) can leave a stale entry behind when they
255    /// overwrite a tile that used to carry extra text without an explicit
256    /// cleanup call. Since those paths only ever hand out or store tiles
257    /// with `HAS_EXTRA` clear, a stale entry is harmless: it is simply
258    /// never looked up until the slot is reused by `write_grapheme`, which
259    /// always overwrites it.
260    extras: BTreeMap<usize, Arc<str>>,
261}
262
263impl LayerBuf {
264    fn new(width: u16, height: u16) -> Self {
265        let n = usize::from(width) * usize::from(height);
266        Self {
267            buf: GridBuf::from_buffer(alloc::vec![Tile::default(); n], usize::from(width)),
268            extras: BTreeMap::new(),
269        }
270    }
271
272    /// Returns the grapheme text for the tile at flat index `idx`, or `None`
273    /// if `tile` doesn't have [`TileFlags::HAS_EXTRA`] set.
274    fn extra_for(&self, idx: usize, tile: &Tile) -> Option<&str> {
275        if tile.flags.contains(TileFlags::HAS_EXTRA) {
276            self.extras.get(&idx).map(|s| &**s)
277        } else {
278            None
279        }
280    }
281
282    /// Returns a cloned `Arc` handle to the grapheme text at flat index
283    /// `idx`, or `None` if `tile` doesn't have [`TileFlags::HAS_EXTRA`] set.
284    /// Used to copy extras between grids (e.g. [`Grid::blit`]) without
285    /// re-allocating the string.
286    fn extra_arc_for(&self, idx: usize, tile: &Tile) -> Option<Arc<str>> {
287        if tile.flags.contains(TileFlags::HAS_EXTRA) {
288            self.extras.get(&idx).cloned()
289        } else {
290            None
291        }
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Grid
297// ---------------------------------------------------------------------------
298
299/// A 2D buffer of [`Tile`]s, addressable across up to 256 stacked layers.
300///
301/// Layer 0 is always allocated; higher layers are allocated on first write, growing the
302/// layer-table `Vec` up to that layer's id as needed (see [`Grid::new`]). Single-layer use pays
303/// no overhead: layers 1+ stay unallocated until used, and the layer table itself never grows
304/// past a single slot.
305///
306/// Requires an allocator (backed by `alloc::vec::Vec`), so it is unavailable
307/// in strictly static, no-alloc environments.
308#[derive(Clone)]
309pub struct Grid {
310    width: u16,
311    height: u16,
312    /// Indexed by layer ID (0–255), but only as long as the highest layer id ever written to
313    /// (see [`layer_or_alloc`](Self::layer_or_alloc)) -- not always all 256 slots. Index 0 is
314    /// always `Some`. Unwritten layers within the current length are `None`; ids past the end
315    /// are treated identically to a `None` slot (see [`layer`](Self::layer)).
316    layers: Vec<Option<LayerBuf>>,
317    /// Highest layer ID that has been allocated. Always at least 0.
318    max_layer: u8,
319}
320
321// ---------------------------------------------------------------------------
322// Internal helpers
323// ---------------------------------------------------------------------------
324
325impl Grid {
326    /// Borrow a specific layer, or `None` if unallocated.
327    ///
328    /// `id` may be beyond the current layer-table `Vec`'s length -- the table only grows as far
329    /// as the highest layer id ever written (see [`layer_or_alloc`](Self::layer_or_alloc)), so an
330    /// id past the end simply means "never written", same as an in-bounds `None` slot.
331    fn layer(&self, id: u8) -> Option<&LayerBuf> {
332        self.layers.get(usize::from(id))?.as_ref()
333    }
334
335    /// Borrow a specific layer mutably, allocating it if necessary.
336    ///
337    /// Grows the layer-table `Vec` up to `id + 1` slots on demand, rather than the table always
338    /// holding all 256 possible slots (see retroglyph#264): a `Grid` that only ever writes to
339    /// layer 0, or a handful of low ids, never pays for the 250+ slots it never touches.
340    fn layer_or_alloc(&mut self, id: u8) -> &mut LayerBuf {
341        let idx = usize::from(id);
342        if idx >= self.layers.len() {
343            self.layers.resize_with(idx + 1, || None);
344        }
345        if self.layers[idx].is_none() {
346            self.layers[idx] = Some(LayerBuf::new(self.width, self.height));
347        }
348        if id > self.max_layer {
349            self.max_layer = id;
350        }
351        self.layers[idx].as_mut().unwrap()
352    }
353
354    /// Borrow layer 0 (always allocated).
355    fn layer0(&self) -> &LayerBuf {
356        // SAFETY: layer 0 is always `Some` (set in `new`).
357        self.layers[0].as_ref().unwrap()
358    }
359
360    /// Borrow layer 0 mutably (always allocated).
361    fn layer0_mut(&mut self) -> &mut LayerBuf {
362        self.layers[0].as_mut().unwrap()
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Grid — public API (all forward to layer 0)
368// ---------------------------------------------------------------------------
369
370impl Grid {
371    /// Creates a new grid of the given dimensions.
372    ///
373    /// Layer 0 is allocated immediately. Layers 1–255 are `None` until first
374    /// write via [`put_tile`](Self::put_tile); the layer table itself only
375    /// grows as far as the highest layer id ever written, not all 256 slots
376    /// up front.
377    #[must_use]
378    pub fn new(width: u16, height: u16) -> Self {
379        Self {
380            width,
381            height,
382            layers: alloc::vec![Some(LayerBuf::new(width, height))],
383            max_layer: 0,
384        }
385    }
386
387    /// Build a grid from a rectangular character map, one [`Tile`] per cell.
388    ///
389    /// `map` is split on `\n`; the grid width is the longest line's character
390    /// count and the height is the number of lines. Lines shorter than the
391    /// widest are padded with the default tile. `f` maps each character to its
392    /// tile, called once per character in reading order.
393    ///
394    /// Characters are counted as Unicode scalar values (one column each), which
395    /// matches ASCII / CP437 maps and level/prefab strings. Wide characters are
396    /// not width-adjusted.
397    ///
398    /// # Example
399    ///
400    /// ```
401    /// use retroglyph_core::{Grid, Style, Tile};
402    ///
403    /// let grid = Grid::from_charmap("##\n#.", |c| match c {
404    ///     '#' => Tile::new('#', Style::default()),
405    ///     _ => Tile::default(),
406    /// });
407    /// assert_eq!((grid.width(), grid.height()), (2, 2));
408    /// assert_eq!(grid.get(0, 0).glyph(), '#');
409    /// assert_eq!(grid.get(1, 1).glyph(), ' ');
410    /// ```
411    #[must_use]
412    pub fn from_charmap<F>(map: &str, mut f: F) -> Self
413    where
414        F: FnMut(char) -> Tile,
415    {
416        let mut width: u16 = 0;
417        let mut height: u16 = 0;
418        for line in map.lines() {
419            let len = u16::try_from(line.chars().count()).unwrap_or(u16::MAX);
420            width = width.max(len);
421            height = height.saturating_add(1);
422        }
423        let mut grid = Self::new(width, height);
424        for (y, line) in map.lines().enumerate() {
425            #[allow(clippy::cast_possible_truncation)]
426            let y = y as u16;
427            for (x, ch) in line.chars().enumerate() {
428                #[allow(clippy::cast_possible_truncation)]
429                let x = x as u16;
430                grid.put_tile(0, x, y, f(ch));
431            }
432        }
433        grid
434    }
435
436    /// Returns the width of the grid.
437    #[must_use]
438    pub const fn width(&self) -> u16 {
439        self.width
440    }
441
442    /// Returns the height of the grid.
443    #[must_use]
444    pub const fn height(&self) -> u16 {
445        self.height
446    }
447
448    /// Returns the highest layer id that has ever been allocated.
449    ///
450    /// Always at least 0 (layer 0 is always allocated). This only grows:
451    /// clearing a layer does not deallocate it, so the value does not shrink
452    /// once a higher layer has been written.
453    #[must_use]
454    pub const fn max_layer(&self) -> u8 {
455        self.max_layer
456    }
457
458    /// Sets the tile at the given coordinates on layer 0.
459    ///
460    /// Since [`Tile`] cannot carry a multi-codepoint grapheme itself (see
461    /// [`grapheme`](Self::grapheme)), overwriting a cell this way always
462    /// clears any extra text previously stored for it -- use
463    /// [`write_grapheme`](Self::write_grapheme) to write EGCs.
464    ///
465    /// # Panics
466    ///
467    /// Panics if the coordinates are out of bounds.
468    pub fn put(&mut self, x: u16, y: u16, tile: Tile) {
469        let pos = to_grixy_pos(Pos::new(x, y));
470        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
471        let lb = self.layer0_mut();
472        assert!(
473            lb.buf.contains(pos),
474            "coordinates out of bounds: ({x}, {y})"
475        );
476        lb.extras.remove(&idx);
477        lb.buf[pos] = tile;
478    }
479
480    /// Gets the tile at the given coordinates on layer 0.
481    ///
482    /// # Panics
483    ///
484    /// Panics if the coordinates are out of bounds.
485    #[must_use]
486    pub fn get(&self, x: u16, y: u16) -> &Tile {
487        &self.layer0().buf[to_grixy_pos(Pos::new(x, y))]
488    }
489
490    /// Returns the full grapheme cluster stored for the tile at `(x, y)` on
491    /// `layer`, if any.
492    ///
493    /// `Some` only when the tile has [`TileFlags::HAS_EXTRA`] set, i.e. it
494    /// was written via [`write_grapheme`](Self::write_grapheme) with a
495    /// multi-codepoint EGC (combining marks, ZWJ sequences, etc.). For the
496    /// common single-codepoint case, or without the `egc` feature, this is
497    /// always `None`; use [`get_tile`](Self::get_tile)'s
498    /// [`Tile::glyph`](crate::tile::Tile::glyph) and
499    /// [`encode_utf8`](char::encode_utf8) to reconstruct the string instead.
500    ///
501    /// Returns `None` if the layer is unallocated or the coordinates are out
502    /// of bounds.
503    #[must_use]
504    pub fn grapheme(&self, layer: u8, x: u16, y: u16) -> Option<&str> {
505        let lb = self.layer(layer)?;
506        let pos = to_grixy_pos(Pos::new(x, y));
507        let tile = lb.buf.get(pos)?;
508        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
509        lb.extra_for(idx, tile)
510    }
511
512    /// Tries to set the tile at the given coordinates on layer 0.
513    ///
514    /// Returns `None` if the coordinates are out of bounds. See
515    /// [`put`](Self::put) for the EGC-clearing caveat.
516    pub fn checked_put(&mut self, x: u16, y: u16, tile: Tile) -> Option<()> {
517        let pos = to_grixy_pos(Pos::new(x, y));
518        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
519        let lb = self.layer0_mut();
520        if lb.buf.contains(pos) {
521            lb.extras.remove(&idx);
522            lb.buf[pos] = tile;
523            Some(())
524        } else {
525            None
526        }
527    }
528
529    /// Tries to get the tile at the given coordinates on layer 0.
530    ///
531    /// Returns `None` if the coordinates are out of bounds.
532    #[must_use]
533    pub fn checked_get(&self, x: u16, y: u16) -> Option<&Tile> {
534        let pos = to_grixy_pos(Pos::new(x, y));
535        self.layer0().buf.get(pos)
536    }
537
538    /// Tries to get a mutable reference to the tile at the given coordinates
539    /// on layer 0.
540    ///
541    /// Returns `None` if the coordinates are out of bounds.
542    pub fn checked_get_mut(&mut self, x: u16, y: u16) -> Option<&mut Tile> {
543        let pos = to_grixy_pos(Pos::new(x, y));
544        self.layer0_mut().buf.get_mut(pos)
545    }
546
547    /// Iterates all tiles on `layer` with their `(x, y)` coordinates.
548    ///
549    /// Returns `None` if the layer is unallocated.
550    #[must_use]
551    pub fn cells(&self, layer: u8) -> Option<Cells<'_>> {
552        let lb = self.layer(layer)?;
553        Some(Cells {
554            iter: lb.buf.as_ref().iter().enumerate(),
555            width: usize::from(self.width),
556        })
557    }
558
559    /// Iterates all tiles on `layer` mutably with their `(x, y)` coordinates.
560    ///
561    /// If the layer has not been written to yet, it is allocated first.
562    pub fn cells_mut(&mut self, layer: u8) -> CellsMut<'_> {
563        let width = usize::from(self.width);
564        let lb = self.layer_or_alloc(layer);
565        CellsMut {
566            iter: lb.buf.as_mut().iter_mut().enumerate(),
567            width,
568        }
569    }
570
571    /// Clears a specific layer, resetting all tiles to the default.
572    ///
573    /// Does nothing if the layer is unallocated.
574    pub fn clear(&mut self, layer: u8) {
575        if let Some(lb) = self
576            .layers
577            .get_mut(usize::from(layer))
578            .and_then(Option::as_mut)
579        {
580            lb.buf.clear();
581            lb.extras.clear();
582        }
583    }
584
585    /// Resize the grid to `width` × `height` tiles.
586    ///
587    /// Content within the overlapping region is preserved on all allocated
588    /// layers. New cells are initialised to the default tile. Shrinking
589    /// discards tiles outside the new bounds.
590    pub fn resize(&mut self, width: u16, height: u16) {
591        let old_width = usize::from(self.width);
592        let new_width = usize::from(width);
593        let new_height = usize::from(height);
594        self.width = width;
595        self.height = height;
596        for layer in self.layers.iter_mut().flatten() {
597            // The extras side-table is keyed by flat row-major index, which
598            // shifts whenever the width changes -- remap it in lockstep with
599            // `buf.resize` (below) rather than leaving it pointing at stale
600            // (or now out-of-bounds) cells.
601            if !layer.extras.is_empty() {
602                layer.extras = layer
603                    .extras
604                    .iter()
605                    .filter_map(|(&old_idx, s)| {
606                        let x = old_idx % old_width;
607                        let y = old_idx / old_width;
608                        (x < new_width && y < new_height).then(|| (y * new_width + x, s.clone()))
609                    })
610                    .collect();
611            }
612            layer.buf.resize(new_width, new_height);
613        }
614    }
615
616    // ------------------------------------------------------------------
617    // Write grapheme — layer 0 only
618    // ------------------------------------------------------------------
619
620    /// Write a grapheme cluster at `(x, y)` on layer 0, enforcing wide-
621    /// character invariants.
622    ///
623    /// This is the canonical way to place content into the grid when the `egc`
624    /// feature is enabled. It:
625    ///
626    /// - Clears any wide character whose primary or spacer cell would be
627    ///   overwritten.
628    /// - Sets [`TileFlags::WIDE_CHAR`] on the primary cell and places a
629    ///   [`TileFlags::WIDE_CHAR_SPACER`] in the adjacent cell for 2-column
630    ///   characters.
631    /// - Stores multi-codepoint EGCs (combining marks, ZWJ sequences) in the
632    ///   layer's EGC side-table (see [`grapheme`](Self::grapheme)), capped at
633    ///   8 codepoints total.
634    ///
635    /// Does nothing if `(x, y)` is out of bounds, if the grapheme has zero
636    /// display width, or if a 2-column wide character would overflow the grid
637    /// (the last column needs both its own cell and a spacer).
638    ///
639    /// # Panics
640    ///
641    /// Panics if the grapheme's display width exceeds [`u16::MAX`]. In
642    /// practice this cannot happen: the maximum Unicode grapheme width is 2.
643    ///
644    /// Only present when the `egc` feature is enabled.
645    #[cfg(feature = "egc")]
646    pub fn write_grapheme(&mut self, layer: u8, x: u16, y: u16, grapheme: &str, style: Style) {
647        use unicode_width::UnicodeWidthStr;
648
649        let width = u16::try_from(grapheme.width()).expect("grapheme width exceeds u16");
650        if width == 0 {
651            return;
652        }
653
654        // Capture dimensions as plain values to avoid borrow conflicts.
655        let w = usize::from(self.width);
656        let cap = w * usize::from(self.height);
657        let idx = usize::from(y) * w + usize::from(x);
658        if idx >= cap {
659            return;
660        }
661
662        // A 2-column char needs a spacer at x+1. If that's out of bounds,
663        // silently refuse rather than leaving an orphaned primary cell.
664        if width == 2 && x.saturating_add(1) as usize >= w {
665            return;
666        }
667
668        // Clear any wide-char cell that would be partially overwritten.
669        self.clear_overlap(layer, x, y, width);
670
671        // Capture width before borrowing self mutably.
672        let grid_w = usize::from(self.width);
673        let idx = usize::from(y) * grid_w + usize::from(x);
674
675        let lb = self.layer_or_alloc(layer);
676        // Build cell content.
677        let mut chars = grapheme.chars();
678        let first = chars.next().unwrap_or(' ');
679        let has_extra = chars.next().is_some();
680        let flags = if width == 2 {
681            TileFlags::WIDE_CHAR
682        } else {
683            TileFlags::empty()
684        };
685        let flags = if has_extra {
686            flags | TileFlags::HAS_EXTRA
687        } else {
688            flags
689        };
690
691        lb.buf.as_mut()[idx].glyph = first;
692        lb.buf.as_mut()[idx].style = style;
693        lb.buf.as_mut()[idx].flags = flags;
694        // `width` here is the full grapheme's display width (1 or 2), not just `first`'s -- more
695        // accurate than recomputing from the primary codepoint alone, and exactly what the
696        // terminal renderer needs to advance the cursor after printing this cell.
697        #[allow(clippy::cast_possible_truncation)]
698        {
699            lb.buf.as_mut()[idx].width = width as u8;
700        }
701        if has_extra {
702            lb.extras.insert(idx, Arc::from(cap_grapheme(grapheme)));
703        } else {
704            lb.extras.remove(&idx);
705        }
706
707        // Place spacer for wide characters.
708        if width == 2 {
709            let spacer_idx = usize::from(y) * grid_w + usize::from(x + 1);
710            if spacer_idx < cap {
711                let spacer = &mut lb.buf.as_mut()[spacer_idx];
712                spacer.glyph = ' ';
713                spacer.style = style;
714                spacer.width = 0;
715                spacer.flags = TileFlags::WIDE_CHAR_SPACER;
716                lb.extras.remove(&spacer_idx);
717            }
718        }
719    }
720
721    /// Clears wide-character cells that would be partially overwritten by a
722    /// write starting at `(x, y)` spanning `width` columns.
723    #[cfg(feature = "egc")]
724    fn clear_overlap(&mut self, layer: u8, x: u16, y: u16, width: u16) {
725        let w = usize::from(self.width);
726        let cap = w * usize::from(self.height);
727        let lb = self.layer_or_alloc(layer);
728        for cx in x..x.saturating_add(width) {
729            let idx = usize::from(y) * w + usize::from(cx);
730            if idx >= cap {
731                continue;
732            }
733            // flags is Copy, so reading through the shared ref is fine.
734            let flags = lb.buf.as_ref()[idx].flags;
735
736            if flags.contains(TileFlags::WIDE_CHAR_SPACER) && cx > 0 {
737                let pidx = usize::from(y) * w + usize::from(cx - 1);
738                if pidx < cap {
739                    lb.buf.as_mut()[pidx].reset();
740                    lb.extras.remove(&pidx);
741                }
742            }
743
744            if flags.contains(TileFlags::WIDE_CHAR) {
745                let sidx = usize::from(y) * w + usize::from(cx + 1);
746                if sidx < cap {
747                    lb.buf.as_mut()[sidx].reset();
748                    lb.extras.remove(&sidx);
749                }
750            }
751        }
752    }
753}
754
755// ---------------------------------------------------------------------------
756// Grid — multi-layer API
757// ---------------------------------------------------------------------------
758
759impl Grid {
760    /// Write a tile to `layer` at `(x, y)`.
761    ///
762    /// Allocates the layer if it has not been written to yet. Returns `None`
763    /// if `(x, y)` is out of bounds.
764    ///
765    /// To read back, use [`get_tile`](Self::get_tile).
766    ///
767    /// Like [`put`](Self::put), any tile written this way has its extra
768    /// grapheme text cleared, since a caller-constructed [`Tile`] can never
769    /// legitimately carry [`TileFlags::HAS_EXTRA`] (the flag is
770    /// crate-private). Internal callers that need to preserve EGC text
771    /// across a copy (e.g. [`blit`](Self::blit)) follow up with a direct
772    /// extras-table write.
773    pub fn put_tile(&mut self, layer: u8, x: u16, y: u16, mut tile: Tile) -> Option<()> {
774        let pos = to_grixy_pos(Pos::new(x, y));
775        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
776        let lb = self.layer_or_alloc(layer);
777        if !lb.buf.contains(pos) {
778            return None;
779        }
780        lb.extras.remove(&idx);
781        tile.flags.remove(TileFlags::HAS_EXTRA);
782        lb.buf[pos] = tile;
783        Some(())
784    }
785
786    /// Sets the extra grapheme text for an already-written tile at `(x, y)`
787    /// on `layer`, setting [`TileFlags::HAS_EXTRA`] to match. Does nothing if
788    /// out of bounds. Crate-private: the only external way to write EGC text
789    /// is [`write_grapheme`](Self::write_grapheme).
790    pub(crate) fn set_extra(&mut self, layer: u8, x: u16, y: u16, extra: Arc<str>) {
791        let pos = to_grixy_pos(Pos::new(x, y));
792        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
793        let lb = self.layer_or_alloc(layer);
794        if lb.buf.contains(pos) {
795            lb.buf[pos].flags.insert(TileFlags::HAS_EXTRA);
796            lb.extras.insert(idx, extra);
797        }
798    }
799
800    /// Read a tile on `layer` at `(x, y)`, or `None` if the layer is
801    /// unallocated or the coordinates are out of bounds.
802    #[must_use]
803    pub fn get_tile(&self, layer: u8, x: u16, y: u16) -> Option<&Tile> {
804        let pos = to_grixy_pos(Pos::new(x, y));
805        self.layer(layer)?.buf.get(pos)
806    }
807
808    /// Copy tiles from `src` within `src_rect` to `self` at `(dst_x, dst_y)`
809    /// on `layer`. Empty tiles (nothing written; see [`Tile::is_empty`]) are
810    /// treated as transparent and skipped. An explicit space is copied and
811    /// overwrites the destination.
812    ///
813    /// Walks `src`'s and `self`'s layer buffers directly by flat index instead of going through
814    /// [`get_tile`](Self::get_tile)/[`put_tile`](Self::put_tile) per cell (see retroglyph#263):
815    /// each of those recomputes a coordinate conversion and a bounds check per cell, which this
816    /// does once per row instead. The destination layer is allocated once, up front, rather than
817    /// as a side effect of the first written cell -- but only if `src_rect` (clamped to `src`'s
818    /// bounds) contains at least one non-empty tile, matching `put_tile`'s original
819    /// allocate-on-first-write behavior for a `src_rect` that is entirely transparent.
820    pub fn blit(&mut self, layer: u8, src: &Self, src_rect: Rect, dst_x: u16, dst_y: u16) {
821        let Some(src_lb) = src.layer(layer) else {
822            return;
823        };
824        let src_width = usize::from(src.width);
825        let sx0 = src_rect.left().min(src.width);
826        let sx1 = src_rect.right().min(src.width);
827        let sy0 = src_rect.top().min(src.height);
828        let sy1 = src_rect.bottom().min(src.height);
829        if sx0 >= sx1 || sy0 >= sy1 {
830            return;
831        }
832
833        // Matches the original's implicit allocate-on-first-write: only touch the destination
834        // layer at all if there's at least one visible (non-empty) source tile to copy.
835        let has_visible = (sy0..sy1).any(|sy| {
836            let start = usize::from(sy) * src_width + usize::from(sx0);
837            let end = usize::from(sy) * src_width + usize::from(sx1);
838            src_lb.buf.as_ref()[start..end]
839                .iter()
840                .any(|t| !t.flags.contains(TileFlags::EMPTY))
841        });
842        if !has_visible {
843            return;
844        }
845
846        let dst_width = usize::from(self.width);
847        let dst_height = usize::from(self.height);
848        let dst_lb = self.layer_or_alloc(layer);
849        let mut pending_extras: Vec<(usize, Arc<str>)> = Vec::new();
850
851        // `dst_x`/`dst_y` saturate on overflow (retroglyph#268): a `u16::MAX`-adjacent origin
852        // combined with a `src_rect` offset would otherwise wrap silently and either write to
853        // the wrong cell or get rejected by luck rather than by design. Saturating to `u16::MAX`
854        // is always caught by the `>= dst_width`/`>= dst_height` bounds check below, since a
855        // valid index must be strictly less than a `u16`-derived dimension.
856        for sy in sy0..sy1 {
857            let dy = dst_y.saturating_add(sy - src_rect.top());
858            if usize::from(dy) >= dst_height {
859                continue;
860            }
861            for sx in sx0..sx1 {
862                let dx = dst_x.saturating_add(sx - src_rect.left());
863                if usize::from(dx) >= dst_width {
864                    continue;
865                }
866                let src_idx = usize::from(sy) * src_width + usize::from(sx);
867                let tile = &src_lb.buf.as_ref()[src_idx];
868                if tile.flags.contains(TileFlags::EMPTY) {
869                    continue;
870                }
871                let dst_idx = usize::from(dy) * dst_width + usize::from(dx);
872                let mut out_tile = *tile;
873                out_tile.flags.remove(TileFlags::HAS_EXTRA);
874                dst_lb.buf.as_mut()[dst_idx] = out_tile;
875                if tile.flags.contains(TileFlags::HAS_EXTRA) {
876                    if let Some(extra) = src_lb.extra_arc_for(src_idx, tile) {
877                        pending_extras.push((dst_idx, extra));
878                    }
879                } else {
880                    dst_lb.extras.remove(&dst_idx);
881                }
882            }
883        }
884
885        for (idx, extra) in pending_extras {
886            dst_lb.buf.as_mut()[idx].flags.insert(TileFlags::HAS_EXTRA);
887            dst_lb.extras.insert(idx, extra);
888        }
889    }
890
891    /// Same as [`blit`](Self::blit) but blends foreground and background
892    /// colors with the given alpha factors, using `mode` to compute the
893    /// blended color. `fg_alpha` and `bg_alpha` are in 0.0-1.0 range where
894    /// 0.0 = keep destination, 1.0 = replace with src; for a non-
895    /// [`Linear`](BlendMode::Linear) `mode`, "replace with src" instead means
896    /// "replace with `mode`'s fully blended color" (see [`BlendMode`]).
897    ///
898    /// Blending operates on packed RGB values; [`Color::Default`] preserves
899    /// the destination. Non-RGB color variants (Ansi/Indexed) are passed
900    /// through unblended, regardless of `mode`.
901    ///
902    /// Requires the `gem` feature (default on): [`BlendMode::Linear`]'s
903    /// per-channel color lerp is delegated to [`gem::rgb::Lerp`]; the other
904    /// modes delegate to [`alpha_blend::blend_modes::SeparableBlendMode`].
905    ///
906    /// Like [`blit`](Self::blit) (see retroglyph#262/#263), walks `src`'s and `self`'s layer
907    /// buffers directly by flat index instead of per-cell [`get_tile`](Self::get_tile)/
908    /// [`put_tile`](Self::put_tile), and allocates the destination layer once, up front, rather
909    /// than as a side effect of the first written cell.
910    #[cfg(feature = "gem")]
911    #[allow(clippy::too_many_arguments, clippy::float_cmp)]
912    pub fn blit_alpha(
913        &mut self,
914        layer: u8,
915        src: &Self,
916        src_rect: Rect,
917        dst_x: u16,
918        dst_y: u16,
919        mode: BlendMode,
920        fg_alpha: f32,
921        bg_alpha: f32,
922    ) {
923        let Some(src_lb) = src.layer(layer) else {
924            return;
925        };
926        let src_width = usize::from(src.width);
927        let sx0 = src_rect.left().min(src.width);
928        let sx1 = src_rect.right().min(src.width);
929        let sy0 = src_rect.top().min(src.height);
930        let sy1 = src_rect.bottom().min(src.height);
931        if sx0 >= sx1 || sy0 >= sy1 {
932            return;
933        }
934
935        // Matches the original's implicit allocate-on-first-write: only touch the destination
936        // layer at all if there's at least one visible (non-empty) source tile to copy.
937        let has_visible = (sy0..sy1).any(|sy| {
938            let start = usize::from(sy) * src_width + usize::from(sx0);
939            let end = usize::from(sy) * src_width + usize::from(sx1);
940            src_lb.buf.as_ref()[start..end]
941                .iter()
942                .any(|t| !t.flags.contains(TileFlags::EMPTY))
943        });
944        if !has_visible {
945            return;
946        }
947
948        let dst_width = usize::from(self.width);
949        let dst_height = usize::from(self.height);
950        let dst_lb = self.layer_or_alloc(layer);
951        let mut pending_extras: Vec<(usize, Arc<str>)> = Vec::new();
952
953        // See `blit`'s matching comment (retroglyph#268): `saturating_add` here, paired with the
954        // bounds checks below, prevents a `u16::MAX`-adjacent destination origin from wrapping.
955        for sy in sy0..sy1 {
956            let dy = dst_y.saturating_add(sy - src_rect.top());
957            if usize::from(dy) >= dst_height {
958                continue;
959            }
960            for sx in sx0..sx1 {
961                let dx = dst_x.saturating_add(sx - src_rect.left());
962                if usize::from(dx) >= dst_width {
963                    continue;
964                }
965                let src_idx = usize::from(sy) * src_width + usize::from(sx);
966                let tile = &src_lb.buf.as_ref()[src_idx];
967                if tile.flags.contains(TileFlags::EMPTY) {
968                    continue;
969                }
970                let dst_idx = usize::from(dy) * dst_width + usize::from(dx);
971                let mut blended = *tile;
972                {
973                    let dst_tile = &dst_lb.buf.as_ref()[dst_idx];
974                    // `fg_alpha == 1.0` only lets `Linear` skip the call: `Linear` at `t ==
975                    // 1.0` is `src` by definition, but a `Screen`/`Dodge`/`Burn`/`Overlay`
976                    // mix at full alpha still needs to run the mode's formula -- it isn't
977                    // equivalent to the raw source color (see `blend_color`'s matching guard).
978                    if mode != BlendMode::Linear || fg_alpha != 1.0 {
979                        blended.style.fg =
980                            blend_fg(mode, tile.style.fg, dst_tile.style.fg, fg_alpha);
981                    }
982                    if mode != BlendMode::Linear || bg_alpha != 1.0 {
983                        blended.style.bg =
984                            blend_bg(mode, tile.style.bg, dst_tile.style.bg, bg_alpha);
985                    }
986                }
987                blended.flags.remove(TileFlags::HAS_EXTRA);
988                dst_lb.buf.as_mut()[dst_idx] = blended;
989                if tile.flags.contains(TileFlags::HAS_EXTRA) {
990                    if let Some(extra) = src_lb.extra_arc_for(src_idx, tile) {
991                        pending_extras.push((dst_idx, extra));
992                    }
993                } else {
994                    dst_lb.extras.remove(&dst_idx);
995                }
996            }
997        }
998
999        for (idx, extra) in pending_extras {
1000            dst_lb.buf.as_mut()[idx].flags.insert(TileFlags::HAS_EXTRA);
1001            dst_lb.extras.insert(idx, extra);
1002        }
1003    }
1004
1005    /// Yield `(layer_id, Pos, &Tile, Option<&str>)` for every allocated cell
1006    /// across all layers, in layer-major (0 → `max_layer`) then row-major
1007    /// order. The last element is the tile's grapheme text (see
1008    /// [`grapheme`](Self::grapheme)), `Some` only when
1009    /// [`TileFlags::HAS_EXTRA`] is set.
1010    ///
1011    /// Unallocated layers are skipped. This is used by backends that need
1012    /// the full frame on every draw (see [`crate::Output::needs_full_frame`]).
1013    ///
1014    /// This iterator is zero-allocation: it walks the layer buffers inline.
1015    pub fn layers(&self) -> impl Iterator<Item = (u8, Pos, &Tile, Option<&str>)> + '_ {
1016        let width = usize::from(self.width);
1017        (0..=self.max_layer)
1018            .filter_map(move |id| self.layer(id).map(|lb| (id, lb)))
1019            .flat_map(move |(id, lb)| {
1020                lb.buf.as_ref().iter().enumerate().map(move |(i, tile)| {
1021                    #[allow(clippy::cast_possible_truncation)]
1022                    let x = (i % width) as u16;
1023                    #[allow(clippy::cast_possible_truncation)]
1024                    let y = (i / width) as u16;
1025                    (id, Pos::new(x, y), tile, lb.extra_for(i, tile))
1026                })
1027            })
1028    }
1029
1030    /// Clear every allocated layer.
1031    pub fn clear_all(&mut self) {
1032        for layer in self.layers.iter_mut().flatten() {
1033            layer.buf.clear();
1034            layer.extras.clear();
1035        }
1036    }
1037
1038    /// Composite every allocated layer into `dst`'s layer 0, one tile per cell.
1039    ///
1040    /// Used by [`crate::Terminal::present`] for backends that do not composite
1041    /// layers themselves (see [`crate::Output::composites_layers`]). The rule
1042    /// matches the software renderer's pixel semantics and the [`blit`](Self::blit)
1043    /// transparency convention:
1044    ///
1045    /// - Start from layer 0's tile (its `bg` fills the cell).
1046    /// - For each higher allocated layer, in ascending order: if the tile is
1047    ///   not empty (see [`Tile::is_empty`]) replace the glyph, foreground,
1048    ///   offsets, flags, and extra; if its background is not
1049    ///   [`Color::Default`], replace the background.
1050    ///
1051    /// Because an explicit space is not empty, drawing one on a higher layer
1052    /// overwrites (erases) the glyph beneath it.
1053    ///
1054    /// `dst` must have the same dimensions as `self`.
1055    ///
1056    /// Walks layer buffers directly by flat index instead of calling
1057    /// [`get`](Self::get)/[`get_tile`](Self::get_tile) per cell (see retroglyph#262): each of
1058    /// those recomputes a coordinate conversion and a bounds check per cell, which a flat scan
1059    /// over each layer's backing buffer -- the same style [`layers`](Self::layers) and
1060    /// [`diff`](Self::diff) already use -- avoids entirely.
1061    pub(crate) fn flatten_into(&self, dst: &mut Self) {
1062        let layer0 = self.layer0();
1063        let cell_count = layer0.buf.as_ref().len();
1064
1065        // Seed every destination cell from layer 0: its tile verbatim, and its extra text
1066        // filtered through `HAS_EXTRA` (the flag is authoritative -- see `LayerBuf::extras`'
1067        // doc comment -- so a stale, unflagged entry in `layer0.extras` is not carried over).
1068        let dst_layer0 = dst.layer0_mut();
1069        dst_layer0.buf.as_mut().copy_from_slice(layer0.buf.as_ref());
1070        dst_layer0.extras.clear();
1071        for (&idx, extra) in &layer0.extras {
1072            if layer0.buf.as_ref()[idx]
1073                .flags
1074                .contains(TileFlags::HAS_EXTRA)
1075            {
1076                dst_layer0.extras.insert(idx, extra.clone());
1077            }
1078        }
1079
1080        // Overlay every higher allocated layer, in ascending order, index-for-index.
1081        for id in 1..=self.max_layer {
1082            let Some(lb) = self.layer(id) else {
1083                continue;
1084            };
1085            let src_buf = lb.buf.as_ref();
1086            debug_assert_eq!(src_buf.len(), cell_count);
1087            let dst_layer0 = dst.layer0_mut();
1088            for (idx, tile) in src_buf.iter().enumerate() {
1089                if !tile.flags.contains(TileFlags::EMPTY) {
1090                    {
1091                        let out = &mut dst_layer0.buf.as_mut()[idx];
1092                        out.glyph = tile.glyph;
1093                        out.width = tile.width;
1094                        out.style.fg = tile.style.fg;
1095                        out.dx = tile.dx;
1096                        out.dy = tile.dy;
1097                        out.flags = tile.flags;
1098                    }
1099                    if tile.flags.contains(TileFlags::HAS_EXTRA) {
1100                        if let Some(extra) = lb.extra_arc_for(idx, tile) {
1101                            dst_layer0.extras.insert(idx, extra);
1102                        }
1103                    } else {
1104                        dst_layer0.extras.remove(&idx);
1105                    }
1106                }
1107                if tile.style.bg != Color::Default {
1108                    dst_layer0.buf.as_mut()[idx].style.bg = tile.style.bg;
1109                }
1110            }
1111        }
1112    }
1113
1114    /// Yield `(layer_id, Pos, &Tile, Option<&str>)` for every changed
1115    /// position across all layers, in layer-major (0 → `max_layer`) then
1116    /// row-major order. The last element is the changed tile's grapheme text
1117    /// (see [`grapheme`](Self::grapheme)).
1118    ///
1119    /// Three cases per layer:
1120    /// - Layer absent in `self`: nothing yielded.
1121    /// - Layer in `self`, absent in `other` (newly allocated): all
1122    ///   `width × height` tiles yielded.
1123    /// - Layer in both: only positions where the `Tile` or its grapheme text
1124    ///   differs are yielded. `self` and `other` must have matching
1125    ///   dimensions for this case; the crate never calls `diff` otherwise.
1126    ///
1127    /// This iterator is zero-allocation: it walks the layer buffers inline.
1128    pub fn diff<'a>(
1129        &'a self,
1130        other: &'a Self,
1131    ) -> impl Iterator<Item = (u8, Pos, &'a Tile, Option<&'a str>)> + 'a {
1132        let width = usize::from(self.width);
1133        let max = self.max_layer;
1134        (0..=max).flat_map(move |id| {
1135            match (self.layer(id), other.layer(id)) {
1136                // Layer absent in `self`: nothing changed.
1137                (None, _) => LayerDiff::Empty,
1138                // Newly allocated layer: all cells are "changed".
1139                (Some(cur_lb), None) => LayerDiff::Full(
1140                    cur_lb
1141                        .buf
1142                        .as_ref()
1143                        .iter()
1144                        .enumerate()
1145                        .map(move |(i, tile)| {
1146                            #[allow(clippy::cast_possible_truncation)]
1147                            let x = (i % width) as u16;
1148                            #[allow(clippy::cast_possible_truncation)]
1149                            let y = (i / width) as u16;
1150                            (id, Pos::new(x, y), tile, cur_lb.extra_for(i, tile))
1151                        }),
1152                ),
1153                // Layer in both: only the differing cells. Compared by hand
1154                // (rather than delegating to grixy's `GridDiff`) because a
1155                // `Tile`-only comparison can't see grapheme-text changes: two
1156                // multi-codepoint EGCs sharing a primary codepoint but
1157                // different combining marks (e.g. `e\u{0301}` vs `e\u{0300}`)
1158                // compare equal on every `Tile` field.
1159                (Some(cur_lb), Some(prev_lb)) => {
1160                    LayerDiff::Diff(cur_lb.buf.as_ref().iter().enumerate().filter_map(
1161                        move |(i, tile)| {
1162                            let prev_tile = &prev_lb.buf.as_ref()[i];
1163                            let cur_extra = cur_lb.extra_for(i, tile);
1164                            let prev_extra = prev_lb.extra_for(i, prev_tile);
1165                            if tile == prev_tile && cur_extra == prev_extra {
1166                                return None;
1167                            }
1168                            #[allow(clippy::cast_possible_truncation)]
1169                            let x = (i % width) as u16;
1170                            #[allow(clippy::cast_possible_truncation)]
1171                            let y = (i / width) as u16;
1172                            Some((id, Pos::new(x, y), tile, cur_extra))
1173                        },
1174                    ))
1175                }
1176            }
1177        })
1178    }
1179}
1180
1181/// Per-layer diff iterator, replacing a boxed trait object so `diff` performs
1182/// no per-layer heap allocation.
1183enum LayerDiff<F, D> {
1184    Empty,
1185    Full(F),
1186    Diff(D),
1187}
1188
1189impl<'a, F, D> Iterator for LayerDiff<F, D>
1190where
1191    F: Iterator<Item = (u8, Pos, &'a Tile, Option<&'a str>)>,
1192    D: Iterator<Item = (u8, Pos, &'a Tile, Option<&'a str>)>,
1193{
1194    type Item = (u8, Pos, &'a Tile, Option<&'a str>);
1195
1196    fn next(&mut self) -> Option<Self::Item> {
1197        match self {
1198            Self::Empty => None,
1199            Self::Full(iter) => iter.next(),
1200            Self::Diff(iter) => iter.next(),
1201        }
1202    }
1203}
1204
1205/// Blend two [`Color`] values using `mode`. [`Color::Default`] preserves the
1206/// destination. Non-RGB source colors are returned as-is (no resolution).
1207///
1208/// [`BlendMode::Linear`] is a per-channel sRGB-domain lerp (dst -> src by
1209/// `t`) delegated to [`gem::rgb::Lerp`], which is `no_std`-safe (round-half-
1210/// away via `floor(x + 0.5)`, no `std`/`libm` float intrinsics). The other
1211/// modes evaluate [`SeparableBlendMode::mix`] per channel in `0.0..=1.0`
1212/// (converting u8 <-> f32 at the boundary; see [`blend_separable_channel`]),
1213/// then lerp that fully mixed color against the destination by `t`, same as
1214/// `Linear`.
1215#[cfg(feature = "gem")]
1216#[allow(clippy::float_cmp)]
1217fn blend_color(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
1218    use gem::rgb::{HasBlue as _, HasGreen as _, HasRed as _, Lerp as _, Rgb888};
1219    match (src, dst) {
1220        (Color::Default, _) => Color::Default,
1221        (
1222            Color::Rgb {
1223                r: sr,
1224                g: sg,
1225                b: sb,
1226            },
1227            Color::Rgb {
1228                r: dr,
1229                g: dg,
1230                b: db,
1231            },
1232        ) if mode != BlendMode::Linear || t != 1.0 => {
1233            // `Linear` at `t == 1.0` is `src` by definition (skip to the catch-all arm below);
1234            // the other modes must still run their mix formula at `t == 1.0` -- see `blit_alpha`.
1235            let (r, g, b) = mode.separable().map_or_else(
1236                || {
1237                    // `dst.lerp(src, t)`, not `src.lerp(dst, t)`: at `t == 0.0` this must return
1238                    // `dst` ("keep destination", per `blit_alpha`'s doc comment) and only reach
1239                    // `src` at `t == 1.0` -- the same `0.0 == dst, 1.0 == fully blended` contract
1240                    // every other `BlendMode` follows (see `blend_separable_channel`).
1241                    let out = Rgb888::from_rgb(dr, dg, db).lerp(Rgb888::from_rgb(sr, sg, sb), t);
1242                    (out.red(), out.green(), out.blue())
1243                },
1244                |sep| {
1245                    (
1246                        blend_separable_channel(sep, sr, dr, t),
1247                        blend_separable_channel(sep, sg, dg, t),
1248                        blend_separable_channel(sep, sb, db, t),
1249                    )
1250                },
1251            );
1252            Color::Rgb { r, g, b }
1253        }
1254        (src, _) => src,
1255    }
1256}
1257
1258/// Evaluates `sep`'s per-channel mixing function for one RGB channel (`src`/`dst` are u8, `sep`
1259/// operates in `0.0..=1.0` f32), then lerps that mixed value against `dst` by `t` -- `0.0` keeps
1260/// `dst`, `1.0` uses the fully mixed color. Rounds with `libm::roundf` rather than `f32::round`
1261/// (a `std`-only method not available in `core`, same reasoning as `libm::fmaf` in
1262/// `animate::easing`) and clamps before converting back to u8, since `ColorDodge`/`ColorBurn`'s
1263/// `min(1.0, ...)` branches can round a hair outside `0.0..=1.0` at the float boundary.
1264#[cfg(feature = "gem")]
1265fn blend_separable_channel(sep: SeparableBlendMode, src: u8, dst: u8, t: f32) -> u8 {
1266    let cs = f32::from(src) / 255.0;
1267    let cb = f32::from(dst) / 255.0;
1268    let mixed = sep.mix(cb, cs);
1269    // Not `f32::mul_add`: it's a std-only inherent method, not in `core`. `libm::fmaf` is the
1270    // no_std-safe equivalent (see `animate::easing` for the same reasoning).
1271    let blended = libm::fmaf(mixed - cb, t, cb);
1272    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1273    let out = libm::roundf(blended.clamp(0.0, 1.0) * 255.0) as u8;
1274    out
1275}
1276
1277#[cfg(feature = "gem")]
1278fn blend_fg(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
1279    blend_color(mode, src, dst, t)
1280}
1281
1282#[cfg(feature = "gem")]
1283fn blend_bg(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
1284    blend_color(mode, src, dst, t)
1285}
1286
1287// ---------------------------------------------------------------------------
1288// Index / IndexMut — layer 0
1289// ---------------------------------------------------------------------------
1290
1291impl Index<Pos> for Grid {
1292    type Output = Tile;
1293
1294    fn index(&self, pos: Pos) -> &Tile {
1295        &self.layer0().buf[to_grixy_pos(pos)]
1296    }
1297}
1298
1299impl IndexMut<Pos> for Grid {
1300    fn index_mut(&mut self, pos: Pos) -> &mut Tile {
1301        let pos = to_grixy_pos(pos);
1302        &mut self.layer0_mut().buf[pos]
1303    }
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Display / Debug — layer 0
1308// ---------------------------------------------------------------------------
1309
1310impl fmt::Display for Grid {
1311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1312        for y in 0..self.height() {
1313            for x in 0..self.width() {
1314                let tile = self.get(x, y);
1315                #[cfg(feature = "egc")]
1316                let is_spacer = tile.flags.contains(TileFlags::WIDE_CHAR_SPACER);
1317                #[cfg(not(feature = "egc"))]
1318                let is_spacer = tile.glyph == '\0';
1319                let c = if is_spacer {
1320                    ' ' // right half of a wide char — don't print twice
1321                } else if tile.glyph == ' ' {
1322                    '·' // empty cell marker
1323                } else {
1324                    tile.glyph
1325                };
1326                write!(f, "{c}")?;
1327            }
1328            writeln!(f)?;
1329        }
1330        Ok(())
1331    }
1332}
1333
1334impl fmt::Debug for Grid {
1335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1336        f.debug_struct("Grid")
1337            .field("width", &self.width)
1338            .field("height", &self.height)
1339            .finish_non_exhaustive()
1340    }
1341}
1342
1343// ---------------------------------------------------------------------------
1344// Tests
1345// ---------------------------------------------------------------------------
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350
1351    // --- Existing tests (must pass unchanged) ---
1352
1353    #[test]
1354    fn test_grid_new() {
1355        let grid = Grid::new(80, 25);
1356        assert_eq!(grid.width(), 80);
1357        assert_eq!(grid.height(), 25);
1358    }
1359
1360    #[test]
1361    fn test_grid_put_get() {
1362        let mut grid = Grid::new(10, 10);
1363        let tile = Tile::default().with_glyph('X');
1364
1365        grid.put(5, 5, tile);
1366        assert_eq!(grid.get(5, 5).glyph(), 'X');
1367    }
1368
1369    #[test]
1370    fn test_grid_checked_put_get() {
1371        let mut grid = Grid::new(10, 10);
1372        let tile = Tile::default().with_glyph('Y');
1373
1374        assert!(grid.checked_put(5, 5, tile).is_some());
1375        assert_eq!(grid.checked_get(5, 5).unwrap().glyph(), 'Y');
1376
1377        assert!(grid.checked_get(10, 0).is_none());
1378        assert!(grid.checked_put(0, 10, Tile::default()).is_none());
1379    }
1380
1381    #[test]
1382    #[should_panic(expected = "coordinates out of bounds")]
1383    fn test_grid_panic_put() {
1384        let mut grid = Grid::new(10, 10);
1385        grid.put(10, 0, Tile::default());
1386    }
1387
1388    #[test]
1389    fn test_grid_diff() {
1390        let mut g1 = Grid::new(2, 2);
1391        let g2 = Grid::new(2, 2);
1392
1393        g1.put(0, 0, Tile::default().with_glyph('A'));
1394
1395        let diffs: Vec<_> = g1.diff(&g2).collect();
1396        assert_eq!(diffs.len(), 1);
1397        assert_eq!(diffs[0], (0, Pos::new(0, 0), g1.get(0, 0), None));
1398    }
1399
1400    #[test]
1401    fn test_grid_resize_expand() {
1402        let mut grid = Grid::new(3, 3);
1403        grid.put(1, 1, Tile::default().with_glyph('X'));
1404        grid.resize(6, 6);
1405        assert_eq!(grid.width(), 6);
1406        assert_eq!(grid.height(), 6);
1407        assert_eq!(grid.get(1, 1).glyph(), 'X'); // preserved
1408        assert_eq!(grid.get(5, 5).glyph(), ' '); // new cells default
1409    }
1410
1411    #[test]
1412    fn test_grid_resize_shrink() {
1413        let mut grid = Grid::new(10, 10);
1414        grid.put(1, 1, Tile::default().with_glyph('A'));
1415        grid.resize(5, 5);
1416        assert_eq!(grid.width(), 5);
1417        assert_eq!(grid.height(), 5);
1418        assert_eq!(grid.get(1, 1).glyph(), 'A'); // still in bounds, preserved
1419    }
1420
1421    #[test]
1422    fn test_grid_resize_preserves_overlap() {
1423        let mut grid = Grid::new(4, 4);
1424        grid.put(0, 0, Tile::default().with_glyph('@'));
1425        grid.put(3, 3, Tile::default().with_glyph('X'));
1426        grid.resize(3, 3); // shrink: (3,3) falls outside
1427        assert_eq!(grid.get(0, 0).glyph(), '@');
1428        assert_eq!(grid.get(2, 2).glyph(), ' '); // was default, still default
1429    }
1430
1431    #[test]
1432    fn test_grid_display() {
1433        let mut grid = Grid::new(3, 2);
1434        grid.put(0, 0, Tile::default().with_glyph('A'));
1435
1436        let s = alloc::format!("{grid}");
1437        assert_eq!(s, "A··\n···\n");
1438    }
1439
1440    #[test]
1441    fn test_grid_cells_count() {
1442        let grid = Grid::new(4, 3);
1443        assert_eq!(grid.cells(0).unwrap().count(), 12);
1444    }
1445
1446    #[test]
1447    fn test_grid_cells_coordinates() {
1448        let grid = Grid::new(3, 2);
1449        let coords: Vec<(u16, u16)> = grid.cells(0).unwrap().map(|(x, y, _)| (x, y)).collect();
1450        assert_eq!(
1451            coords,
1452            vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1),]
1453        );
1454    }
1455
1456    #[test]
1457    fn test_grid_cells_mut() {
1458        use crate::style::Style;
1459        let mut grid = Grid::new(2, 2);
1460        for (x, y, tile) in grid.cells_mut(0) {
1461            #[allow(clippy::cast_possible_truncation)]
1462            let idx = (y * 2 + x) as u8;
1463            *tile = Tile::new(char::from(b'A' + idx), Style::default());
1464        }
1465        assert_eq!(grid.get(0, 0).glyph(), 'A');
1466        assert_eq!(grid.get(1, 0).glyph(), 'B');
1467        assert_eq!(grid.get(0, 1).glyph(), 'C');
1468        assert_eq!(grid.get(1, 1).glyph(), 'D');
1469    }
1470
1471    #[test]
1472    fn test_rect_contains() {
1473        let r = Rect::new(2, 3, 4, 5);
1474        assert!(r.contains_pos(Pos::new(2, 3)));
1475        assert!(r.contains_pos(Pos::new(5, 7)));
1476        assert!(!r.contains_pos(Pos::new(6, 3))); // x == x+width, exclusive
1477        assert!(!r.contains_pos(Pos::new(2, 8))); // y == y+height, exclusive
1478        assert!(!r.contains_pos(Pos::new(1, 3)));
1479    }
1480
1481    #[test]
1482    fn test_rect_area() {
1483        assert_eq!(Rect::new(0, 0, 5, 3).area(), 15);
1484        assert_eq!(Rect::default().area(), 0);
1485    }
1486
1487    #[test]
1488    fn test_rect_top_left_bottom_right() {
1489        let r = Rect::new(1, 2, 3, 4);
1490        assert_eq!(r.top_left(), Pos::new(1, 2));
1491        assert_eq!(r.bottom_right(), Pos::new(4, 6));
1492    }
1493
1494    #[test]
1495    fn test_rect_intersects() {
1496        let a = Rect::new(0, 0, 4, 4);
1497        let b = Rect::new(2, 2, 4, 4);
1498        let c = Rect::new(4, 0, 4, 4); // touches edge, no overlap
1499        assert!(!a.intersect(b).is_empty());
1500        assert!(a.intersect(c).is_empty());
1501    }
1502
1503    #[test]
1504    fn test_rect_positions() {
1505        let r = Rect::new(1, 2, 2, 2);
1506        let pts: Vec<Pos> = r.pos_iter().collect();
1507        assert_eq!(
1508            pts,
1509            vec![
1510                Pos::new(1, 2),
1511                Pos::new(2, 2),
1512                Pos::new(1, 3),
1513                Pos::new(2, 3),
1514            ]
1515        );
1516    }
1517
1518    #[test]
1519    fn test_index_position() {
1520        let mut grid = Grid::new(5, 5);
1521        let pos = Pos::new(2, 3);
1522        grid[pos] = Tile::default().with_glyph('Z');
1523        assert_eq!(grid[pos].glyph(), 'Z');
1524    }
1525
1526    #[test]
1527    fn test_position_from_tuple() {
1528        let p: Pos = (3u16, 7u16).into();
1529        assert_eq!(p, Pos::new(3, 7));
1530        let t: (u16, u16) = p.into();
1531        assert_eq!(t, (3, 7));
1532    }
1533
1534    #[test]
1535    fn test_size_from_tuple() {
1536        let s: Size = (80u16, 25u16).into();
1537        assert_eq!(
1538            s,
1539            Size {
1540                width: 80,
1541                height: 25
1542            }
1543        );
1544        let t: (u16, u16) = s.into();
1545        assert_eq!(t, (80, 25));
1546    }
1547
1548    #[test]
1549    fn test_position_ord_row_major() {
1550        let mut positions = vec![Pos::new(5, 0), Pos::new(0, 1), Pos::new(3, 0)];
1551        positions.sort();
1552        assert_eq!(
1553            positions,
1554            vec![Pos::new(3, 0), Pos::new(5, 0), Pos::new(0, 1),]
1555        );
1556    }
1557
1558    #[test]
1559    fn test_size_ord() {
1560        assert!(
1561            Size {
1562                width: 1,
1563                height: 2
1564            } < Size {
1565                width: 2,
1566                height: 1
1567            }
1568        );
1569    }
1570
1571    // --- New tests for multi-layer API ---
1572
1573    #[test]
1574    fn test_grid_layer_zero_always_allocated() {
1575        let g = Grid::new(5, 5);
1576        assert!(g.layer(0).is_some());
1577        for id in 1u8..=5 {
1578            assert!(g.layer(id).is_none(), "layer {id} should be None");
1579        }
1580    }
1581
1582    #[test]
1583    fn test_grid_put_tile_allocates_layer() {
1584        let mut g = Grid::new(5, 5);
1585        g.put_tile(3, 0, 0, Tile::new('@', Style::default()));
1586        assert!(g.layer(3).is_some());
1587        assert!(g.layer(4).is_none());
1588    }
1589
1590    #[test]
1591    fn test_grid_new_layer_table_starts_at_a_single_slot() {
1592        // retroglyph#264: the layer-table `Vec` itself should start small (a single slot for
1593        // layer 0), not pre-allocate all 256 possible slots up front.
1594        let g = Grid::new(5, 5);
1595        assert_eq!(g.layers.len(), 1);
1596        assert_eq!(g.max_layer(), 0);
1597    }
1598
1599    #[test]
1600    fn test_grid_layer_or_alloc_grows_table_lazily_to_the_written_id() {
1601        let mut g = Grid::new(5, 5);
1602        g.put_tile(10, 0, 0, Tile::new('@', Style::default()));
1603        // The table grows to exactly `id + 1` slots -- not all 256.
1604        assert_eq!(g.layers.len(), 11);
1605        assert_eq!(g.max_layer(), 10);
1606        assert!(g.layer(10).is_some());
1607        for id in 1u8..10 {
1608            assert!(g.layer(id).is_none(), "layer {id} should be None");
1609        }
1610    }
1611
1612    #[test]
1613    fn test_grid_layer_beyond_table_length_reads_as_none() {
1614        // A layer id past the current table length (never written) must read identically to an
1615        // in-bounds `None` slot, not panic or error.
1616        let g = Grid::new(5, 5);
1617        assert_eq!(g.layers.len(), 1);
1618        assert!(g.layer(255).is_none());
1619        assert!(g.get_tile(255, 0, 0).is_none());
1620        assert!(g.grapheme(255, 0, 0).is_none());
1621    }
1622
1623    #[test]
1624    fn test_grid_clear_beyond_table_length_is_a_no_op() {
1625        // Clearing an id past the current table length must not panic -- it's equivalent to
1626        // clearing an unallocated in-bounds layer (does nothing).
1627        let mut g = Grid::new(5, 5);
1628        g.clear(255);
1629        assert_eq!(g.layers.len(), 1);
1630    }
1631
1632    #[test]
1633    fn test_grid_layer_table_growth_is_monotonic_across_writes() {
1634        // Writing to a lower layer id after a higher one must not shrink the table, and must
1635        // preserve the higher layer's content.
1636        let mut g = Grid::new(5, 5);
1637        g.put_tile(20, 1, 1, Tile::new('H', Style::default()));
1638        assert_eq!(g.layers.len(), 21);
1639        g.put_tile(2, 0, 0, Tile::new('L', Style::default()));
1640        assert_eq!(
1641            g.layers.len(),
1642            21,
1643            "writing a lower id must not shrink the table"
1644        );
1645        assert_eq!(g.max_layer(), 20);
1646        assert_eq!(g.get_tile(20, 1, 1).unwrap().glyph, 'H');
1647        assert_eq!(g.get_tile(2, 0, 0).unwrap().glyph, 'L');
1648    }
1649
1650    #[test]
1651    fn test_grid_diff_empty_when_identical() {
1652        let g = Grid::new(5, 5);
1653        let prev = Grid::new(5, 5);
1654        assert_eq!(g.diff(&prev).count(), 0);
1655    }
1656
1657    #[test]
1658    fn test_grid_diff_reports_changed_cell() {
1659        let mut cur = Grid::new(5, 5);
1660        let prev = Grid::new(5, 5);
1661        cur.put_tile(0, 2, 3, Tile::new('X', Style::default()));
1662        let diffs: Vec<_> = cur.diff(&prev).collect();
1663        assert_eq!(diffs.len(), 1);
1664        assert_eq!(diffs[0].0, 0);
1665        assert_eq!(diffs[0].1, Pos::new(2, 3));
1666        assert_eq!(diffs[0].2.glyph, 'X');
1667    }
1668
1669    #[test]
1670    fn test_grid_diff_new_layer_yields_all_cells() {
1671        let mut cur = Grid::new(3, 4);
1672        let prev = Grid::new(3, 4);
1673        cur.put_tile(1, 0, 0, Tile::new('A', Style::default()));
1674        let diffs: Vec<_> = cur.diff(&prev).collect();
1675        // All 12 cells of the newly allocated layer 1 are yielded.
1676        assert_eq!(diffs.len(), 12);
1677        assert!(diffs.iter().all(|(l, _, _, _)| *l == 1));
1678    }
1679
1680    #[test]
1681    fn test_grid_diff_layer_major_order() {
1682        let mut cur = Grid::new(3, 3);
1683        let prev = Grid::new(3, 3);
1684        cur.put_tile(2, 0, 0, Tile::new('B', Style::default()));
1685        cur.put_tile(0, 1, 0, Tile::new('A', Style::default()));
1686        let layers: Vec<u8> = cur.diff(&prev).map(|(l, _, _, _)| l).collect();
1687        // Layer 0's change appears first, then all of layer 2.
1688        assert_eq!(layers[0], 0);
1689        assert!(layers[1..].iter().all(|&l| l == 2));
1690    }
1691
1692    #[test]
1693    fn test_grid_put_and_get_on_layer_2() {
1694        use crate::style::Style;
1695        let mut g = Grid::new(5, 5);
1696        g.put_tile(2, 1, 1, Tile::new('Z', Style::default()));
1697        assert_eq!(g.get_tile(2, 1, 1).unwrap().glyph, 'Z');
1698        // Layer 0 at same position should still be default.
1699        assert_eq!(g.get(1, 1).glyph, ' ');
1700        // Unallocated layer returns None.
1701        assert!(g.get_tile(3, 0, 0).is_none());
1702    }
1703
1704    #[test]
1705    fn test_grid_clear_layer() {
1706        let mut g = Grid::new(5, 5);
1707        g.put_tile(1, 0, 0, Tile::new('Z', Style::default()));
1708        g.put_tile(0, 0, 0, Tile::new('A', Style::default()));
1709        g.clear(1);
1710        assert_eq!(g.get_tile(0, 0, 0).unwrap().glyph, 'A');
1711        assert!(g.get_tile(1, 0, 0).is_some());
1712        assert_eq!(g.get_tile(1, 0, 0).unwrap().glyph, ' '); // cleared
1713    }
1714
1715    #[test]
1716    fn test_grid_clear_all() {
1717        let mut g = Grid::new(5, 5);
1718        g.put_tile(1, 0, 0, Tile::new('Z', Style::default()));
1719        g.put_tile(0, 0, 0, Tile::new('A', Style::default()));
1720        g.clear_all();
1721        // Both layers reset to default (space).
1722        assert_eq!(g.get(0, 0).glyph, ' ');
1723        assert_eq!(g.get_tile(1, 0, 0).unwrap().glyph, ' ');
1724    }
1725
1726    #[test]
1727    fn test_grid_clone_is_independent() {
1728        let mut g = Grid::new(3, 3);
1729        g.put_tile(0, 0, 0, Tile::new('A', Style::default()));
1730        g.put_tile(2, 1, 1, Tile::new('B', Style::default()));
1731
1732        let mut cloned = g.clone();
1733        assert_eq!(cloned.get(0, 0).glyph, 'A');
1734        assert_eq!(cloned.get_tile(2, 1, 1).unwrap().glyph, 'B');
1735        assert_eq!(cloned.max_layer(), g.max_layer());
1736
1737        // Mutating the clone must not affect the original (deep copy).
1738        cloned.put_tile(0, 0, 0, Tile::new('Z', Style::default()));
1739        assert_eq!(cloned.get(0, 0).glyph, 'Z');
1740        assert_eq!(g.get(0, 0).glyph, 'A');
1741    }
1742
1743    // --- Extra grapheme text (EGC side-table) ---
1744
1745    #[cfg(feature = "egc")]
1746    #[test]
1747    fn test_grid_write_grapheme_stores_and_reads_extra() {
1748        let mut g = Grid::new(5, 5);
1749        g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
1750        assert_eq!(g.get(1, 1).glyph, 'e');
1751        assert_eq!(g.grapheme(0, 1, 1), Some("e\u{0301}"));
1752
1753        // Single-codepoint writes never populate the side-table.
1754        g.write_grapheme(0, 2, 2, "a", Style::default());
1755        assert_eq!(g.grapheme(0, 2, 2), None);
1756    }
1757
1758    #[cfg(feature = "egc")]
1759    #[test]
1760    fn test_grid_overwrite_clears_extra() {
1761        let mut g = Grid::new(5, 5);
1762        g.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
1763        assert_eq!(g.grapheme(0, 0, 0), Some("e\u{0301}"));
1764
1765        // A plain `put` (or a later single-codepoint `write_grapheme`) must
1766        // drop the stale side-table entry, not just leave it unreachable.
1767        g.put(0, 0, Tile::new('X', Style::default()));
1768        assert_eq!(g.grapheme(0, 0, 0), None);
1769        assert!(!g.get(0, 0).flags().contains(TileFlags::HAS_EXTRA));
1770    }
1771
1772    #[cfg(feature = "egc")]
1773    #[test]
1774    fn test_grid_resize_remaps_extras_to_new_stride() {
1775        let mut g = Grid::new(4, 4);
1776        g.write_grapheme(0, 3, 1, "e\u{0301}", Style::default());
1777        assert_eq!(g.grapheme(0, 3, 1), Some("e\u{0301}"));
1778
1779        // Widening changes the row stride, so the flat index for (3, 1)
1780        // changes even though the cell itself is preserved.
1781        g.resize(8, 4);
1782        assert_eq!(g.get(3, 1).glyph, 'e');
1783        assert_eq!(g.grapheme(0, 3, 1), Some("e\u{0301}"));
1784        // No ghost entry landed on some other cell at the old flat index.
1785        assert_eq!(g.grapheme(0, 7, 0), None);
1786
1787        // Shrinking past the cell drops its extras entry along with the tile.
1788        g.resize(2, 4);
1789        assert_eq!(g.grapheme(0, 3, 1), None);
1790    }
1791
1792    #[cfg(feature = "egc")]
1793    #[test]
1794    fn test_grid_diff_detects_grapheme_only_change() {
1795        // Same glyph, style, and flags on both sides -- only the combining
1796        // mark differs. A `Tile`-only diff would miss this.
1797        let mut cur = Grid::new(2, 2);
1798        let mut prev = Grid::new(2, 2);
1799        cur.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
1800        prev.write_grapheme(0, 0, 0, "e\u{0300}", Style::default());
1801
1802        let diffs: Vec<_> = cur.diff(&prev).collect();
1803        assert_eq!(diffs.len(), 1);
1804        assert_eq!(diffs[0].1, Pos::new(0, 0));
1805        assert_eq!(diffs[0].3, Some("e\u{0301}"));
1806
1807        // Identical grapheme text on both sides: no diff.
1808        let mut prev2 = Grid::new(2, 2);
1809        prev2.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
1810        assert_eq!(cur.diff(&prev2).count(), 0);
1811    }
1812
1813    #[cfg(feature = "egc")]
1814    #[test]
1815    fn test_grid_blit_preserves_extra() {
1816        let mut src = Grid::new(2, 2);
1817        src.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
1818
1819        let mut dst = Grid::new(2, 2);
1820        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
1821        assert_eq!(dst.get(0, 0).glyph, 'e');
1822        assert_eq!(dst.grapheme(0, 0, 0), Some("e\u{0301}"));
1823    }
1824
1825    #[test]
1826    fn test_grid_blit_empty_rect_is_a_no_op() {
1827        // A zero-area `src_rect` has no cells at all -- `sx0 >= sx1` should short-circuit before
1828        // touching the destination.
1829        let src = Grid::new(2, 2);
1830        let mut dst = Grid::new(2, 2);
1831        dst.put(0, 0, Tile::new('x', Style::default()));
1832        dst.blit(0, &src, Rect::new(0, 0, 0, 0), 0, 0);
1833        assert_eq!(dst.get(0, 0).glyph(), 'x');
1834        assert_eq!(dst.max_layer(), 0);
1835    }
1836
1837    #[test]
1838    fn test_grid_blit_fully_transparent_source_does_not_allocate_dst_layer() {
1839        // Perf refactor (#263): the destination layer is allocated up front, but only after
1840        // confirming the (clamped) source region has at least one non-empty tile -- matching
1841        // `put_tile`'s original allocate-on-first-write behavior for an all-transparent blit.
1842        let src = Grid::new(2, 2);
1843        let mut dst = Grid::new(2, 2);
1844        dst.blit(3, &src, Rect::new(0, 0, 2, 2), 0, 0);
1845        assert_eq!(dst.max_layer(), 0);
1846    }
1847
1848    #[test]
1849    fn test_grid_blit_skips_out_of_bounds_source_and_dest_regions() {
1850        let mut src = Grid::new(4, 4);
1851        for y in 0..4 {
1852            for x in 0..4 {
1853                src.put(x, y, Tile::new('#', Style::default()));
1854            }
1855        }
1856
1857        let mut dst = Grid::new(2, 2);
1858        // `src_rect` extends past `src`'s bounds and the destination offset pushes part of the
1859        // copied region past `dst`'s bounds too; both should be silently clamped, not panic.
1860        dst.blit(0, &src, Rect::new(2, 2, 10, 10), 1, 1);
1861        assert_eq!(dst.get(1, 1).glyph(), '#');
1862        assert_eq!(dst.get(0, 0).glyph(), ' ');
1863        assert_eq!(dst.get(0, 1).glyph(), ' ');
1864        assert_eq!(dst.get(1, 0).glyph(), ' ');
1865    }
1866
1867    #[test]
1868    fn test_grid_blit_sub_cell_offset_and_transparency() {
1869        let mut src = Grid::new(2, 2);
1870        src.put(0, 0, Tile::new('A', Style::default()));
1871        // (1, 0) and (1, 1) stay at their default (empty) tile -- transparent, should not
1872        // overwrite the destination.
1873        src.put(0, 1, Tile::new('B', Style::default()));
1874
1875        let mut dst = Grid::new(3, 3);
1876        dst.put(2, 2, Tile::new('Z', Style::default()));
1877        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 1, 1);
1878
1879        assert_eq!(dst.get(1, 1).glyph(), 'A');
1880        assert_eq!(dst.get(1, 2).glyph(), 'B');
1881        // Untouched by the (transparent) source cells at (1, 0) and (1, 1).
1882        assert_eq!(dst.get(2, 1).glyph(), ' ');
1883        assert_eq!(dst.get(2, 2).glyph(), 'Z');
1884    }
1885
1886    #[test]
1887    fn test_grid_blit_multi_layer_independent() {
1888        let mut src = Grid::new(2, 2);
1889        src.put_tile(0, 0, 0, Tile::new('a', Style::default()));
1890        src.put_tile(2, 0, 0, Tile::new('b', Style::default()));
1891
1892        let mut dst = Grid::new(2, 2);
1893        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
1894        dst.blit(2, &src, Rect::new(0, 0, 2, 2), 0, 0);
1895
1896        assert_eq!(dst.get_tile(0, 0, 0).map(Tile::glyph), Some('a'));
1897        assert_eq!(dst.get_tile(2, 0, 0).map(Tile::glyph), Some('b'));
1898        // Layer 1 was never written by either blit call.
1899        assert!(dst.get_tile(1, 0, 0).is_none());
1900    }
1901
1902    #[test]
1903    fn test_grid_blit_dest_origin_near_u16_max_does_not_wrap() {
1904        // retroglyph#268: with a plain (non-saturating) `dst_x + (sx - src_rect.left())`, an
1905        // origin this close to `u16::MAX` overflows and wraps back into a small, in-bounds
1906        // value -- silently corrupting an unrelated cell instead of being clamped out. Picked so
1907        // that `dst_x + 3` overflows `u16` and wraps to `1`, which *is* in-bounds for this small
1908        // `dst` grid: `65534u16.wrapping_add(3) == 1`.
1909        let mut src = Grid::new(4, 1);
1910        src.put(3, 0, Tile::new('Q', Style::default()));
1911
1912        let mut dst = Grid::new(4, 1);
1913        dst.blit(0, &src, Rect::new(0, 0, 4, 1), u16::MAX - 1, 0);
1914
1915        // The would-be-wrapped cell (index 1) must not have been touched.
1916        assert_eq!(dst.get(1, 0).glyph(), ' ');
1917        // No other cell was touched either -- the whole row's writes overflowed and were
1918        // skipped (dst_x saturates to u16::MAX for every column in this row).
1919        for x in 0..4 {
1920            assert_eq!(
1921                dst.get(x, 0).glyph(),
1922                ' ',
1923                "cell ({x}, 0) unexpectedly written"
1924            );
1925        }
1926    }
1927
1928    #[test]
1929    fn test_grid_blit_normal_offset_unaffected_by_overflow_fix() {
1930        // A typical, non-overflowing blit must still work exactly as before.
1931        let mut src = Grid::new(2, 2);
1932        src.put(0, 0, Tile::new('A', Style::default()));
1933        src.put(1, 1, Tile::new('B', Style::default()));
1934
1935        let mut dst = Grid::new(4, 4);
1936        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 1, 1);
1937
1938        assert_eq!(dst.get(1, 1).glyph(), 'A');
1939        assert_eq!(dst.get(2, 2).glyph(), 'B');
1940    }
1941
1942    // --- `BlendMode` / `blit_alpha` ---
1943
1944    #[cfg(feature = "gem")]
1945    #[test]
1946    fn test_blend_separable_channel_screen() {
1947        // cb = 102 (0.4), cs = 204 (0.8): screen = cb + cs - cb*cs = 0.88.
1948        assert_eq!(
1949            blend_separable_channel(SeparableBlendMode::Screen, 204, 102, 1.0),
1950            224
1951        );
1952        // t = 0.5 lerps the destination halfway to that fully mixed color.
1953        assert_eq!(
1954            blend_separable_channel(SeparableBlendMode::Screen, 204, 102, 0.5),
1955            163
1956        );
1957    }
1958
1959    #[cfg(feature = "gem")]
1960    #[test]
1961    fn test_blend_separable_channel_dodge() {
1962        // cb = 51 (0.2), cs = 204 (0.8): min(1, 0.2 / 0.2) saturates to 1.0.
1963        assert_eq!(
1964            blend_separable_channel(SeparableBlendMode::ColorDodge, 204, 51, 1.0),
1965            255
1966        );
1967        assert_eq!(
1968            blend_separable_channel(SeparableBlendMode::ColorDodge, 204, 51, 0.5),
1969            153
1970        );
1971    }
1972
1973    #[cfg(feature = "gem")]
1974    #[test]
1975    fn test_blend_separable_channel_burn() {
1976        // cb = 204 (0.8), cs = 51 (0.2): 1 - min(1, 0.2 / 0.2) bottoms out at 0.0.
1977        assert_eq!(
1978            blend_separable_channel(SeparableBlendMode::ColorBurn, 51, 204, 1.0),
1979            0
1980        );
1981        assert_eq!(
1982            blend_separable_channel(SeparableBlendMode::ColorBurn, 51, 204, 0.5),
1983            102
1984        );
1985    }
1986
1987    #[cfg(feature = "gem")]
1988    #[test]
1989    fn test_blend_separable_channel_overlay() {
1990        // cb = 51 (0.2, the <= 0.5 branch): 2 * cb * cs.
1991        assert_eq!(
1992            blend_separable_channel(SeparableBlendMode::Overlay, 204, 51, 1.0),
1993            82
1994        );
1995        assert_eq!(
1996            blend_separable_channel(SeparableBlendMode::Overlay, 204, 51, 0.5),
1997            66
1998        );
1999        // cb = 204 (0.8, the > 0.5 branch): 1 - 2 * (1 - cb) * (1 - cs).
2000        assert_eq!(
2001            blend_separable_channel(SeparableBlendMode::Overlay, 51, 204, 1.0),
2002            173
2003        );
2004    }
2005
2006    /// End-to-end through `blit_alpha`, not just the per-channel helper: proves `BlendMode`
2007    /// actually reaches `blend_fg`/`blend_bg` and lands on the destination tile's style.
2008    #[cfg(feature = "gem")]
2009    #[test]
2010    fn test_grid_blit_alpha_screen_blends_fg() {
2011        let mut src = Grid::new(1, 1);
2012        src.put(
2013            0,
2014            0,
2015            Tile::default()
2016                .with_glyph('X')
2017                .with_style(Style::new().fg(Color::Rgb {
2018                    r: 204,
2019                    g: 204,
2020                    b: 204,
2021                })),
2022        );
2023
2024        let mut dst = Grid::new(1, 1);
2025        dst.put(
2026            0,
2027            0,
2028            Tile::default()
2029                .with_glyph('_')
2030                .with_style(Style::new().fg(Color::Rgb {
2031                    r: 102,
2032                    g: 102,
2033                    b: 102,
2034                })),
2035        );
2036
2037        dst.blit_alpha(
2038            0,
2039            &src,
2040            Rect::new(0, 0, 1, 1),
2041            0,
2042            0,
2043            BlendMode::Screen,
2044            1.0,
2045            1.0,
2046        );
2047        assert_eq!(
2048            dst.get(0, 0).style.fg,
2049            Color::Rgb {
2050                r: 224,
2051                g: 224,
2052                b: 224
2053            }
2054        );
2055    }
2056
2057    /// retroglyph#268: same wraparound guard as `blit`'s
2058    /// `test_grid_blit_dest_origin_near_u16_max_does_not_wrap`, but through `blit_alpha`'s
2059    /// separate `dst_x`/`dst_y` computation.
2060    #[cfg(feature = "gem")]
2061    #[test]
2062    fn test_grid_blit_alpha_dest_origin_near_u16_max_does_not_wrap() {
2063        let mut src = Grid::new(4, 1);
2064        src.put(3, 0, Tile::new('Q', Style::default()));
2065
2066        let mut dst = Grid::new(4, 1);
2067        dst.blit_alpha(
2068            0,
2069            &src,
2070            Rect::new(0, 0, 4, 1),
2071            u16::MAX - 1,
2072            0,
2073            BlendMode::Linear,
2074            1.0,
2075            1.0,
2076        );
2077
2078        for x in 0..4 {
2079            assert_eq!(
2080                dst.get(x, 0).glyph(),
2081                ' ',
2082                "cell ({x}, 0) unexpectedly written"
2083            );
2084        }
2085    }
2086
2087    /// `BlendMode::Linear` at `t == 0.0` keeps the destination and at `t == 1.0` uses the source
2088    /// -- matching `blit_alpha`'s doc comment (this direction was actually inverted before this
2089    /// change: the underlying `gem::rgb::Lerp` call had `src`/`dst` swapped, so `t == 0.0` used
2090    /// to return `src` and `t == 1.0` returned `dst`. No prior tests covered `blit_alpha`, so
2091    /// this had shipped unnoticed).
2092    #[cfg(feature = "gem")]
2093    #[test]
2094    fn test_grid_blit_alpha_linear_direction() {
2095        let mut src = Grid::new(1, 1);
2096        src.put(
2097            0,
2098            0,
2099            Tile::default()
2100                .with_glyph('X')
2101                .with_style(Style::new().fg(Color::Rgb {
2102                    r: 255,
2103                    g: 255,
2104                    b: 255,
2105                })),
2106        );
2107
2108        let dst_color = Color::Rgb { r: 0, g: 0, b: 0 };
2109        let at = |t: f32| {
2110            let mut dst = Grid::new(1, 1);
2111            dst.put(
2112                0,
2113                0,
2114                Tile::default()
2115                    .with_glyph('_')
2116                    .with_style(Style::new().fg(dst_color)),
2117            );
2118            dst.blit_alpha(
2119                0,
2120                &src,
2121                Rect::new(0, 0, 1, 1),
2122                0,
2123                0,
2124                BlendMode::Linear,
2125                t,
2126                1.0,
2127            );
2128            dst.get(0, 0).style.fg
2129        };
2130
2131        assert_eq!(at(0.0), dst_color);
2132        assert_eq!(
2133            at(1.0),
2134            Color::Rgb {
2135                r: 255,
2136                g: 255,
2137                b: 255
2138            }
2139        );
2140        let Color::Rgb { r, g, b } = at(0.5) else {
2141            panic!("expected Color::Rgb");
2142        };
2143        assert!(r > 0 && r < 255, "expected a mid-gray, got {r}");
2144        assert_eq!(r, g);
2145        assert_eq!(g, b);
2146    }
2147
2148    /// Every `BlendMode` preserves `Color::Default` and passes non-RGB colors through unblended,
2149    /// same as the pre-existing `Linear` behavior.
2150    #[cfg(feature = "gem")]
2151    #[test]
2152    fn test_blend_color_non_rgb_passthrough_all_modes() {
2153        for mode in [
2154            BlendMode::Linear,
2155            BlendMode::Screen,
2156            BlendMode::Dodge,
2157            BlendMode::Burn,
2158            BlendMode::Overlay,
2159        ] {
2160            assert_eq!(
2161                blend_color(mode, Color::Default, Color::Rgb { r: 1, g: 2, b: 3 }, 0.5),
2162                Color::Default
2163            );
2164            assert_eq!(
2165                blend_color(mode, Color::BLACK, Color::WHITE, 0.5),
2166                Color::BLACK
2167            );
2168        }
2169    }
2170
2171    #[cfg(feature = "egc")]
2172    #[test]
2173    fn test_grid_clone_preserves_extra() {
2174        let mut g = Grid::new(2, 2);
2175        g.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
2176        let cloned = g.clone();
2177        assert_eq!(cloned.grapheme(0, 0, 0), Some("e\u{0301}"));
2178    }
2179
2180    #[cfg(feature = "egc")]
2181    #[test]
2182    fn test_grid_flatten_into_carries_extra_from_higher_layer() {
2183        let mut g = Grid::new(2, 2);
2184        g.write_grapheme(1, 0, 0, "e\u{0301}", Style::default());
2185        let mut flattened = Grid::new(2, 2);
2186        g.flatten_into(&mut flattened);
2187        assert_eq!(flattened.get(0, 0).glyph, 'e');
2188        assert_eq!(flattened.grapheme(0, 0, 0), Some("e\u{0301}"));
2189    }
2190
2191    #[test]
2192    fn test_grid_flatten_into_single_layer_is_a_plain_copy() {
2193        let mut g = Grid::new(2, 2);
2194        g.put(0, 0, Tile::new('a', Style::default()));
2195        g.put(1, 1, Tile::new('b', Style::default()));
2196        let mut flattened = Grid::new(2, 2);
2197        g.flatten_into(&mut flattened);
2198        assert_eq!(flattened.get(0, 0).glyph(), 'a');
2199        assert_eq!(flattened.get(1, 1).glyph(), 'b');
2200        assert_eq!(flattened.get(1, 0).glyph(), ' ');
2201    }
2202
2203    #[test]
2204    fn test_grid_flatten_into_higher_layer_overwrites_glyph_and_fg_but_not_default_bg() {
2205        let mut g = Grid::new(1, 1);
2206        g.put(
2207            0,
2208            0,
2209            Tile::new('a', Style::new().fg(Color::BLACK).bg(Color::WHITE)),
2210        );
2211        g.put_tile(1, 0, 0, Tile::new('b', Style::new().fg(Color::WHITE)));
2212
2213        let mut flattened = Grid::new(1, 1);
2214        g.flatten_into(&mut flattened);
2215        let out = flattened.get(0, 0);
2216        assert_eq!(out.glyph(), 'b');
2217        assert_eq!(out.style().fg, Color::WHITE);
2218        // Layer 1's tile has a `Default` background, so layer 0's background shows through.
2219        assert_eq!(out.style().bg, Color::WHITE);
2220    }
2221
2222    #[test]
2223    fn test_grid_flatten_into_empty_higher_layer_cell_is_transparent() {
2224        let mut g = Grid::new(2, 1);
2225        g.put(0, 0, Tile::new('a', Style::default()));
2226        g.put(1, 0, Tile::new('b', Style::default()));
2227        // Only touch (0, 0) on layer 1; (1, 0) on layer 1 stays at its default (EMPTY) tile.
2228        g.put_tile(1, 0, 0, Tile::new('c', Style::default()));
2229
2230        let mut flattened = Grid::new(2, 1);
2231        g.flatten_into(&mut flattened);
2232        assert_eq!(flattened.get(0, 0).glyph(), 'c');
2233        // Untouched by the transparent layer-1 cell -- layer 0's glyph shows through.
2234        assert_eq!(flattened.get(1, 0).glyph(), 'b');
2235    }
2236
2237    #[test]
2238    fn test_grid_flatten_into_multi_layer_stale_dst_extra_is_cleared() {
2239        // `dst` may be a reused scratch buffer with stale content from a previous frame (see
2240        // `Terminal::present`) -- `flatten_into` must fully overwrite it, not merge with it.
2241        let mut flattened = Grid::new(1, 1);
2242        flattened.put(0, 0, Tile::new('z', Style::default()));
2243
2244        let g = Grid::new(1, 1);
2245        g.flatten_into(&mut flattened);
2246        assert_eq!(flattened.get(0, 0).glyph(), ' ');
2247    }
2248}
2249
2250/// Property tests for the wide-character (EGC) grid invariants.
2251///
2252/// These exercise the trickiest code in the crate — `write_grapheme` and its
2253/// `clear_overlap` helper — by hammering a small grid with random sequences of
2254/// narrow, wide, combining, and emoji graphemes and checking that the
2255/// wide-character bookkeeping never desyncs.
2256#[cfg(all(test, feature = "egc"))]
2257mod egc_proptests {
2258    use super::*;
2259    use crate::style::Style;
2260    use proptest::prelude::*;
2261
2262    const W: u16 = 8;
2263    const H: u16 = 4;
2264
2265    /// Narrow, wide (CJK), combining-mark, and wide-emoji graphemes.
2266    const GRAPHEMES: &[&str] = &["a", "\u{4e2d}", "e\u{0301}", "\u{1f600}"];
2267
2268    /// Every `WIDE_CHAR` has its spacer to the right, every `WIDE_CHAR_SPACER`
2269    /// has its lead to the left, and no cell is both.
2270    fn assert_wide_invariants(grid: &Grid) {
2271        for y in 0..grid.height() {
2272            for x in 0..grid.width() {
2273                let flags = grid.get(x, y).flags();
2274                let lead = flags.contains(TileFlags::WIDE_CHAR);
2275                let spacer = flags.contains(TileFlags::WIDE_CHAR_SPACER);
2276
2277                assert!(
2278                    !(lead && spacer),
2279                    "cell ({x}, {y}) is both wide lead and spacer"
2280                );
2281
2282                if lead {
2283                    assert!(x + 1 < grid.width(), "wide lead at ({x}, {y}) has no room");
2284                    assert!(
2285                        grid.get(x + 1, y)
2286                            .flags()
2287                            .contains(TileFlags::WIDE_CHAR_SPACER),
2288                        "wide lead at ({x}, {y}) is missing its spacer"
2289                    );
2290                }
2291
2292                if spacer {
2293                    assert!(x > 0, "orphan spacer at ({x}, {y}) (no cell to the left)");
2294                    assert!(
2295                        grid.get(x - 1, y).flags().contains(TileFlags::WIDE_CHAR),
2296                        "orphan spacer at ({x}, {y}) (left cell is not a wide lead)"
2297                    );
2298                }
2299            }
2300        }
2301    }
2302
2303    proptest! {
2304        #[test]
2305        fn wide_char_bookkeeping_never_desyncs(
2306            ops in prop::collection::vec(
2307                (0u16..W, 0u16..H, 0usize..GRAPHEMES.len()),
2308                0..64,
2309            ),
2310        ) {
2311            let mut grid = Grid::new(W, H);
2312            for (x, y, gi) in ops {
2313                grid.write_grapheme(0, x, y, GRAPHEMES[gi], Style::default());
2314                // The invariant must hold after every single write, not just
2315                // at the end — an intermediate orphan would be a real bug.
2316                assert_wide_invariants(&grid);
2317            }
2318        }
2319    }
2320}