Skip to main content

retroglyph_core/grid/layers/
mod.rs

1//! `Grid`'s per-cell tile access: [`Grid::put_tile`], [`Grid::fill_region`], [`Grid::tile`], and
2//! [`Grid::tile_mut`], plus per-layer allocation lifecycle ([`Grid::deallocate_layer`],
3//! [`Grid::layer_is_empty`]).
4//!
5//! Per-cell tint/grapheme storage lives in `tint`, cross-grid copies in `blit`, and whole-grid
6//! iteration/compositing in `flatten`.
7
8mod blit;
9mod flatten;
10mod tint;
11
12#[cfg(test)]
13use super::TileExtra;
14use super::{Grid, Pos, Rect, to_grixy_pos};
15#[cfg(test)]
16use crate::color::{Style, Tint};
17use crate::tile::{Tile, TileFlags};
18use alloc::vec::Vec;
19use grixy::ops::{ExactSizeGrid, GridRead, GridWrite};
20
21impl Grid {
22    /// Writes a tile to `layer` at `pos`, honoring `tile`'s own precomputed
23    /// [`width`](Tile::width): a fresh 2-column tile also gets a
24    /// [`TileFlags::WIDE_CHAR_SPACER`] at `pos.x + 1`, the same pairing
25    /// [`write_grapheme`](Self::write_grapheme) writes, on every feature combination (`Tile::width`
26    /// comes from `unicode-width`, an unconditional dependency, not the `egc`-gated
27    /// `unicode-segmentation`).
28    ///
29    /// Allocates the layer if it has not been written to yet. Returns `None` if `pos` is out of
30    /// bounds, or if a fresh `tile` is 2 columns wide and `pos.x + 1` (the spacer's column) is
31    /// not: the same last-column refusal `write_grapheme` makes, rather than leaving an orphaned
32    /// primary cell with no spacer.
33    ///
34    /// To read back, use [`tile`](Self::tile).
35    ///
36    /// # Replaying an already-resolved tile
37    ///
38    /// The wide-char synthesis above only applies to a **fresh** `tile`: one built through public
39    /// API ([`Tile::new`], [`with_glyph`](Tile::with_glyph), [`Tile::default`]), which can never
40    /// carry [`TileFlags::WIDE_CHAR`]/[`TileFlags::WIDE_CHAR_SPACER`] (both `pub(crate)`-only to
41    /// set). A `tile` that already carries either flag is, by construction, an already-resolved
42    /// tile read back out of some grid (e.g. [`Headless`](crate::backend::Headless) replaying a
43    /// [`DrawCell`](crate::backend::DrawCell) stream verbatim into its own copy) rather than a new
44    /// glyph placement, and is written through exactly as given, with no bounds refusal, spacer
45    /// synthesis, or overlap clearing of its own: those already happened on the call that
46    /// produced it, and re-running them here would (for a spacer tile specifically) clear the
47    /// *other* half of the very same wide pair being replayed, mistaking it for some unrelated
48    /// write landing on that spacer.
49    ///
50    /// Any tile written this way has its extra grapheme text cleared, since a
51    /// caller-constructed [`Tile`] can never legitimately carry
52    /// [`TileFlags::HAS_EXTRA`] (the flag is crate-private). Internal callers
53    /// that need to preserve EGC text across a copy (e.g. [`blit`](Self::blit))
54    /// follow up with a direct extras-table write. Any multi-cell span the
55    /// cell belongs to is cleared first, so a write can never leave an anchor
56    /// pointing at cells it no longer owns; a fresh wide `tile` additionally clears any wide
57    /// character it would partially overwrite, the same as `write_grapheme`.
58    ///
59    /// `tile`'s own [`TileFlags::SPAN_ANCHOR`]/[`TileFlags::SPAN_COVERED`] role, if it has one, is
60    /// stripped too, for the same reason as `HAS_EXTRA`: those flags are crate-private, so a
61    /// caller-supplied `tile` (fresh or replayed, e.g. read back via [`tile`](Self::tile)) can
62    /// only carry one by copying it out of some other cell, and writing it through verbatim would
63    /// plant an anchor with no covered cells (or a covered cell with no anchor) at `pos` --
64    /// exactly the dangling footprint [`write_span`](Self::write_span)'s own doc calls a broken
65    /// invariant. [`blit`](Self::blit) makes the same call for a copied span it cannot preserve
66    /// whole.
67    pub fn put_tile(&mut self, layer: u8, pos: impl Into<Pos>, mut tile: Tile) -> Option<()> {
68        let pos = pos.into();
69
70        // See "Replaying an already-resolved tile" above: only a tile that couldn't have come
71        // from a public constructor gets treated as verbatim, pre-resolved storage.
72        let fresh = !tile
73            .flags
74            .intersects(TileFlags::WIDE_CHAR | TileFlags::WIDE_CHAR_SPACER);
75        let width = if fresh { tile.width() } else { 1 };
76
77        // A 2-column tile needs a spacer at `pos.x + 1`: refuse rather than leave an orphaned
78        // primary cell, matching `write_grapheme`'s own last-column refusal.
79        if width == 2 && pos.x.saturating_add(1) >= self.width {
80            return None;
81        }
82
83        // Refuse out-of-bounds before touching `clear_overlap`/`layer_or_alloc` below: neither
84        // should allocate the layer for a write that is about to be refused anyway (retroglyph#1012).
85        if pos.x >= self.width || pos.y >= self.height {
86            return None;
87        }
88
89        self.clear_span_overlap(layer, pos.x, pos.y, width.max(1));
90        if fresh {
91            self.clear_overlap(layer, pos.x, pos.y, width.max(1));
92        }
93
94        // Capture the grid width before borrowing `self` mutably below (same reason
95        // `write_grapheme` does): `self.width` isn't reachable once `lb` holds `&mut self`.
96        let grid_w = usize::from(self.width);
97        let gpos = to_grixy_pos(pos);
98        let idx = usize::from(pos.y) * grid_w + usize::from(pos.x);
99        let lb = self.layer_or_alloc(layer);
100        debug_assert!(lb.buf.contains(gpos), "bounds already checked above");
101        lb.extras.remove(&idx);
102        tile.flags.remove(TileFlags::HAS_EXTRA);
103        tile.clear_span();
104        if width == 2 {
105            tile.flags.insert(TileFlags::WIDE_CHAR);
106        }
107        let style = tile.style;
108        lb.buf[gpos] = tile;
109
110        if width == 2 {
111            // The last-column refusal above guarantees `pos.x + 1` is in bounds.
112            let spacer_x = pos.x + 1;
113            let spacer_gpos = to_grixy_pos(Pos::new(spacer_x, pos.y));
114            let spacer_idx = usize::from(pos.y) * grid_w + usize::from(spacer_x);
115            lb.extras.remove(&spacer_idx);
116            let spacer = &mut lb.buf[spacer_gpos];
117            spacer.glyph = ' ';
118            spacer.style = style;
119            spacer.width = 0;
120            spacer.flags = TileFlags::WIDE_CHAR_SPACER;
121        }
122        Some(())
123    }
124
125    /// Fills every cell of `rect` (clipped to this grid) on `layer` with `tile`.
126    ///
127    /// The batch counterpart to calling [`put_tile`](Self::put_tile) once per cell of `rect`:
128    /// same result, but the span/extras bookkeeping and the layer allocation each happen once for
129    /// the whole region rather than once per cell, and the write itself is one
130    /// [`fill_rect_solid`](grixy::ops::GridWrite::fill_rect_solid) call instead of `rect.width() *
131    /// rect.height()` individual cell writes. [`Surface::fill_rect`](crate::surface::Surface::fill_rect),
132    /// [`Surface::clear`](crate::surface::Surface::clear), and
133    /// [`Surface::clear_region`](crate::surface::Surface::clear_region) are built on this.
134    ///
135    /// A no-op if `rect` (after clipping to the grid) is empty. As with [`put_tile`](Self::put_tile), `tile` can
136    /// never legitimately carry [`TileFlags::HAS_EXTRA`] (the flag is crate-private), so every
137    /// cell's own extras entry, if any, is dropped rather than orphaned. `tile`'s own span role,
138    /// if it has one, is stripped for the same reason: see `put_tile`'s doc for why writing it
139    /// through verbatim would plant a dangling anchor or an anchorless covered cell in every cell
140    /// of `rect`.
141    ///
142    /// Also a no-op if `tile.width() != 1`: unlike `put_tile`, this does not synthesize
143    /// [`TileFlags::WIDE_CHAR`]/[`TileFlags::WIDE_CHAR_SPACER`] lead/spacer pairs across the
144    /// region, so a wide `tile` (or a zero-width one) would otherwise leave every cell in `rect`
145    /// carrying the same glyph with no spacer, desyncing any cursor-advancing consumer that
146    /// trusts `Tile::width`/`TileFlags::WIDE_CHAR_SPACER` to track column position. Callers with a
147    /// wide glyph need a per-cell [`put_tile`](Self::put_tile) loop instead; see
148    /// [`Surface::fill_rect`](crate::surface::Surface::fill_rect)'s own fallback.
149    ///
150    /// ```
151    /// use retroglyph_core::color::Style;
152    /// use retroglyph_core::grid::{Grid, Pos, Rect};
153    /// use retroglyph_core::tile::Tile;
154    ///
155    /// let mut grid = Grid::new(4, 4);
156    /// grid.fill_region(0, Rect::new(1, 1, 2, 2), Tile::new('#', Style::default()));
157    ///
158    /// assert_eq!(grid[Pos::new(1, 1)].glyph(), '#');
159    /// assert_eq!(grid[Pos::new(2, 2)].glyph(), '#');
160    /// assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
161    /// ```
162    pub fn fill_region(&mut self, layer: u8, rect: Rect, mut tile: Tile) {
163        let bounds = self.size().to_rect();
164        let rect = rect.intersect(bounds);
165        if rect.is_empty() {
166            return;
167        }
168
169        // See this method's own doc comment: a wide `tile` would need a lead/spacer pair
170        // synthesized per cell, which this batch path does not do. Refuse rather than write a
171        // row of look-alike wide glyphs with no spacers.
172        if tile.width() != 1 {
173            return;
174        }
175
176        // Clear every span this fill would partially overwrite in one pass over the whole rect
177        // (see `clear_span_overlap_rect`), rather than once per row: a span spanning several rows
178        // of `rect` would otherwise be collected, and fully reset, once per row it occupies
179        // (retroglyph#1020). A no-op on a grid that has never used spans.
180        self.clear_span_overlap_rect(layer, rect.left(), rect.top(), rect.width(), rect.height());
181        // Wide-char overlap has no region-scoped variant (yet): still one call per row, but that
182        // remains O(rows) since it never re-collects a growing anchor set. Neither call is gated
183        // on `egc`: `put_tile` writes a `WIDE_CHAR`/`WIDE_CHAR_SPACER` pair on every feature
184        // combination (see its own doc comment), so a fill that can land inside one has to clean
185        // it up regardless of `egc`.
186        for row in rect.rows() {
187            self.clear_overlap(layer, row.left(), row.top(), row.width());
188        }
189
190        // `tile` is a caller-constructed `Tile` (see `put_tile`'s own doc comment for why that
191        // can never carry `HAS_EXTRA`), so every cell's own extras entry is now stale. Drop them
192        // per row rather than scanning the whole side table: bounded by the region, not by
193        // however much of the layer happens to carry extras elsewhere.
194        tile.flags.remove(TileFlags::HAS_EXTRA);
195        tile.clear_span();
196        let grid_w = usize::from(self.width);
197        let lb = self.layer_or_alloc(layer);
198        for y in rect.top()..rect.bottom() {
199            let row_start = usize::from(y) * grid_w + usize::from(rect.left());
200            let row_end = row_start + usize::from(rect.width());
201            let stale: Vec<usize> = lb
202                .extras
203                .range(row_start..row_end)
204                .map(|(&idx, _)| idx)
205                .collect();
206            for idx in stale {
207                lb.extras.remove(&idx);
208            }
209        }
210
211        let dst = grixy::core::Rect::new(
212            usize::from(rect.left()),
213            usize::from(rect.top()),
214            usize::from(rect.width()),
215            usize::from(rect.height()),
216        );
217        lb.buf.fill_rect_solid(dst, tile);
218    }
219
220    /// Reads a tile on `layer` at `pos`, or `None` if the layer is
221    /// unallocated or `pos` is out of bounds.
222    #[must_use]
223    pub fn tile(&self, layer: u8, pos: impl Into<Pos>) -> Option<&Tile> {
224        let pos = to_grixy_pos(pos.into());
225        self.layer(layer)?.buf.get(pos)
226    }
227
228    /// Mutably borrows a tile on `layer` at `pos`, or `None` if the layer is
229    /// unallocated or `pos` is out of bounds.
230    ///
231    /// This hands out a direct `&mut Tile`, so it cannot intercept a write the way
232    /// [`put_tile`](Self::put_tile) does: it does not clear a multi-cell span `pos` belongs to,
233    /// and it does not clear grapheme extras stored for the tile. Call
234    /// [`clear_span`](Self::clear_span) first if `pos` may belong to a span.
235    pub fn tile_mut(&mut self, layer: u8, pos: impl Into<Pos>) -> Option<&mut Tile> {
236        let pos = to_grixy_pos(pos.into());
237        self.layers
238            .get_mut(usize::from(layer))?
239            .as_mut()?
240            .buf
241            .get_mut(pos)
242    }
243
244    /// [`tile_mut`](Self::tile_mut), allocating `layer` if it is not allocated yet.
245    ///
246    /// `None` only when `pos` is out of bounds, which (as in [`set_extra`](Self::set_extra))
247    /// leaves `layer` unallocated rather than allocating a buffer nothing can be written to.
248    /// Shares `tile_mut`'s caveat: this is a raw `&mut Tile`, so it performs none of
249    /// [`put_tile`](Self::put_tile)'s span or overlap repair.
250    pub(crate) fn tile_mut_or_alloc(&mut self, layer: u8, pos: Pos) -> Option<&mut Tile> {
251        if pos.x >= self.width || pos.y >= self.height {
252            return None;
253        }
254        let gpos = to_grixy_pos(pos);
255        self.layer_or_alloc(layer).buf.get_mut(gpos)
256    }
257
258    /// Deallocates `layer`, freeing its buffer entirely rather than clearing its content in
259    /// place.
260    ///
261    /// Unlike [`clear_all`](Self::clear_all) (and a per-layer clear via [`cells_mut`](Self::cells_mut)),
262    /// which empty a layer's cells but leave it allocated, this drops the [`LayerBuf`] itself, the
263    /// same table slot [`layer_or_alloc`](Self::layer_or_alloc) fills in on first write. That
264    /// matters because [`max_layer`](Self::max_layer) only ever grows on write (see its own doc):
265    /// a layer that is merely cleared still counts toward it, while a deallocated one does not,
266    /// letting `max_layer` fall back down once every layer above it is also gone. This is what lets
267    /// [`crate::terminal::Terminal::drop_layer`] undo a layer's permanent allocation and, once every layer
268    /// above 0 is dropped, put [`crate::terminal::Terminal::present`] back on its single-layer fast path
269    /// (retroglyph#1028).
270    ///
271    /// If `layer` was the current `max_layer`, the table is rescanned downward for the next
272    /// highest still-allocated layer, `O(max_layer)` in the worst case; deallocating any other
273    /// layer is `O(1)`. Deallocating an already-unallocated layer (or one past the table's
274    /// current length) is a no-op.
275    ///
276    /// # Panics
277    ///
278    /// Panics if `layer` is 0: layer 0 is always allocated (see [`Grid::new`]) and can never be
279    /// deallocated.
280    pub(crate) fn deallocate_layer(&mut self, layer: u8) {
281        assert_ne!(
282            layer, 0,
283            "layer 0 is always allocated and cannot be deallocated"
284        );
285        let idx = usize::from(layer);
286        if idx >= self.layers.len() {
287            return;
288        }
289        self.layers[idx] = None;
290        if layer == self.max_layer {
291            self.max_layer = (0..layer)
292                .rev()
293                .find(|&id| self.layers[usize::from(id)].is_some())
294                .unwrap_or(0);
295        }
296    }
297
298    /// Whether `layer` is unallocated, or allocated but every tile on it is untouched (see
299    /// [`Tile::is_empty`]).
300    ///
301    /// Used by [`crate::terminal::Terminal::drop_layer`]'s deferred deallocation to tell a layer that is
302    /// still genuinely empty (safe to free) apart from one that was redrawn after the drop was
303    /// requested (some tile is no longer empty), which must cancel the drop instead of silently
304    /// discarding content the app just wrote.
305    pub(crate) fn layer_is_empty(&self, layer: u8) -> bool {
306        self.layer(layer)
307            .is_none_or(|lb| lb.buf.as_ref().iter().all(Tile::is_empty))
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn put_tile_then_index_reads_back_the_written_glyph() {
317        let mut grid = Grid::new(10, 10);
318        let tile = Tile::default().with_glyph('X');
319
320        grid.put_tile(0, (5, 5), tile);
321        assert_eq!(grid[Pos::new(5, 5)].glyph(), 'X');
322    }
323
324    #[test]
325    fn put_tile_then_tile_reads_back_the_written_glyph_and_out_of_bounds_is_none() {
326        let mut grid = Grid::new(10, 10);
327        let tile = Tile::default().with_glyph('Y');
328
329        assert!(grid.put_tile(0, (5, 5), tile).is_some());
330        assert_eq!(grid.tile(0, (5, 5)).unwrap().glyph(), 'Y');
331
332        assert!(grid.tile(0, (10, 0)).is_none());
333        assert!(grid.put_tile(0, (0, 10), Tile::default()).is_none());
334    }
335
336    #[test]
337    fn put_tile_out_of_bounds_returns_none() {
338        let mut grid = Grid::new(10, 10);
339        assert!(grid.put_tile(0, (10, 0), Tile::default()).is_none());
340    }
341
342    #[test]
343    fn layers_yields_every_allocated_cell_in_layer_then_row_major_order() {
344        let mut grid = Grid::new(2, 2);
345        grid.put_tile(0, (1, 0), Tile::default().with_glyph('A'));
346        grid.put_tile(2, (0, 1), Tile::default().with_glyph('B'));
347
348        let cells: Vec<_> = grid
349            .layers()
350            .map(|c| (c.layer, c.pos, c.tile.glyph()))
351            .collect();
352
353        // Layer 1 is never allocated, so it's skipped entirely; layer 0's four cells (row-major)
354        // come before layer 2's four cells.
355        assert_eq!(
356            cells,
357            [
358                (0, Pos::new(0, 0), ' '),
359                (0, Pos::new(1, 0), 'A'),
360                (0, Pos::new(0, 1), ' '),
361                (0, Pos::new(1, 1), ' '),
362                (2, Pos::new(0, 0), ' '),
363                (2, Pos::new(1, 0), ' '),
364                (2, Pos::new(0, 1), 'B'),
365                (2, Pos::new(1, 1), ' '),
366            ]
367        );
368    }
369
370    #[test]
371    fn layer_zero_always_allocated() {
372        let g = Grid::new(5, 5);
373        assert!(g.layer(0).is_some());
374        for id in 1u8..=5 {
375            assert!(g.layer(id).is_none(), "layer {id} should be None");
376        }
377    }
378
379    #[test]
380    fn put_tile_allocates_layer() {
381        let mut g = Grid::new(5, 5);
382        g.put_tile(3, (0, 0), Tile::new('@', Style::default()));
383        assert!(g.layer(3).is_some());
384        assert!(g.layer(4).is_none());
385    }
386
387    #[test]
388    fn new_layer_table_starts_at_a_single_slot() {
389        // retroglyph#264: the layer-table `Vec` itself should start small (a single slot for
390        // layer 0), not pre-allocate all 256 possible slots up front.
391        let g = Grid::new(5, 5);
392        assert_eq!(g.layers.len(), 1);
393        assert_eq!(g.max_layer(), 0);
394    }
395
396    #[test]
397    fn layer_or_alloc_grows_table_lazily_to_the_written_id() {
398        let mut g = Grid::new(5, 5);
399        g.put_tile(10, (0, 0), Tile::new('@', Style::default()));
400        // The table grows to exactly `id + 1` slots, not all 256.
401        assert_eq!(g.layers.len(), 11);
402        assert_eq!(g.max_layer(), 10);
403        assert!(g.layer(10).is_some());
404        for id in 1u8..10 {
405            assert!(g.layer(id).is_none(), "layer {id} should be None");
406        }
407    }
408
409    #[test]
410    fn layer_beyond_table_length_reads_as_none() {
411        // A layer id past the current table length (never written) must read identically to an
412        // in-bounds `None` slot, not panic or error.
413        let g = Grid::new(5, 5);
414        assert_eq!(g.layers.len(), 1);
415        assert!(g.layer(255).is_none());
416        assert!(g.tile(255, (0, 0)).is_none());
417        assert!(crate::grid::grapheme_at(&g, 255, 0, 0).is_none());
418    }
419
420    #[test]
421    fn clear_beyond_table_length_is_a_no_op() {
422        // Clearing an id past the current table length must not panic: it's equivalent to
423        // clearing an unallocated in-bounds layer (does nothing).
424        let mut g = Grid::new(5, 5);
425        g.clear(255);
426        assert_eq!(g.layers.len(), 1);
427    }
428
429    #[test]
430    fn put_tile_out_of_bounds_does_not_allocate_the_layer() {
431        // retroglyph#1012: a refused out-of-bounds write must not allocate the layer or raise
432        // `max_layer`, matching `put_tile`'s own "does nothing" contract.
433        let mut g = Grid::new(4, 4);
434        assert_eq!(
435            g.put_tile(200, (99, 99), Tile::new('x', Style::default())),
436            None
437        );
438        assert_eq!(g.max_layer(), 0);
439        assert!(g.tile(200, (0, 0)).is_none());
440        assert!(g.layer(200).is_none());
441    }
442
443    #[test]
444    fn set_tint_out_of_bounds_does_not_allocate_the_layer() {
445        // retroglyph#1012: same guarantee as `put_tile`, for the `set_tint` write path.
446        let mut g = Grid::new(4, 4);
447        g.set_tint(200, 99, 99, Tint::multiply(1, 2, 3));
448        assert_eq!(g.max_layer(), 0);
449        assert!(g.layer(200).is_none());
450    }
451
452    #[test]
453    #[should_panic(expected = "layer 0 is always allocated")]
454    fn deallocate_layer_zero_panics() {
455        let mut g = Grid::new(4, 4);
456        g.deallocate_layer(0);
457    }
458
459    #[test]
460    fn deallocate_layer_the_top_layer_lowers_max_layer() {
461        let mut g = Grid::new(4, 4);
462        g.put_tile(3, (0, 0), Tile::new('@', Style::default()));
463        assert_eq!(g.max_layer(), 3);
464
465        g.deallocate_layer(3);
466        assert_eq!(g.max_layer(), 0);
467        assert!(g.layer(3).is_none());
468    }
469
470    #[test]
471    fn deallocate_layer_rescans_down_to_the_next_allocated_layer() {
472        let mut g = Grid::new(4, 4);
473        g.put_tile(2, (0, 0), Tile::new('a', Style::default()));
474        g.put_tile(5, (0, 0), Tile::new('b', Style::default()));
475        assert_eq!(g.max_layer(), 5);
476
477        g.deallocate_layer(5);
478        assert_eq!(g.max_layer(), 2);
479        assert!(g.layer(5).is_none());
480        assert!(g.layer(2).is_some());
481    }
482
483    #[test]
484    fn deallocate_layer_below_the_top_leaves_max_layer_unchanged() {
485        let mut g = Grid::new(4, 4);
486        g.put_tile(2, (0, 0), Tile::new('a', Style::default()));
487        g.put_tile(5, (0, 0), Tile::new('b', Style::default()));
488
489        g.deallocate_layer(2);
490        assert_eq!(g.max_layer(), 5);
491        assert!(g.layer(2).is_none());
492        assert!(g.layer(5).is_some());
493    }
494
495    #[test]
496    fn deallocate_layer_reads_back_as_unallocated() {
497        let mut g = Grid::new(4, 4);
498        g.put_tile(1, (0, 0), Tile::new('@', Style::default()));
499        g.deallocate_layer(1);
500        assert!(g.tile(1, (0, 0)).is_none());
501    }
502
503    #[test]
504    fn deallocate_layer_already_unallocated_is_a_no_op() {
505        let mut g = Grid::new(4, 4);
506        g.deallocate_layer(1);
507        assert_eq!(g.max_layer(), 0);
508
509        // Past the table's current length entirely.
510        g.deallocate_layer(200);
511        assert_eq!(g.max_layer(), 0);
512    }
513
514    #[test]
515    fn set_extra_out_of_bounds_does_not_allocate_the_layer() {
516        // retroglyph#1012: same guarantee as `put_tile`/`set_tint`, for the crate-private
517        // `set_extra` write path (reached from `Headless::draw_layers` with whatever `pos` the
518        // replayed `DrawCell` stream carries, which is not itself bounds-checked there).
519        let mut g = Grid::new(4, 4);
520        g.set_extra(
521            200,
522            99,
523            99,
524            TileExtra {
525                grapheme: None,
526                tint: Tint::multiply(1, 2, 3),
527            },
528        );
529        assert_eq!(g.max_layer(), 0);
530        assert!(g.layer(200).is_none());
531    }
532
533    #[test]
534    fn layer_table_growth_is_monotonic_across_writes() {
535        // Writing to a lower layer id after a higher one must not shrink the table, and must
536        // preserve the higher layer's content.
537        let mut g = Grid::new(5, 5);
538        g.put_tile(20, (1, 1), Tile::new('H', Style::default()));
539        assert_eq!(g.layers.len(), 21);
540        g.put_tile(2, (0, 0), Tile::new('L', Style::default()));
541        assert_eq!(
542            g.layers.len(),
543            21,
544            "writing a lower id must not shrink the table"
545        );
546        assert_eq!(g.max_layer(), 20);
547        assert_eq!(g.tile(20, (1, 1)).unwrap().glyph, 'H');
548        assert_eq!(g.tile(2, (0, 0)).unwrap().glyph, 'L');
549    }
550
551    #[test]
552    fn put_tile_on_layer_2_reads_back_independently_of_layer_0() {
553        use crate::color::Style;
554        let mut g = Grid::new(5, 5);
555        g.put_tile(2, (1, 1), Tile::new('Z', Style::default()));
556        assert_eq!(g.tile(2, (1, 1)).unwrap().glyph, 'Z');
557        // Layer 0 at same position should still be default.
558        assert_eq!(g[Pos::new(1, 1)].glyph, ' ');
559        // Unallocated layer returns None.
560        assert!(g.tile(3, (0, 0)).is_none());
561    }
562
563    #[test]
564    fn tile_mut_writes_in_place_without_clearing_spans() {
565        let mut g = Grid::new(4, 4);
566        g.write_span(0, 0, 0, &["C=", "[]"], Style::default())
567            .unwrap();
568
569        // Unlike `put_tile`, `tile_mut` hands out a direct `&mut Tile` and does not intercept
570        // the write, so the span's other cells are left dangling on purpose here.
571        g.tile_mut(0, (0, 0)).unwrap().glyph = 'x';
572        assert_eq!(g[Pos::new(0, 0)].glyph(), 'x');
573
574        // Unallocated layer and out-of-bounds position both report `None`, not a panic.
575        assert!(g.tile_mut(1, (0, 0)).is_none());
576        assert!(g.tile_mut(0, (10, 10)).is_none());
577    }
578
579    #[test]
580    fn clear_layer_resets_only_that_layer() {
581        let mut g = Grid::new(5, 5);
582        g.put_tile(1, (0, 0), Tile::new('Z', Style::default()));
583        g.put_tile(0, (0, 0), Tile::new('A', Style::default()));
584        g.clear(1);
585        assert_eq!(g.tile(0, (0, 0)).unwrap().glyph, 'A');
586        assert!(g.tile(1, (0, 0)).is_some());
587        assert_eq!(g.tile(1, (0, 0)).unwrap().glyph, ' '); // cleared
588    }
589
590    #[test]
591    fn clear_all_resets_every_layer() {
592        let mut g = Grid::new(5, 5);
593        g.put_tile(1, (0, 0), Tile::new('Z', Style::default()));
594        g.put_tile(0, (0, 0), Tile::new('A', Style::default()));
595        g.clear_all();
596        // Both layers reset to default (space).
597        assert_eq!(g[Pos::new(0, 0)].glyph, ' ');
598        assert_eq!(g.tile(1, (0, 0)).unwrap().glyph, ' ');
599    }
600
601    #[test]
602    fn clone_is_independent() {
603        let mut g = Grid::new(3, 3);
604        g.put_tile(0, (0, 0), Tile::new('A', Style::default()));
605        g.put_tile(2, (1, 1), Tile::new('B', Style::default()));
606
607        let mut cloned = g.clone();
608        assert_eq!(cloned[Pos::new(0, 0)].glyph, 'A');
609        assert_eq!(cloned.tile(2, (1, 1)).unwrap().glyph, 'B');
610        assert_eq!(cloned.max_layer(), g.max_layer());
611
612        // Mutating the clone must not affect the original (deep copy).
613        cloned.put_tile(0, (0, 0), Tile::new('Z', Style::default()));
614        assert_eq!(cloned[Pos::new(0, 0)].glyph, 'Z');
615        assert_eq!(g[Pos::new(0, 0)].glyph, 'A');
616    }
617
618    /// `put_tile` is wide-char aware on every feature combination (retroglyph#869): a 2-column
619    /// tile gets a `WIDE_CHAR` primary cell and a `WIDE_CHAR_SPACER` to its right, the same pair
620    /// `write_grapheme` (egc-only) writes.
621    #[test]
622    fn put_tile_writes_a_spacer_for_a_wide_glyph() {
623        let mut g = Grid::new(4, 4);
624        assert_eq!(
625            g.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default())),
626            Some(())
627        );
628
629        assert_eq!(g[Pos::new(0, 0)].glyph(), '\u{4e2d}');
630        assert!(g[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
631        assert_eq!(g[Pos::new(1, 0)].glyph(), ' ');
632        assert!(
633            g[Pos::new(1, 0)]
634                .flags()
635                .contains(TileFlags::WIDE_CHAR_SPACER)
636        );
637        // Untouched past the spacer.
638        assert_eq!(g[Pos::new(2, 0)].glyph(), ' ');
639        assert!(
640            !g[Pos::new(2, 0)]
641                .flags()
642                .contains(TileFlags::WIDE_CHAR_SPACER)
643        );
644    }
645
646    /// Mirrors `write_grapheme`'s own last-column refusal: a wide tile whose spacer would fall
647    /// off the grid is refused outright rather than leaving an orphaned primary cell.
648    #[test]
649    fn put_tile_refuses_a_wide_glyph_at_the_last_column() {
650        let mut g = Grid::new(4, 4);
651        assert_eq!(
652            g.put_tile(0, (3, 0), Tile::new('\u{4e2d}', Style::default())),
653            None
654        );
655        assert_eq!(g[Pos::new(3, 0)].glyph(), ' ');
656    }
657
658    /// Overwriting a wide char's primary cell (with a narrow tile) must clear its now-orphaned
659    /// spacer, the same overlap-clearing `write_grapheme` already does.
660    #[test]
661    fn put_tile_clears_the_spacer_of_a_wide_glyph_it_overwrites() {
662        let mut g = Grid::new(4, 4);
663        g.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
664        g.put_tile(0, (0, 0), Tile::new('a', Style::default()));
665
666        assert_eq!(g[Pos::new(0, 0)].glyph(), 'a');
667        assert!(
668            !g[Pos::new(1, 0)]
669                .flags()
670                .contains(TileFlags::WIDE_CHAR_SPACER)
671        );
672    }
673
674    /// Overwriting a wide char's spacer cell must clear its now-orphaned primary cell too.
675    #[test]
676    fn put_tile_clears_the_lead_of_a_wide_glyph_whose_spacer_it_overwrites() {
677        let mut g = Grid::new(4, 4);
678        g.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
679        g.put_tile(0, (1, 0), Tile::new('a', Style::default()));
680
681        assert_eq!(g[Pos::new(1, 0)].glyph(), 'a');
682        assert!(!g[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
683    }
684
685    /// A caller-constructed `tile` can carry a stale [`TileFlags::SPAN_ANCHOR`]/
686    /// [`TileFlags::SPAN_COVERED`] role only by having been read back out of some grid cell
687    /// (e.g. via [`tile`](Grid::tile)), since neither flag has a public builder. `put_tile` must
688    /// strip it rather than plant a dangling anchor: one that claims a footprint no covered cell
689    /// agrees it owns (retroglyph#984).
690    #[test]
691    fn put_tile_strips_a_span_anchor_replayed_from_elsewhere() {
692        let mut g = Grid::new(8, 4);
693        g.write_span_uniform(0, (0, 0), (2u16, 2u16), 'A', '.', Style::default())
694            .expect("2x2 span fits in an 8x4 grid");
695
696        // Replay the anchor tile somewhere unrelated.
697        let anchor = *g.tile(0, Pos::new(0, 0)).unwrap();
698        assert!(anchor.flags().contains(TileFlags::SPAN_ANCHOR));
699        g.put_tile(0, Pos::new(5, 3), anchor);
700
701        let replayed = g.tile(0, Pos::new(5, 3)).unwrap();
702        assert!(!replayed.flags().contains(TileFlags::SPAN_ANCHOR));
703        assert_eq!(replayed.span(), (1, 1));
704        assert_eq!(g.span_owner(0, 5, 3), None);
705        // No dangling footprint means nothing beyond (5, 3) got claimed either.
706        assert_eq!(g.span_owner(0, 6, 3), None);
707    }
708
709    /// The dangling anchor from the previous test would otherwise make `clear_span` (via
710    /// `reset_span_at`) walk the anchor's declared `span_w`/`span_h` and reset every cell in that
711    /// bogus footprint, destroying unrelated content that was never part of any span
712    /// (retroglyph#984).
713    #[test]
714    fn put_tile_strips_a_span_anchor_so_clear_span_cannot_destroy_a_neighbour() {
715        let mut g = Grid::new(8, 4);
716        g.write_span_uniform(0, (0, 0), (2u16, 2u16), 'A', '.', Style::default())
717            .expect("2x2 span fits in an 8x4 grid");
718        let anchor = *g.tile(0, Pos::new(0, 0)).unwrap();
719
720        g.put_tile(0, Pos::new(5, 3), anchor);
721        g.put_tile(0, Pos::new(6, 3), Tile::new('Z', Style::default()));
722        g.clear_span(0, 5, 3);
723
724        assert_eq!(g.tile(0, Pos::new(6, 3)).unwrap().glyph(), 'Z');
725    }
726
727    /// A replayed `SPAN_COVERED` tile must also lose its role, or a pixel backend (which skips
728    /// drawing any covered cell on the assumption its anchor drew the art) would render nothing
729    /// at the destination (retroglyph#984).
730    #[test]
731    fn put_tile_strips_a_span_covered_role_replayed_from_elsewhere() {
732        let mut g = Grid::new(8, 4);
733        g.write_span_uniform(0, (0, 0), (2u16, 2u16), 'A', '.', Style::default())
734            .expect("2x2 span fits in an 8x4 grid");
735        let covered = *g.tile(0, Pos::new(1, 1)).unwrap();
736        assert!(covered.flags().contains(TileFlags::SPAN_COVERED));
737
738        g.put_tile(0, Pos::new(5, 3), covered);
739
740        let replayed = g.tile(0, Pos::new(5, 3)).unwrap();
741        assert!(!replayed.flags().contains(TileFlags::SPAN_COVERED));
742        assert_eq!(g.span_owner(0, 5, 3), None);
743    }
744
745    /// A tile rebuilt via `with_glyph` from a spacer read back out of a grid must actually get
746    /// drawn: before the fix, the stale `WIDE_CHAR_SPACER` flag made `put_tile` treat it as an
747    /// already-resolved replay and store it verbatim, so backends skipped it (retroglyph#986).
748    #[test]
749    fn put_tile_draws_a_spacer_rebuilt_through_with_glyph() {
750        let mut g = Grid::new(8, 4);
751        g.put_tile(0, (0, 0), Tile::new('\u{6f22}', Style::default()));
752
753        let spacer = g[Pos::new(1, 0)];
754        assert!(spacer.flags().contains(TileFlags::WIDE_CHAR_SPACER));
755
756        let modified = spacer.with_glyph('!');
757        g.put_tile(0, (4, 0), modified);
758
759        let placed = g[Pos::new(4, 0)];
760        assert_eq!(placed.glyph(), '!');
761        assert_eq!(placed.width(), 1);
762        assert!(!placed.flags().contains(TileFlags::WIDE_CHAR_SPACER));
763    }
764
765    /// A tile rebuilt via `with_glyph` from a wide lead read back out of a grid must not carry a
766    /// stale `WIDE_CHAR` flag: before the fix, `clear_overlap` trusted it to mean "my right
767    /// neighbour is my spacer" and reset an unrelated tile on the next overlapping write
768    /// (retroglyph#986).
769    #[test]
770    fn put_tile_narrowed_through_with_glyph_does_not_clobber_its_neighbour() {
771        let mut g = Grid::new(8, 4);
772        g.put_tile(0, (0, 0), Tile::new('\u{6f22}', Style::default()));
773
774        let wide = g[Pos::new(0, 0)];
775        assert!(wide.flags().contains(TileFlags::WIDE_CHAR));
776
777        let narrow = wide.with_glyph('A');
778        g.put_tile(0, (4, 0), narrow);
779        g.put_tile(0, (5, 0), Tile::new('Z', Style::default()));
780        g.put_tile(0, (4, 0), Tile::new('B', Style::default()));
781
782        assert_eq!(g[Pos::new(5, 0)].glyph(), 'Z');
783    }
784
785    /// `fill_region` must clear any span it would partially overwrite the same way a per-cell
786    /// `put_tile` loop would (via `clear_span_overlap`), or the surviving span's anchor would
787    /// keep claiming a footprint the fill just overwrote part of.
788    #[test]
789    fn fill_region_clears_a_span_it_partially_overwrites() {
790        let mut g = Grid::new(4, 4);
791        g.write_span(0, 0, 0, &["C=", "[]"], Style::default())
792            .expect("2x2 span fits in a 4x4 grid");
793
794        // Overlaps only the span's right column, (1, 0) and (1, 1).
795        g.fill_region(0, Rect::new(1, 0, 3, 3), Tile::new('#', Style::default()));
796
797        // The anchor at (0, 0) is gone, not left claiming a footprint that no longer matches
798        // reality.
799        let anchor = g.tile(0, (0, 0)).unwrap();
800        assert!(!anchor.flags().contains(TileFlags::SPAN_ANCHOR));
801        assert_eq!(anchor.glyph(), ' ');
802    }
803
804    /// `fill_region` scans the region for overlapping spans once, not once per row (see
805    /// `clear_span_overlap_rect`, retroglyph#1020): a span several rows tall, entirely inside
806    /// `rect`, must still come out fully and correctly reset rather than leaving a stale anchor
807    /// or covered cell behind from a row the single-pass collection missed.
808    #[test]
809    fn fill_region_clears_a_multi_row_span_it_fully_covers() {
810        let mut g = Grid::new(6, 6);
811        g.write_span(0, 1, 1, &["AB", "CD", "EF", "GH"], Style::default())
812            .expect("2x4 span fits in a 6x6 grid");
813
814        g.fill_region(0, Rect::new(0, 0, 6, 6), Tile::new('#', Style::default()));
815
816        for y in 0..6 {
817            for x in 0..6 {
818                assert_eq!(g.tile(0, (x, y)).unwrap().glyph(), '#', "({x}, {y})");
819            }
820        }
821    }
822
823    /// `clear_overlap` runs regardless of `egc` (see its own doc comment): `fill_region` gated it
824    /// behind the feature until retroglyph#1014, so a wide pair written by `put_tile` (which is
825    /// not itself `egc`-gated) kept a stale `WIDE_CHAR` flag after a fill partially overwrote it
826    /// with `egc` off.
827    #[test]
828    fn fill_region_clears_a_wide_char_it_partially_overwrites() {
829        let mut g = Grid::new(4, 1);
830        g.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
831        assert!(
832            g.tile(0, (0, 0))
833                .unwrap()
834                .flags()
835                .contains(TileFlags::WIDE_CHAR)
836        );
837
838        g.fill_region(0, Rect::new(1, 0, 3, 1), Tile::new('#', Style::default()));
839
840        assert!(
841            !g.tile(0, (0, 0))
842                .unwrap()
843                .flags()
844                .contains(TileFlags::WIDE_CHAR)
845        );
846    }
847
848    /// `fill_region` writing a wide `tile` raw (no lead/spacer synthesis) would desync any
849    /// cursor-advancing consumer that trusts `Tile::width`/`WIDE_CHAR_SPACER` to track column
850    /// position (retroglyph#1014). It refuses instead, leaving the region untouched.
851    #[test]
852    fn fill_region_refuses_a_wide_tile() {
853        let mut g = Grid::new(4, 1);
854
855        g.fill_region(
856            0,
857            Rect::new(0, 0, 4, 1),
858            Tile::new('\u{4e2d}', Style::default()),
859        );
860
861        for x in 0..4 {
862            let tile = g.tile(0, (x, 0)).unwrap();
863            assert_eq!(tile.glyph(), ' ');
864            assert_eq!(tile.flags(), TileFlags::EMPTY);
865        }
866    }
867
868    /// `fill_region` writes a caller-constructed `Tile`, which (like `put_tile`) can never
869    /// legitimately carry `HAS_EXTRA`, so any grapheme/tint side-table entry the fill's cells
870    /// used to own must be dropped, not left dangling under the new tile.
871    #[cfg(feature = "egc")]
872    #[test]
873    fn fill_region_drops_stale_extras() {
874        let mut g = Grid::new(4, 4);
875        g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
876        g.set_tint(0, 2, 2, Tint::multiply(1, 2, 3));
877
878        g.fill_region(0, Rect::new(0, 0, 4, 4), Tile::new('#', Style::default()));
879
880        assert_eq!(crate::grid::grapheme_at(&g, 0, 1, 1), None);
881        assert_eq!(g.tint(0, 2, 2), Tint::None);
882    }
883
884    /// A `rect` that extends past the grid's own edges only fills the in-bounds overlap, the
885    /// same clipping `put_tile` gets for free per cell by refusing an out-of-bounds `pos`.
886    #[test]
887    fn fill_region_clips_to_grid_bounds() {
888        let mut g = Grid::new(4, 4);
889        g.fill_region(0, Rect::new(2, 2, 10, 10), Tile::new('#', Style::default()));
890
891        assert_eq!(g.tile(0, (3, 3)).unwrap().glyph(), '#');
892        assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), ' ');
893    }
894
895    /// A `tile` carrying a replayed `SPAN_ANCHOR` role must not survive into every cell of
896    /// `rect`: without stripping it, a 2x2 fill with a replayed anchor would produce four anchors
897    /// each wrongly claiming their own 2x2 footprint (retroglyph#984).
898    #[test]
899    fn fill_region_strips_a_span_anchor_replayed_from_elsewhere() {
900        let mut g = Grid::new(8, 4);
901        g.write_span_uniform(0, (0, 0), (2u16, 2u16), 'A', '.', Style::default())
902            .expect("2x2 span fits in an 8x4 grid");
903        let anchor = *g.tile(0, Pos::new(0, 0)).unwrap();
904
905        g.fill_region(0, Rect::new(4, 0, 2, 2), anchor);
906
907        for y in 0..2 {
908            for x in 4..6 {
909                let cell = g.tile(0, Pos::new(x, y)).unwrap();
910                assert!(!cell.flags().contains(TileFlags::SPAN_ANCHOR));
911                assert_eq!(g.span_owner(0, x, y), None);
912            }
913        }
914    }
915
916    /// An empty (or fully out-of-bounds) `rect` allocates nothing: `fill_region` returns before
917    /// touching `layer_or_alloc`.
918    #[test]
919    fn fill_region_on_an_empty_rect_does_not_allocate_the_layer() {
920        let mut g = Grid::new(4, 4);
921        g.fill_region(1, Rect::new(10, 10, 2, 2), Tile::new('#', Style::default()));
922        assert_eq!(g.tile(1, (0, 0)), None);
923    }
924}