Notcurses-Native.git | xt/ | 17-visual.rakutest


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;