Skip to main content

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}