use Test;
use lib 'lib';
use lib 't/lib';
use TestHelper;
use NativeCall;
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Cell;
use Notcurses::Native::Plane;
use Notcurses::Native::Channel;
use Notcurses::Native::Visual;
plan 15;
my $opts = NotcursesOptions.new(
:loglevel(NCLOGLEVEL_SILENT),
:flags(NCOPTION_SUPPRESS_BANNERS +| NCOPTION_NO_ALTERNATE_SCREEN +| NCOPTION_INHIBIT_SETLOCALE),
);
my ($nc, $) = test-init-nc($opts);
unless $nc.defined {
skip 'No terminal available', 8;
exit 0;
}
LEAVE { notcurses_stop($nc) if $nc.defined }
my $std = notcurses_stdplane($nc);
my $fixture = 't/fixtures/test-4x4.png';
unless $fixture.IO.e {
skip 'Test fixture missing', 8;
exit 0;
}
subtest 'ncvisual_from_file and destroy' => {
plan 2;
my $v = ncvisual_from_file($fixture);
ok $v.defined, 'Visual loaded from PNG';
ncvisual_destroy($v);
ok True, 'Destroy succeeded without crash';
};
subtest 'ncvisual_decode on still image' => {
plan 4;
my $v = ncvisual_from_file($fixture);
# FFmpeg decodes PNG on from_file, so first decode returns 1 (already decoded/EOF)
my $rc = ncvisual_decode($v);
ok $rc >= 0, "First decode returns $rc (non-negative = success)";
# Second decode on a still image returns 1 (no more frames)
$rc = ncvisual_decode($v);
is $rc, 1, 'Second decode returns 1 (EOF, no more frames)';
my $v2 = ncvisual_from_file($fixture);
$rc = ncvisual_decode_loop($v2);
ok $rc >= 0, "decode_loop first call returns $rc";
$rc = ncvisual_decode_loop($v2);
is $rc, 1, 'decode_loop second call returns 1 (looped)';
ncvisual_destroy($v);
ncvisual_destroy($v2);
};
subtest 'ncvisual_geom with fixture' => {
plan 3;
my $v = ncvisual_from_file($fixture);
my $vopts = NcvisualOptions.new(:blitter(NCBLIT_1x1));
my $geom = Ncvgeom.new;
my $rc = ncvisual_geom($nc, $v, $vopts, $geom);
is $rc, 0, 'ncvisual_geom returns 0';
is $geom.pixy, 4, 'Pixel height = 4';
is $geom.pixx, 4, 'Pixel width = 4';
ncvisual_destroy($v);
};
subtest 'ncvisual_blit to plane' => {
plan 3;
my $v = ncvisual_from_file($fixture);
my $vopts = NcvisualOptions.new(:blitter(NCBLIT_1x1));
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'Blit returned a plane';
my $rows = ncplane_dim_y($plane);
my $cols = ncplane_dim_x($plane);
ok $rows > 0, "Blitted plane has rows = $rows";
ok $cols > 0, "Blitted plane has cols = $cols";
ncplane_destroy($plane);
ncvisual_destroy($v);
};
subtest 'ncvisual_at_yx and set_yx pixel manipulation' => {
plan 6;
my $v = ncvisual_from_file($fixture);
# Read pixel at (0,0) — should be red RGB(255,0,0)
# notcurses stores pixels as ABGR: 0xAABBGGRR
my uint32 $pixel;
my $rc = ncvisual_at_yx($v, 0, 0, $pixel);
is $rc, 0, 'at_yx(0,0) returns 0';
my $r = $pixel +& 0xFF;
my $g = ($pixel +> 8) +& 0xFF;
my $b = ($pixel +> 16) +& 0xFF;
is $r, 255, 'Pixel (0,0) R = 255 (red)';
is $g, 0, 'Pixel (0,0) G = 0';
is $b, 0, 'Pixel (0,0) B = 0';
# Set pixel at (0,0) to pure green and verify
my uint32 $green-pixel = 0xFF00FF00; # ABGR: A=FF B=00 G=FF R=00
$rc = ncvisual_set_yx($v, 0, 0, $green-pixel);
is $rc, 0, 'set_yx returns 0';
ncvisual_at_yx($v, 0, 0, $pixel);
is $pixel, $green-pixel, 'Pixel readback matches what we set';
ncvisual_destroy($v);
};
subtest 'ncvisual_resize changes geometry' => {
plan 3;
my $v = ncvisual_from_file($fixture);
my $rc = ncvisual_resize($v, 8, 8);
is $rc, 0, 'Resize to 8x8 returns 0';
my $vopts = NcvisualOptions.new(:blitter(NCBLIT_1x1));
my $geom = Ncvgeom.new;
ncvisual_geom($nc, $v, $vopts, $geom);
is $geom.pixy, 8, 'After resize, height = 8';
is $geom.pixx, 8, 'After resize, width = 8';
ncvisual_destroy($v);
};
subtest 'ncvisual_resize_noninterpolative' => {
plan 3;
my $v = ncvisual_from_file($fixture);
my $rc = ncvisual_resize_noninterpolative($v, 8, 8);
is $rc, 0, 'Noninterpolative resize to 8x8 returns 0';
my $vopts = NcvisualOptions.new(:blitter(NCBLIT_1x1));
my $geom = Ncvgeom.new;
ncvisual_geom($nc, $v, $vopts, $geom);
is $geom.pixy, 8, 'After noninterp resize, height = 8';
is $geom.pixx, 8, 'After noninterp resize, width = 8';
ncvisual_destroy($v);
};
subtest 'ncvisual_media_defblitter and polyfill' => {
plan 3;
# media_defblitter returns the default blitter for a given scale mode
my $blitter = ncvisual_media_defblitter($nc, NCSCALE_NONE);
ok $blitter >= 0, "Default blitter for NONE scale = $blitter";
my $blitter2 = ncvisual_media_defblitter($nc, NCSCALE_SCALE);
ok $blitter2 >= 0, "Default blitter for SCALE = $blitter2";
# polyfill: fill a region of pixels with a color
my $v = ncvisual_from_file($fixture);
# Fill from (0,0) which is red — flood fill with white
my uint32 $white = 0xFFFFFFFF; # ABGR white
my $filled = ncvisual_polyfill_yx($v, 0, 0, $white);
# polyfill returns number of pixels changed (should be 1 since only (0,0) is red)
ok $filled >= 1, "Polyfill changed $filled pixels";
ncvisual_destroy($v);
};
# ---------------------------------------------------------------------------
# ncvisual_options.begy/begx/leny/lenx cropping coverage
# ---------------------------------------------------------------------------
# These subtests prove that the source-region offsets land through
# ncvisual_blit_internal correctly. Upstream notcurses 3.0.17 silently
# dropped begy/begx for any path that resized the source (sprixel
# blitters consume from data[0]; cell blitters mixed input-space
# offsets with output-space buffers — see src/lib/internal.h FIXME on
# `blitterargs` in the fork pinned by NOTCURSES_FORK). Our patched
# ncvisual_blit_internal
# pre-crops the source so every downstream path sees an exact-region
# buffer.
#
# Strategy: build an 8x8 ncvisual via ncvisual_from_rgba where each
# pixel carries a unique colour `(y*32, x*32, 64)`. Blit with NCBLIT_1x1
# + NCSCALE_NONE — one output cell per source pixel, with the cell's
# bg painted to the source pixel colour by tria_blit_ascii. Reading
# the cell's bg back via ncchannels_bg_rgb8 yields the source pixel's
# (R, G, B), so a mismatch between expected and actual source row/col
# is directly observable.
sub make-gradient-visual(--> NcvisualHandle) {
# 8x8 RGBA buffer with pixel (y, x) = (y*32, x*32, 64, 255).
# Stored as little-endian uint32 = (a<<24) | (b<<16) | (g<<8) | r,
# matching the byte-order ncvisual_from_rgba expects.
my $pixels = CArray[uint32].allocate(64);
for ^8 -> $y {
for ^8 -> $x {
my uint32 $r = $y * 32;
my uint32 $g = $x * 32;
my uint32 $b = 64;
my uint32 $a = 255;
$pixels[$y * 8 + $x] =
($a +< 24) +| ($b +< 16) +| ($g +< 8) +| $r;
}
}
my $ptr = nativecast(Pointer, $pixels);
# rowstride = 8 cols * 4 bytes = 32. ncvisual_from_rgba copies the
# data internally, so $pixels can go out of scope after this call.
ncvisual_from_rgba($ptr, 8, 32, 8);
}
sub cell-bg-rgb($plane, Int $y, Int $x) {
# Use ncplane_at_yx_cell rather than ncplane_at_yx because the
# latter returns a malloc'd char* marshalled as Str — that path
# leaks the buffer per call and corrupts the heap on re-encode,
# per the project's NativeCall Str/free trap memo. The _cell
# variant copies fields into a caller-provided Nccell with no
# string round-trip.
my $cell = Nccell.new;
ncplane_at_yx_cell($plane, $y, $x, $cell);
my uint32 $r; my uint32 $g; my uint32 $b;
ncchannels_bg_rgb8($cell.channels, $r, $g, $b);
nccell_release($plane, $cell);
($r.Int, $g.Int, $b.Int);
}
sub expected-pixel(Int $src-y, Int $src-x) {
($src-y * 32, $src-x * 32, 64);
}
subtest 'cropped blit honors begy (top crop)' => {
plan 5;
my $v = make-gradient-visual();
# begy=3, leny=5 → 5x8 output, cell (0, x) shows source pixel (3, x).
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begy(3), :leny(5),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'blit returned a plane';
is ncplane_dim_y($plane), 5, 'output plane is 5 cells tall';
is ncplane_dim_x($plane), 8, 'output plane is 8 cells wide';
is-deeply cell-bg-rgb($plane, 0, 0), expected-pixel(3, 0),
'cell (0, 0) shows source pixel (3, 0)';
is-deeply cell-bg-rgb($plane, 4, 7), expected-pixel(7, 7),
'cell (4, 7) shows source pixel (7, 7)';
ncplane_destroy($plane);
ncvisual_destroy($v);
};
subtest 'cropped blit honors begx (left crop)' => {
plan 5;
my $v = make-gradient-visual();
# begx=2, lenx=6 → 8x6 output, cell (y, 0) shows source pixel (y, 2).
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begx(2), :lenx(6),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'blit returned a plane';
is ncplane_dim_y($plane), 8, 'output plane is 8 cells tall';
is ncplane_dim_x($plane), 6, 'output plane is 6 cells wide';
is-deeply cell-bg-rgb($plane, 0, 0), expected-pixel(0, 2),
'cell (0, 0) shows source pixel (0, 2)';
is-deeply cell-bg-rgb($plane, 7, 5), expected-pixel(7, 7),
'cell (7, 5) shows source pixel (7, 7)';
ncplane_destroy($plane);
ncvisual_destroy($v);
};
subtest 'cropped blit honors both begy and begx' => {
plan 5;
my $v = make-gradient-visual();
# begy=2, leny=4, begx=1, lenx=5 → 4x5 output.
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begy(2), :leny(4),
:begx(1), :lenx(5),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'blit returned a plane';
is ncplane_dim_y($plane), 4, 'output plane is 4 cells tall';
is ncplane_dim_x($plane), 5, 'output plane is 5 cells wide';
is-deeply cell-bg-rgb($plane, 0, 0), expected-pixel(2, 1),
'cell (0, 0) shows source pixel (2, 1)';
is-deeply cell-bg-rgb($plane, 3, 4), expected-pixel(5, 5),
'cell (3, 4) shows source pixel (5, 5) — bottom-right of crop';
ncplane_destroy($plane);
ncvisual_destroy($v);
};
subtest 'cropped blit baseline (no crop, regression guard)' => {
plan 4;
my $v = make-gradient-visual();
# Default crop fields (all 0): full source.
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'blit returned a plane';
is ncplane_dim_y($plane), 8, 'output plane is 8 cells tall';
is-deeply cell-bg-rgb($plane, 0, 0), expected-pixel(0, 0),
'cell (0, 0) shows source pixel (0, 0)';
is-deeply cell-bg-rgb($plane, 7, 7), expected-pixel(7, 7),
'cell (7, 7) shows source pixel (7, 7)';
ncplane_destroy($plane);
ncvisual_destroy($v);
};
subtest 'cropped blit honors edge slice (one-row)' => {
plan 3;
my $v = make-gradient-visual();
# begy=7, leny=1 → 1x8 output showing only the last source row.
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begy(7), :leny(1),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'blit returned a plane';
is ncplane_dim_y($plane), 1, 'output plane is 1 cell tall';
is-deeply cell-bg-rgb($plane, 0, 3), expected-pixel(7, 3),
'cell (0, 3) shows source pixel (7, 3)';
ncplane_destroy($plane);
ncvisual_destroy($v);
};
subtest 'cropped blit rejects out-of-bounds region' => {
plan 2;
my $v = make-gradient-visual();
# begy=8 == pixy → fully out of bounds, rejected at
# ncvisual_geom_inner before any blit pipeline work happens.
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begy(8),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
nok $plane.defined, 'out-of-bounds begy fails the blit';
# begy+leny exceeds pixy: also rejected.
my $vopts2 = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begy(6), :leny(5),
);
my $plane2 = ncvisual_blit($nc, $v, $vopts2);
nok $plane2.defined, 'begy+leny > pixy fails the blit';
ncvisual_destroy($v);
};
subtest 'cropped blit with NOINTERPOLATE flag' => {
plan 3;
my $v = make-gradient-visual();
# NOINTERPOLATE forces the generic (non-impl-backed) resize path.
# Our fix lives upstream of that branch, so the crop must hold here too.
my $vopts = NcvisualOptions.new(
:scaling(NCSCALE_NONE),
:blitter(NCBLIT_1x1),
:begy(3), :leny(5),
:flags(NCVISUAL_OPTION_NOINTERPOLATE),
);
my $plane = ncvisual_blit($nc, $v, $vopts);
ok $plane.defined, 'NOINTERPOLATE blit returned a plane';
is-deeply cell-bg-rgb($plane, 0, 0), expected-pixel(3, 0),
'NOINTERPOLATE path honors begy';
is-deeply cell-bg-rgb($plane, 4, 7), expected-pixel(7, 7),
'NOINTERPOLATE path renders correct bottom-right of crop';
ncplane_destroy($plane);
ncvisual_destroy($v);
};
done-testing;