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}