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