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 `None`, 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 `Some(())` otherwise, once the write has landed:
256 /// the same success/refusal split [`put_tile`](Self::put_tile) reports.
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 ) -> Option<()> {
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 None;
278 }
279
280 let mut chars = grapheme.chars();
281 let first = chars.next().unwrap_or(' ');
282 let has_extra = chars.next().is_some();
283
284 let mut tile = Tile::new(first, style);
285 // The full grapheme cluster's display width (1 or 2), not just `first`'s: more accurate
286 // than the primary codepoint alone, and what `put_tile` needs to decide whether to
287 // synthesize a spacer.
288 #[allow(clippy::cast_possible_truncation)]
289 {
290 tile.width = width as u8;
291 }
292 self.put_tile(layer, Pos::new(x, y), tile)?;
293
294 if has_extra {
295 self.set_extra(
296 layer,
297 x,
298 y,
299 TileExtra {
300 grapheme: Some(Arc::from(cap_grapheme(grapheme))),
301 tint: Tint::None,
302 },
303 );
304 }
305
306 Some(())
307 }
308
309 /// Clears wide-character cells that would be partially overwritten by a
310 /// write starting at `(x, y)` spanning `width` columns.
311 ///
312 /// `clear_span_overlap` is the multi-cell-span analogue. Not gated behind `egc`: `write_grapheme`
313 /// is `egc`-only, but [`put_tile`](Self::put_tile) writes a wide-character pair on every
314 /// feature combination (see its own doc comment), so a write that can land inside either kind
315 /// of multi-cell structure calls both regardless of `egc`.
316 pub(super) fn clear_overlap(&mut self, layer: u8, x: u16, y: u16, width: u16) {
317 let w = usize::from(self.width);
318 let cap = w * usize::from(self.height);
319 // An unallocated layer has never written a wide-character cell, so there is nothing to
320 // clear: return before `layer_or_alloc` would allocate one just to find that (retroglyph#1012).
321 let Some(lb) = self
322 .layers
323 .get_mut(usize::from(layer))
324 .and_then(Option::as_mut)
325 else {
326 return;
327 };
328 // Every call site bounds-checks `x`/`y` (and, where relevant, the whole `x..x+width`
329 // range) against `self.width`/`self.height` before reaching here (`put_tile`,
330 // `write_grapheme`, `fill_rect`'s already-clipped `rect`, `blit_with`'s per-cell `dx`
331 // check, and `write_span_cells`'s own footprint check). A `cx` past the row's own width
332 // would still compute an `idx` `< cap` below (just landing in the next row) and clear an
333 // unrelated cell instead of being treated as out of bounds, so this is asserted rather
334 // than silently trusted.
335 debug_assert!(
336 usize::from(x.saturating_add(width)) <= w,
337 "caller must bounds-check"
338 );
339 for cx in x..x.saturating_add(width) {
340 let idx = usize::from(y) * w + usize::from(cx);
341 if idx >= cap {
342 continue;
343 }
344 // flags is Copy, so reading through the shared ref is fine.
345 let flags = lb.buf.as_ref()[idx].flags;
346
347 if flags.contains(TileFlags::WIDE_CHAR_SPACER) && cx > 0 {
348 let pidx = usize::from(y) * w + usize::from(cx - 1);
349 if pidx < cap {
350 lb.buf.as_mut()[pidx].reset();
351 lb.extras.remove(&pidx);
352 }
353 }
354
355 if flags.contains(TileFlags::WIDE_CHAR) {
356 let sidx = usize::from(y) * w + usize::from(cx + 1);
357 if sidx < cap {
358 lb.buf.as_mut()[sidx].reset();
359 lb.extras.remove(&sidx);
360 }
361 }
362 }
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn new_reports_the_requested_width_and_height() {
372 let grid = Grid::new(80, 25);
373 assert_eq!(grid.width(), 80);
374 assert_eq!(grid.height(), 25);
375 }
376
377 #[test]
378 #[should_panic(expected = "Grid width must be at least 1")]
379 fn new_zero_width_panics() {
380 let _ = Grid::new(0, 5);
381 }
382
383 #[test]
384 fn new_zero_height_is_allowed() {
385 let grid = Grid::new(5, 0);
386 assert_eq!(grid.width(), 5);
387 assert_eq!(grid.height(), 0);
388 }
389
390 #[test]
391 #[should_panic(expected = "Grid width must be at least 1")]
392 fn new_zero_by_zero_panics() {
393 let _ = Grid::new(0, 0);
394 }
395
396 #[test]
397 fn resize_to_zero_width_is_allowed() {
398 let mut grid = Grid::new(5, 5);
399 grid.resize(0, 5);
400 assert_eq!(grid.width(), 0);
401 assert_eq!(grid.height(), 5);
402 }
403
404 #[test]
405 fn resize_to_zero_height_is_allowed() {
406 let mut grid = Grid::new(5, 5);
407 grid.resize(5, 0);
408 assert_eq!(grid.width(), 5);
409 assert_eq!(grid.height(), 0);
410 }
411
412 #[test]
413 fn resize_to_zero_by_zero_is_allowed() {
414 let mut grid = Grid::new(5, 5);
415 grid.resize(0, 0);
416 assert_eq!(grid.width(), 0);
417 assert_eq!(grid.height(), 0);
418 }
419
420 #[test]
421 fn resize_expand_preserves_existing_cells_and_defaults_new_ones() {
422 let mut grid = Grid::new(3, 3);
423 grid.put_tile(0, (1, 1), Tile::default().with_glyph('X'));
424 grid.resize(6, 6);
425 assert_eq!(grid.width(), 6);
426 assert_eq!(grid.height(), 6);
427 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'X'); // preserved
428 assert_eq!(grid[Pos::new(5, 5)].glyph(), ' '); // new cells default
429 }
430
431 #[test]
432 fn resize_shrink_preserves_cells_still_in_bounds() {
433 let mut grid = Grid::new(10, 10);
434 grid.put_tile(0, (1, 1), Tile::default().with_glyph('A'));
435 grid.resize(5, 5);
436 assert_eq!(grid.width(), 5);
437 assert_eq!(grid.height(), 5);
438 assert_eq!(grid[Pos::new(1, 1)].glyph(), 'A'); // still in bounds, preserved
439 }
440
441 #[test]
442 fn resize_shrink_drops_cells_that_fall_outside_the_new_bounds() {
443 let mut grid = Grid::new(4, 4);
444 grid.put_tile(0, (0, 0), Tile::default().with_glyph('@'));
445 grid.put_tile(0, (3, 3), Tile::default().with_glyph('X'));
446 grid.resize(3, 3); // shrink: (3,3) falls outside
447 assert_eq!(grid[Pos::new(0, 0)].glyph(), '@');
448 assert_eq!(grid[Pos::new(2, 2)].glyph(), ' '); // was default, still default
449 }
450
451 // --- Extra grapheme text (EGC side-table) ---
452 #[cfg(feature = "egc")]
453 #[test]
454 fn write_grapheme_stores_and_reads_extra() {
455 let mut g = Grid::new(5, 5);
456 g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
457 assert_eq!(g[Pos::new(1, 1)].glyph, 'e');
458 assert_eq!(crate::grid::grapheme_at(&g, 0, 1, 1), Some("e\u{0301}"));
459
460 // Single-codepoint writes never populate the side-table.
461 g.write_grapheme(0, 2, 2, "a", Style::default());
462 assert_eq!(crate::grid::grapheme_at(&g, 0, 2, 2), None);
463 }
464
465 #[cfg(feature = "egc")]
466 #[test]
467 fn write_grapheme_x_past_width_wraps_onto_the_next_row() {
468 let mut grid = Grid::new(10, 10);
469 grid.write_grapheme(0, 12, 0, "A", Style::default()); // x = 12 on a 10-wide grid
470 assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
471 assert_eq!(grid[Pos::new(2, 1)].glyph(), ' ');
472 }
473
474 #[cfg(feature = "egc")]
475 #[test]
476 fn write_grapheme_y_past_height_is_refused() {
477 let mut grid = Grid::new(10, 10);
478 assert!(
479 grid.write_grapheme(0, 0, 12, "A", Style::default())
480 .is_none()
481 ); // y = 12 on a 10-tall grid
482 for y in 0..10 {
483 assert_eq!(grid[Pos::new(0, y)].glyph(), ' ');
484 }
485 }
486
487 #[cfg(feature = "egc")]
488 #[test]
489 fn write_grapheme_zero_width_is_refused() {
490 let mut grid = Grid::new(10, 10);
491 // A lone combining mark, with no base character in front of it, has zero display width
492 // on its own.
493 assert!(
494 grid.write_grapheme(0, 2, 1, "\u{0301}", Style::default())
495 .is_none()
496 );
497 assert_eq!(grid[Pos::new(2, 1)].glyph(), ' ');
498 }
499
500 /// Mirrors `put_tile_refuses_a_wide_glyph_at_the_last_column`: a wide grapheme whose spacer
501 /// would fall off the grid is refused outright rather than leaving an orphaned primary cell.
502 #[cfg(feature = "egc")]
503 #[test]
504 fn write_grapheme_refuses_a_wide_glyph_at_the_last_column() {
505 let mut grid = Grid::new(4, 4);
506 assert!(
507 grid.write_grapheme(0, 3, 0, "\u{4e2d}", Style::default())
508 .is_none()
509 );
510 assert_eq!(grid[Pos::new(3, 0)].glyph(), ' ');
511 }
512
513 /// retroglyph#1012, via delegation to `put_tile`: a refused write must not allocate the
514 /// layer just to find out it was going to be refused.
515 #[cfg(feature = "egc")]
516 #[test]
517 fn write_grapheme_refused_at_the_last_column_does_not_allocate_the_layer() {
518 let mut grid = Grid::new(4, 4);
519 assert!(
520 grid.write_grapheme(5, 3, 0, "\u{4e2d}", Style::default())
521 .is_none()
522 );
523 assert_eq!(grid.max_layer(), 0);
524 assert!(grid.layer(5).is_none());
525 }
526
527 /// `write_grapheme` writes a whole fresh `Tile` (via `put_tile`), so a prior sub-cell offset
528 /// on the overwritten cell does not survive, the same as any other `put_tile` write.
529 #[cfg(feature = "egc")]
530 #[test]
531 fn write_grapheme_overwrite_resets_a_prior_sub_cell_offset() {
532 let mut grid = Grid::new(4, 4);
533 grid.put_tile(
534 0,
535 (1, 1),
536 Tile::new('A', Style::default()).with_offset(3, -2),
537 );
538 assert_eq!(grid[Pos::new(1, 1)].dx(), 3);
539 assert_eq!(grid[Pos::new(1, 1)].dy(), -2);
540
541 grid.write_grapheme(0, 1, 1, "B", Style::default());
542 assert_eq!(grid[Pos::new(1, 1)].dx(), 0);
543 assert_eq!(grid[Pos::new(1, 1)].dy(), 0);
544 }
545
546 #[cfg(feature = "egc")]
547 #[test]
548 fn write_grapheme_in_bounds_still_writes() {
549 let mut grid = Grid::new(10, 10);
550 grid.write_grapheme(0, 2, 1, "A", Style::default());
551 assert_eq!(grid[Pos::new(2, 1)].glyph(), 'A');
552 }
553
554 #[cfg(feature = "egc")]
555 #[test]
556 fn put_tile_overwrite_clears_extra() {
557 let mut g = Grid::new(5, 5);
558 g.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
559 assert_eq!(crate::grid::grapheme_at(&g, 0, 0, 0), Some("e\u{0301}"));
560
561 // A plain `put` (or a later single-codepoint `write_grapheme`) must
562 // drop the stale side-table entry, not just leave it unreachable.
563 g.put_tile(0, (0, 0), Tile::new('X', Style::default()));
564 assert_eq!(crate::grid::grapheme_at(&g, 0, 0, 0), None);
565 assert!(!g[Pos::new(0, 0)].flags().contains(TileFlags::HAS_EXTRA));
566 }
567
568 #[cfg(feature = "egc")]
569 #[test]
570 fn resize_remaps_extras_to_new_stride() {
571 let mut g = Grid::new(4, 4);
572 g.write_grapheme(0, 3, 1, "e\u{0301}", Style::default());
573 assert_eq!(crate::grid::grapheme_at(&g, 0, 3, 1), Some("e\u{0301}"));
574
575 // Widening changes the row stride, so the flat index for (3, 1)
576 // changes even though the cell itself is preserved.
577 g.resize(8, 4);
578 assert_eq!(g[Pos::new(3, 1)].glyph, 'e');
579 assert_eq!(crate::grid::grapheme_at(&g, 0, 3, 1), Some("e\u{0301}"));
580 // No ghost entry landed on some other cell at the old flat index.
581 assert_eq!(crate::grid::grapheme_at(&g, 0, 7, 0), None);
582
583 // Shrinking past the cell drops its extras entry along with the tile.
584 g.resize(2, 4);
585 assert_eq!(crate::grid::grapheme_at(&g, 0, 3, 1), None);
586 }
587
588 #[test]
589 fn clear_drops_every_tint_on_the_layer() {
590 let mut g = Grid::new(4, 4);
591 g.set_tint(0, 1, 1, Tint::multiply(1, 2, 3));
592 g.set_tint(1, 1, 1, Tint::multiply(4, 5, 6));
593
594 g.clear(0);
595 assert_eq!(g.tint(0, 1, 1), Tint::None);
596 assert_eq!(g.tint(1, 1, 1), Tint::multiply(4, 5, 6));
597 }
598
599 #[cfg(feature = "egc")]
600 #[test]
601 fn resize_remaps_a_tint_to_the_new_stride() {
602 let mut g = Grid::new(4, 4);
603 g.write_grapheme(0, 3, 1, "@", Style::default());
604 g.set_tint(0, 3, 1, Tint::mix(200, 100, 50, 128));
605
606 // Widening changes the row stride, so (3, 1)'s flat index moves.
607 g.resize(8, 4);
608 assert_eq!(g.tint(0, 3, 1), Tint::mix(200, 100, 50, 128));
609 // No ghost entry landed on whatever cell now holds the old flat index.
610 assert_eq!(g.tint(0, 7, 0), Tint::None);
611
612 // Shrinking past the cell drops its entry with the tile.
613 g.resize(2, 4);
614 assert_eq!(g.tint(0, 3, 1), Tint::None);
615 }
616
617 #[test]
618 fn resize_narrower_resets_a_wide_char_split_by_the_new_last_column() {
619 use crate::color::Style;
620
621 let mut g = Grid::new(4, 1);
622 // Lead lands at (2, 0), spacer at (3, 0).
623 g.put_tile(0, (2, 0), Tile::new('\u{4e2d}', Style::default()));
624 assert!(
625 g.tile(0, (2, 0))
626 .unwrap()
627 .flags()
628 .contains(TileFlags::WIDE_CHAR)
629 );
630
631 // Drops column 3, which held the spacer: the lead can no longer be paired, so it must be
632 // reset rather than survive unpaired in the new last column.
633 g.resize(3, 1);
634 assert!(
635 !g.tile(0, (2, 0))
636 .unwrap()
637 .flags()
638 .contains(TileFlags::WIDE_CHAR)
639 );
640 }
641
642 #[test]
643 fn from_charmap_lone_wide_char() {
644 use crate::color::Style;
645
646 // A single wide char needs 2 columns of width; there is no narrower char after it to
647 // clobber its spacer, but sizing must give it the room in the first place.
648 let g = Grid::from_charmap("\u{4e2d}", |c| Tile::new(c, Style::default()));
649 assert_eq!(g.width(), 2);
650 assert_eq!(g.height(), 1);
651 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), '\u{4e2d}');
652 assert!(
653 g.tile(0, (0, 0))
654 .unwrap()
655 .flags()
656 .contains(TileFlags::WIDE_CHAR)
657 );
658 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), ' ');
659 assert!(
660 g.tile(0, (1, 0))
661 .unwrap()
662 .flags()
663 .contains(TileFlags::WIDE_CHAR_SPACER)
664 );
665 }
666
667 #[test]
668 fn from_charmap_wide_char_mid_line() {
669 use crate::color::Style;
670
671 // The wide char's spacer occupies column 1; the following 'x' must land at column 2,
672 // not column 1 where it would clobber the spacer and clear the wide lead.
673 let g = Grid::from_charmap("\u{4e2d}x", |c| Tile::new(c, Style::default()));
674 assert_eq!(g.width(), 3);
675 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), '\u{4e2d}');
676 assert!(
677 g.tile(0, (0, 0))
678 .unwrap()
679 .flags()
680 .contains(TileFlags::WIDE_CHAR)
681 );
682 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), ' ');
683 assert!(
684 g.tile(0, (1, 0))
685 .unwrap()
686 .flags()
687 .contains(TileFlags::WIDE_CHAR_SPACER)
688 );
689 assert_eq!(g.tile(0, (2, 0)).unwrap().glyph(), 'x');
690 assert_eq!(g.tile(0, (2, 0)).unwrap().flags(), TileFlags::empty());
691 }
692
693 #[test]
694 fn from_charmap_wide_char_at_end_of_line_fits_exactly() {
695 use crate::color::Style;
696
697 // The wide char is the last character of the widest (and only) line, so `from_charmap`
698 // must size the grid with room for both its lead and its spacer: unlike a caller passing
699 // an already-fixed width to `put_tile` directly, there is no way for this to hit
700 // `put_tile`'s last-column refusal, since the sizing pass and the write pass measure the
701 // same display width.
702 let g = Grid::from_charmap("a\u{4e2d}", |c| Tile::new(c, Style::default()));
703 assert_eq!(g.width(), 3);
704 assert_eq!(g.height(), 1);
705 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), 'a');
706 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), '\u{4e2d}');
707 assert!(
708 g.tile(0, (1, 0))
709 .unwrap()
710 .flags()
711 .contains(TileFlags::WIDE_CHAR)
712 );
713 assert_eq!(g.tile(0, (2, 0)).unwrap().glyph(), ' ');
714 assert!(
715 g.tile(0, (2, 0))
716 .unwrap()
717 .flags()
718 .contains(TileFlags::WIDE_CHAR_SPACER)
719 );
720 }
721
722 #[test]
723 fn from_charmap_ragged_map_mixing_wide_and_narrow_rows() {
724 use crate::color::Style;
725
726 // Row 0 is all narrow ("ab", width 2); row 1 is one wide char ("\u{4e2d}", width 2). Both
727 // rows have the same display width, so the grid is 2 columns wide and neither row needs
728 // padding, but row 1's single char must still claim both columns via the spacer.
729 let g = Grid::from_charmap("ab\n\u{4e2d}", |c| Tile::new(c, Style::default()));
730 assert_eq!(g.width(), 2);
731 assert_eq!(g.height(), 2);
732 assert_eq!(g.tile(0, (0, 0)).unwrap().glyph(), 'a');
733 assert_eq!(g.tile(0, (1, 0)).unwrap().glyph(), 'b');
734 assert_eq!(g.tile(0, (0, 1)).unwrap().glyph(), '\u{4e2d}');
735 assert!(
736 g.tile(0, (0, 1))
737 .unwrap()
738 .flags()
739 .contains(TileFlags::WIDE_CHAR)
740 );
741 assert_eq!(g.tile(0, (1, 1)).unwrap().glyph(), ' ');
742 assert!(
743 g.tile(0, (1, 1))
744 .unwrap()
745 .flags()
746 .contains(TileFlags::WIDE_CHAR_SPACER)
747 );
748 }
749
750 #[cfg(test)]
751 mod wide_char_proptests {
752 use super::*;
753 use crate::color::Style;
754 use crate::grid::{BlendMode, Rect};
755 use proptest::prelude::*;
756
757 const W: u16 = 8;
758 const H: u16 = 4;
759
760 /// Narrow and wide (CJK) single-char glyphs, for the ops that take a plain `char`
761 /// (`put_tile`, `fill_rect`, and the small stamp grids `blit`/`blit_alpha` copy from).
762 const CHARS: &[char] = &['a', '\u{4e2d}'];
763
764 /// Narrow, wide (CJK), combining-mark, and wide-emoji graphemes, for `write_grapheme`
765 /// (`egc`-only: a multi-codepoint combining-mark grapheme has no plain-`char` spelling).
766 #[cfg(feature = "egc")]
767 const GRAPHEMES: &[&str] = &["a", "\u{4e2d}", "e\u{0301}", "\u{1f600}"];
768
769 /// Every `WIDE_CHAR` has its spacer to the right, every `WIDE_CHAR_SPACER`
770 /// has its lead to the left, and no cell is both.
771 ///
772 /// Feature-independent: `put_tile` writes a `WIDE_CHAR`/`WIDE_CHAR_SPACER` pair on every
773 /// feature combination (retroglyph#869), so this check (unlike the `egc`-only
774 /// `write_grapheme` op below) does not belong behind `#[cfg(feature = "egc")]` (compare
775 /// retroglyph#994, the same over-gating problem in `tile.rs`).
776 fn assert_wide_invariants(grid: &Grid) {
777 for y in 0..grid.height() {
778 for x in 0..grid.width() {
779 let flags = grid[Pos::new(x, y)].flags();
780 let lead = flags.contains(TileFlags::WIDE_CHAR);
781 let spacer = flags.contains(TileFlags::WIDE_CHAR_SPACER);
782
783 assert!(
784 !(lead && spacer),
785 "cell ({x}, {y}) is both wide lead and spacer"
786 );
787
788 if lead {
789 assert!(x + 1 < grid.width(), "wide lead at ({x}, {y}) has no room");
790 assert!(
791 grid[Pos::new(x + 1, y)]
792 .flags()
793 .contains(TileFlags::WIDE_CHAR_SPACER),
794 "wide lead at ({x}, {y}) is missing its spacer"
795 );
796 }
797
798 if spacer {
799 assert!(x > 0, "orphan spacer at ({x}, {y}) (no cell to the left)");
800 assert!(
801 grid[Pos::new(x - 1, y)]
802 .flags()
803 .contains(TileFlags::WIDE_CHAR),
804 "orphan spacer at ({x}, {y}) (left cell is not a wide lead)"
805 );
806 }
807 }
808 }
809 }
810
811 /// One operation from `wide_char_bookkeeping_never_desyncs`'s op alphabet. Every variant
812 /// but `WriteGrapheme` is available on every feature combination, since `put_tile` (and
813 /// everything built on it: `fill_rect`, `blit`, `blit_alpha`, `write_span`) writes wide
814 /// pairs regardless of `egc` (retroglyph#869); only `write_grapheme` itself is `egc`-only.
815 #[derive(Debug, Clone)]
816 enum Op {
817 PutTile(u16, u16, usize),
818 FillRegion(u16, u16, u16, u16, usize),
819 /// Blits a small 2x2 stamp grid (built fresh from `gi`, a glyph at its origin) onto
820 /// `(dst_x, dst_y)`, which lands the stamp's own wide pair (or narrow tile) astride
821 /// an existing wide pair already in `grid`. `dst_x`/`dst_y` are kept off the grid's
822 /// far edge (see `arb_op`) so the 2x2 stamp is never itself clipped mid-pair by the
823 /// destination bounds; `blit`/`blit_alpha` writing a clipped half of a wide pair to
824 /// the destination edge is a separate, unresolved gap, not the overlap-clearing
825 /// behavior this proptest targets (see the follow-up filed alongside this PR).
826 Blit(u16, u16, usize),
827 BlitAlpha(u16, u16, usize),
828 WriteSpan(u16, u16),
829 Resize(u16, u16),
830 #[cfg(feature = "egc")]
831 WriteGrapheme(u16, u16, usize),
832 }
833
834 fn arb_op() -> impl Strategy<Value = Op> {
835 let base = prop_oneof![
836 (0u16..W, 0u16..H, 0usize..CHARS.len())
837 .prop_map(|(x, y, gi)| Op::PutTile(x, y, gi)),
838 (0u16..W, 0u16..H, 1u16..4, 1u16..4, 0usize..CHARS.len())
839 .prop_map(|(x, y, w, h, gi)| Op::FillRegion(x, y, w, h, gi)),
840 (0u16..(W - 1), 0u16..(H - 1), 0usize..CHARS.len())
841 .prop_map(|(x, y, gi)| Op::Blit(x, y, gi)),
842 (0u16..(W - 1), 0u16..(H - 1), 0usize..CHARS.len())
843 .prop_map(|(x, y, gi)| Op::BlitAlpha(x, y, gi)),
844 (0u16..W, 0u16..H).prop_map(|(x, y)| Op::WriteSpan(x, y)),
845 // Bounded away from 0/1: a grid narrower or shorter than the 2x2 `Blit`/
846 // `BlitAlpha` stamp can never hold a wide pair at all regardless of where it
847 // lands, which is the same unresolved destination-clipping gap `Blit`/
848 // `BlitAlpha`'s own doc comment calls out, just reached from the other side.
849 (2u16..W * 2, 2u16..H * 2).prop_map(|(w, h)| Op::Resize(w, h)),
850 ];
851 #[cfg(feature = "egc")]
852 let base = prop_oneof![
853 base,
854 (0u16..W, 0u16..H, 0usize..GRAPHEMES.len())
855 .prop_map(|(x, y, gi)| Op::WriteGrapheme(x, y, gi)),
856 ];
857 base
858 }
859
860 fn apply(grid: &mut Grid, op: &Op) {
861 match *op {
862 Op::PutTile(x, y, gi) => {
863 grid.put_tile(0, (x, y), Tile::new(CHARS[gi], Style::default()));
864 }
865 Op::FillRegion(x, y, w, h, gi) => {
866 grid.fill_rect(
867 0,
868 Rect::new(x, y, w, h),
869 Tile::new(CHARS[gi], Style::default()),
870 );
871 }
872 Op::Blit(dst_x, dst_y, gi) => {
873 let mut stamp = Grid::new(2, 2);
874 stamp.put_tile(0, (0, 0), Tile::new(CHARS[gi], Style::default()));
875 // Reclamped to the *current* grid size (a prior `Resize` op may have shrunk
876 // it below `W`/`H`): stays off the far edge for the same reason `arb_op`
877 // keeps the un-clamped values off it.
878 let dst_x = dst_x.min(grid.width().saturating_sub(2));
879 let dst_y = dst_y.min(grid.height().saturating_sub(2));
880 grid.blit(0, &stamp, Rect::new(0, 0, 2, 2), dst_x, dst_y);
881 }
882 Op::BlitAlpha(dst_x, dst_y, gi) => {
883 let mut stamp = Grid::new(2, 2);
884 stamp.put_tile(0, (0, 0), Tile::new(CHARS[gi], Style::default()));
885 let dst_x = dst_x.min(grid.width().saturating_sub(2));
886 let dst_y = dst_y.min(grid.height().saturating_sub(2));
887 grid.blit_alpha(
888 0,
889 &stamp,
890 Rect::new(0, 0, 2, 2),
891 dst_x,
892 dst_y,
893 BlendMode::Linear,
894 1.0,
895 1.0,
896 );
897 }
898 Op::WriteSpan(x, y) => {
899 grid.write_span(0, x, y, &["ab"], Style::default());
900 }
901 Op::Resize(w, h) => {
902 grid.resize(w, h);
903 }
904 #[cfg(feature = "egc")]
905 Op::WriteGrapheme(x, y, gi) => {
906 grid.write_grapheme(0, x, y, GRAPHEMES[gi], Style::default());
907 }
908 }
909 }
910
911 proptest! {
912 #[test]
913 fn wide_char_bookkeeping_never_desyncs(
914 ops in prop::collection::vec(arb_op(), 0..64),
915 ) {
916 let mut grid = Grid::new(W, H);
917 for op in &ops {
918 apply(&mut grid, op);
919 // The invariant must hold after every single op, not just at the end: an
920 // intermediate orphan would be a real bug.
921 assert_wide_invariants(&grid);
922 }
923 }
924
925 /// Sibling of `wide_char_bookkeeping_never_desyncs` above, narrowed to just
926 /// `write_grapheme` interleaved with `resize` (both growing and shrinking, on both
927 /// axes): a dedicated regression pin for `resize` splitting a wide-character pair
928 /// (retroglyph#1015), on top of the broader op alphabet already covering the same
929 /// ground probabilistically.
930 #[cfg(feature = "egc")]
931 #[test]
932 fn wide_char_bookkeeping_never_desyncs_across_resizes(
933 ops in prop::collection::vec(
934 prop_oneof![
935 (0u16..W, 0u16..H, 0usize..GRAPHEMES.len())
936 .prop_map(|(x, y, gi)| Op::WriteGrapheme(x, y, gi)),
937 (1u16..=W, 1u16..=H).prop_map(|(w, h)| Op::Resize(w, h)),
938 ],
939 0..64,
940 ),
941 ) {
942 let mut grid = Grid::new(W, H);
943 for op in &ops {
944 apply(&mut grid, op);
945 assert_wide_invariants(&grid);
946 }
947 }
948 }
949 }
950}