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}