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 `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}