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}