Skip to main content

retroglyph_core/grid/layers/
blit.rs

1//! Cross-grid copies: [`Grid::blit`], [`Grid::blit_alpha`], and [`Grid::blit_cross_layer`], built
2//! on the shared [`Grid::blit_with`] copy loop.
3//!
4//! The [`BlendMode`] blend math backing [`Grid::blit_alpha`] lives here too, next to its only
5//! caller.
6
7#[cfg(test)]
8use super::super::Pos;
9use super::super::{BlendMode, Grid, Rect, TileExtra};
10use crate::color::Color;
11#[cfg(test)]
12use crate::color::{Style, Tint};
13use crate::tile::{Tile, TileFlags};
14use alloc::vec::Vec;
15use alpha_blend::BlendMode as SeparableBlendMode;
16use alpha_blend::channel::Channel;
17
18impl Grid {
19    /// Copies tiles from `src` within `src_rect` to `self` at `(dst_x, dst_y)`
20    /// on `layer`. Empty tiles (nothing written; see [`Tile::is_empty`]) are
21    /// treated as transparent and skipped. An explicit space is copied and
22    /// overwrites the destination.
23    ///
24    /// Multi-cell spans (see [`write_span`](Self::write_span)) do **not** survive a blit: copied
25    /// tiles keep their glyphs but lose [`TileFlags::SPAN_ANCHOR`]/[`TileFlags::SPAN_COVERED`],
26    /// so a span degrades to exactly its text fallback. `src_rect` can clip a span in half, and
27    /// half a span is not a thing the grid can represent; degrading to the fallback glyphs is
28    /// both representable and the same content a cell backend would have drawn anyway.
29    ///
30    /// The same is true of wide-character pairs: `src_rect` clipping a lead from its spacer, or
31    /// the copy landing on only one half of a destination pair, both leave half a pair, which is
32    /// equally unrepresentable. Either case strips [`TileFlags::WIDE_CHAR`]/
33    /// [`TileFlags::WIDE_CHAR_SPACER`] from the surviving half (or clears the destination half
34    /// the copy overwrites), so a blit can never leave a dangling lead or an orphaned spacer
35    /// behind (retroglyph#1013).
36    ///
37    /// Walks `src`'s and `self`'s layer buffers directly by flat index instead of going through
38    /// [`tile`](Self::tile)/[`put_tile`](Self::put_tile) per cell (see retroglyph#263):
39    /// each of those recomputes a coordinate conversion and a bounds check per cell, which this
40    /// does once per row instead. The destination layer is allocated once, up front, rather than
41    /// as a side effect of the first written cell, but only if `src_rect` (clamped to `src`'s
42    /// bounds) contains at least one non-empty tile, matching `put_tile`'s original
43    /// allocate-on-first-write behavior for a `src_rect` that is entirely transparent.
44    pub fn blit(&mut self, layer: u8, src: &Self, src_rect: Rect, dst_x: u16, dst_y: u16) {
45        self.blit_with(
46            layer,
47            src,
48            layer,
49            src_rect,
50            dst_x,
51            dst_y,
52            |tile, _dst_tile| *tile,
53        );
54    }
55
56    /// Same as [`blit`](Self::blit) but blends foreground and background
57    /// colors with the given alpha factors, using `mode` to compute the
58    /// blended color. `fg_alpha` and `bg_alpha` are in 0.0-1.0 range where
59    /// 0.0 = keep destination, 1.0 = replace with src; for a non-
60    /// [`Linear`](BlendMode::Linear) `mode`, "replace with src" instead means
61    /// "replace with `mode`'s fully blended color" (see [`BlendMode`]).
62    ///
63    /// Blending operates on packed RGB values; [`Color::Default`] preserves
64    /// the destination. Non-RGB color variants (Ansi/Indexed) are passed
65    /// through unblended, regardless of `mode`.
66    ///
67    /// [`BlendMode::Linear`]'s per-channel color lerp is delegated to [`gem::Mix`]. The other
68    /// modes delegate to [`alpha_blend::BlendMode`] (imported in this module as
69    /// `SeparableBlendMode` to avoid colliding with this crate's own [`BlendMode`]).
70    ///
71    /// Like [`blit`](Self::blit) (see retroglyph#262/#263), walks `src`'s and `self`'s layer
72    /// buffers directly by flat index instead of per-cell [`tile`](Self::tile)/
73    /// [`put_tile`](Self::put_tile), and allocates the destination layer once, up front, rather
74    /// than as a side effect of the first written cell.
75    #[allow(clippy::too_many_arguments, clippy::float_cmp)]
76    pub fn blit_alpha(
77        &mut self,
78        layer: u8,
79        src: &Self,
80        src_rect: Rect,
81        dst_x: u16,
82        dst_y: u16,
83        mode: BlendMode,
84        fg_alpha: f32,
85        bg_alpha: f32,
86    ) {
87        self.blit_with(
88            layer,
89            src,
90            layer,
91            src_rect,
92            dst_x,
93            dst_y,
94            |tile, dst_tile| {
95                let mut blended = *tile;
96                // `fg_alpha == 1.0` only lets `Linear` skip the call: `Linear` at `t ==
97                // 1.0` is `src` by definition, but a `Screen`/`Dodge`/`Burn`/`Overlay`
98                // mix at full alpha still needs to run the mode's formula: it isn't
99                // equivalent to the raw source color (see `blend_color`'s matching guard).
100                if mode != BlendMode::Linear || fg_alpha != 1.0 {
101                    blended.style.fg =
102                        blend_color(mode, tile.style.fg, dst_tile.style.fg, fg_alpha);
103                }
104                if mode != BlendMode::Linear || bg_alpha != 1.0 {
105                    blended.style.bg =
106                        blend_color(mode, tile.style.bg, dst_tile.style.bg, bg_alpha);
107                }
108                blended
109            },
110        );
111    }
112
113    /// Same as [`blit`](Self::blit), except the source tiles are read from `src_layer` on `src`
114    /// rather than from `dst_layer` (the layer this writes to on `self`).
115    ///
116    /// [`blit`](Self::blit) uses one `layer` for both sides, which is exactly right for two
117    /// grids sharing the same layer scheme (e.g. [`Surface::on_layer`](crate::surface::Surface::on_layer)
118    /// copying within itself), but wrong for [`Surface::blit`](crate::surface::Surface::blit)'s case: a
119    /// `src` that is a standalone, layer-0-only `Grid` (composed content like `BoxStyle::render`'s
120    /// output), stamped onto a destination surface that may currently be on any layer. Calling
121    /// [`blit`](Self::blit) with the destination's layer there looks up that same layer on `src`,
122    /// finds nothing (`src` only ever populated layer 0), and silently copies nothing
123    /// (retroglyph#824). This method exists so a caller in that position can pin `src_layer` to
124    /// `0` independently of `dst_layer`.
125    pub(crate) fn blit_cross_layer(
126        &mut self,
127        dst_layer: u8,
128        src: &Self,
129        src_layer: u8,
130        src_rect: Rect,
131        dst_x: u16,
132        dst_y: u16,
133    ) {
134        self.blit_with(
135            dst_layer,
136            src,
137            src_layer,
138            src_rect,
139            dst_x,
140            dst_y,
141            |tile, _dst_tile| *tile,
142        );
143    }
144
145    /// Shared copy loop behind [`blit`](Self::blit), [`blit_alpha`](Self::blit_alpha), and
146    /// [`blit_cross_layer`](Self::blit_cross_layer): clamps `src_rect` to `src`'s bounds, skips
147    /// the whole call if nothing in it is visible, clears any destination span or wide-character
148    /// pair the copy is about to partially overwrite (retroglyph#710, retroglyph#1013), and walks
149    /// matching `src`/destination cells by
150    /// flat index (retroglyph#262/#263), applying `transform` to each non-empty source tile
151    /// (given the source tile and, for context, the destination tile it's about to replace)
152    /// before writing it and fixing up grapheme extras. `dst_x`/`dst_y` saturate on overflow
153    /// (retroglyph#268) rather than wrapping; the bounds checks below always catch a saturated
154    /// `u16::MAX` origin.
155    ///
156    /// `dst_layer` and `src_layer` are separate parameters (rather than the one `layer` [`blit`]
157    /// and [`blit_alpha`] expose) so [`blit_cross_layer`](Self::blit_cross_layer) can read a
158    /// different source layer than the one it writes: see that method's own doc for why (this is
159    /// the retroglyph#824 fix).
160    ///
161    /// ```text
162    ///   src (read from src_layer)              self (written at dst_layer)
163    ///   +-----------------------+              +-----------------------+
164    ///   |     src_rect          |              |                       |
165    ///   |     +--------+        |  translate   |    (dst_x,dst_y)      |
166    ///   |     |  A  B  |        |  by          |    +--------+         |
167    ///   |     |  C  D..|........|. (dst_x -    |    |  A  B  |         |
168    ///   |     +-----|--+   src  |   src_rect   |    |  C  D  |         |
169    ///   |           |   bounds  |   origin)    |    +-----|--+  self   |
170    ///   +-----------|-----------+              +----------|---bounds--+
171    ///               clipped to src's edge                 clipped again to self's edge
172    ///
173    ///   Copied region = src_rect ∩ src bounds ∩ (self bounds shifted back by the offset).
174    ///   Cells outside any of the three are skipped, never wrapped (dst_x/dst_y saturate).
175    /// ```
176    #[allow(clippy::too_many_arguments)]
177    fn blit_with(
178        &mut self,
179        dst_layer: u8,
180        src: &Self,
181        src_layer: u8,
182        src_rect: Rect,
183        dst_x: u16,
184        dst_y: u16,
185        transform: impl Fn(&Tile, &Tile) -> Tile,
186    ) {
187        let Some(src_lb) = src.layer(src_layer) else {
188            return;
189        };
190        let src_width = usize::from(src.width);
191        let sx0 = src_rect.left().min(src.width);
192        let sx1 = src_rect.right().min(src.width);
193        let sy0 = src_rect.top().min(src.height);
194        let sy1 = src_rect.bottom().min(src.height);
195        if sx0 >= sx1 || sy0 >= sy1 {
196            return;
197        }
198
199        // Matches the original's implicit allocate-on-first-write: only touch the destination
200        // layer at all if there's at least one visible (non-empty) source tile to copy.
201        let has_visible = (sy0..sy1).any(|sy| {
202            let start = usize::from(sy) * src_width + usize::from(sx0);
203            let end = usize::from(sy) * src_width + usize::from(sx1);
204            src_lb.buf.as_ref()[start..end]
205                .iter()
206                .any(|t| !t.flags.contains(TileFlags::EMPTY))
207        });
208        if !has_visible {
209            return;
210        }
211
212        let dst_width = usize::from(self.width);
213        let dst_height = usize::from(self.height);
214
215        // A blit writes straight into the destination buffer below, bypassing `put_tile`, so it
216        // has to do `put_tile`'s `clear_span_overlap`/`clear_overlap` calls itself, or a cell that
217        // used to anchor (or be covered by) a multi-cell span, or half of a wide-character pair,
218        // would keep claiming cells this blit just overwrote (retroglyph#710, retroglyph#1013).
219        // Only the cells actually being overwritten (in bounds, non-empty source tile) are
220        // cleared: an empty source tile is transparent and leaves the destination untouched, so
221        // clearing a whole row's footprint up front would wipe out spans/pairs the blit never
222        // actually touches. `clear_span_overlap` is gated on `has_spans` so a grid that never uses
223        // spans pays only the one `bool` check; `clear_overlap` has no such gate because `put_tile`
224        // itself never gates it (`WIDE_CHAR`/`WIDE_CHAR_SPACER` are set on every feature
225        // combination, not just under `egc`).
226        for sy in sy0..sy1 {
227            let dy = dst_y.saturating_add(sy - src_rect.top());
228            if usize::from(dy) >= dst_height {
229                continue;
230            }
231            for sx in sx0..sx1 {
232                let dx = dst_x.saturating_add(sx - src_rect.left());
233                if usize::from(dx) >= dst_width {
234                    continue;
235                }
236                let src_idx = usize::from(sy) * src_width + usize::from(sx);
237                if src_lb.buf.as_ref()[src_idx]
238                    .flags
239                    .contains(TileFlags::EMPTY)
240                {
241                    continue;
242                }
243                if self.has_spans {
244                    self.clear_span_overlap(dst_layer, dx, dy, 1);
245                }
246                self.clear_overlap(dst_layer, dx, dy, 1);
247            }
248        }
249
250        let dst_lb = self.layer_or_alloc(dst_layer);
251        let mut pending_extras: Vec<(usize, TileExtra)> = Vec::new();
252
253        for sy in sy0..sy1 {
254            let dy = dst_y.saturating_add(sy - src_rect.top());
255            if usize::from(dy) >= dst_height {
256                continue;
257            }
258            for sx in sx0..sx1 {
259                let dx = dst_x.saturating_add(sx - src_rect.left());
260                if usize::from(dx) >= dst_width {
261                    continue;
262                }
263                let src_idx = usize::from(sy) * src_width + usize::from(sx);
264                let tile = &src_lb.buf.as_ref()[src_idx];
265                if tile.flags.contains(TileFlags::EMPTY) {
266                    continue;
267                }
268                let dst_idx = usize::from(dy) * dst_width + usize::from(dx);
269                let dst_tile = dst_lb.buf.as_ref()[dst_idx];
270                let mut out_tile = transform(tile, &dst_tile);
271                out_tile.flags.remove(TileFlags::HAS_EXTRA);
272                out_tile.clear_span();
273
274                // Half a wide-character pair is as unrepresentable as half a span (see
275                // `clear_span` above): `src_rect` or the destination clip can separate a lead
276                // from its spacer, so drop the flag on whichever half survives the copy alone
277                // rather than leave a dangling lead (no spacer to its right) or an orphaned
278                // spacer (no lead to its left) (retroglyph#1013).
279                if out_tile.flags.contains(TileFlags::WIDE_CHAR) {
280                    let partner_survived = sx + 1 < sx1 && usize::from(dx) + 1 < dst_width;
281                    if !partner_survived {
282                        out_tile.clear_wide();
283                    }
284                } else if out_tile.flags.contains(TileFlags::WIDE_CHAR_SPACER) {
285                    let partner_survived = sx > sx0 && dx > 0;
286                    if !partner_survived {
287                        out_tile.clear_wide();
288                    }
289                }
290                dst_lb.buf.as_mut()[dst_idx] = out_tile;
291                if tile.flags.contains(TileFlags::HAS_EXTRA) {
292                    if let Some(extra) = src_lb.extra_entry_for(src_idx, tile) {
293                        pending_extras.push((dst_idx, extra));
294                    }
295                } else {
296                    dst_lb.extras.remove(&dst_idx);
297                }
298            }
299        }
300
301        for (idx, extra) in pending_extras {
302            dst_lb.buf.as_mut()[idx].flags.insert(TileFlags::HAS_EXTRA);
303            dst_lb.extras.insert(idx, extra);
304        }
305    }
306}
307
308/// Blends two [`Color`] values using `mode`. [`Color::Default`] preserves the
309/// destination. Non-RGB source colors are returned as-is (no resolution).
310///
311/// [`BlendMode::Linear`] is a per-channel sRGB-domain lerp (dst -> src by `t`) delegated to
312/// [`gem::Mix`], which is `no_std`-safe (round-half-away via `floor(x + 0.5)`, no `std`/`libm`
313/// float intrinsics). The other modes evaluate [`SeparableBlendMode::mix`] per channel in
314/// `0.0..=1.0` (converting u8 <-> f32 at the boundary; see [`blend_separable_channel`]), then lerp
315/// that fully mixed color against the destination by `t`, same as `Linear`.
316#[allow(clippy::float_cmp)]
317fn blend_color(mode: BlendMode, src: Color, dst: Color, t: f32) -> Color {
318    use gem::Mix as _;
319    use gem::rgb::{HasBlue as _, HasGreen as _, HasRed as _, Rgb888};
320    match (src, dst) {
321        (Color::Default, _) => Color::Default,
322        (
323            Color::Rgb {
324                r: sr,
325                g: sg,
326                b: sb,
327            },
328            Color::Rgb {
329                r: dr,
330                g: dg,
331                b: db,
332            },
333        ) if mode != BlendMode::Linear || t != 1.0 => {
334            // `Linear` at `t == 1.0` is `src` by definition (skip to the catch-all arm below);
335            // the other modes must still run their mix formula at `t == 1.0`: see `blit_alpha`.
336            let (r, g, b) = mode.separable().map_or_else(
337                || {
338                    // `dst.mix(src, t)`, not `src.mix(dst, t)`: at `t == 0.0` this must return
339                    // `dst` ("keep destination", per `blit_alpha`'s doc comment) and only reach
340                    // `src` at `t == 1.0`: the same `0.0 == dst, 1.0 == fully blended` contract
341                    // every other `BlendMode` follows (see `blend_separable_channel`).
342                    let out = Rgb888::from_rgb(dr, dg, db).mix(Rgb888::from_rgb(sr, sg, sb), t);
343                    (out.red(), out.green(), out.blue())
344                },
345                |sep| {
346                    (
347                        blend_separable_channel(sep, sr, dr, t),
348                        blend_separable_channel(sep, sg, dg, t),
349                        blend_separable_channel(sep, sb, db, t),
350                    )
351                },
352            );
353            Color::Rgb { r, g, b }
354        }
355        (src, _) => src,
356    }
357}
358
359/// Evaluates `sep`'s per-channel mixing function for one RGB channel (`src`/`dst` are u8, `sep`
360/// operates in `0.0..=1.0` f32), then lerps that mixed value against `dst` by `t`: `0.0` keeps
361/// `dst`, `1.0` uses the fully mixed color. Clamps before converting back to u8 via
362/// `Channel::from_f32`, since `ColorDodge`/`ColorBurn`'s `min(1.0, ...)` branches can round a
363/// hair outside `0.0..=1.0` at the float boundary.
364fn blend_separable_channel(sep: SeparableBlendMode, src: u8, dst: u8, t: f32) -> u8 {
365    let cs = Channel::to_f32(src);
366    let cb = Channel::to_f32(dst);
367    let mixed = sep.mix(cb, cs);
368    // A plain multiply-add measurably disagrees with a fused one (`crate::math::mul_add`) by
369    // ±1 LSB on some inputs.
370    let blended = crate::math::mul_add(mixed - cb, t, cb);
371    Channel::from_f32(blended.clamp(0.0, 1.0))
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[cfg(feature = "egc")]
379    #[test]
380    fn blit_carries_a_tint_across_grids() {
381        let mut src = Grid::new(4, 4);
382        src.write_grapheme(0, 1, 1, "@", Style::default());
383        src.set_tint(0, 1, 1, Tint::multiply(64, 128, 192));
384
385        let mut dst = Grid::new(4, 4);
386        // Pre-existing tint on the destination cell, to prove the copy replaces rather than
387        // merges with whatever was there.
388        dst.set_tint(0, 1, 1, Tint::mix(9, 9, 9, 9));
389        dst.blit(0, &src, Rect::new(0, 0, 4, 4), 0, 0);
390
391        assert_eq!(dst.tint(0, 1, 1), Tint::multiply(64, 128, 192));
392        assert_eq!(dst.tint(0, 0, 0), Tint::None);
393    }
394
395    #[cfg(feature = "egc")]
396    #[test]
397    fn blit_clears_a_destination_tint_where_the_source_has_none() {
398        let mut src = Grid::new(2, 2);
399        src.write_grapheme(0, 0, 0, "@", Style::default());
400
401        let mut dst = Grid::new(2, 2);
402        dst.set_tint(0, 0, 0, Tint::multiply(1, 2, 3));
403        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
404
405        assert_eq!(dst.tint(0, 0, 0), Tint::None);
406    }
407
408    #[cfg(feature = "egc")]
409    #[test]
410    fn blit_preserves_extra() {
411        let mut src = Grid::new(2, 2);
412        src.write_grapheme(0, 0, 0, "e\u{0301}", Style::default());
413
414        let mut dst = Grid::new(2, 2);
415        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
416        assert_eq!(dst[Pos::new(0, 0)].glyph, 'e');
417        assert_eq!(crate::grid::grapheme_at(&dst, 0, 0, 0), Some("e\u{0301}"));
418    }
419
420    #[test]
421    fn blit_empty_rect_is_a_no_op() {
422        // A zero-area `src_rect` has no cells at all: `sx0 >= sx1` should short-circuit before
423        // touching the destination.
424        let src = Grid::new(2, 2);
425        let mut dst = Grid::new(2, 2);
426        dst.put_tile(0, (0, 0), Tile::new('x', Style::default()));
427        dst.blit(0, &src, Rect::new(0, 0, 0, 0), 0, 0);
428        assert_eq!(dst[Pos::new(0, 0)].glyph(), 'x');
429        assert_eq!(dst.max_layer(), 0);
430    }
431
432    #[test]
433    fn blit_fully_transparent_source_does_not_allocate_dst_layer() {
434        // Perf refactor (#263): the destination layer is allocated up front, but only after
435        // confirming the (clamped) source region has at least one non-empty tile, matching
436        // `put_tile`'s original allocate-on-first-write behavior for an all-transparent blit.
437        let src = Grid::new(2, 2);
438        let mut dst = Grid::new(2, 2);
439        dst.blit(3, &src, Rect::new(0, 0, 2, 2), 0, 0);
440        assert_eq!(dst.max_layer(), 0);
441    }
442
443    #[test]
444    fn blit_skips_out_of_bounds_source_and_dest_regions() {
445        let mut src = Grid::new(4, 4);
446        for y in 0..4 {
447            for x in 0..4 {
448                src.put_tile(0, (x, y), Tile::new('#', Style::default()));
449            }
450        }
451
452        let mut dst = Grid::new(2, 2);
453        // `src_rect` extends past `src`'s bounds and the destination offset pushes part of the
454        // copied region past `dst`'s bounds too; both should be silently clamped, not panic.
455        dst.blit(0, &src, Rect::new(2, 2, 10, 10), 1, 1);
456        assert_eq!(dst[Pos::new(1, 1)].glyph(), '#');
457        assert_eq!(dst[Pos::new(0, 0)].glyph(), ' ');
458        assert_eq!(dst[Pos::new(0, 1)].glyph(), ' ');
459        assert_eq!(dst[Pos::new(1, 0)].glyph(), ' ');
460    }
461
462    #[test]
463    fn blit_sub_cell_offset_and_transparency() {
464        let mut src = Grid::new(2, 2);
465        src.put_tile(0, (0, 0), Tile::new('A', Style::default()));
466        // (1, 0) and (1, 1) stay at their default (empty) tile: transparent, should not
467        // overwrite the destination.
468        src.put_tile(0, (0, 1), Tile::new('B', Style::default()));
469
470        let mut dst = Grid::new(3, 3);
471        dst.put_tile(0, (2, 2), Tile::new('Z', Style::default()));
472        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 1, 1);
473
474        assert_eq!(dst[Pos::new(1, 1)].glyph(), 'A');
475        assert_eq!(dst[Pos::new(1, 2)].glyph(), 'B');
476        // Untouched by the (transparent) source cells at (1, 0) and (1, 1).
477        assert_eq!(dst[Pos::new(2, 1)].glyph(), ' ');
478        assert_eq!(dst[Pos::new(2, 2)].glyph(), 'Z');
479    }
480
481    #[test]
482    fn blit_multi_layer_independent() {
483        let mut src = Grid::new(2, 2);
484        src.put_tile(0, (0, 0), Tile::new('a', Style::default()));
485        src.put_tile(2, (0, 0), Tile::new('b', Style::default()));
486
487        let mut dst = Grid::new(2, 2);
488        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
489        dst.blit(2, &src, Rect::new(0, 0, 2, 2), 0, 0);
490
491        assert_eq!(dst.tile(0, (0, 0)).map(Tile::glyph), Some('a'));
492        assert_eq!(dst.tile(2, (0, 0)).map(Tile::glyph), Some('b'));
493        // Layer 1 was never written by either blit call.
494        assert!(dst.tile(1, (0, 0)).is_none());
495    }
496
497    #[test]
498    fn blit_dest_origin_near_u16_max_does_not_wrap() {
499        // retroglyph#268: with a plain (non-saturating) `dst_x + (sx - src_rect.left())`, an
500        // origin this close to `u16::MAX` overflows and wraps back into a small, in-bounds
501        // value: silently corrupting an unrelated cell instead of being clamped out. Picked so
502        // that `dst_x + 3` overflows `u16` and wraps to `1`, which *is* in-bounds for this small
503        // `dst` grid: `65534u16.wrapping_add(3) == 1`.
504        let mut src = Grid::new(4, 1);
505        src.put_tile(0, (3, 0), Tile::new('Q', Style::default()));
506
507        let mut dst = Grid::new(4, 1);
508        dst.blit(0, &src, Rect::new(0, 0, 4, 1), u16::MAX - 1, 0);
509
510        // The would-be-wrapped cell (index 1) must not have been touched.
511        assert_eq!(dst[Pos::new(1, 0)].glyph(), ' ');
512        // No other cell was touched either: the whole row's writes overflowed and were
513        // skipped (dst_x saturates to u16::MAX for every column in this row).
514        for x in 0..4 {
515            assert_eq!(
516                dst[Pos::new(x, 0)].glyph(),
517                ' ',
518                "cell ({x}, 0) unexpectedly written"
519            );
520        }
521    }
522
523    #[test]
524    fn blit_normal_offset_unaffected_by_overflow_fix() {
525        // A typical, non-overflowing blit must still work exactly as before.
526        let mut src = Grid::new(2, 2);
527        src.put_tile(0, (0, 0), Tile::new('A', Style::default()));
528        src.put_tile(0, (1, 1), Tile::new('B', Style::default()));
529
530        let mut dst = Grid::new(4, 4);
531        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 1, 1);
532
533        assert_eq!(dst[Pos::new(1, 1)].glyph(), 'A');
534        assert_eq!(dst[Pos::new(2, 2)].glyph(), 'B');
535    }
536
537    // --- `BlendMode` / `blit_alpha` ---
538    #[test]
539    fn blend_separable_channel_screen_lightens_toward_the_combined_color() {
540        // cb = 102 (0.4), cs = 204 (0.8): screen = cb + cs - cb*cs = 0.88.
541        assert_eq!(
542            blend_separable_channel(SeparableBlendMode::Screen, 204, 102, 1.0),
543            224
544        );
545        // t = 0.5 lerps the destination halfway to that fully mixed color.
546        assert_eq!(
547            blend_separable_channel(SeparableBlendMode::Screen, 204, 102, 0.5),
548            163
549        );
550    }
551
552    #[test]
553    fn blend_separable_channel_dodge_brightens_toward_white() {
554        // cb = 51 (0.2), cs = 204 (0.8): min(1, 0.2 / 0.2) saturates to 1.0.
555        assert_eq!(
556            blend_separable_channel(SeparableBlendMode::ColorDodge, 204, 51, 1.0),
557            255
558        );
559        assert_eq!(
560            blend_separable_channel(SeparableBlendMode::ColorDodge, 204, 51, 0.5),
561            153
562        );
563    }
564
565    #[test]
566    fn blend_separable_channel_burn_darkens_toward_black() {
567        // cb = 204 (0.8), cs = 51 (0.2): 1 - min(1, 0.2 / 0.2) bottoms out at 0.0.
568        assert_eq!(
569            blend_separable_channel(SeparableBlendMode::ColorBurn, 51, 204, 1.0),
570            0
571        );
572        assert_eq!(
573            blend_separable_channel(SeparableBlendMode::ColorBurn, 51, 204, 0.5),
574            102
575        );
576    }
577
578    #[test]
579    fn blend_separable_channel_overlay_switches_between_multiply_and_screen() {
580        // cb = 51 (0.2, the <= 0.5 branch): 2 * cb * cs.
581        assert_eq!(
582            blend_separable_channel(SeparableBlendMode::Overlay, 204, 51, 1.0),
583            82
584        );
585        assert_eq!(
586            blend_separable_channel(SeparableBlendMode::Overlay, 204, 51, 0.5),
587            66
588        );
589        // cb = 204 (0.8, the > 0.5 branch): 1 - 2 * (1 - cb) * (1 - cs).
590        assert_eq!(
591            blend_separable_channel(SeparableBlendMode::Overlay, 51, 204, 1.0),
592            173
593        );
594    }
595
596    #[test]
597    fn blend_separable_channel_multiply_darkens_toward_the_product() {
598        // cb = 204 (0.8), cs = 51 (0.2): multiply = cb * cs = 0.16.
599        assert_eq!(
600            blend_separable_channel(SeparableBlendMode::Multiply, 204, 51, 1.0),
601            41
602        );
603        // t = 0.5 lerps the destination halfway to that fully mixed color.
604        assert_eq!(
605            blend_separable_channel(SeparableBlendMode::Multiply, 204, 51, 0.5),
606            46
607        );
608    }
609
610    /// End-to-end through `blit_alpha`, not just the per-channel helper: proves `BlendMode`
611    /// actually reaches `blend_color` and lands on the destination tile's style.
612    #[test]
613    fn blit_alpha_screen_blends_fg() {
614        let mut src = Grid::new(1, 1);
615        src.put_tile(
616            0,
617            (0, 0),
618            Tile::default()
619                .with_glyph('X')
620                .with_style(Style::new().fg(Color::Rgb {
621                    r: 204,
622                    g: 204,
623                    b: 204,
624                })),
625        );
626
627        let mut dst = Grid::new(1, 1);
628        dst.put_tile(
629            0,
630            (0, 0),
631            Tile::default()
632                .with_glyph('_')
633                .with_style(Style::new().fg(Color::Rgb {
634                    r: 102,
635                    g: 102,
636                    b: 102,
637                })),
638        );
639
640        dst.blit_alpha(
641            0,
642            &src,
643            Rect::new(0, 0, 1, 1),
644            0,
645            0,
646            BlendMode::Screen,
647            1.0,
648            1.0,
649        );
650        assert_eq!(
651            dst[Pos::new(0, 0)].style.fg,
652            Color::Rgb {
653                r: 224,
654                g: 224,
655                b: 224
656            }
657        );
658    }
659
660    /// Same as `blit_alpha_screen_blends_fg`, but for `style.bg` and `bg_alpha`: both
661    /// alpha factors are independent, so `fg_alpha == 1.0` (fully mixed) and `bg_alpha == 0.5`
662    /// (half-lerped toward the mix) must land different results on the two channels.
663    #[test]
664    fn blit_alpha_screen_blends_bg() {
665        let mut src = Grid::new(1, 1);
666        src.put_tile(
667            0,
668            (0, 0),
669            Tile::default().with_glyph('X').with_style(
670                Style::new()
671                    .fg(Color::Rgb {
672                        r: 204,
673                        g: 204,
674                        b: 204,
675                    })
676                    .bg(Color::Rgb {
677                        r: 204,
678                        g: 204,
679                        b: 204,
680                    }),
681            ),
682        );
683
684        let mut dst = Grid::new(1, 1);
685        dst.put_tile(
686            0,
687            (0, 0),
688            Tile::default().with_glyph('_').with_style(
689                Style::new()
690                    .fg(Color::Rgb {
691                        r: 102,
692                        g: 102,
693                        b: 102,
694                    })
695                    .bg(Color::Rgb {
696                        r: 102,
697                        g: 102,
698                        b: 102,
699                    }),
700            ),
701        );
702
703        dst.blit_alpha(
704            0,
705            &src,
706            Rect::new(0, 0, 1, 1),
707            0,
708            0,
709            BlendMode::Screen,
710            1.0,
711            0.5,
712        );
713        // `fg_alpha == 1.0`: fully mixed, same as `blit_alpha_screen_blends_fg`.
714        assert_eq!(
715            dst[Pos::new(0, 0)].style.fg,
716            Color::Rgb {
717                r: 224,
718                g: 224,
719                b: 224
720            }
721        );
722        // `bg_alpha == 0.5`: only half-lerped from the destination toward that same mix.
723        assert_eq!(
724            dst[Pos::new(0, 0)].style.bg,
725            Color::Rgb {
726                r: 163,
727                g: 163,
728                b: 163
729            }
730        );
731    }
732
733    /// retroglyph#268: same wraparound guard as `blit`'s
734    /// `blit_dest_origin_near_u16_max_does_not_wrap`, but through `blit_alpha`'s
735    /// separate `dst_x`/`dst_y` computation.
736    #[test]
737    fn blit_alpha_dest_origin_near_u16_max_does_not_wrap() {
738        let mut src = Grid::new(4, 1);
739        src.put_tile(0, (3, 0), Tile::new('Q', Style::default()));
740
741        let mut dst = Grid::new(4, 1);
742        dst.blit_alpha(
743            0,
744            &src,
745            Rect::new(0, 0, 4, 1),
746            u16::MAX - 1,
747            0,
748            BlendMode::Linear,
749            1.0,
750            1.0,
751        );
752
753        for x in 0..4 {
754            assert_eq!(
755                dst[Pos::new(x, 0)].glyph(),
756                ' ',
757                "cell ({x}, 0) unexpectedly written"
758            );
759        }
760    }
761
762    /// `BlendMode::Linear` at `t == 0.0` keeps the destination and at `t == 1.0` uses the source,
763    /// matching `blit_alpha`'s doc comment. The underlying `gem::Mix` call takes its arguments in
764    /// the opposite order, so this pins the direction against a silent `src`/`dst` swap.
765    #[test]
766    fn blit_alpha_linear_direction() {
767        let mut src = Grid::new(1, 1);
768        src.put_tile(
769            0,
770            (0, 0),
771            Tile::default()
772                .with_glyph('X')
773                .with_style(Style::new().fg(Color::Rgb {
774                    r: 255,
775                    g: 255,
776                    b: 255,
777                })),
778        );
779
780        let dst_color = Color::Rgb { r: 0, g: 0, b: 0 };
781        let at = |t: f32| {
782            let mut dst = Grid::new(1, 1);
783            dst.put_tile(
784                0,
785                (0, 0),
786                Tile::default()
787                    .with_glyph('_')
788                    .with_style(Style::new().fg(dst_color)),
789            );
790            dst.blit_alpha(
791                0,
792                &src,
793                Rect::new(0, 0, 1, 1),
794                0,
795                0,
796                BlendMode::Linear,
797                t,
798                1.0,
799            );
800            dst[Pos::new(0, 0)].style.fg
801        };
802
803        assert_eq!(at(0.0), dst_color);
804        assert_eq!(
805            at(1.0),
806            Color::Rgb {
807                r: 255,
808                g: 255,
809                b: 255
810            }
811        );
812        let Color::Rgb { r, g, b } = at(0.5) else {
813            panic!("expected Color::Rgb");
814        };
815        assert!(r > 0 && r < 255, "expected a mid-gray, got {r}");
816        assert_eq!(r, g);
817        assert_eq!(g, b);
818    }
819
820    /// Every `BlendMode` preserves `Color::Default` and passes non-RGB colors through unblended,
821    /// same as the pre-existing `Linear` behavior.
822    #[test]
823    fn blend_color_non_rgb_passthrough_all_modes() {
824        for mode in [
825            BlendMode::Linear,
826            BlendMode::Screen,
827            BlendMode::Dodge,
828            BlendMode::Burn,
829            BlendMode::Overlay,
830            BlendMode::Multiply,
831        ] {
832            assert_eq!(
833                blend_color(mode, Color::Default, Color::Rgb { r: 1, g: 2, b: 3 }, 0.5),
834                Color::Default
835            );
836            assert_eq!(
837                blend_color(mode, Color::BLACK, Color::WHITE, 0.5),
838                Color::BLACK
839            );
840        }
841    }
842
843    #[test]
844    fn blit_degrades_a_span_to_its_fallback_glyphs() {
845        // `src_rect` can clip a footprint in half, and half a span is not representable, so
846        // `blit` drops the span role and keeps the glyphs (which are the text fallback anyway).
847        let mut src = Grid::new(4, 4);
848        src.write_span(0, 0, 0, &["C=", "[]"], Style::default())
849            .unwrap();
850
851        let mut dst = Grid::new(4, 4);
852        dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
853
854        assert_eq!(dst[Pos::new(0, 0)].glyph(), 'C');
855        assert_eq!(dst[Pos::new(1, 1)].glyph(), ']');
856        assert_eq!(dst[Pos::new(0, 0)].span(), (1, 1));
857        assert_eq!(dst.span_owner(0, 1, 1), None);
858        for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
859            let flags = dst[Pos::new(x, y)].flags();
860            assert!(!flags.contains(TileFlags::SPAN_ANCHOR), "({x}, {y})");
861            assert!(!flags.contains(TileFlags::SPAN_COVERED), "({x}, {y})");
862        }
863    }
864
865    #[test]
866    fn blit_leaves_a_dangling_span_anchor_in_the_destination() {
867        // retroglyph#710: `blit` writes straight into the destination buffer, bypassing
868        // `put_tile`'s `clear_span_overlap` call, so overwriting a span's covered cell used to
869        // leave the anchor still claiming a cell the blit had just replaced.
870        let mut dst = Grid::new(4, 1);
871        dst.write_span(0, 0, 0, &["ab"], Style::default()).unwrap();
872
873        let mut src = Grid::new(4, 1);
874        src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
875        dst.blit(0, &src, Rect::new(1, 0, 1, 1), 1, 0);
876
877        assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
878        assert_eq!(dst.tile(0, Pos::new(0, 0)).map(Tile::span), Some((1, 1)));
879        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::SPAN_ANCHOR));
880        assert!(
881            !dst[Pos::new(1, 0)]
882                .flags()
883                .contains(TileFlags::SPAN_COVERED)
884        );
885    }
886
887    #[test]
888    fn blit_alpha_leaves_a_dangling_span_anchor_in_the_destination() {
889        // Same bug as `blit_leaves_a_dangling_span_anchor_in_the_destination`, but through
890        // `blit_alpha`'s separate copy path.
891        let mut dst = Grid::new(4, 1);
892        dst.write_span(0, 0, 0, &["ab"], Style::default()).unwrap();
893
894        let mut src = Grid::new(4, 1);
895        src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
896        dst.blit_alpha(
897            0,
898            &src,
899            Rect::new(1, 0, 1, 1),
900            1,
901            0,
902            BlendMode::Linear,
903            1.0,
904            1.0,
905        );
906
907        assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
908        assert_eq!(dst.tile(0, Pos::new(0, 0)).map(Tile::span), Some((1, 1)));
909    }
910
911    /// `blit_cross_layer` (used by `Surface::blit` for the retroglyph#824 fix) reads a fixed
912    /// `src_layer` regardless of the layer it writes to, unlike `blit`'s single shared `layer`.
913    #[test]
914    fn blit_cross_layer_reads_a_different_source_layer_than_it_writes() {
915        let mut src = Grid::new(2, 2);
916        // Only layer 0 is ever populated on `src` (a standalone, layer-0-only composed grid).
917        src.put_tile(0, (0, 0), Tile::new('S', Style::default()));
918
919        let mut dst = Grid::new(2, 2);
920        dst.blit_cross_layer(3, &src, 0, Rect::new(0, 0, 2, 2), 0, 0);
921
922        // Written to layer 3 on `dst`, even though it was read from layer 0 on `src`.
923        assert_eq!(dst.tile(3, (0, 0)).map(Tile::glyph), Some('S'));
924        // Layer 0 is always allocated but untouched by this call: still its default tile.
925        assert_eq!(dst[Pos::new(0, 0)].glyph(), ' ');
926    }
927
928    #[test]
929    fn blit_leaves_a_dangling_wide_char_lead_in_the_destination() {
930        // retroglyph#1013: `blit` writes straight into the destination buffer, bypassing
931        // `put_tile`'s `clear_overlap` call, so overwriting a wide-character pair's spacer used
932        // to leave the lead cell still claiming a spacer the blit had just replaced.
933        let mut dst = Grid::new(4, 1);
934        dst.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
935
936        let mut src = Grid::new(4, 1);
937        src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
938        dst.blit(0, &src, Rect::new(1, 0, 1, 1), 1, 0);
939
940        assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
941        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
942    }
943
944    #[test]
945    fn blit_alpha_leaves_a_dangling_wide_char_lead_in_the_destination() {
946        // Same bug as `blit_leaves_a_dangling_wide_char_lead_in_the_destination`, but through
947        // `blit_alpha`'s separate copy path.
948        let mut dst = Grid::new(4, 1);
949        dst.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
950
951        let mut src = Grid::new(4, 1);
952        src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
953        dst.blit_alpha(
954            0,
955            &src,
956            Rect::new(1, 0, 1, 1),
957            1,
958            0,
959            BlendMode::Linear,
960            1.0,
961            1.0,
962        );
963
964        assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
965        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
966    }
967
968    #[test]
969    fn blit_degrades_a_wide_char_pair_clipped_by_src_rect() {
970        // `src_rect` can clip a wide-character pair in half, and half a pair is not
971        // representable, so `blit` drops the `WIDE_CHAR` flag on the lead it does copy, the same
972        // way it already degrades a clipped span (retroglyph#1013).
973        let mut src = Grid::new(4, 1);
974        src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
975
976        let mut dst = Grid::new(4, 1);
977        dst.blit(0, &src, Rect::new(0, 0, 1, 1), 0, 0);
978
979        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
980    }
981
982    #[test]
983    fn blit_alpha_degrades_a_wide_char_pair_clipped_by_src_rect() {
984        // Same bug as `blit_degrades_a_wide_char_pair_clipped_by_src_rect`, but through
985        // `blit_alpha`'s separate copy path.
986        let mut src = Grid::new(4, 1);
987        src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
988
989        let mut dst = Grid::new(4, 1);
990        dst.blit_alpha(
991            0,
992            &src,
993            Rect::new(0, 0, 1, 1),
994            0,
995            0,
996            BlendMode::Linear,
997            1.0,
998            1.0,
999        );
1000
1001        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
1002    }
1003
1004    #[test]
1005    fn blit_copies_a_whole_wide_char_pair_intact() {
1006        // The lead-clip and spacer-clip tests above both exercise the `!partner_survived` half of
1007        // `blit_with`'s wide-pair check; this covers the other half, where `src_rect` includes
1008        // both halves and neither flag should be stripped.
1009        let mut src = Grid::new(4, 1);
1010        src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
1011
1012        let mut dst = Grid::new(4, 1);
1013        dst.blit(0, &src, Rect::new(0, 0, 2, 1), 0, 0);
1014
1015        assert!(dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
1016        assert!(
1017            dst[Pos::new(1, 0)]
1018                .flags()
1019                .contains(TileFlags::WIDE_CHAR_SPACER)
1020        );
1021    }
1022
1023    #[test]
1024    fn blit_degrades_a_bare_wide_char_spacer_clipped_by_src_rect() {
1025        // The spacer twin of `blit_degrades_a_wide_char_pair_clipped_by_src_rect`: `src_rect` can
1026        // just as easily clip out the lead and leave the spacer, which is equally unrepresentable
1027        // on its own, so `blit` drops `WIDE_CHAR_SPACER` on the spacer it does copy.
1028        let mut src = Grid::new(4, 1);
1029        src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
1030
1031        let mut dst = Grid::new(4, 1);
1032        dst.blit(0, &src, Rect::new(1, 0, 1, 1), 1, 0);
1033
1034        assert!(
1035            !dst[Pos::new(1, 0)]
1036                .flags()
1037                .contains(TileFlags::WIDE_CHAR_SPACER)
1038        );
1039    }
1040
1041    #[test]
1042    fn blit_degrades_a_wide_char_pair_clipped_by_the_destinations_edge() {
1043        // retroglyph#1087: unlike the `_clipped_by_src_rect` tests above, `src_rect` here covers
1044        // the *whole* pair (both halves are present and in-bounds on the source side); it's the
1045        // destination that's too narrow to hold both, so the lead lands in the last dst column
1046        // with no room for a spacer beside it. `blit_with`'s `partner_survived` check for the
1047        // `WIDE_CHAR` half includes `usize::from(dx) + 1 < dst_width` precisely so this case
1048        // degrades the same way as a source-side clip, instead of writing a dangling lead with no
1049        // spacer.
1050        let mut src = Grid::new(4, 1);
1051        src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
1052
1053        let mut dst = Grid::new(1, 1);
1054        dst.blit(0, &src, Rect::new(0, 0, 2, 1), 0, 0);
1055
1056        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
1057    }
1058
1059    #[test]
1060    fn blit_alpha_degrades_a_wide_char_pair_clipped_by_the_destinations_edge() {
1061        // Same as `blit_degrades_a_wide_char_pair_clipped_by_the_destinations_edge`, but through
1062        // `blit_alpha`'s separate copy path.
1063        let mut src = Grid::new(4, 1);
1064        src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
1065
1066        let mut dst = Grid::new(1, 1);
1067        dst.blit_alpha(
1068            0,
1069            &src,
1070            Rect::new(0, 0, 2, 1),
1071            0,
1072            0,
1073            BlendMode::Linear,
1074            1.0,
1075            1.0,
1076        );
1077
1078        assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
1079    }
1080
1081    /// A single `blit`-vs-`copy_rect_clamped` comparison case for
1082    /// `blit_clamp_matches_grixys_copy_rect_clamped_on_shared_clipped_rect_cases`.
1083    struct BlitClampCase {
1084        name: &'static str,
1085        src_w: u16,
1086        src_h: u16,
1087        dst_w: u16,
1088        dst_h: u16,
1089        src_rect: Rect,
1090        dst_x: u16,
1091        dst_y: u16,
1092    }
1093
1094    /// Every source cell gets a unique glyph derived from its position, so a mismatch in the
1095    /// clamp/translate math (an off-by-one, a row misaligned after clipping, ...) shows up as the
1096    /// wrong letter landing in the wrong destination cell, not just a wrong cell count.
1097    fn blit_clamp_case_glyph_at(x: u16, y: u16, width: u16) -> char {
1098        let idx = u32::from(y) * u32::from(width) + u32::from(x);
1099        char::from_u32(u32::from(b'A') + idx).expect("case grids stay within 'A'..='Z'")
1100    }
1101
1102    /// Runs one [`BlitClampCase`] through both `Grid::blit` and `grixy::ops::copy_rect_clamped`
1103    /// on an equivalent pair of plain `grixy::buf::GridBuf`s, and asserts the copied region
1104    /// agrees cell-for-cell.
1105    fn assert_blit_clamp_case(case: &BlitClampCase) {
1106        use grixy::buf::GridBuf;
1107        use grixy::ops::GridWrite as _;
1108        use grixy::transform::GridConvertExt as _;
1109
1110        let mut rg_src = Grid::new(case.src_w, case.src_h);
1111        for y in 0..case.src_h {
1112            for x in 0..case.src_w {
1113                let glyph = blit_clamp_case_glyph_at(x, y, case.src_w);
1114                rg_src.put_tile(0, (x, y), Tile::default().with_glyph(glyph));
1115            }
1116        }
1117        let mut rg_dst = Grid::new(case.dst_w, case.dst_h);
1118        rg_dst.blit(0, &rg_src, case.src_rect, case.dst_x, case.dst_y);
1119        let rg_result: Vec<char> = (0..case.dst_h)
1120            .flat_map(|y| (0..case.dst_w).map(move |x| (x, y)))
1121            .map(|(x, y)| rg_dst.tile(0, (x, y)).map_or(' ', Tile::glyph))
1122            .collect();
1123
1124        let mut gx_src = GridBuf::<char, _, _>::new_filled(
1125            usize::from(case.src_w),
1126            usize::from(case.src_h),
1127            ' ',
1128        );
1129        for y in 0..case.src_h {
1130            for x in 0..case.src_w {
1131                let glyph = blit_clamp_case_glyph_at(x, y, case.src_w);
1132                gx_src
1133                    .set(grixy::core::Pos::new(usize::from(x), usize::from(y)), glyph)
1134                    .unwrap();
1135            }
1136        }
1137        let mut gx_dst = GridBuf::<char, _, _>::new_filled(
1138            usize::from(case.dst_w),
1139            usize::from(case.dst_h),
1140            ' ',
1141        );
1142        grixy::ops::copy_rect_clamped(
1143            &gx_src.copied(),
1144            &mut gx_dst,
1145            grixy::core::Rect::from_ltwh(
1146                usize::from(case.src_rect.left()),
1147                usize::from(case.src_rect.top()),
1148                usize::from(case.src_rect.width()),
1149                usize::from(case.src_rect.height()),
1150            ),
1151            grixy::core::Pos::new(usize::from(case.dst_x), usize::from(case.dst_y)),
1152        );
1153        let (gx_result, _, _) = gx_dst.into_inner();
1154
1155        assert_eq!(rg_result, gx_result, "case: {}", case.name);
1156    }
1157
1158    /// `blit_with`'s clamp math (clamp `src_rect` to `src`'s bounds, translate into destination
1159    /// space, clamp again to `dst`'s bounds) is a hand-written copy of the algorithm
1160    /// `grixy::ops::copy_rect_clamped` generalizes (retroglyph#831). This walks a shared set of
1161    /// clipped-rect cases through both `Grid::blit` and `copy_rect_clamped` on an equivalent pair
1162    /// of plain `grixy::buf::GridBuf`s, and asserts the copied region agrees cell-for-cell, so the
1163    /// two can't silently drift apart. `Grid` can't implement `GridRead`/`GridWrite` itself (its
1164    /// span/extras bookkeeping has no equivalent there), so this compares outcomes rather than
1165    /// sharing code.
1166    #[test]
1167    fn blit_clamp_matches_grixys_copy_rect_clamped_on_shared_clipped_rect_cases() {
1168        let cases = [
1169            BlitClampCase {
1170                name: "fully inside both grids",
1171                src_w: 4,
1172                src_h: 4,
1173                dst_w: 6,
1174                dst_h: 6,
1175                src_rect: Rect::new(0, 0, 4, 4),
1176                dst_x: 1,
1177                dst_y: 1,
1178            },
1179            BlitClampCase {
1180                name: "src_rect wider than src (source-side clip)",
1181                src_w: 3,
1182                src_h: 3,
1183                dst_w: 5,
1184                dst_h: 5,
1185                src_rect: Rect::new(0, 0, 10, 10),
1186                dst_x: 0,
1187                dst_y: 0,
1188            },
1189            BlitClampCase {
1190                name: "destination-side clip",
1191                src_w: 3,
1192                src_h: 3,
1193                dst_w: 5,
1194                dst_h: 5,
1195                src_rect: Rect::new(0, 0, 3, 3),
1196                dst_x: 3,
1197                dst_y: 3,
1198            },
1199            BlitClampCase {
1200                name: "both sides clip, tighter bound wins",
1201                src_w: 4,
1202                src_h: 4,
1203                dst_w: 6,
1204                dst_h: 6,
1205                src_rect: Rect::new(0, 0, 10, 10),
1206                dst_x: 3,
1207                dst_y: 3,
1208            },
1209            BlitClampCase {
1210                name: "src_rect offset, clipped on src's right/bottom",
1211                src_w: 4,
1212                src_h: 4,
1213                dst_w: 6,
1214                dst_h: 6,
1215                src_rect: Rect::new(2, 2, 5, 5),
1216                dst_x: 0,
1217                dst_y: 0,
1218            },
1219            BlitClampCase {
1220                name: "source completely out of bounds",
1221                src_w: 3,
1222                src_h: 3,
1223                dst_w: 5,
1224                dst_h: 5,
1225                src_rect: Rect::new(5, 5, 2, 2),
1226                dst_x: 0,
1227                dst_y: 0,
1228            },
1229            BlitClampCase {
1230                name: "destination completely out of bounds",
1231                src_w: 3,
1232                src_h: 3,
1233                dst_w: 5,
1234                dst_h: 5,
1235                src_rect: Rect::new(0, 0, 3, 3),
1236                dst_x: 10,
1237                dst_y: 10,
1238            },
1239            BlitClampCase {
1240                name: "zero-size src_rect",
1241                src_w: 3,
1242                src_h: 3,
1243                dst_w: 5,
1244                dst_h: 5,
1245                src_rect: Rect::new(0, 0, 0, 0),
1246                dst_x: 0,
1247                dst_y: 0,
1248            },
1249        ];
1250
1251        for case in &cases {
1252            assert_blit_clamp_case(case);
1253        }
1254    }
1255}