Selkie.git | lib/Selkie/Widget/ | Image.rakumod


=begin pod

=head1 NAME

Selkie::Widget::Image - Display an image via notcurses pixel graphics

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Widget::Image;
use Selkie::Sizing;

my $img = Selkie::Widget::Image.new(
    file   => 'avatar.png',
    sizing => Sizing.fixed(20),
);

$img.set-file('new-avatar.png');

# Or render straight from decoded pixels you already have in memory.
# Pass a stable :id so the render cache never compares the bytes.
$img.set-rgba($rgba-bytes, :width(640), :height(480), :id("frame-42"));

$img.clear-image;

=end code

=head1 DESCRIPTION

Loads and renders an image onto its plane. If the terminal supports
pixel graphics — Sixel, Kitty graphics protocol, iTerm2 inline images
(notcurses unifies all three behind C<NCBLIT_PIXEL>) — full-resolution
pixels are rendered. Otherwise notcurses falls back to Unicode block /
quadrant / braille art.

=head2 Two source ingresses

An Image can be fed from a B<file path> (C<set-file>, decoded by
notcurses via C<ncvisual_from_file>) or from B<raw RGBA pixels in
memory> (C<set-rgba>, blitted via C<ncvisual_from_rgba>). The RGBA path
is for images you already hold as pixels — decoded yourself, generated,
or sourced from somewhere you don't want to round-trip through a file;
the caller produces the pixel buffer and hands it over. Everything
downstream of C<$!visual> (scaling, blitting, the clip-only crop path,
teardown) is identical for both sources.

The render cache keys on a small B<source-id> string (the path, a
caller-supplied id, or a gen counter) — never the pixel buffer — so a
re-render never pays the cost of comparing two multi-megabyte buffers.

=head2 Rendering model

C<Image.render> is a pure function of three inputs:

=item Source file content (the PNG / JPEG path).
=item The Image's own plane's notcurses-tracked screen rectangle (live, queried each render via C<ncplane_abs_y> / C<ncplane_dim_yx>).
=item The chain of ancestor plane rectangles up to the terminal viewport (also queried via notcurses each render).

Every user-visible state change — a parent scrolling, a sibling card
resizing the layout, a terminal zoom, a modal mounting — flows through
Selkie's dirty-propagation framework (C<set-viewport>, C<handle-resize>,
C<Container.!render-children>) and triggers C<Image.render> via the
normal dirty-driven render walk.

The render does exactly one thing: if the Image's plane is fully
contained in every ancestor's plane and within the terminal viewport,
emit the sprixel. Otherwise, ensure no live blit exists and skip. There
is no "park" position, no Widget-cache visibility check, no per-Image
state machine — just a notcurses-driven bounds intersection.

=head2 The two blit paths, and their caches

An Image emits its sprixel through exactly one of two paths, never
both:

=item B<C<render>> — the ordinary path. Emits the whole picture into a
blit plane bound to the Image's own plane, and only when the Image is
fully contained in every ancestor (see below).

=item B<C<render-viewport-crop>> — the hook a row-scrolling container
(C<Selkie::Widget::ViewportedCardList>) calls to composite a partially
visible Image. It emits a source crop into a blit plane bound to the
B<container's> plane, so the container's own clipping applies.

Both paths cache. Each compares the inputs that can change the emitted
sprixel against the previous successful emit, and returns without
touching the sprixel when nothing has changed: C<render> diffs live
notcurses geometry plus cell-pixel dims and the source id;
C<render-viewport-crop> diffs those plus the caller's crop window (see
the C<crop-cache-key> sub). This is not merely an optimisation. Tearing
a sprixel down marks every widget under its screen rect dirty — the
cells beneath it have to be repainted by whoever owns them — so a
teardown that only exists to be immediately re-emitted re-dirties the
widget that asked for the render, and the next frame does it all again.

The corollary is that C<render> leaves a crop-owned blit plane alone.
For a C<:clip-only> Image the crop path is the sole blit authority; the
container renders each card and B<then> composites it, so a C<render>
that tore the sprixel down would undo the previous frame's work on
every single frame. Teardown still happens on every path that actually
means it: C<park>, modal occlusion, an unloadable source, C<destroy>,
and a crop whose cache key changed.

=head2 Why notcurses, not Widget cache, for visibility decisions

Selkie keeps two parallel position states for every widget: the Widget
attributes (C<$.abs-y>, C<$.abs-x>, C<$.rows>, C<$.cols>) updated by
parent layouts via C<set-viewport> / C<handle-resize>, and the
notcurses plane position updated by C<ncplane_move_yx> and notcurses's
internal C<move_bound_planes> cascade. For text widgets these stay in
sync because cell ops are bounded by the plane and any divergence is
invisible. For sprixels they catastrophically diverge — sprixel pixels
paint at notcurses-tracked coordinates, and any cache desync produces
blits at the wrong place.

So Image's visibility decision queries notcurses directly. Widget
cache is fine for everything else.

=head2 Park

C<park> destroys the blit-plane and does nothing else — notably it does
B<not> move the Image's own plane, so unlike every other widget an
Image's C<is-parked> latch stays clear while its ancestors are parked.
C<Container.park> in the ancestor chain handles moving planes
off-screen via its reposition cascade; notcurses's
C<move_bound_planes> carries the Image's plane along with the ancestor
moves. When the cascade later
unparks (e.g., a card scrolls back into view), the dirty-driven render
walk reaches Image, the visibility check sees the now-on-screen
notcurses position, and a fresh blit is emitted.

=head2 Pixel bleed protection

Notcurses doesn't clip child planes' pixels to ancestor bounds. The
"fully contained in every ancestor" gate replaces any clipping —
Image is hidden during partial overlap rather than emitting pixels
that could bleed past an ancestor's edge. Partial-clip rendering
(showing the visible portion only) is intentionally out of scope for
this version; can be added later via Vips-based source cropping.

There's a second, protocol-specific source of bleed: Sixel emits
pixels in 6-pixel-tall groups, so a sprixel always rounds UP to the
next multiple of 6 vertical pixels on the wire. When the cell pixel
height isn't a multiple of 6, that rounding paints a few pixels
beyond the plane's pixel rectangle and into the next cell row. C<!emit-blit>
detects the active pixel implementation and shrinks the blit-plane
vertically by at most one cell when the protocol has a > 1 vertical
granularity, so the rounded-up pixel emit always fits inside Image's
own plane. Kitty graphics protocol and iTerm2 inline images use
exact pixel sizes (granularity 1); for those, no shrinkage applies.

=head1 EXAMPLES

=head2 Preview in a Split

=begin code :lang<raku>

my $preview = Selkie::Widget::Image.new(sizing => Sizing.flex);
my $border  = Selkie::Widget::Border.new(title => 'Preview', sizing => Sizing.flex);
$border.set-content($preview);

$preview.set-file($selected-path);

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::Border> — wrap to contain visual bleed of cell content
=item L<Selkie::EffectiveBounds> — value class returned by the bounds intersection helper

=end pod

use NativeCall;

use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Visual;
use Notcurses::Native::Plane;
use Notcurses::Native::Context;

use Selkie::Widget;
use Selkie::EffectiveBounds;
use Selkie::Tree;
use Selkie::Trace;

unit class Selkie::Widget::Image does Selkie::Widget;

# --- Debug logging (env-var-gated) -----------------------------------------
#
# Writes via spurt :append rather than $*ERR / `note`, because Selkie::App
# uses dup2(2, log-fd) to redirect stderr — and Raku's $*ERR can have its
# own buffering that doesn't always follow the dup2 cleanly. Going direct
# to a known file path side-steps the question.
#
# SELKIE_IMAGE_DEBUG=1            → $*TMPDIR/selkie-image-debug.{pid}.log
# SELKIE_IMAGE_DEBUG=/path/to/log → that path
# unset / empty / "0"             → no logging.
my Str  $LOG-PATH;
my Bool $LOG-INITIALIZED = False;

has Str $!file;
has NcvisualHandle $!visual;
has Bool $!loaded = False;
has NcplaneHandle $!blit-plane;

#|( Raw-RGBA source, the in-memory alternative to C<$!file>. When set,
    C<!load> builds the visual via C<ncvisual_from_rgba> instead of
    C<ncvisual_from_file>, so an image can be rendered straight from
    decoded pixels in memory (the caller produces the RGBA bytes and
    hands them to us).

    C<$!rgba-buf> is a tightly-packed 4-band RGBA buffer of
    C<$!rgba-w × $!rgba-h × 4> bytes. We retain the caller's C<Buf> B<by
    reference> (no copy) because the clip-only reload-before-rescale path
    (C<!ensure-scaled-visual>) rebuilds the visual from the pristine
    source on every rescale; C<!load> passes
    C<nativecast(Pointer, $!rgba-buf)> straight to C<ncvisual_from_rgba>,
    which copies the pixels into its own buffer. Memory cost: the one
    C<w × h × 4> Buf, shared by reference with whatever else holds it
    (e.g. a decode cache, or several Image widgets showing the same
    picture). )
has Buf $!rgba-buf;
has UInt $!rgba-w = 0;
has UInt $!rgba-h = 0;

# Clip-only mode pre-scales C<$!visual> to its B<cell-aligned> natural
# rendering pixel dims — i.e. (rcelly × cell-px-y) tall by
# (rcellx × cell-px-x) wide — so that every image cell row maps to
# exactly cell-px-y source pixels and partial-clip renders use pure
# integer pixel math (no aspect drift). Cache invalidates whenever
# the widget's natural rendering dims change (terminal resize, widget
# cols/rows change); reload-from-file before re-scale to avoid
# compounding interpolation loss from repeated downscales of an
# already-downscaled visual.
has UInt $!visual-scaled-rpix-y = 0;
has UInt $!visual-scaled-rpix-x = 0;

#|( When True, partial-clip rendering (C<render-viewport-crop>, called by
    row-scrolling containers like C<ViewportedCardList>) preserves the
    image's natural scale and on-screen position: the image is rendered
    as it would appear when fully visible, and rows / columns outside the
    visible viewport are simply not emitted. Defaults to False — the
    standard behaviour, where the visible cell rectangle is filled with
    a scaled-down crop of the source.

    Use this when you have a stack of images inside a row-scrolling list
    and want the picture to feel pinned to its host card rather than
    breathing in and out as the card slides past the viewport edge. The
    cost is that fully-clipped images (no visible cells overlap the
    natural image rectangle) produce no output — exactly the desired
    behaviour, but worth knowing. )
has Bool $.clip-only = False;

#|( Cached state from the previous successful blit. Compared each render
    against the live notcurses values; when they diverge we tear down +
    re-blit. Cleared by C<destroy-blit-plane>. )
has Int  $!last-abs-y;
has Int  $!last-abs-x;
has UInt $!last-rows = 0;
has UInt $!last-cols = 0;
has UInt $!last-cell-px-y = 0;
has UInt $!last-cell-px-x = 0;

#|( Render-cache identity of the source displayed at the last successful
    blit. A small string — the file path, a caller-supplied id, or an
    auto-incrementing gen counter — compared against C<$!source-id> each
    render. It is NEVER the pixel bytes: comparing two multi-MB buffers
    (a 4 MB-vs-4 MB C<eqv> every frame) is exactly the cost this token
    avoids. )
has Str  $!last-source-id;

#|( Render cache for C<render-viewport-crop>, the counterpart of the
    C<$!last-*> snapshot C<render> keeps. The crop path is driven by a
    container that hands us its own geometry, so the cache key is a
    single token built by C<crop-cache-key> from every input that can
    change the emitted sprixel; see that sub for the field list and why
    each one belongs. Cleared by C<destroy-blit-plane> so park,
    occlusion, and any other teardown re-blit from scratch.

    C<$!last-crop-empty> records that the cached verdict was "the image
    has scrolled entirely out of the visible window, emit nothing" — a
    real, cacheable outcome that has no blit-plane to prove it. )
has Str  $!last-crop-key;
has Bool $!last-crop-empty = False;

#|( True when the live C<$!blit-plane> was created by
    C<render-viewport-crop> (bound to the caller's viewport plane)
    rather than by C<!emit-blit> (bound to our own plane). C<render>
    uses it to leave the crop path's sprixel alone: for a
    C<:clip-only> Image the crop path is the sole blit authority, and
    a render pass that tore its plane down would restart the
    destroy → C<mark-widgets-in-rect-dirty> → re-render loop the cache
    exists to stop. )
has Bool $!blit-from-crop = False;

#|( Identity of the current source. set-file uses the path; set-rgba
    uses the caller's C<:id> (a stable token like C<"frame-42">) or,
    absent that, C<"gen-N"> from C<$!source-gen>. )
has Str  $!source-id;
has Int  $!source-gen = 0;

#| Height in rows. Same as C<self.rows>; provided for the ScrollView contract.
method logical-height(--> UInt) { self.rows }

#|( React to a parent layout's resize cascade by updating own dimensions
    and marking dirty. We always mark dirty even when our own dims didn't
    change — handle-resize fires from ancestor state changes that may
    affect our visibility chain even without changing our own size, and
    a redundant render that hits the unchanged cache is essentially free
    (one rect-intersection walk + one snapshot diff). )
method handle-resize(UInt $rows, UInt $cols) {
    my $changed = $rows != self.rows || $cols != self.cols;
    self.resize($rows, $cols) if $changed;
    self.mark-dirty;
}

#| The currently displayed file path, or C<Nil>.
method file(--> Str) { $!file }

#| True when an active blit-plane currently exists.
method has-blit-plane(--> Bool) { $!blit-plane.defined }

#| The render-cache identity of the current source (path / caller id /
#| gen token). Exposed for tests; not part of the rendering contract.
method source-id(--> Str) { $!source-id }

#| Swap the displayed image to a file path. No-op if the same path is
#| already loaded (and no RGBA source is active). Triggers a re-blit on
#| the next render via the dirty cascade.
method set-file(Str $path) {
    # Same-path no-op BEFORE the span: guards above spans everywhere a
    # no-op is the common case, or the tracer drowns in events that
    # record nothing (the 2026-07 trace capture was 27 events/frame,
    # dominated by exactly this pattern).
    return if !$!rgba-buf.defined && $path eq ($!file // '');
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.set-file', cat => 'render',
            args => %(path => $path,
                      current => ($!file // ''),
                      source_id => ($!source-id // '')))
        !! Nil;
    my Bool $trace-changed = False;
    LEAVE { $span.finish(changed => $trace-changed) with $span; }
    self!unload;
    $!rgba-buf  = Buf;
    $!rgba-w    = 0;
    $!rgba-h    = 0;
    $!file      = $path;
    $!source-id = $path;
    self.mark-dirty;
    $trace-changed = True;
}

#|( Display an image straight from decoded RGBA pixels in memory — the
    privacy-preserving alternative to C<set-file>. C<$rgba> must be a
    tightly-packed 4-band buffer of exactly C<$width × $height × 4>
    bytes (R, G, B, A order), the layout consumed by
    C<ncvisual_from_rgba>.

    Pass a stable C<:id> identifying the content (e.g. a key like
    C<"frame-42">) so the render cache can tell when the image has
    actually changed without ever comparing the pixel bytes. When
    omitted, each call is treated as new content (an internal gen
    counter), which is correct but forces a re-blit on every set.

    B<Ownership:> the C<Buf> is retained B<by reference>, not copied —
    C<!load> hands its storage straight to C<ncvisual_from_rgba> (which
    makes its own copy). The caller must therefore treat the buffer as
    immutable for the widget's lifetime; do not mutate it in place after
    handing it over. Sharing one buffer across several Image widgets is
    fine (and cheap) precisely because nothing here writes to it.

    =begin code :lang<raku>
    # $rgba-bytes: a width×height×4 Buf you produced however you like
    $widget.set-rgba($rgba-bytes, :width(640), :height(480),
                     :id("frame-42"));
    =end code )
method set-rgba(Buf:D $rgba, UInt:D :$width!, UInt:D :$height!, Str :$id) {
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.set-rgba', cat => 'render',
            args => %(bytes => $rgba.bytes,
                      width => $width,
                      height => $height,
                      id => ($id // '')))
        !! Nil;
    my Bool $trace-ok = False;
    LEAVE { $span.finish(ok => $trace-ok, source_id => ($!source-id // '')) with $span; }
    my $expected = $width * $height * 4;
    die "Image.set-rgba: buffer is {$rgba.bytes} bytes but "
        ~ "width×height×4 = $expected"
        unless $rgba.bytes == $expected;
    self!unload;
    # Retain the caller's Buf by reference — no copy. ncvisual_from_rgba
    # copies the pixels into the ncvisual's own buffer at !load time, and
    # the clip-only reload-before-rescale path re-nativecasts this same
    # Buf, so it just has to stay reachable for the widget's lifetime.
    $!rgba-buf  = $rgba;
    $!rgba-w    = $width;
    $!rgba-h    = $height;
    $!file      = Str;
    $!source-id = $id // "gen-{++$!source-gen}";
    self.mark-dirty;
    $trace-ok = True;
}

#| Unload the current image and clear the widget (file or RGBA source).
method clear-image() {
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.clear-image', cat => 'render',
            args => %(source_id => ($!source-id // ''),
                      loaded => $!loaded,
                      had_blit => $!blit-plane.defined.Bool))
        !! Nil;
    self!unload;
    $!file      = Str;
    $!rgba-buf  = Buf;
    $!rgba-w    = 0;
    $!rgba-h    = 0;
    $!source-id = Str;
    self.mark-dirty;
    $span.finish(ok => True) with $span;
}

method !load() {
    # Already-loaded is the per-render common case (render calls !load
    # unconditionally) — return before any span so the tracer only
    # records loads that do real ncvisual work.
    return if $!loaded;
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.load', cat => 'render',
            args => %(source_id => ($!source-id // ''),
                      has_rgba => $!rgba-buf.defined.Bool,
                      file => ($!file // '')))
        !! Nil;
    my Str $trace-path = $!rgba-buf.defined ?? 'rgba' !! 'file';
    my Bool $trace-ok = False;
    LEAVE { $span.finish(ok => $trace-ok, path => $trace-path) with $span; }
    if $!rgba-buf.defined {
        # rows = height, rowstride = width × 4 (tightly packed), cols = width.
        # nativecast hands ncvisual_from_rgba the Buf's storage directly —
        # no Raku-level copy; notcurses copies internally.
        $!visual = ncvisual_from_rgba(
            nativecast(Pointer, $!rgba-buf), $!rgba-h, $!rgba-w * 4, $!rgba-w);
    } else {
        return without $!file;
        $!visual = ncvisual_from_file($!file);
    }
    $!loaded = $!visual.defined;
    $trace-ok = $!loaded;
    # A fresh load wipes any prior pre-scaling, so the cache markers
    # need to reset too — otherwise the next render thinks the visual
    # is still at its pre-scale dims and skips a needed re-scale.
    $!visual-scaled-rpix-y = 0;
    $!visual-scaled-rpix-x = 0;
}

#|( For the clip-only render path: ensure C<$!visual>'s pixel
    dimensions equal the natural rendering dims (the dims notcurses's
    NCSCALE_SCALE would produce when blitting the original source into
    the full widget cell rect). When this holds, NCSCALE_NONE blits
    source pixels 1:1 to dest pixels, so partial-clip renders are
    pixel-exact regardless of source-crop aspect — no NCSCALE_SCALE
    letterbox drift per scroll step.

    Returns True iff C<$!visual> is now at natural dims and ready for
    NCSCALE_NONE blits. )
#|( For the clip-only render path: pre-scale C<$!visual> to its
    B<cell-aligned> natural rendering dimensions — i.e.
    C<rcelly × cell-px-y> tall by C<rcellx × cell-px-x> wide. After
    this, every image cell row is exactly C<cell-px-y> source pixels
    tall (and similarly for cols), so the per-frame source-crop math
    in C<render-viewport-crop>'s clip-only branch is pure integer
    arithmetic with no rounding drift, and a source crop of N image
    cell rows is exactly N × cell-px-y source pixels — which matches
    the dest blit plane's pixel rect of N cells × cell-px-y.

    Returns True iff the visual is now at cell-aligned dims and ready
    for C<NCSCALE_STRETCH> blits that render 1:1.

    The cache invalidates whenever the widget's natural cell footprint
    changes (terminal resize / widget cols/rows change), at which point
    we reload from the source file to avoid compounding interpolation
    loss from repeated re-scaling of an already-downscaled visual. )
method !ensure-scaled-visual($nc, UInt $cell-px-y, UInt $cell-px-x --> Bool) {
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.ensure-scaled-visual', cat => 'render',
            args => %(source_id => ($!source-id // ''),
                      cell_px_y => $cell-px-y,
                      cell_px_x => $cell-px-x))
        !! Nil;
    my Str $trace-state = 'start';
    my Bool $trace-ok = False;
    LEAVE { $span.finish(ok => $trace-ok, state => $trace-state) with $span; }
    return False unless $!loaded && $!visual.defined;

    # Probe geom against the CURRENT visual to find the natural cell
    # footprint (rcelly / rcellx). These are notcurses-computed and
    # account for cell-px geometry of the parent pile.
    my $geom = Ncvgeom.new;
    my $probe = NcvisualOptions.new(scaling => NCSCALE_SCALE, blitter => NCBLIT_PIXEL);
    $probe.set-plane(self.plane);
    ncvisual_geom($nc, $!visual, $probe, $geom);

    # Target: cell-aligned natural rendering size.
    my UInt $target-y = ($geom.rcelly.UInt * $cell-px-y);
    my UInt $target-x = ($geom.rcellx.UInt * $cell-px-x);
    $trace-state = 'zero-target';
    return False if $target-y == 0 || $target-x == 0;

    # Already at the right dims — either we pre-scaled to this exact
    # size in a prior call, or the source happens to match natively.
    if $geom.pixy == $target-y && $geom.pixx == $target-x {
        $!visual-scaled-rpix-y = $target-y;
        $!visual-scaled-rpix-x = $target-x;
        $trace-ok = True;
        $trace-state = 'cache-hit';
        return True;
    }

    # Either never scaled, or widget dims changed. Reload from file to
    # get the original-resolution source, then re-scale to the new
    # cell-aligned target. Reload-before-scale matters: repeatedly
    # resizing an already-resized visual compounds interpolation loss.
    self!unload;
    self!load;
    $trace-state = 'reload-failed';
    return False unless $!loaded;

    ncvisual_resize_noninterpolative($!visual, $target-y, $target-x);
    $!visual-scaled-rpix-y = $target-y;
    $!visual-scaled-rpix-x = $target-x;
    self!debug-log("ensure-scaled-visual",
        :target-y($target-y), :target-x($target-x),
        :rcelly($geom.rcelly), :rcellx($geom.rcellx),
        :cell-px-y($cell-px-y), :cell-px-x($cell-px-x));
    $trace-ok = True;
    $trace-state = 'resized';
    True;
}

method !unload() {
    # Span only when there is a visual to destroy — unload is called
    # from paths where nothing is loaded far more often than not
    # (98% no-op in the 2026-07 trace capture).
    if $!visual {
        my $span = Selkie::Trace.enabled
            ?? Selkie::Trace.start('image.unload', cat => 'render',
                args => %(loaded => $!loaded,
                          source_id => ($!source-id // '')))
            !! Nil;
        ncvisual_destroy($!visual);
        $!visual = NcvisualHandle;
        $span.finish(destroyed => True) with $span;
    }
    $!loaded = False;
}

#|( Tear down the current blit-plane and clear the cached state. Sets
    the underlying notcurses sprixel to SPRIXEL_HIDE; the actual
    sprixel-remove escape goes out at the next end-of-frame
    notcurses_render, where rasterize_sprixels() processes every
    SPRIXEL_HIDE before any SPRIXEL_INVALIDATED in a single pass. So a
    destroy + create within the same frame produces the correct wire
    sequence — no mid-walk render needed.

    Idempotent: returns immediately if no live blit. Note the C<without>
    test is on C<$!blit-plane> directly, NOT C<$!blit-plane.defined> —
    the latter returns a Bool which is always defined, so C<without
    Bool> never fires the early-return. Subtle Raku gotcha. )
method destroy-blit-plane(--> Nil) {
    # No-blit no-op BEFORE the span. Parked image cards re-enter here on
    # every layout pass (VBox/HBox park their zero-sized children each
    # render), which made this the single largest span source in the
    # 2026-07 capture — 138k events, 98.4% of them recording nothing.
    return without $!blit-plane;
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.destroy-blit-plane', cat => 'render',
            args => %(source_id => ($!source-id // '')))
        !! Nil;
    my Bool $trace-destroyed = False;
    LEAVE { $span.finish(destroyed => $trace-destroyed) with $span; }
    # Capture the blit plane's absolute screen rectangle BEFORE
    # destroying it. After destruction the handle is gone and notcurses
    # forgets the geometry, but the cells where the sprixel was painted
    # still need to be repainted by the widgets that own them —
    # otherwise the parent's last-rendered cell content stays on screen
    # under where the sprixel was, and (worse on Kitty graphics)
    # framework-side cell ops won't fire to overwrite any pixel
    # residue near the sprixel's emit area.
    my Int $blit-abs-y = ncplane_abs_y($!blit-plane);
    my Int $blit-abs-x = ncplane_abs_x($!blit-plane);
    my uint32 $blit-rows = 0; my uint32 $blit-cols = 0;
    ncplane_dim_yx($!blit-plane, $blit-rows, $blit-cols);

    ncplane_destroy($!blit-plane);
    $trace-destroyed = True;
    $!blit-plane = NcplaneHandle;
    $!last-rows  = 0;
    $!last-cols  = 0;
    $!last-abs-y = Int;
    $!last-abs-x = Int;
    $!last-source-id = Str;
    $!last-cell-px-y = 0;
    $!last-cell-px-x = 0;
    $!last-crop-key   = Str;
    $!last-crop-empty = False;
    $!blit-from-crop  = False;

    # Mark every widget whose plane intersects the now-destroyed
    # sprixel's screen rect as dirty so they repaint their cells.
    # Without this, the next frame happily skips painting (everyone
    # is clean) and any stale framebuffer cells left in the wake of
    # the sprixel — particularly visible near the cards container's
    # bottom border when an image scrolls off the top of a row-
    # scrolling viewport — survive.
    if $blit-rows > 0 && $blit-cols > 0 {
        mark-widgets-in-rect-dirty(
            abs-y => $blit-abs-y,
            abs-x => $blit-abs-x,
            rows  => $blit-rows.UInt,
            cols  => $blit-cols.UInt,
        );
    }

    self!debug-log("destroy-blit-plane",
        :abs-y($blit-abs-y), :abs-x($blit-abs-x),
        :rows($blit-rows.UInt), :cols($blit-cols.UInt));
}

#|( Park: destroy the blit-plane. Container.park's reposition cascade
    handles moving the plane off-screen — notcurses's move_bound_planes
    carries this Image's plane along with the ancestor moves. The next
    dirty-driven render reaches us via the cascade, the visibility
    chain sees the off-screen notcurses position, and the emit is
    skipped. When ancestors unpark (e.g., a card scrolls back into
    view), the dirty cascade fires again, the visibility check sees
    the on-screen notcurses position, and a fresh blit emits.

    The park latch is set explicitly rather than through a reposition,
    for the same reason: moving our own plane would fight the ancestor
    cascade. See C<Selkie::Widget.is-parked>. )
method park() {
    self!debug-log("park");
    self.destroy-blit-plane;
}

#| Tear down the sprixel and the underlying ncvisual / blit plane,
#| then destroy the widget's own plane. Always called on app shutdown
#| or when the widget is explicitly removed; sprixel cleanup is
#| critical because notcurses won't auto-evict pixels left on the
#| terminal when their carrier plane goes away.
method destroy() {
    self.destroy-blit-plane;
    self!unload;
    self!destroy-plane;
}

#| Per-frame render. When the source file is unset / unloadable, paints
#| a fallback message in dim text. When occluded by a modal or off
#| the visible region, ensures any prior sprixel is torn down before
#| returning. The blit plane is created lazily on the first render
#| that actually emits pixels.
method render() {
    # Unattached / parked widgets have no plane and nothing to record —
    # exit before the span (half of all image.render events in the
    # 2026-07 capture were this branch).
    return without self.plane;
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.render', cat => 'render',
            args => %(source_id => ($!source-id // ''),
                      rows => self.rows,
                      cols => self.cols,
                      loaded => $!loaded,
                      clip_only => $!clip-only,
                      has_blit => $!blit-plane.defined.Bool))
        !! Nil;
    my Str $trace-exit = 'done';
    LEAVE {
        $span.finish(exit => $trace-exit,
            loaded => $!loaded,
            has_blit => $!blit-plane.defined.Bool,
            last_source_id => ($!last-source-id // '')) with $span;
    }
    $trace-exit = 'start';
    ncplane_resize_simple(self.plane, self.rows, self.cols);
    ncplane_erase(self.plane);

    self!load;
    unless $!loaded {
        $trace-exit = 'fallback';
        my $style = self.theme.text-dim;
        self.apply-style($style);
        my $msg = $!file.defined     ?? "Cannot load: {$!file.IO.basename}"
               !! $!rgba-buf.defined ?? "Cannot load image"
               !!                        "No image";
        ncplane_putstr_yx(self.plane, self.rows div 2, 1, $msg);
        self!debug-log("render-fallback", :file($!file), :source-id($!source-id));
        self!ensure-no-blit;
        self.clear-dirty;
        return;
    }

    if self!is-occluded-by-modal {
        $trace-exit = 'occluded';
        self!debug-log("render-occluded");
        self!ensure-no-blit;
        self.clear-dirty;
        return;
    }

    # Clip-only images live inside a row-scrolling container (e.g.
    # ViewportedCardList) which drives the sprixel exclusively via
    # render-viewport-crop. Image.render's own emit-blit must NOT
    # fire in that case: it would create a competing sprixel as a
    # child of Image's own plane (in the container's backing chain,
    # which gets parked off-screen at end-of-frame). That competing
    # plane survives past render-viewport-crop's destroy + replace
    # (because emit-blit's plane is in a different parent chain than
    # render-viewport-crop's plane), leaving a leftover sprixel that
    # notcurses moves with the parked backing chain and emits at the
    # wrong screen row — visible as a one-row ghost over the
    # container's bottom border the moment the card scrolls fully
    # off the top. render-viewport-crop is the single canonical
    # blit pathway for clip-only images.
    if $!clip-only {
        $trace-exit = 'clip-only-defer';
        self!debug-log("render-clip-only-defer");
        # Tear down only a blit WE created. A crop-owned plane is the
        # canonical sprixel for a clip-only Image and its geometry is
        # the container's business, not ours: destroying it here would
        # force render-viewport-crop to re-create it on the same frame,
        # and the destroy's mark-widgets-in-rect-dirty would re-dirty
        # the card that just rendered — the self-sustaining teardown
        # loop that made idle frames cost 250ms+ in a consumer app. The
        # crop path's own cache diff decides when the sprixel actually
        # needs replacing; park / occlusion / destroy still tear it
        # down through destroy-blit-plane as before.
        self!ensure-no-blit unless $!blit-from-crop;
        self.clear-dirty;
        return;
    }

    # Authoritative bounds via notcurses chain. We read the position +
    # dimensions of every plane in the ancestor chain directly from
    # notcurses (live values, never the Widget cache that can desync
    # from notcurses when ancestors move via Container.park or other
    # cascades). The intersection with the terminal viewport is the
    # final clip.
    my $rect = effective-screen-rect-for(self);
    my Int $own-y = ncplane_abs_y(self.plane);
    my Int $own-x = ncplane_abs_x(self.plane);

    # Full-containment check: Image renders only when its entire plane
    # fits inside every ancestor and within the terminal. Partial
    # overlap (e.g., a card half-clipped at the top of CardList while
    # scrolling past) hides the Image until it's fully back in bounds.
    # This is intentional for v1 — partial-clip rendering can be added
    # later via Vips-based source cropping; the priority here is
    # correctness (no bleed past ancestor borders).
    my Bool $fully-contained = $rect.rows == self.rows
                            && $rect.cols == self.cols
                            && $rect.abs-y == $own-y
                            && $rect.abs-x == $own-x;

    unless $fully-contained {
        $trace-exit = 'clipped';
        self!debug-log("render-clipped",
            :own-y($own-y), :own-x($own-x),
            :own-rows(self.rows), :own-cols(self.cols),
            :rect-y($rect.abs-y), :rect-x($rect.abs-x),
            :rect-rows($rect.rows), :rect-cols($rect.cols));
        self!ensure-no-blit;
        self.clear-dirty;
        return;
    }

    # Cache diff — same fields as before but anchored on live notcurses
    # values. cell-px shifts (font zoom) trigger a re-blit even when
    # cell footprint is unchanged.
    my uint32 $pxy = 0; my uint32 $pxx = 0;
    my uint32 $cdy = 0; my uint32 $cdx = 0;
    my uint32 $bmy = 0; my uint32 $bmx = 0;
    ncplane_pixel_geom(self.plane, $pxy, $pxx, $cdy, $cdx, $bmy, $bmx);
    my UInt $cell-px-y = $cdy.UInt;
    my UInt $cell-px-x = $cdx.UInt;

    my Bool $unchanged = $!blit-plane.defined
        && $!last-abs-y.defined && $own-y == $!last-abs-y
        && $!last-abs-x.defined && $own-x == $!last-abs-x
        && self.rows == $!last-rows
        && self.cols == $!last-cols
        && $cell-px-y == $!last-cell-px-y
        && $cell-px-x == $!last-cell-px-x
        && ($!source-id // '') eq ($!last-source-id // '');

    if $unchanged {
        $trace-exit = 'cache-hit';
        self!debug-log("render-cache-hit");
        self.clear-dirty;
        return;
    }

    self!debug-log("render-reblit",
        :own-y($own-y), :own-x($own-x),
        :rows(self.rows), :cols(self.cols),
        :cell-px-y($cell-px-y), :cell-px-x($cell-px-x));

    self.destroy-blit-plane;
    self!emit-blit;
    $trace-exit = 'reblit';

    $!last-abs-y     = $own-y;
    $!last-abs-x     = $own-x;
    $!last-rows      = self.rows;
    $!last-cols      = self.cols;
    $!last-cell-px-y = $cell-px-y;
    $!last-cell-px-x = $cell-px-x;
    $!last-source-id = $!source-id;

    self.clear-dirty;
}

#|( Internal hook for row-viewport containers that render children into an
    offscreen logical plane and then composite only a visible cell slice.
    Normal Image.render intentionally hides partially clipped sprixels to
    prevent bleed; this hook performs an explicit source crop and blits
    the visible rectangle directly into the caller's viewport plane. )
method render-viewport-crop(
    NcplaneHandle :$parent-plane!,
    Int :$dest-y!,
    Int :$dest-x!,
    Int :$source-row!,
    Int :$source-col = 0,
    UInt :$rows!,
    UInt :$cols!,
    --> Bool
) {
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.render-viewport-crop', cat => 'render',
            args => %(source_id => ($!source-id // ''),
                      dest_y => $dest-y,
                      dest_x => $dest-x,
                      rows => $rows,
                      cols => $cols,
                      source_row => $source-row,
                      source_col => $source-col,
                      clip_only => $!clip-only))
        !! Nil;
    my Str $trace-exit = 'done';
    LEAVE {
        $span.finish(exit => $trace-exit,
            has_blit => $!blit-plane.defined.Bool,
            loaded => $!loaded) with $span;
    }
    $trace-exit = 'no-plane';
    return False without self.plane;
    if self!is-occluded-by-modal {
        self!debug-log("render-viewport-crop-occluded");
        self!ensure-no-blit;
        self.clear-dirty;
        $trace-exit = 'occluded';
        return True;
    }

    self!load;
    $trace-exit = 'not-loaded';
    return False unless $!loaded;
    $trace-exit = 'start';

    my $nc = ncplane_notcurses($parent-plane);
    my $pixel-impl = notcurses_check_pixel_support($nc);
    my $blitter = $pixel-impl > 0 ?? NCBLIT_PIXEL
                                  !! ncvisual_media_defblitter($nc, NCSCALE_SCALE);

    my uint32 $pxy = 0; my uint32 $pxx = 0;
    my uint32 $cdy = 0; my uint32 $cdx = 0;
    my uint32 $bmy = 0; my uint32 $bmx = 0;
    ncplane_pixel_geom($parent-plane, $pxy, $pxx, $cdy, $cdx, $bmy, $bmx);
    my UInt $cell-px-y = $cdy.UInt max 1;
    my UInt $cell-px-x = $cdx.UInt max 1;

    # Cache diff, the crop-path counterpart of render's :711-725 check.
    # Everything downstream of here is a pure function of these inputs
    # plus the loaded visual, so an unchanged key means an unchanged
    # sprixel — and re-emitting an unchanged sprixel is not free: the
    # teardown half walks every widget tree (see
    # Selkie::Tree::mark-widgets-in-rect-dirty) and re-dirties the very
    # card that asked us to render.
    my Str $key = crop-cache-key(
        parent-plane => plane-address($parent-plane),
        source-id    => $!source-id,
        clip-only    => $!clip-only,
        self-rows    => self.rows,   self-cols  => self.cols,
        cell-px-y    => $cell-px-y,  cell-px-x  => $cell-px-x,
        dest-y       => $dest-y,     dest-x     => $dest-x,
        source-row   => $source-row, source-col => $source-col,
        rows         => $rows,       cols       => $cols,
        pixel-impl   => $pixel-impl.Int,
    );
    if $!last-crop-key.defined && $key eq $!last-crop-key
       && ($!blit-plane.defined || $!last-crop-empty) {
        self!debug-log("render-viewport-crop-cache-hit");
        self.clear-dirty;
        $trace-exit = 'cache-hit';
        return True;
    }

    # Miss: drop the cached verdict before doing anything destructive so
    # a mid-way bail (failed plane create) can't leave a key claiming a
    # blit that isn't there.
    $!last-crop-key   = Str;
    $!last-crop-empty = False;
    self.destroy-blit-plane;

    my $geom = Ncvgeom.new;
    my $probe = NcvisualOptions.new(scaling => NCSCALE_SCALE, :$blitter);
    $probe.set-plane(self.plane);
    ncvisual_geom($nc, $!visual, $probe, $geom);

    my UInt $src-total-y = $geom.pixy.UInt max ($cell-px-y * (self.rows max 1));
    my UInt $src-total-x = $geom.pixx.UInt max ($cell-px-x * (self.cols max 1));

    my (UInt $begy, UInt $begx, UInt $leny, UInt $lenx);
    my (Int $blit-dest-y, Int $blit-dest-x);
    my (UInt $blit-rows, UInt $blit-cols);

    my Int $blit-scaling;

    if $!clip-only {
        # Clip-only path: pre-scale the visual to cell-aligned natural
        # dims (so every image cell row is exactly cell-px-y source
        # pixels tall), then ask the pure-function helper for the
        # blit parameters. NCSCALE_STRETCH blits the source crop into
        # a plane whose pixel rect equals the crop's pixel rect — 1:1
        # rendering, no aspect drift, no scale_visual call.
        self!ensure-scaled-visual($nc, $cell-px-y, $cell-px-x);

        # Re-probe geom against the now-cell-aligned visual; rcelly
        # and rcellx are now `pixy / cell-px-y` and `pixx / cell-px-x`
        # exactly.
        ncvisual_geom($nc, $!visual, $probe, $geom);

        my %r = compute-clip-only-blit(
            self-rows  => self.rows,    self-cols  => self.cols,
            cell-px-y  => $cell-px-y,   cell-px-x  => $cell-px-x,
            rcelly     => $geom.rcelly.UInt, rcellx => $geom.rcellx.UInt,
            dest-y     => $dest-y,      dest-x     => $dest-x,
            source-row => $source-row,  source-col => $source-col,
            rows       => $rows,        cols       => $cols,
        );

        unless %r {
            # Image is entirely outside the visible viewport — emit
            # nothing. The cache-miss teardown above already removed any
            # previous frame's blit-plane. "Emit nothing" is a genuine
            # verdict about these inputs, so it gets cached too:
            # otherwise a fully-scrolled-off image re-runs the rescale +
            # geom probe on every frame it stays off-screen.
            $!last-rows = 0;
            $!last-cols = 0;
            $!last-crop-key   = $key;
            $!last-crop-empty = True;
            self.clear-dirty;
            $trace-exit = 'outside';
            return True;
        }

        $begy        = %r<begy>;
        $begx        = %r<begx>;
        $leny        = %r<leny>;
        $lenx        = %r<lenx>;
        $blit-rows   = %r<blit-rows>;
        $blit-cols   = %r<blit-cols>;
        $blit-dest-y = %r<blit-dest-y>;
        $blit-dest-x = %r<blit-dest-x>;
        $blit-scaling = NCSCALE_STRETCH;
    } else {
        $begy = (($source-row max 0) * $src-total-y / (self.rows max 1)).floor.UInt;
        $begx = (($source-col max 0) * $src-total-x / (self.cols max 1)).floor.UInt;
        $leny = ($rows * $src-total-y / (self.rows max 1)).ceiling.UInt max 1;
        $lenx = ($cols * $src-total-x / (self.cols max 1)).ceiling.UInt max 1;
        $leny = $leny min ($src-total-y - $begy) if $begy < $src-total-y;
        $lenx = $lenx min ($src-total-x - $begx) if $begx < $src-total-x;
        $blit-dest-y = $dest-y;
        $blit-dest-x = $dest-x;
        $blit-rows   = $rows;
        $blit-cols   = $cols;
        $blit-scaling = NCSCALE_SCALE;
    }

    # Sixel granularity rounding: sixel emits in 6-px-tall bands, so the
    # blit plane's pixel height has to be a multiple of 6 or the bottom
    # band is dropped. Round down rather than up so the actual rendered
    # area stays within the plane's cell footprint; on a sixel terminal
    # this costs at most one band off the bottom edge.
    my UInt $dest-rows = $blit-rows;
    my UInt $gran-y = $pixel-impl == NCPIXEL_SIXEL ?? 6 !! 1;
    if $gran-y > 1 && $cell-px-y > 0 {
        my UInt $own-px = $blit-rows * $cell-px-y;
        my UInt $max-emit-px = ($own-px div $gran-y) * $gran-y;
        my UInt $max-rows = ($max-emit-px div $cell-px-y) max 1;
        $dest-rows = $dest-rows min $max-rows;
    }

    my $opts = NcplaneOptions.new(
        y => $blit-dest-y, x => $blit-dest-x,
        rows => $dest-rows, cols => $blit-cols,
    );
    $!blit-plane = ncplane_create($parent-plane, $opts);
    $trace-exit = 'create-plane-failed';
    return True without $!blit-plane;
    $!blit-from-crop = True;
    $trace-exit = 'blit';

    my $vopts = NcvisualOptions.new(
        scaling => $blit-scaling,
        :$blitter,
        :$begy, :$begx, :$leny, :$lenx,
    );
    $vopts.set-plane($!blit-plane);
    my $result = ncvisual_blit($nc, $!visual, $vopts);

    if !$result.defined && $blitter == NCBLIT_PIXEL {
        $trace-exit = 'blit-fallback-default';
        $blitter = ncvisual_media_defblitter($nc, $blit-scaling);
        $vopts = NcvisualOptions.new(
            scaling => $blit-scaling,
            :$blitter,
            :$begy, :$begx, :$leny, :$lenx,
        );
        $vopts.set-plane($!blit-plane);
        $result = ncvisual_blit($nc, $!visual, $vopts);
    }

    if !$result.defined {
        $trace-exit = 'blit-fallback-1x1';
        $vopts = NcvisualOptions.new(
            scaling => $blit-scaling,
            blitter => NCBLIT_1x1,
            :$begy, :$begx, :$leny, :$lenx,
        );
        $vopts.set-plane($!blit-plane);
        ncvisual_blit($nc, $!visual, $vopts);
    }

    $!last-rows = 0;
    $!last-cols = 0;
    $!last-crop-key = $key;
    self.clear-dirty;
    $trace-exit = 'done';
    True;
}

method !ensure-no-blit() {
    self.destroy-blit-plane if $!blit-plane;
}

method !is-occluded-by-modal(--> Bool) {
    widget-occluded-by-active-modal(self);
}

#|( Create a fresh blit-plane sized to the Image's full plane area at
    offset (0, 0) and blit the loaded visual into it. Called from
    render() only when the visibility check passed (Image is fully
    contained in every ancestor) — so we know we can emit the entire
    image without bleeding past anything. )
method !emit-blit(--> Nil) {
    my $span = Selkie::Trace.enabled
        ?? Selkie::Trace.start('image.emit-blit', cat => 'render',
            args => %(source_id => ($!source-id // ''),
                      rows => self.rows,
                      cols => self.cols))
        !! Nil;
    my Str $trace-path = 'pixel';
    my Bool $trace-ok = False;
    my UInt $trace-img-rows = 0;
    my UInt $trace-img-cols = 0;
    LEAVE {
        $span.finish(ok => $trace-ok,
            path => $trace-path,
            img_rows => $trace-img-rows,
            img_cols => $trace-img-cols,
            has_blit => $!blit-plane.defined.Bool) with $span;
    }
    my $nc = self!notcurses-handle;

    my $pixel-impl = notcurses_check_pixel_support($nc);
    my $blitter    = $pixel-impl > 0 ?? NCBLIT_PIXEL
                                     !! ncvisual_media_defblitter($nc, NCSCALE_SCALE);

    my $geom = Ncvgeom.new;
    my $probe = NcvisualOptions.new(scaling => NCSCALE_SCALE, :$blitter);
    $probe.set-plane(self.plane);
    ncvisual_geom($nc, $!visual, $probe, $geom);

    # Cap the rendered cell footprint at our own plane size. Defensive
    # against notcurses geometry rounding up — the cap means the
    # blit-plane never exceeds Image's plane in cells, which combined
    # with the visibility check (Image fully contained in ancestors)
    # keeps the cell footprint bounded.
    my UInt $img-rows = ($geom.rcelly min self.rows) max 1;
    my UInt $img-cols = ($geom.rcellx min self.cols) max 1;
    $trace-img-rows = $img-rows;
    $trace-img-cols = $img-cols;

    # Sprixel pixel-rounding cap. Even when the blit-plane is bounded in
    # CELLS to our own plane, the actual pixel emit on the wire can
    # exceed cell boundaries depending on the pixel protocol:
    #
    #   * Sixel groups pixels into 6-pixel-tall sixels — the wire
    #     format always emits a multiple of 6 vertical pixels. So a
    #     blit of N rows × cell-px-y pixels gets rounded UP to the
    #     next multiple of 6, painting up to 5 pixels below the
    #     plane's pixel rectangle. Those extra pixels land in cells
    #     OUTSIDE Image's plane (typically the avatar's backdrop
    #     colour over whatever's beneath — bottom border, next card,
    #     etc.).
    #
    #   * Kitty graphics protocol + iTerm2 inline images use exact
    #     pixel sizes (granularity 1) — no rounding, no overflow.
    #
    # When the protocol has > 1 vertical granularity, shrink blit-rows
    # so that ceil(rows × cell-px-y / gran) × gran ≤ self.rows ×
    # cell-px-y. The image renders into a slightly smaller cell
    # footprint, centered, and pixels stay inside our plane.
    my UInt $gran-y = $pixel-impl == NCPIXEL_SIXEL ?? 6 !! 1;
    if $gran-y > 1 {
        my uint32 $pxy0 = 0; my uint32 $pxx0 = 0;
        my uint32 $cdy0 = 0; my uint32 $cdx0 = 0;
        my uint32 $bmy0 = 0; my uint32 $bmx0 = 0;
        ncplane_pixel_geom(self.plane, $pxy0, $pxx0, $cdy0, $cdx0, $bmy0, $bmx0);
        my UInt $cell-px-y = $cdy0.UInt;
        if $cell-px-y > 0 {
            # Largest blit-rows where the rounded-up pixel emit fits
            # within our own pixel rectangle.
            my UInt $own-px      = self.rows * $cell-px-y;
            my UInt $max-emit-px = ($own-px div $gran-y) * $gran-y;
            my UInt $max-rows    = ($max-emit-px div $cell-px-y) max 1;
            $img-rows = $img-rows min $max-rows;
        }
    }

    my UInt $offset-y = ($img-rows < self.rows) ?? (self.rows - $img-rows) div 2 !! 0;
    my UInt $offset-x = ($img-cols < self.cols) ?? (self.cols - $img-cols) div 2 !! 0;

    my $opts = NcplaneOptions.new(
        y => $offset-y, x => $offset-x,
        rows => $img-rows, cols => $img-cols,
    );
    $!blit-plane = ncplane_create(self.plane, $opts);
    $!blit-from-crop = False;

    my $vopts = NcvisualOptions.new(scaling => NCSCALE_SCALE, :$blitter);
    $vopts.set-plane($!blit-plane);

    my $result = ncvisual_blit($nc, $!visual, $vopts);

    if !$result.defined && $blitter == NCBLIT_PIXEL {
        $trace-path = 'default-blitter';
        $blitter = ncvisual_media_defblitter($nc, NCSCALE_SCALE);
        $vopts = NcvisualOptions.new(scaling => NCSCALE_SCALE, :$blitter);
        $vopts.set-plane($!blit-plane);
        $result = ncvisual_blit($nc, $!visual, $vopts);
    }

    if !$result.defined {
        $trace-path = '1x1';
        $vopts = NcvisualOptions.new(scaling => NCSCALE_SCALE, blitter => NCBLIT_1x1);
        $vopts.set-plane($!blit-plane);
        $result = ncvisual_blit($nc, $!visual, $vopts);
    }
    $trace-ok = $result.defined.Bool;
}

method !notcurses-handle(--> NotcursesHandle) {
    ncplane_notcurses(self.plane);
}

method !debug-log(Str:D $stage, *%kv --> Nil) {
    unless $LOG-INITIALIZED {
        $LOG-INITIALIZED = True;
        my $env = %*ENV<SELKIE_IMAGE_DEBUG>;
        if $env.defined && $env.chars > 0 && $env ne '0' {
            $LOG-PATH = ($env eq '1' || $env.lc eq 'true')
                ?? $*TMPDIR.add("selkie-image-debug.{$*PID}.log").Str
                !! $env;
            my $parent = $LOG-PATH.IO.parent;
            try $parent.mkdir unless $parent.e;
            try spurt $LOG-PATH,
                "=== selkie image debug pid={$*PID} {DateTime.now.truncated-to('second')} ===\n",
                :append;
        }
    }
    return without $LOG-PATH;
    my @parts = "[selkie:image:$stage]";
    @parts.push: "id={self.widget-id}";
    for %kv.kv -> $k, $v {
        @parts.push: "$k={$v // 'Nil'}";
    }
    try spurt $LOG-PATH, "{@parts.join(' ')}\n", :append;
}

#|( Walk the widget's parent chain via notcurses queries (NOT Widget
    cache) and return the rectangular intersection of the widget's
    plane with every ancestor's plane and the terminal viewport. The
    result is the on-screen rectangle into which the widget could
    safely paint pixels. Empty when the widget is fully outside any
    ancestor or off the terminal.

    Used by C<Image.render> to decide whether to emit the sprixel.
    Reading from notcurses each call (rather than from cached Widget
    abs-y/x) means we don't depend on the Widget cache being in sync
    with the actual notcurses plane positions — which can desync any
    time something moves a plane outside the normal layout cascade
    (Container.park reposition cascade, direct ncplane_move_yx, etc).
    Notcurses position is the source of truth for sprixel visibility
    because that's where pixels actually paint. )
sub effective-screen-rect-for(Selkie::Widget $w --> Selkie::EffectiveBounds) is export {
    return Selkie::EffectiveBounds.new(
        abs-y => 0, abs-x => 0, rows => 0, cols => 0,
    ) without $w.plane;

    my Int  $cur-y = ncplane_abs_y($w.plane);
    my Int  $cur-x = ncplane_abs_x($w.plane);
    my uint32 $h0 = 0; my uint32 $w0 = 0;
    ncplane_dim_yx($w.plane, $h0, $w0);
    my UInt $cur-h = $h0.UInt;
    my UInt $cur-w = $w0.UInt;

    my $ancestor = $w.parent;
    while $ancestor.defined && $ancestor.plane && !($cur-h == 0 || $cur-w == 0) {
        my Int $py = ncplane_abs_y($ancestor.plane);
        my Int $px = ncplane_abs_x($ancestor.plane);
        my uint32 $ph = 0; my uint32 $pw = 0;
        ncplane_dim_yx($ancestor.plane, $ph, $pw);

        my Int $top    = $cur-y max $py;
        my Int $left   = $cur-x max $px;
        my Int $bottom = ($cur-y + $cur-h.Int) min ($py + $ph.Int);
        my Int $right  = ($cur-x + $cur-w.Int) min ($px + $pw.Int);
        $cur-y = $top;
        $cur-x = $left;
        $cur-h = (($bottom - $top) max 0).UInt;
        $cur-w = (($right  - $left) max 0).UInt;
        $ancestor = $ancestor.parent;
    }

    my ($vp-rows, $vp-cols) = terminal-viewport();
    my Int $tb = $vp-rows.Int;
    my Int $tr = $vp-cols.Int;
    my Int $top    = $cur-y max 0;
    my Int $left   = $cur-x max 0;
    my Int $bottom = ($cur-y + $cur-h.Int) min $tb;
    my Int $right  = ($cur-x + $cur-w.Int) min $tr;
    Selkie::EffectiveBounds.new(
        abs-y => $top, abs-x => $left,
        rows  => (($bottom - $top) max 0).UInt,
        cols  => (($right  - $left) max 0).UInt,
    );
}

#|( The numeric address behind a notcurses plane handle. C<NcplaneHandle>
    is a C<CPointer> repr, so two handles onto the same plane are
    distinct Raku objects and C<===> can't compare them — the address is
    the only stable identity. Returns 0 for an undefined handle. )
sub plane-address(NcplaneHandle $p --> Int) is export {
    return 0 without $p;
    nativecast(Pointer, $p).Int;
}

#|( Build the render cache token for C<Image.render-viewport-crop>.

    The crop path's output is a pure function of the caller's viewport
    geometry, the widget's own cell rect, the terminal's cell-pixel
    geometry, and the loaded source — so a token over exactly those
    inputs is a sound "nothing to do" test. Field by field:

    =item B<parent-plane> — the blit plane is created bound to it, so a
      different plane means a different sprixel parent even at identical
      relative coordinates. Pass an address (see C<plane-address>), not
      a handle.
    =item B<source-id> — the render-cache identity of the picture. Never
      the pixel bytes; see the C<$!last-source-id> notes.
    =item B<clip-only> — selects between two entirely different blit
      parameter derivations.
    =item B<self-rows> / B<self-cols> — the widget's own cell rect feeds
      both derivations (image centering, source-pixel scaling).
    =item B<cell-px-y> / B<cell-px-x> — a font zoom changes the pixel
      size of the emit without moving a single cell.
    =item B<dest-y> / B<dest-x> / B<source-row> / B<source-col> /
      B<rows> / B<cols> — the caller's crop window.
    =item B<pixel-impl> — selects the blitter and the sixel row
      granularity cap.

    Exported so the test suite can pin the invalidation matrix without
    a notcurses context. )
sub crop-cache-key(
    Int  :$parent-plane!,
    Str  :$source-id,
    Bool :$clip-only!,
    UInt :$self-rows!,  UInt :$self-cols!,
    UInt :$cell-px-y!,  UInt :$cell-px-x!,
    Int  :$dest-y!,     Int  :$dest-x!,
    Int  :$source-row!, Int  :$source-col!,
    UInt :$rows!,       UInt :$cols!,
    Int  :$pixel-impl!,
    --> Str
) is export {
    # NUL joins for the same reason Store's path-key uses it: source-id
    # is caller-supplied and must not be able to forge a key boundary.
    (
        $parent-plane, ($source-id // ''), ($clip-only ?? 1 !! 0),
        $self-rows, $self-cols, $cell-px-y, $cell-px-x,
        $dest-y, $dest-x, $source-row, $source-col,
        $rows, $cols, $pixel-impl,
    ).join("\0");
}

#|( Pure math for C<Image.render-viewport-crop>'s C<:clip-only> path.

    Given the widget's cell rect, the cell-pixel dims, the image's
    natural rendered cell footprint (rcelly / rcellx) — as reported by
    notcurses for the visual blitted at the FULL widget rect — and the
    caller's visible-cell window (source-row + rows in widget cells,
    source-col + cols horizontally), returns either an empty Hash
    (visible cells = 0, no blit) or the full set of blit parameters:

    =item B<begy>, B<begx> — source pixel crop origin, in pixels of the
      C<cell-aligned pre-scaled> visual (rcelly × cell-px-y by rcellx ×
      cell-px-x). Since the source is cell-aligned, every cell row of
      the image is exactly C<cell-px-y> source pixels tall.
    =item B<leny>, B<lenx> — source pixel crop length.
    =item B<blit-rows>, B<blit-cols> — dest plane cell dims.
    =item B<blit-dest-y>, B<blit-dest-x> — dest plane position within
      the parent plane (parent = the caller's C<$parent-plane>), as
      C<dest-y + (vis-cell-y-start - source-row)> and the analogous
      C<x>. C<dest-y>/C<dest-x> are the visible-viewport offsets the
      caller already computed.

    The output dims have the invariant that
    B<lenx = blit-cols × cell-px-x> and B<leny = blit-rows × cell-px-y>,
    which is what makes C<NCSCALE_STRETCH> render the source crop into
    the dest plane at exactly 1:1 — no scale_visual call, no aspect
    drift. Width-invariance across vertical scroll positions follows
    from the fact that C<blit-cols> and C<lenx> only depend on
    C<self-cols>, C<rcellx>, C<source-col> and C<cols> — never on
    C<source-row> or C<rows>.

    Exported so the test suite can exercise the math without spinning
    up notcurses. )
sub compute-clip-only-blit(
    UInt :$self-rows!,  UInt :$self-cols!,
    UInt :$cell-px-y!,  UInt :$cell-px-x!,
    UInt :$rcelly!,     UInt :$rcellx!,
    Int  :$dest-y!,     Int  :$dest-x!,
    Int  :$source-row!, Int  :$source-col!,
    UInt :$rows!,       UInt :$cols!,
    --> Hash
) is export {
    # If the image is too tall/wide for its own widget, clip its cell
    # footprint to the widget's. This matches !emit-blit's `min self.rows`.
    my Int $img-cell-rows = ($rcelly min $self-rows).Int;
    my Int $img-cell-cols = ($rcellx min $self-cols).Int;
    return {} if $img-cell-rows <= 0 || $img-cell-cols <= 0;

    # Where the image sits inside the widget cell grid (centered).
    my Int $img-top-cell  = (($self-rows - $img-cell-rows) div 2).Int;
    my Int $img-left-cell = (($self-cols - $img-cell-cols) div 2).Int;

    # Image cell range in widget cell coords.
    my Int $img-cell-y-end = $img-top-cell  + $img-cell-rows;
    my Int $img-cell-x-end = $img-left-cell + $img-cell-cols;

    # Visible widget cell range, intersected with image cell range.
    my Int $vis-cell-y-start = $img-top-cell  max $source-row;
    my Int $vis-cell-y-end   = $img-cell-y-end min ($source-row + $rows.Int);
    my Int $vis-cell-x-start = $img-left-cell max $source-col;
    my Int $vis-cell-x-end   = $img-cell-x-end min ($source-col + $cols.Int);

    # No overlap — image isn't in the visible window.
    return {} if $vis-cell-y-end <= $vis-cell-y-start
              || $vis-cell-x-end <= $vis-cell-x-start;

    # Convert to image-local cell coords (which row/col of the image
    # itself is the first visible one + how many are visible).
    my Int $img-row-start = $vis-cell-y-start - $img-top-cell;
    my Int $img-row-count = $vis-cell-y-end   - $vis-cell-y-start;
    my Int $img-col-start = $vis-cell-x-start - $img-left-cell;
    my Int $img-col-count = $vis-cell-x-end   - $vis-cell-x-start;

    # Source pixel crop. With the visual pre-scaled to cell-aligned
    # natural dims, every image cell row is exactly cell-px-y source
    # pixels tall (and similarly for cols).
    my UInt $begy = ($img-row-start * $cell-px-y).UInt;
    my UInt $leny = ($img-row-count * $cell-px-y).UInt;
    my UInt $begx = ($img-col-start * $cell-px-x).UInt;
    my UInt $lenx = ($img-col-count * $cell-px-x).UInt;

    # Dest plane position within $parent-plane (the caller already
    # computed the visible viewport's dest-y / dest-x — we offset from
    # there by however many widget cells separate the visible top from
    # the image's first visible cell).
    my Int $blit-dest-y = $dest-y + ($vis-cell-y-start - $source-row);
    my Int $blit-dest-x = $dest-x + ($vis-cell-x-start - $source-col);

    {
        begy        => $begy,
        begx        => $begx,
        leny        => $leny,
        lenx        => $lenx,
        blit-rows   => $img-row-count.UInt,
        blit-cols   => $img-col-count.UInt,
        blit-dest-y => $blit-dest-y,
        blit-dest-x => $blit-dest-x,
    };
}