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}