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(204, 204, 204))),
621 );
622
623 let mut dst = Grid::new(1, 1);
624 dst.put_tile(
625 0,
626 (0, 0),
627 Tile::default()
628 .with_glyph('_')
629 .with_style(Style::new().fg(Color::rgb(102, 102, 102))),
630 );
631
632 dst.blit_alpha(
633 0,
634 &src,
635 Rect::new(0, 0, 1, 1),
636 0,
637 0,
638 BlendMode::Screen,
639 1.0,
640 1.0,
641 );
642 assert_eq!(dst[Pos::new(0, 0)].style.fg, Color::rgb(224, 224, 224));
643 }
644
645 /// Same as `blit_alpha_screen_blends_fg`, but for `style.bg` and `bg_alpha`: both
646 /// alpha factors are independent, so `fg_alpha == 1.0` (fully mixed) and `bg_alpha == 0.5`
647 /// (half-lerped toward the mix) must land different results on the two channels.
648 #[test]
649 fn blit_alpha_screen_blends_bg() {
650 let mut src = Grid::new(1, 1);
651 src.put_tile(
652 0,
653 (0, 0),
654 Tile::default().with_glyph('X').with_style(
655 Style::new()
656 .fg(Color::rgb(204, 204, 204))
657 .bg(Color::rgb(204, 204, 204)),
658 ),
659 );
660
661 let mut dst = Grid::new(1, 1);
662 dst.put_tile(
663 0,
664 (0, 0),
665 Tile::default().with_glyph('_').with_style(
666 Style::new()
667 .fg(Color::rgb(102, 102, 102))
668 .bg(Color::rgb(102, 102, 102)),
669 ),
670 );
671
672 dst.blit_alpha(
673 0,
674 &src,
675 Rect::new(0, 0, 1, 1),
676 0,
677 0,
678 BlendMode::Screen,
679 1.0,
680 0.5,
681 );
682 // `fg_alpha == 1.0`: fully mixed, same as `blit_alpha_screen_blends_fg`.
683 assert_eq!(dst[Pos::new(0, 0)].style.fg, Color::rgb(224, 224, 224));
684 // `bg_alpha == 0.5`: only half-lerped from the destination toward that same mix.
685 assert_eq!(dst[Pos::new(0, 0)].style.bg, Color::rgb(163, 163, 163));
686 }
687
688 /// retroglyph#268: same wraparound guard as `blit`'s
689 /// `blit_dest_origin_near_u16_max_does_not_wrap`, but through `blit_alpha`'s
690 /// separate `dst_x`/`dst_y` computation.
691 #[test]
692 fn blit_alpha_dest_origin_near_u16_max_does_not_wrap() {
693 let mut src = Grid::new(4, 1);
694 src.put_tile(0, (3, 0), Tile::new('Q', Style::default()));
695
696 let mut dst = Grid::new(4, 1);
697 dst.blit_alpha(
698 0,
699 &src,
700 Rect::new(0, 0, 4, 1),
701 u16::MAX - 1,
702 0,
703 BlendMode::Linear,
704 1.0,
705 1.0,
706 );
707
708 for x in 0..4 {
709 assert_eq!(
710 dst[Pos::new(x, 0)].glyph(),
711 ' ',
712 "cell ({x}, 0) unexpectedly written"
713 );
714 }
715 }
716
717 /// `BlendMode::Linear` at `t == 0.0` keeps the destination and at `t == 1.0` uses the source,
718 /// matching `blit_alpha`'s doc comment. The underlying `gem::Mix` call takes its arguments in
719 /// the opposite order, so this pins the direction against a silent `src`/`dst` swap.
720 #[test]
721 fn blit_alpha_linear_direction() {
722 let mut src = Grid::new(1, 1);
723 src.put_tile(
724 0,
725 (0, 0),
726 Tile::default()
727 .with_glyph('X')
728 .with_style(Style::new().fg(Color::rgb(255, 255, 255))),
729 );
730
731 let dst_color = Color::rgb(0, 0, 0);
732 let at = |t: f32| {
733 let mut dst = Grid::new(1, 1);
734 dst.put_tile(
735 0,
736 (0, 0),
737 Tile::default()
738 .with_glyph('_')
739 .with_style(Style::new().fg(dst_color)),
740 );
741 dst.blit_alpha(
742 0,
743 &src,
744 Rect::new(0, 0, 1, 1),
745 0,
746 0,
747 BlendMode::Linear,
748 t,
749 1.0,
750 );
751 dst[Pos::new(0, 0)].style.fg
752 };
753
754 assert_eq!(at(0.0), dst_color);
755 assert_eq!(at(1.0), Color::rgb(255, 255, 255));
756 let Color::Rgb { r, g, b } = at(0.5) else {
757 panic!("expected Color::Rgb");
758 };
759 assert!(r > 0 && r < 255, "expected a mid-gray, got {r}");
760 assert_eq!(r, g);
761 assert_eq!(g, b);
762 }
763
764 /// Every `BlendMode` preserves `Color::Default` and passes non-RGB colors through unblended,
765 /// same as the pre-existing `Linear` behavior.
766 #[test]
767 fn blend_color_non_rgb_passthrough_all_modes() {
768 for mode in [
769 BlendMode::Linear,
770 BlendMode::Screen,
771 BlendMode::Dodge,
772 BlendMode::Burn,
773 BlendMode::Overlay,
774 BlendMode::Multiply,
775 ] {
776 assert_eq!(
777 blend_color(mode, Color::Default, Color::rgb(1, 2, 3), 0.5),
778 Color::Default
779 );
780 assert_eq!(
781 blend_color(mode, Color::BLACK, Color::WHITE, 0.5),
782 Color::BLACK
783 );
784 }
785 }
786
787 #[test]
788 fn blit_degrades_a_span_to_its_fallback_glyphs() {
789 // `src_rect` can clip a footprint in half, and half a span is not representable, so
790 // `blit` drops the span role and keeps the glyphs (which are the text fallback anyway).
791 let mut src = Grid::new(4, 4);
792 src.write_span(0, 0, 0, &["C=", "[]"], Style::default())
793 .unwrap();
794
795 let mut dst = Grid::new(4, 4);
796 dst.blit(0, &src, Rect::new(0, 0, 2, 2), 0, 0);
797
798 assert_eq!(dst[Pos::new(0, 0)].glyph(), 'C');
799 assert_eq!(dst[Pos::new(1, 1)].glyph(), ']');
800 assert_eq!(dst[Pos::new(0, 0)].span(), (1, 1));
801 assert_eq!(dst.span_owner(0, 1, 1), None);
802 for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
803 let flags = dst[Pos::new(x, y)].flags();
804 assert!(!flags.contains(TileFlags::SPAN_ANCHOR), "({x}, {y})");
805 assert!(!flags.contains(TileFlags::SPAN_COVERED), "({x}, {y})");
806 }
807 }
808
809 #[test]
810 fn blit_leaves_a_dangling_span_anchor_in_the_destination() {
811 // retroglyph#710: `blit` writes straight into the destination buffer, bypassing
812 // `put_tile`'s `clear_span_overlap` call, so overwriting a span's covered cell used to
813 // leave the anchor still claiming a cell the blit had just replaced.
814 let mut dst = Grid::new(4, 1);
815 dst.write_span(0, 0, 0, &["ab"], Style::default()).unwrap();
816
817 let mut src = Grid::new(4, 1);
818 src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
819 dst.blit(0, &src, Rect::new(1, 0, 1, 1), 1, 0);
820
821 assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
822 assert_eq!(dst.tile(0, Pos::new(0, 0)).map(Tile::span), Some((1, 1)));
823 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::SPAN_ANCHOR));
824 assert!(
825 !dst[Pos::new(1, 0)]
826 .flags()
827 .contains(TileFlags::SPAN_COVERED)
828 );
829 }
830
831 #[test]
832 fn blit_alpha_leaves_a_dangling_span_anchor_in_the_destination() {
833 // Same bug as `blit_leaves_a_dangling_span_anchor_in_the_destination`, but through
834 // `blit_alpha`'s separate copy path.
835 let mut dst = Grid::new(4, 1);
836 dst.write_span(0, 0, 0, &["ab"], Style::default()).unwrap();
837
838 let mut src = Grid::new(4, 1);
839 src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
840 dst.blit_alpha(
841 0,
842 &src,
843 Rect::new(1, 0, 1, 1),
844 1,
845 0,
846 BlendMode::Linear,
847 1.0,
848 1.0,
849 );
850
851 assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
852 assert_eq!(dst.tile(0, Pos::new(0, 0)).map(Tile::span), Some((1, 1)));
853 }
854
855 /// `blit_cross_layer` (used by `Surface::blit` for the retroglyph#824 fix) reads a fixed
856 /// `src_layer` regardless of the layer it writes to, unlike `blit`'s single shared `layer`.
857 #[test]
858 fn blit_cross_layer_reads_a_different_source_layer_than_it_writes() {
859 let mut src = Grid::new(2, 2);
860 // Only layer 0 is ever populated on `src` (a standalone, layer-0-only composed grid).
861 src.put_tile(0, (0, 0), Tile::new('S', Style::default()));
862
863 let mut dst = Grid::new(2, 2);
864 dst.blit_cross_layer(3, &src, 0, Rect::new(0, 0, 2, 2), 0, 0);
865
866 // Written to layer 3 on `dst`, even though it was read from layer 0 on `src`.
867 assert_eq!(dst.tile(3, (0, 0)).map(Tile::glyph), Some('S'));
868 // Layer 0 is always allocated but untouched by this call: still its default tile.
869 assert_eq!(dst[Pos::new(0, 0)].glyph(), ' ');
870 }
871
872 #[test]
873 fn blit_leaves_a_dangling_wide_char_lead_in_the_destination() {
874 // retroglyph#1013: `blit` writes straight into the destination buffer, bypassing
875 // `put_tile`'s `clear_overlap` call, so overwriting a wide-character pair's spacer used
876 // to leave the lead cell still claiming a spacer the blit had just replaced.
877 let mut dst = Grid::new(4, 1);
878 dst.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
879
880 let mut src = Grid::new(4, 1);
881 src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
882 dst.blit(0, &src, Rect::new(1, 0, 1, 1), 1, 0);
883
884 assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
885 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
886 }
887
888 #[test]
889 fn blit_alpha_leaves_a_dangling_wide_char_lead_in_the_destination() {
890 // Same bug as `blit_leaves_a_dangling_wide_char_lead_in_the_destination`, but through
891 // `blit_alpha`'s separate copy path.
892 let mut dst = Grid::new(4, 1);
893 dst.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
894
895 let mut src = Grid::new(4, 1);
896 src.put_tile(0, (1, 0), Tile::new('X', Style::default()));
897 dst.blit_alpha(
898 0,
899 &src,
900 Rect::new(1, 0, 1, 1),
901 1,
902 0,
903 BlendMode::Linear,
904 1.0,
905 1.0,
906 );
907
908 assert_eq!(dst[Pos::new(1, 0)].glyph(), 'X');
909 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
910 }
911
912 #[test]
913 fn blit_degrades_a_wide_char_pair_clipped_by_src_rect() {
914 // `src_rect` can clip a wide-character pair in half, and half a pair is not
915 // representable, so `blit` drops the `WIDE_CHAR` flag on the lead it does copy, the same
916 // way it already degrades a clipped span (retroglyph#1013).
917 let mut src = Grid::new(4, 1);
918 src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
919
920 let mut dst = Grid::new(4, 1);
921 dst.blit(0, &src, Rect::new(0, 0, 1, 1), 0, 0);
922
923 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
924 }
925
926 #[test]
927 fn blit_alpha_degrades_a_wide_char_pair_clipped_by_src_rect() {
928 // Same bug as `blit_degrades_a_wide_char_pair_clipped_by_src_rect`, but through
929 // `blit_alpha`'s separate copy path.
930 let mut src = Grid::new(4, 1);
931 src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
932
933 let mut dst = Grid::new(4, 1);
934 dst.blit_alpha(
935 0,
936 &src,
937 Rect::new(0, 0, 1, 1),
938 0,
939 0,
940 BlendMode::Linear,
941 1.0,
942 1.0,
943 );
944
945 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
946 }
947
948 #[test]
949 fn blit_copies_a_whole_wide_char_pair_intact() {
950 // The lead-clip and spacer-clip tests above both exercise the `!partner_survived` half of
951 // `blit_with`'s wide-pair check; this covers the other half, where `src_rect` includes
952 // both halves and neither flag should be stripped.
953 let mut src = Grid::new(4, 1);
954 src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
955
956 let mut dst = Grid::new(4, 1);
957 dst.blit(0, &src, Rect::new(0, 0, 2, 1), 0, 0);
958
959 assert!(dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
960 assert!(
961 dst[Pos::new(1, 0)]
962 .flags()
963 .contains(TileFlags::WIDE_CHAR_SPACER)
964 );
965 }
966
967 #[test]
968 fn blit_degrades_a_bare_wide_char_spacer_clipped_by_src_rect() {
969 // The spacer twin of `blit_degrades_a_wide_char_pair_clipped_by_src_rect`: `src_rect` can
970 // just as easily clip out the lead and leave the spacer, which is equally unrepresentable
971 // on its own, so `blit` drops `WIDE_CHAR_SPACER` on the spacer it does copy.
972 let mut src = Grid::new(4, 1);
973 src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
974
975 let mut dst = Grid::new(4, 1);
976 dst.blit(0, &src, Rect::new(1, 0, 1, 1), 1, 0);
977
978 assert!(
979 !dst[Pos::new(1, 0)]
980 .flags()
981 .contains(TileFlags::WIDE_CHAR_SPACER)
982 );
983 }
984
985 #[test]
986 fn blit_degrades_a_wide_char_pair_clipped_by_the_destinations_edge() {
987 // retroglyph#1087: unlike the `_clipped_by_src_rect` tests above, `src_rect` here covers
988 // the *whole* pair (both halves are present and in-bounds on the source side); it's the
989 // destination that's too narrow to hold both, so the lead lands in the last dst column
990 // with no room for a spacer beside it. `blit_with`'s `partner_survived` check for the
991 // `WIDE_CHAR` half includes `usize::from(dx) + 1 < dst_width` precisely so this case
992 // degrades the same way as a source-side clip, instead of writing a dangling lead with no
993 // spacer.
994 let mut src = Grid::new(4, 1);
995 src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
996
997 let mut dst = Grid::new(1, 1);
998 dst.blit(0, &src, Rect::new(0, 0, 2, 1), 0, 0);
999
1000 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
1001 }
1002
1003 #[test]
1004 fn blit_alpha_degrades_a_wide_char_pair_clipped_by_the_destinations_edge() {
1005 // Same as `blit_degrades_a_wide_char_pair_clipped_by_the_destinations_edge`, but through
1006 // `blit_alpha`'s separate copy path.
1007 let mut src = Grid::new(4, 1);
1008 src.put_tile(0, (0, 0), Tile::new('\u{4e2d}', Style::default()));
1009
1010 let mut dst = Grid::new(1, 1);
1011 dst.blit_alpha(
1012 0,
1013 &src,
1014 Rect::new(0, 0, 2, 1),
1015 0,
1016 0,
1017 BlendMode::Linear,
1018 1.0,
1019 1.0,
1020 );
1021
1022 assert!(!dst[Pos::new(0, 0)].flags().contains(TileFlags::WIDE_CHAR));
1023 }
1024
1025 /// A single `blit`-vs-`copy_rect_clamped` comparison case for
1026 /// `blit_clamp_matches_grixys_copy_rect_clamped_on_shared_clipped_rect_cases`.
1027 struct BlitClampCase {
1028 name: &'static str,
1029 src_w: u16,
1030 src_h: u16,
1031 dst_w: u16,
1032 dst_h: u16,
1033 src_rect: Rect,
1034 dst_x: u16,
1035 dst_y: u16,
1036 }
1037
1038 /// Every source cell gets a unique glyph derived from its position, so a mismatch in the
1039 /// clamp/translate math (an off-by-one, a row misaligned after clipping, ...) shows up as the
1040 /// wrong letter landing in the wrong destination cell, not just a wrong cell count.
1041 fn blit_clamp_case_glyph_at(x: u16, y: u16, width: u16) -> char {
1042 let idx = u32::from(y) * u32::from(width) + u32::from(x);
1043 char::from_u32(u32::from(b'A') + idx).expect("case grids stay within 'A'..='Z'")
1044 }
1045
1046 /// Runs one [`BlitClampCase`] through both `Grid::blit` and `grixy::ops::copy_rect_clamped`
1047 /// on an equivalent pair of plain `grixy::buf::GridBuf`s, and asserts the copied region
1048 /// agrees cell-for-cell.
1049 fn assert_blit_clamp_case(case: &BlitClampCase) {
1050 use grixy::buf::GridBuf;
1051 use grixy::ops::GridWrite as _;
1052 use grixy::transform::GridConvertExt as _;
1053
1054 let mut rg_src = Grid::new(case.src_w, case.src_h);
1055 for y in 0..case.src_h {
1056 for x in 0..case.src_w {
1057 let glyph = blit_clamp_case_glyph_at(x, y, case.src_w);
1058 rg_src.put_tile(0, (x, y), Tile::default().with_glyph(glyph));
1059 }
1060 }
1061 let mut rg_dst = Grid::new(case.dst_w, case.dst_h);
1062 rg_dst.blit(0, &rg_src, case.src_rect, case.dst_x, case.dst_y);
1063 let rg_result: Vec<char> = (0..case.dst_h)
1064 .flat_map(|y| (0..case.dst_w).map(move |x| (x, y)))
1065 .map(|(x, y)| rg_dst.tile(0, (x, y)).map_or(' ', Tile::glyph))
1066 .collect();
1067
1068 let mut gx_src = GridBuf::<char, _, _>::new_filled(
1069 usize::from(case.src_w),
1070 usize::from(case.src_h),
1071 ' ',
1072 );
1073 for y in 0..case.src_h {
1074 for x in 0..case.src_w {
1075 let glyph = blit_clamp_case_glyph_at(x, y, case.src_w);
1076 gx_src
1077 .set(grixy::core::Pos::new(usize::from(x), usize::from(y)), glyph)
1078 .unwrap();
1079 }
1080 }
1081 let mut gx_dst = GridBuf::<char, _, _>::new_filled(
1082 usize::from(case.dst_w),
1083 usize::from(case.dst_h),
1084 ' ',
1085 );
1086 grixy::ops::copy_rect_clamped(
1087 &gx_src.copied(),
1088 &mut gx_dst,
1089 grixy::core::Rect::from_ltwh(
1090 usize::from(case.src_rect.left()),
1091 usize::from(case.src_rect.top()),
1092 usize::from(case.src_rect.width()),
1093 usize::from(case.src_rect.height()),
1094 ),
1095 grixy::core::Pos::new(usize::from(case.dst_x), usize::from(case.dst_y)),
1096 );
1097 let (gx_result, _, _) = gx_dst.into_inner();
1098
1099 assert_eq!(rg_result, gx_result, "case: {}", case.name);
1100 }
1101
1102 /// `blit_with`'s clamp math (clamp `src_rect` to `src`'s bounds, translate into destination
1103 /// space, clamp again to `dst`'s bounds) is a hand-written copy of the algorithm
1104 /// `grixy::ops::copy_rect_clamped` generalizes (retroglyph#831). This walks a shared set of
1105 /// clipped-rect cases through both `Grid::blit` and `copy_rect_clamped` on an equivalent pair
1106 /// of plain `grixy::buf::GridBuf`s, and asserts the copied region agrees cell-for-cell, so the
1107 /// two can't silently drift apart. `Grid` can't implement `GridRead`/`GridWrite` itself (its
1108 /// span/extras bookkeeping has no equivalent there), so this compares outcomes rather than
1109 /// sharing code.
1110 #[test]
1111 fn blit_clamp_matches_grixys_copy_rect_clamped_on_shared_clipped_rect_cases() {
1112 let cases = [
1113 BlitClampCase {
1114 name: "fully inside both grids",
1115 src_w: 4,
1116 src_h: 4,
1117 dst_w: 6,
1118 dst_h: 6,
1119 src_rect: Rect::new(0, 0, 4, 4),
1120 dst_x: 1,
1121 dst_y: 1,
1122 },
1123 BlitClampCase {
1124 name: "src_rect wider than src (source-side clip)",
1125 src_w: 3,
1126 src_h: 3,
1127 dst_w: 5,
1128 dst_h: 5,
1129 src_rect: Rect::new(0, 0, 10, 10),
1130 dst_x: 0,
1131 dst_y: 0,
1132 },
1133 BlitClampCase {
1134 name: "destination-side clip",
1135 src_w: 3,
1136 src_h: 3,
1137 dst_w: 5,
1138 dst_h: 5,
1139 src_rect: Rect::new(0, 0, 3, 3),
1140 dst_x: 3,
1141 dst_y: 3,
1142 },
1143 BlitClampCase {
1144 name: "both sides clip, tighter bound wins",
1145 src_w: 4,
1146 src_h: 4,
1147 dst_w: 6,
1148 dst_h: 6,
1149 src_rect: Rect::new(0, 0, 10, 10),
1150 dst_x: 3,
1151 dst_y: 3,
1152 },
1153 BlitClampCase {
1154 name: "src_rect offset, clipped on src's right/bottom",
1155 src_w: 4,
1156 src_h: 4,
1157 dst_w: 6,
1158 dst_h: 6,
1159 src_rect: Rect::new(2, 2, 5, 5),
1160 dst_x: 0,
1161 dst_y: 0,
1162 },
1163 BlitClampCase {
1164 name: "source completely out of bounds",
1165 src_w: 3,
1166 src_h: 3,
1167 dst_w: 5,
1168 dst_h: 5,
1169 src_rect: Rect::new(5, 5, 2, 2),
1170 dst_x: 0,
1171 dst_y: 0,
1172 },
1173 BlitClampCase {
1174 name: "destination completely out of bounds",
1175 src_w: 3,
1176 src_h: 3,
1177 dst_w: 5,
1178 dst_h: 5,
1179 src_rect: Rect::new(0, 0, 3, 3),
1180 dst_x: 10,
1181 dst_y: 10,
1182 },
1183 BlitClampCase {
1184 name: "zero-size src_rect",
1185 src_w: 3,
1186 src_h: 3,
1187 dst_w: 5,
1188 dst_h: 5,
1189 src_rect: Rect::new(0, 0, 0, 0),
1190 dst_x: 0,
1191 dst_y: 0,
1192 },
1193 ];
1194
1195 for case in &cases {
1196 assert_blit_clamp_case(case);
1197 }
1198 }
1199}