retroglyph_core/grid/api.rs
1//! `Grid`'s public API forwarding to layer 0: construction, dimensions, per-cell reads,
2//! iteration, resizing, and (`egc`-gated) grapheme writes.
3
4#[cfg(feature = "egc")]
5use super::TileExtra;
6use super::{Grid, LayerBuf, Pos, Size};
7#[cfg(feature = "egc")]
8use crate::color::Style;
9#[cfg(any(test, feature = "egc"))]
10use crate::color::Tint;
11#[cfg(feature = "egc")]
12use crate::tile::cap_grapheme;
13use crate::tile::{Tile, TileFlags};
14#[cfg(feature = "egc")]
15use alloc::sync::Arc;
16use grixy::ops::GridWrite;
17
18impl Grid {
19 /// Creates a new grid of the given dimensions.
20 ///
21 /// Layer 0 is allocated immediately. Layers 1–255 are `None` until first
22 /// write via [`put_tile`](Self::put_tile); the layer table itself only
23 /// grows as far as the highest layer id ever written, not all 256 slots
24 /// up front.
25 ///
26 /// `height` may be 0 (an empty grid with no rows). [`resize`](Self::resize) may shrink an
27 /// existing grid to 0 on either axis, including width; only construction requires a nonzero
28 /// width.
29 ///
30 /// # Panics
31 ///
32 /// Panics if `width` is 0.
33 #[must_use]
34 pub fn new(width: u16, height: u16) -> Self {
35 assert!(width > 0, "Grid width must be at least 1, got 0");
36 Self {
37 width,
38 height,
39 layers: alloc::vec![Some(LayerBuf::new(width, height))],
40 max_layer: 0,
41 has_spans: false,
42 }
43 }
44
45 /// Builds a grid from a rectangular character map, one [`Tile`] per cell.
46 ///
47 /// `map` is split on `\n`; the grid width is the longest line's display
48 /// width (`unicode-width`'s [`UnicodeWidthStr`](unicode_width::UnicodeWidthStr))
49 /// and the height is the number of lines. Lines shorter than the widest are
50 /// padded with the default tile. `f` maps each character to its tile,
51 /// called once per character in reading order.
52 ///
53 /// Each character is written through [`put_tile`](Self::put_tile) at its own
54 /// display column, so a 2-column (wide) character gets the same
55 /// [`TileFlags::WIDE_CHAR`]/[`TileFlags::WIDE_CHAR_SPACER`] lead/spacer pair
56 /// `put_tile` writes for any other fresh wide tile; the next character in the
57 /// line lands one column further along, past the spacer. A wide character in
58 /// the map's last column has no room for its spacer and is refused, the same
59 /// as any other `put_tile` call in that position.
60 ///
61 /// # Examples
62 ///
63 /// ```
64 /// use retroglyph_core::color::Style;
65 /// use retroglyph_core::grid::{Grid, Pos};
66 /// use retroglyph_core::tile::Tile;
67 ///
68 /// // A ragged map: the second line is shorter than the first.
69 /// let grid = Grid::from_charmap("###\n#.", |c| match c {
70 /// '#' => Tile::new('#', Style::default()),
71 /// _ => Tile::default(),
72 /// });
73 ///
74 /// // Width comes from the longest line; the shorter line is padded with the default
75 /// // tile rather than truncating the grid to the shortest line.
76 /// assert_eq!((grid.width(), grid.height()), (3, 2));
77 /// assert_eq!(grid[Pos::new(0, 0)].glyph(), '#');
78 /// assert_eq!(grid[Pos::new(1, 1)].glyph(), ' '); // '.' maps to the default tile
79 /// assert_eq!(grid[Pos::new(2, 1)].glyph(), ' '); // padding past the short line's end
80 /// ```
81 #[must_use]
82 pub fn from_charmap<F>(map: &str, mut f: F) -> Self
83 where
84 F: FnMut(char) -> Tile,
85 {
86 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
87
88 let mut width: u16 = 0;
89 let mut height: u16 = 0;
90 for line in map.lines() {
91 let len = u16::try_from(line.width()).unwrap_or(u16::MAX);
92 width = width.max(len);
93 height = height.saturating_add(1);
94 }
95 let mut grid = Self::new(width, height);
96 for (y, line) in map.lines().enumerate() {
97 #[allow(clippy::cast_possible_truncation)]
98 let y = y as u16;
99 let mut x: u16 = 0;
100 for ch in line.chars() {
101 grid.put_tile(0, Pos::new(x, y), f(ch));
102 #[allow(clippy::cast_possible_truncation)]
103 let ch_width = ch.width().unwrap_or(0) as u16;
104 x = x.saturating_add(ch_width);
105 }
106 }
107 grid
108 }
109
110 /// The grid's width in cells (columns), not pixels. Valid column indices are `0..width`; a
111 /// `Pos` with `x >= width` is out of bounds (see [`Grid`]'s out-of-bounds drawing rule).
112 ///
113 /// Always at least 1: [`new`](Self::new) refuses a zero width, though [`resize`](Self::resize)
114 /// can later shrink it to 0.
115 #[must_use]
116 pub const fn width(&self) -> u16 {
117 self.width
118 }
119
120 /// The grid's height in cells (rows), not pixels. Valid row indices are `0..height`; a `Pos`
121 /// with `y >= height` is out of bounds. May be 0 (an empty grid with no rows).
122 #[must_use]
123 pub const fn height(&self) -> u16 {
124 self.height
125 }
126
127 /// The grid's dimensions in cells: `Size::new(self.width(), self.height())`. See
128 /// [`width`](Self::width)/[`height`](Self::height) for the per-axis bounds and units.
129 #[must_use]
130 pub const fn size(&self) -> Size {
131 Size::new(self.width, self.height)
132 }
133
134 /// Returns the highest layer id that has ever been allocated.
135 ///
136 /// Always at least 0 (layer 0 is always allocated). This only grows:
137 /// clearing a layer ([`clear`](Self::clear)) does not deallocate it, so
138 /// the value does not shrink once a higher layer has been written.
139 ///
140 /// This is the layer id's steady-state cost: every present, diff, and
141 /// full-grid iteration walks `0..=max_layer`, skipping unallocated slots
142 /// with an O(1) `None` check, so compositing is `O(max_layer)` per cell
143 /// rather than `O(topmost opaque layer)`. Writing once to layer 200 and
144 /// never touching layers 1-199 means every future frame walks past 199
145 /// `None` slots to reach it: cheap per skipped layer, but not free, which
146 /// is why low, contiguous ids are preferred for frequently-updated
147 /// content.
148 #[must_use]
149 pub const fn max_layer(&self) -> u8 {
150 self.max_layer
151 }
152
153 /// Clears a specific layer, resetting all tiles to the default.
154 ///
155 /// Does nothing if the layer is unallocated.
156 pub fn clear(&mut self, layer: u8) {
157 if let Some(lb) = self
158 .layers
159 .get_mut(usize::from(layer))
160 .and_then(Option::as_mut)
161 {
162 lb.buf.clear();
163 lb.extras.clear();
164 }
165 }
166
167 /// Resizes the grid to `width` × `height` tiles.
168 ///
169 /// Content within the overlapping region is preserved on all allocated
170 /// layers. New cells are initialised to the default tile. Shrinking
171 /// discards tiles outside the new bounds.
172 ///
173 /// Shrinking can also orphan two structures that span more than one cell, since `resize`
174 /// keeps the top-left corner but a shrink can slice through a footprint's far edge:
175 ///
176 /// - A [`TileFlags::WIDE_CHAR`] lead left in the new last column, with its
177 /// [`TileFlags::WIDE_CHAR_SPACER`] now out of bounds, is reset -- the same thing
178 /// `clear_overlap` does when an ordinary write orphans one.
179 /// - A [`TileFlags::SPAN_ANCHOR`] whose declared footprint no longer fits has its whole span
180 /// cleared via `reset_span_at`, rather than left claiming a truncated area. Half a span is
181 /// not representable, the same reasoning [`blit`](Self::blit) documents for clipping one.
182 ///
183 /// Both repairs are bounded by the shrunk edge, not the whole grid, so a growing resize pays
184 /// nothing for either.
185 pub fn resize(&mut self, width: u16, height: u16) {
186 let old_width = usize::from(self.width);
187 let old_height = usize::from(self.height);
188 let new_width = usize::from(width);
189 let new_height = usize::from(height);
190 let width_shrank = new_width < old_width;
191 let height_shrank = new_height < old_height;
192 self.width = width;
193 self.height = height;
194 for layer in self.layers.iter_mut().flatten() {
195 // The extras side-table is keyed by flat row-major index, which
196 // shifts whenever the width changes: remap it in lockstep with
197 // `buf.resize` (below) rather than leaving it pointing at stale
198 // (or now out-of-bounds) cells.
199 if !layer.extras.is_empty() {
200 layer.extras = layer
201 .extras
202 .iter()
203 .filter_map(|(&old_idx, s)| {
204 let x = old_idx % old_width;
205 let y = old_idx / old_width;
206 (x < new_width && y < new_height).then(|| (y * new_width + x, s.clone()))
207 })
208 .collect();
209 }
210 layer.buf.resize(new_width, new_height);
211
212 // A width shrink is the only way a wide-character pair can be split: a height shrink
213 // drops a lead and its spacer together (same row, both past the new bottom edge), but
214 // a width shrink can leave the lead in the new last column with the spacer it needs
215 // now out of bounds. Bounded to that one column rather than the whole layer.
216 if width_shrank && new_width > 0 {
217 let last_col = new_width - 1;
218 for y in 0..new_height {
219 let idx = y * new_width + last_col;
220 if layer.buf.as_ref()[idx].flags.contains(TileFlags::WIDE_CHAR) {
221 layer.buf.as_mut()[idx].reset();
222 layer.extras.remove(&idx);
223 }
224 }
225 }
226 }
227
228 if width_shrank || height_shrank {
229 self.repair_spans_after_resize(width_shrank, height_shrank);
230 }
231 }
232
233 // ------------------------------------------------------------------
234 // Write grapheme: layer 0 only
235 // ------------------------------------------------------------------
236
237 /// Writes a grapheme cluster at `(x, y)` on layer 0, enforcing wide-
238 /// character invariants.
239 ///
240 /// This is the canonical way to place content into the grid when the `egc`
241 /// feature is enabled. It:
242 ///
243 /// - Clears any wide character whose primary or spacer cell would be
244 /// overwritten.
245 /// - Sets [`TileFlags::WIDE_CHAR`] on the primary cell and places a
246 /// [`TileFlags::WIDE_CHAR_SPACER`] in the adjacent cell for 2-column
247 /// characters.
248 /// - Stores multi-codepoint EGCs (combining marks, ZWJ sequences) in the
249 /// layer's EGC side-table, capped at 8 codepoints total. Read it back via
250 /// [`DrawCell::grapheme`](crate::backend::DrawCell::grapheme), streamed off
251 /// [`Grid::layers`](Self::layers).
252 ///
253 /// Does nothing, and returns `false`, if the grapheme has zero display width, `(x, y)` is out
254 /// of bounds, or a 2-column wide character would overflow the grid (the last column needs
255 /// both its own cell and a spacer). Returns `true` otherwise, once the write has landed: the
256 /// same success/refusal split [`put_tile`](Self::put_tile) reports via `Option`.
257 ///
258 /// # Panics
259 ///
260 /// Panics if the grapheme's display width exceeds [`u16::MAX`]. In
261 /// practice this cannot happen: the maximum Unicode grapheme width is 2.
262 ///
263 /// Only present when the `egc` feature is enabled.
264 #[cfg(feature = "egc")]
265 pub fn write_grapheme(
266 &mut self,
267 layer: u8,
268 x: u16,
269 y: u16,
270 grapheme: &str,
271 style: Style,
272 ) -> bool {
273 use unicode_width::UnicodeWidthStr;
274
275 let width = u16::try_from(grapheme.width()).expect("grapheme width exceeds u16");
276 if width == 0 {
277 return false;
278 }
279
280 if x >= self.width || y >= self.height {
281 return false;
282 }
283
284 // Capture dimensions as plain values to avoid borrow conflicts.
285 let w = usize::from(self.width);
286 let cap = w * usize::from(self.height);
287 let idx = usize::from(y) * w + usize::from(x);
288 if idx >= cap {
289 return false;
290 }
291
292 // A 2-column char needs a spacer at x+1. If that's out of bounds,
293 // silently refuse rather than leaving an orphaned primary cell.
294 if width == 2 && x.saturating_add(1) as usize >= w {
295 return false;
296 }
297
298 // Clear any wide-char cell, or any multi-cell span, that would be partially overwritten.
299 self.clear_span_overlap(layer, x, y, width);
300 self.clear_overlap(layer, x, y, width);
301
302 // Capture width before borrowing self mutably.
303 let grid_w = usize::from(self.width);
304 let idx = usize::from(y) * grid_w + usize::from(x);
305
306 let lb = self.layer_or_alloc(layer);
307 // Build cell content.
308 let mut chars = grapheme.chars();
309 let first = chars.next().unwrap_or(' ');
310 let has_extra = chars.next().is_some();
311 let flags = if width == 2 {
312 TileFlags::WIDE_CHAR
313 } else {
314 TileFlags::empty()
315 };
316 let flags = if has_extra {
317 flags | TileFlags::HAS_EXTRA
318 } else {
319 flags
320 };
321
322 lb.buf.as_mut()[idx].glyph = first;
323 lb.buf.as_mut()[idx].style = style;
324 lb.buf.as_mut()[idx].flags = flags;
325 // `width` here is the full grapheme's display width (1 or 2), not just `first`'s: more
326 // accurate than recomputing from the primary codepoint alone, and exactly what the
327 // terminal renderer needs to advance the cursor after printing this cell.
328 #[allow(clippy::cast_possible_truncation)]
329 {
330 lb.buf.as_mut()[idx].width = width as u8;
331 }
332 // A fresh glyph write replaces the cell's out-of-line data outright rather than merging
333 // with it: a tint belongs to the artwork that was drawn here, not to the cell, so
334 // overwriting the glyph drops it. `Grid::set_tint` is the follow-up that puts one back.
335 if has_extra {
336 lb.extras.insert(
337 idx,
338 TileExtra {
339 grapheme: Some(Arc::from(cap_grapheme(grapheme))),
340 tint: Tint::None,
341 },
342 );
343 } else {
344 lb.extras.remove(&idx);
345 }
346
347 // Place spacer for wide characters.
348 if width == 2 {
349 let spacer_idx = usize::from(y) * grid_w + usize::from(x + 1);
350 if spacer_idx < cap {
351 let spacer = &mut lb.buf.as_mut()[spacer_idx];
352 spacer.glyph = ' ';
353 spacer.style = style;
354 spacer.width = 0;
355 spacer.flags = TileFlags::WIDE_CHAR_SPACER;
356 lb.extras.remove(&spacer_idx);
357 }
358 }
359
360 true
361 }
362
363 /// Clears wide-character cells that would be partially overwritten by a
364 /// write starting at `(x, y)` spanning `width` columns.
365 ///
366 /// `clear_span_overlap` is the multi-cell-span analogue. Not gated behind `egc`: `write_grapheme`
367 /// is `egc`-only, but [`put_tile`](Self::put_tile) writes a wide-character pair on every
368 /// feature combination (see its own doc comment), so a write that can land inside either kind
369 /// of multi-cell structure calls both regardless of `egc`.
370 pub(super) fn clear_overlap(&mut self, layer: u8, x: u16, y: u16, width: u16) {
371 let w = usize::from(self.width);
372 let cap = w * usize::from(self.height);
373 // An unallocated layer has never written a wide-character cell, so there is nothing to
374 // clear: return before `layer_or_alloc` would allocate one just to find that (retroglyph#1012).
375 let Some(lb) = self
376 .layers
377 .get_mut(usize::from(layer))
378 .and_then(Option::as_mut)
379 else {
380 return;
381 };
382 // Every call site bounds-checks `x`/`y` (and, where relevant, the whole `x..x+width`
383 // range) against `self.width`/`self.height` before reaching here (`put_tile`,
384 // `write_grapheme`, `fill_region`'s already-clipped `rect`, `blit_with`'s per-cell `dx`
385 // check, and `write_span_cells`'s own footprint check). A `cx` past the row's own width
386 // would still compute an `idx` `< cap` below (just landing in the next row) and clear an
387 // unrelated cell instead of being treated as out of bounds, so this is asserted rather
388 // than silently trusted.
389 debug_assert!(
390 usize::from(x.saturating_add(width)) <= w,
391 "caller must bounds-check"
392 );
393 for cx in x..x.saturating_add(width) {
394 let idx = usize::from(y) * w + usize::from(cx);
395 if idx >= cap {
396 continue;
397 }
398 // flags is Copy, so reading through the shared ref is fine.
399 let flags = lb.buf.as_ref()[idx].flags;
400
401 if flags.contains(TileFlags::WIDE_CHAR_SPACER) && cx > 0 {
402 let pidx = usize::from(y) * w + usize::from(cx - 1);
403 if pidx < cap {
404 lb.buf.as_mut()[pidx].reset();
405 lb.extras.remove(&pidx);
406 }
407 }
408
409 if flags.contains(TileFlags::WIDE_CHAR) {
410 let sidx = usize::from(y) * w + usize::from(cx + 1);
411 if sidx < cap {
412 lb.buf.as_mut()[sidx].reset();
413 lb.extras.remove(&sidx);
414 }
415 }
416 }
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn new_reports_the_requested_width_and_height() {
426 let grid = Grid::new(80, 25);
427 assert_eq!(grid.width(), 80);
428 assert_eq!(grid.height(), 25);
429 }
430
431 #[test]
432 #[should_panic(expected = "Grid width must be at least 1")]
433 fn new_zero_width_panics() {
434 let _ = Grid::new(0, 5);
435 }
436
437 #[test]
438 fn new_zero_height_is_allowed() {
439 let grid = Grid::new(5, 0);
440 assert_eq!(grid.width(), 5);
441 assert_eq!(grid.height(), 0);
442 }
443
444 #[test]
445 #[should_panic(expected = "Grid width must be at least 1")]
446 fn new_zero_by_zero_panics() {
447 let _ = Grid::new(0, 0);
448 }
449
450 #[test]
451 fn resize_to_zero_width_is_allowed() {
452 let mut grid = Grid::new(5, 5);
453 grid.resize(0, 5);
454 assert_eq!(grid.width(), 0);
455 assert_eq!(grid.height(), 5);
456 }
457
458 #[test]
459 fn resize_to_zero_height_is_allowed() {
460 let mut grid = Grid::new(5, 5);
461 grid.resize(5, 0);
462 assert_eq!(grid.width(), 5);
463 assert_eq!(grid.height(), 0);
464 }
465
466 #[test]
467 fn resize_to_zero_by_zero_is_allowed() {
468 let mut grid = Grid::new(5, 5);
469 grid.resize(0, 0);
470 assert_eq!(grid.width(), 0);
471 assert_eq!(grid.height(), 0);
472 }
473
474 #[test]
475 fn resize_expand_preserves_existing_cells_and_defaults_new_ones() {
476 let mut grid = Grid::new(3, 3);
477 grid.put_tile(0, (1, 1), Tile::default().with_glyph('X'));
478 grid.resize(6, 6);
479 assert_eq!(grid.width(), 6);
480 assert_eq!(grid.height(), 6);
481 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'X'); // preserved
482 assert_eq!(grid[Pos::new(5, 5)].glyph(), ' '); // new cells default
483 }
484
485 #[test]
486 fn resize_shrink_preserves_cells_still_in_bounds() {
487 let mut grid = Grid::new(10, 10);
488 grid.put_tile(0, (1, 1), Tile::default().with_glyph('A'));
489 grid.resize(5, 5);
490 assert_eq!(grid.width(), 5);
491 assert_eq!(grid.height(), 5);
492 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'A'); // still in bounds, preserved
493 }
494
495 #[test]
496 fn resize_shrink_drops_cells_that_fall_outside_the_new_bounds() {
497 let mut grid = Grid::new(4, 4);
498 grid.put_tile(0, (0, 0), Tile::default().with_glyph('@'));
499 grid.put_tile(0, (3, 3), Tile::default().with_glyph('X'));
500 grid.resize(3, 3); // shrink: (3,3) falls outside
501 assert_eq!(grid[Pos::new(0, 0)].glyph(), '@');
502 assert_eq!(grid[Pos::new(2, 2)].glyph(), ' '); // was default, still default
503 }
504
505 // --- Extra grapheme text (EGC side-table) ---
506 #[cfg(feature = "egc")]
507 #[test]
508 fn write_grapheme_stores_and_reads_extra() {
509 let mut g = Grid::new(5, 5);
510 g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
511 assert_eq!(g[Pos::new(1, 1)].glyph, 'e');
512 assert_eq!(crate::grid::grapheme_at(&g, 0, 1, 1), Some("e\u{0301}"));
513
514 // Single-codepoint writes never populate the side-table.
515 g.write_grapheme(0, 2, 2, "a", Style::default());
516 assert_eq!(crate::grid::grapheme_at(&g, 0, 2, 2), None);
517 }
518
519 #[cfg(feature = "egc")]
520 #[test]
521 fn write_grapheme_x_past_width_wraps_onto_the_next_row() {
522 let mut grid = Grid::new(10, 10);
523 grid.write_grapheme(0, 12, 0, "A", Style::default()); // x = 12 on a 10-wide grid
524 assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
525 assert_eq!(grid[Pos::new(2, 1)].glyph(), ' ');
526 }
527
528 #[cfg(feature = "egc")]
529 #[test]
530 fn write_grapheme_y_past_height_is_refused() {
531 let mut grid = Grid::new(10, 10);
532 assert!(!grid.write_grapheme(0, 0, 12, "A", Style::default())); // y = 12 on a 10-tall grid
533 for y in 0..10 {
534 assert_eq!(grid[Pos::new(0, y)].glyph(), ' ');
535 }
536 }
537
538 #[cfg(feature = "egc")]
539 #[test]
540 fn write_grapheme_zero_width_is_refused() {
541 let mut grid = Grid::new(10, 10);
542 // A lone combining mark, with no base character in front of it, has zero display width
543 // on its own.
544 assert!(!grid.write_grapheme(0, 2, 1, "\u{0301}", Style::default()));
545 assert_eq!(grid[Pos::new(2, 1)].glyph(), ' ');
546 }
547
548 #[cfg(feature = "egc")]
549 #[test]
550 fn write_grapheme_in_bounds_still_writes() {
551 let mut grid = Grid::new(10, 10);
552 grid.write_grapheme(0, 2, 1, "A", Style::default());
553 assert_eq!(grid[Pos::new(2, 1)].glyph(), 'A');
554 }
555
556 #[cfg(feature = "egc")]
557 #[test]
558 fn put_tile_overwrite_clears_extra() {
559 let mut g = Grid::new(5, 5);
560 g.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
561 assert_eq!(crate::grid::grapheme_at(&g, 0, 0, 0), Some("e\u{0301}"));
562
563 // A plain `put` (or a later single-codepoint `write_grapheme`) must
564 // drop the stale side-table entry, not just leave it unreachable.
565 g.put_tile(0, (0, 0), Tile::new('X', Style::default()));
566 assert_eq!(crate::grid::grapheme_at(&g, 0, 0, 0), None);
567 assert!(!g[Pos::new(0, 0)].flags().contains(TileFlags::HAS_EXTRA));
568 }
569
570 #[cfg(feature = "egc")]
571 #[test]
572 fn resize_remaps_extras_to_new_stride() {
573 let mut g = Grid::new(4, 4);
574 g.write_grapheme(0, 3, 1, "e\u{0301}", Style::default());
575 assert_eq!(crate::grid::grapheme_at(&g, 0, 3, 1), Some("e\u{0301}"));
576
577 // Widening changes the row stride, so the flat index for (3, 1)
578 // changes even though the cell itself is preserved.
579 g.resize(8, 4);
580 assert_eq!(g[Pos::new(3, 1)].glyph, 'e');
581 assert_eq!(crate::grid::grapheme_at(&g, 0, 3, 1), Some("e\u{0301}"));
582 // No ghost entry landed on some other cell at the old flat index.
583 assert_eq!(crate::grid::grapheme_at(&g, 0, 7, 0), None);
584
585 // Shrinking past the cell drops its extras entry along with the tile.
586 g.resize(2, 4);
587 assert_eq!(crate::grid::grapheme_at(&g, 0, 3, 1), None);
588 }
589
590 #[test]
591 fn clear_drops_every_tint_on_the_layer() {
592 let mut g = Grid::new(4, 4);
593 g.set_tint(0, 1, 1, Tint::multiply(1, 2, 3));
594 g.set_tint(1, 1, 1, Tint::multiply(4, 5, 6));
595
596 g.clear(0);
597 assert_eq!(g.tint(0, 1, 1), Tint::None);
598 assert_eq!(g.tint(1, 1, 1), Tint::multiply(4, 5, 6));
599 }
600
601 #[cfg(feature = "egc")]
602 #[test]
603 fn resize_remaps_a_tint_to_the_new_stride() {
604 let mut g = Grid::new(4, 4);
605 g.write_grapheme(0, 3, 1, "@", Style::default());
606 g.set_tint(0, 3, 1, Tint::mix(200, 100, 50, 128));
607
608 // Widening changes the row stride, so (3, 1)'s flat index moves.
609 g.resize(8, 4);
610 assert_eq!(g.tint(0, 3, 1), Tint::mix(200, 100, 50, 128));
611 // No ghost entry landed on whatever cell now holds the old flat index.
612 assert_eq!(g.tint(0, 7, 0), Tint::None);
613
614 // Shrinking past the cell drops its entry with the tile.
615 g.resize(2, 4);
616 assert_eq!(g.tint(0, 3, 1), Tint::None);
617 }
618
619 #[test]
620 fn resize_narrower_resets_a_wide_char_split_by_the_new_last_column() {
621 use crate::color::Style;
622
623 let mut g = Grid::new(4, 1);
624 // Lead lands at (2, 0), spacer at (3, 0).
625 g.put_tile(0, (2, 0), Tile::new('\u{4e2d}', Style::default()));
626 assert!(
627 g.tile(0, (2, 0))
628 .unwrap()
629 .flags()
630 .contains(TileFlags::WIDE_CHAR)
631 );
632
633 // Drops column 3, which held the spacer: the lead can no longer be paired, so it must be
634 // reset rather than survive unpaired in the new last column.
635 g.resize(3, 1);
636 assert!(
637 !g.tile(0, (2, 0))
638 .unwrap()
639 .flags()
640 .contains(TileFlags::WIDE_CHAR)
641 );
642 }
643
644 #[test]
645 fn from_charmap_lone_wide_char() {
646 use crate::color::Style;
647
648 // A single wide char needs 2 columns of width; there is no narrower char after it to
649 // clobber its spacer, but sizing must give it the room in the first place.
650 let g = Grid::from_charmap("\u{4e2d}", |c| Tile::new(c, Style::default()));
651 assert_eq!(g.width(), 2);
652 assert_eq!(g.height(), 1);
653 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), '\u{4e2d}');
654 assert!(
655 g.tile(0, (0, 0))
656 .unwrap()
657 .flags()
658 .contains(TileFlags::WIDE_CHAR)
659 );
660 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), ' ');
661 assert!(
662 g.tile(0, (1, 0))
663 .unwrap()
664 .flags()
665 .contains(TileFlags::WIDE_CHAR_SPACER)
666 );
667 }
668
669 #[test]
670 fn from_charmap_wide_char_mid_line() {
671 use crate::color::Style;
672
673 // The wide char's spacer occupies column 1; the following 'x' must land at column 2,
674 // not column 1 where it would clobber the spacer and clear the wide lead.
675 let g = Grid::from_charmap("\u{4e2d}x", |c| Tile::new(c, Style::default()));
676 assert_eq!(g.width(), 3);
677 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), '\u{4e2d}');
678 assert!(
679 g.tile(0, (0, 0))
680 .unwrap()
681 .flags()
682 .contains(TileFlags::WIDE_CHAR)
683 );
684 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), ' ');
685 assert!(
686 g.tile(0, (1, 0))
687 .unwrap()
688 .flags()
689 .contains(TileFlags::WIDE_CHAR_SPACER)
690 );
691 assert_eq!(g.tile(0, (2, 0)).unwrap().glyph(), 'x');
692 assert_eq!(g.tile(0, (2, 0)).unwrap().flags(), TileFlags::empty());
693 }
694
695 #[test]
696 fn from_charmap_wide_char_at_end_of_line_fits_exactly() {
697 use crate::color::Style;
698
699 // The wide char is the last character of the widest (and only) line, so `from_charmap`
700 // must size the grid with room for both its lead and its spacer: unlike a caller passing
701 // an already-fixed width to `put_tile` directly, there is no way for this to hit
702 // `put_tile`'s last-column refusal, since the sizing pass and the write pass measure the
703 // same display width.
704 let g = Grid::from_charmap("a\u{4e2d}", |c| Tile::new(c, Style::default()));
705 assert_eq!(g.width(), 3);
706 assert_eq!(g.height(), 1);
707 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), 'a');
708 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), '\u{4e2d}');
709 assert!(
710 g.tile(0, (1, 0))
711 .unwrap()
712 .flags()
713 .contains(TileFlags::WIDE_CHAR)
714 );
715 assert_eq!(g.tile(0, (2, 0)).unwrap().glyph(), ' ');
716 assert!(
717 g.tile(0, (2, 0))
718 .unwrap()
719 .flags()
720 .contains(TileFlags::WIDE_CHAR_SPACER)
721 );
722 }
723
724 #[test]
725 fn from_charmap_ragged_map_mixing_wide_and_narrow_rows() {
726 use crate::color::Style;
727
728 // Row 0 is all narrow ("ab", width 2); row 1 is one wide char ("\u{4e2d}", width 2). Both
729 // rows have the same display width, so the grid is 2 columns wide and neither row needs
730 // padding, but row 1's single char must still claim both columns via the spacer.
731 let g = Grid::from_charmap("ab\n\u{4e2d}", |c| Tile::new(c, Style::default()));
732 assert_eq!(g.width(), 2);
733 assert_eq!(g.height(), 2);
734 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), 'a');
735 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), 'b');
736 assert_eq!(g.tile(0, (0, 1)).unwrap().glyph(), '\u{4e2d}');
737 assert!(
738 g.tile(0, (0, 1))
739 .unwrap()
740 .flags()
741 .contains(TileFlags::WIDE_CHAR)
742 );
743 assert_eq!(g.tile(0, (1, 1)).unwrap().glyph(), ' ');
744 assert!(
745 g.tile(0, (1, 1))
746 .unwrap()
747 .flags()
748 .contains(TileFlags::WIDE_CHAR_SPACER)
749 );
750 }
751
752 #[cfg(test)]
753 mod wide_char_proptests {
754 use super::*;
755 use crate::color::Style;
756 use crate::grid::{BlendMode, Rect};
757 use proptest::prelude::*;
758
759 const W: u16 = 8;
760 const H: u16 = 4;
761
762 /// Narrow and wide (CJK) single-char glyphs, for the ops that take a plain `char`
763 /// (`put_tile`, `fill_region`, and the small stamp grids `blit`/`blit_alpha` copy from).
764 const CHARS: &[char] = &['a', '\u{4e2d}'];
765
766 /// Narrow, wide (CJK), combining-mark, and wide-emoji graphemes, for `write_grapheme`
767 /// (`egc`-only: a multi-codepoint combining-mark grapheme has no plain-`char` spelling).
768 #[cfg(feature = "egc")]
769 const GRAPHEMES: &[&str] = &["a", "\u{4e2d}", "e\u{0301}", "\u{1f600}"];
770
771 /// Every `WIDE_CHAR` has its spacer to the right, every `WIDE_CHAR_SPACER`
772 /// has its lead to the left, and no cell is both.
773 ///
774 /// Feature-independent: `put_tile` writes a `WIDE_CHAR`/`WIDE_CHAR_SPACER` pair on every
775 /// feature combination (retroglyph#869), so this check (unlike the `egc`-only
776 /// `write_grapheme` op below) does not belong behind `#[cfg(feature = "egc")]` (compare
777 /// retroglyph#994, the same over-gating problem in `tile.rs`).
778 fn assert_wide_invariants(grid: &Grid) {
779 for y in 0..grid.height() {
780 for x in 0..grid.width() {
781 let flags = grid[Pos::new(x, y)].flags();
782 let lead = flags.contains(TileFlags::WIDE_CHAR);
783 let spacer = flags.contains(TileFlags::WIDE_CHAR_SPACER);
784
785 assert!(
786 !(lead && spacer),
787 "cell ({x}, {y}) is both wide lead and spacer"
788 );
789
790 if lead {
791 assert!(x + 1 < grid.width(), "wide lead at ({x}, {y}) has no room");
792 assert!(
793 grid[Pos::new(x + 1, y)]
794 .flags()
795 .contains(TileFlags::WIDE_CHAR_SPACER),
796 "wide lead at ({x}, {y}) is missing its spacer"
797 );
798 }
799
800 if spacer {
801 assert!(x > 0, "orphan spacer at ({x}, {y}) (no cell to the left)");
802 assert!(
803 grid[Pos::new(x - 1, y)]
804 .flags()
805 .contains(TileFlags::WIDE_CHAR),
806 "orphan spacer at ({x}, {y}) (left cell is not a wide lead)"
807 );
808 }
809 }
810 }
811 }
812
813 /// One operation from `wide_char_bookkeeping_never_desyncs`'s op alphabet. Every variant
814 /// but `WriteGrapheme` is available on every feature combination, since `put_tile` (and
815 /// everything built on it: `fill_region`, `blit`, `blit_alpha`, `write_span`) writes wide
816 /// pairs regardless of `egc` (retroglyph#869); only `write_grapheme` itself is `egc`-only.
817 #[derive(Debug, Clone)]
818 enum Op {
819 PutTile(u16, u16, usize),
820 FillRegion(u16, u16, u16, u16, usize),
821 /// Blits a small 2x2 stamp grid (built fresh from `gi`, a glyph at its origin) onto
822 /// `(dst_x, dst_y)`, which lands the stamp's own wide pair (or narrow tile) astride
823 /// an existing wide pair already in `grid`. `dst_x`/`dst_y` are kept off the grid's
824 /// far edge (see `arb_op`) so the 2x2 stamp is never itself clipped mid-pair by the
825 /// destination bounds; `blit`/`blit_alpha` writing a clipped half of a wide pair to
826 /// the destination edge is a separate, unresolved gap, not the overlap-clearing
827 /// behavior this proptest targets (see the follow-up filed alongside this PR).
828 Blit(u16, u16, usize),
829 BlitAlpha(u16, u16, usize),
830 WriteSpan(u16, u16),
831 Resize(u16, u16),
832 #[cfg(feature = "egc")]
833 WriteGrapheme(u16, u16, usize),
834 }
835
836 fn arb_op() -> impl Strategy<Value = Op> {
837 let base = prop_oneof![
838 (0u16..W, 0u16..H, 0usize..CHARS.len())
839 .prop_map(|(x, y, gi)| Op::PutTile(x, y, gi)),
840 (0u16..W, 0u16..H, 1u16..4, 1u16..4, 0usize..CHARS.len())
841 .prop_map(|(x, y, w, h, gi)| Op::FillRegion(x, y, w, h, gi)),
842 (0u16..(W - 1), 0u16..(H - 1), 0usize..CHARS.len())
843 .prop_map(|(x, y, gi)| Op::Blit(x, y, gi)),
844 (0u16..(W - 1), 0u16..(H - 1), 0usize..CHARS.len())
845 .prop_map(|(x, y, gi)| Op::BlitAlpha(x, y, gi)),
846 (0u16..W, 0u16..H).prop_map(|(x, y)| Op::WriteSpan(x, y)),
847 // Bounded away from 0/1: a grid narrower or shorter than the 2x2 `Blit`/
848 // `BlitAlpha` stamp can never hold a wide pair at all regardless of where it
849 // lands, which is the same unresolved destination-clipping gap `Blit`/
850 // `BlitAlpha`'s own doc comment calls out, just reached from the other side.
851 (2u16..W * 2, 2u16..H * 2).prop_map(|(w, h)| Op::Resize(w, h)),
852 ];
853 #[cfg(feature = "egc")]
854 let base = prop_oneof![
855 base,
856 (0u16..W, 0u16..H, 0usize..GRAPHEMES.len())
857 .prop_map(|(x, y, gi)| Op::WriteGrapheme(x, y, gi)),
858 ];
859 base
860 }
861
862 fn apply(grid: &mut Grid, op: &Op) {
863 match *op {
864 Op::PutTile(x, y, gi) => {
865 grid.put_tile(0, (x, y), Tile::new(CHARS[gi], Style::default()));
866 }
867 Op::FillRegion(x, y, w, h, gi) => {
868 grid.fill_region(
869 0,
870 Rect::new(x, y, w, h),
871 Tile::new(CHARS[gi], Style::default()),
872 );
873 }
874 Op::Blit(dst_x, dst_y, gi) => {
875 let mut stamp = Grid::new(2, 2);
876 stamp.put_tile(0, (0, 0), Tile::new(CHARS[gi], Style::default()));
877 // Reclamped to the *current* grid size (a prior `Resize` op may have shrunk
878 // it below `W`/`H`): stays off the far edge for the same reason `arb_op`
879 // keeps the un-clamped values off it.
880 let dst_x = dst_x.min(grid.width().saturating_sub(2));
881 let dst_y = dst_y.min(grid.height().saturating_sub(2));
882 grid.blit(0, &stamp, Rect::new(0, 0, 2, 2), dst_x, dst_y);
883 }
884 Op::BlitAlpha(dst_x, dst_y, gi) => {
885 let mut stamp = Grid::new(2, 2);
886 stamp.put_tile(0, (0, 0), Tile::new(CHARS[gi], Style::default()));
887 let dst_x = dst_x.min(grid.width().saturating_sub(2));
888 let dst_y = dst_y.min(grid.height().saturating_sub(2));
889 grid.blit_alpha(
890 0,
891 &stamp,
892 Rect::new(0, 0, 2, 2),
893 dst_x,
894 dst_y,
895 BlendMode::Linear,
896 1.0,
897 1.0,
898 );
899 }
900 Op::WriteSpan(x, y) => {
901 grid.write_span(0, x, y, &["ab"], Style::default());
902 }
903 Op::Resize(w, h) => {
904 grid.resize(w, h);
905 }
906 #[cfg(feature = "egc")]
907 Op::WriteGrapheme(x, y, gi) => {
908 grid.write_grapheme(0, x, y, GRAPHEMES[gi], Style::default());
909 }
910 }
911 }
912
913 proptest! {
914 #[test]
915 fn wide_char_bookkeeping_never_desyncs(
916 ops in prop::collection::vec(arb_op(), 0..64),
917 ) {
918 let mut grid = Grid::new(W, H);
919 for op in &ops {
920 apply(&mut grid, op);
921 // The invariant must hold after every single op, not just at the end: an
922 // intermediate orphan would be a real bug.
923 assert_wide_invariants(&grid);
924 }
925 }
926
927 /// Sibling of `wide_char_bookkeeping_never_desyncs` above, narrowed to just
928 /// `write_grapheme` interleaved with `resize` (both growing and shrinking, on both
929 /// axes): a dedicated regression pin for `resize` splitting a wide-character pair
930 /// (retroglyph#1015), on top of the broader op alphabet already covering the same
931 /// ground probabilistically.
932 #[cfg(feature = "egc")]
933 #[test]
934 fn wide_char_bookkeeping_never_desyncs_across_resizes(
935 ops in prop::collection::vec(
936 prop_oneof![
937 (0u16..W, 0u16..H, 0usize..GRAPHEMES.len())
938 .prop_map(|(x, y, gi)| Op::WriteGrapheme(x, y, gi)),
939 (1u16..=W, 1u16..=H).prop_map(|(w, h)| Op::Resize(w, h)),
940 ],
941 0..64,
942 ),
943 ) {
944 let mut grid = Grid::new(W, H);
945 for op in &ops {
946 apply(&mut grid, op);
947 assert_wide_invariants(&grid);
948 }
949 }
950 }
951 }
952}