Selkie--Widget--Image

NAME

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

SYNOPSIS

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;

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 NCBLIT_PIXEL) — full-resolution pixels are rendered. Otherwise notcurses falls back to Unicode block / quadrant / braille art.

Two source ingresses

An Image can be fed from a file path (set-file, decoded by notcurses via ncvisual_from_file) or from raw RGBA pixels in memory (set-rgba, blitted via 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 $!visual (scaling, blitting, the clip-only crop path, teardown) is identical for both sources.

The render cache keys on a small 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.

Rendering model

Image.render is a pure function of three inputs:

  • Source file content (the PNG / JPEG path).

  • The Image's own plane's notcurses-tracked screen rectangle (live, queried each render via ncplane_abs_y / ncplane_dim_yx).

  • 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 (set-viewport, handle-resize, Container.!render-children) and triggers 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.

The two blit paths, and their caches

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

  • 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).

  • render-viewport-crop — the hook a row-scrolling container (Selkie::Widget::ViewportedCardList) calls to composite a partially visible Image. It emits a source crop into a blit plane bound to the 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: render diffs live notcurses geometry plus cell-pixel dims and the source id; render-viewport-crop diffs those plus the caller's crop window (see the 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 render leaves a crop-owned blit plane alone. For a :clip-only Image the crop path is the sole blit authority; the container renders each card and then composites it, so a 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: park, modal occlusion, an unloadable source, destroy, and a crop whose cache key changed.

Why notcurses, not Widget cache, for visibility decisions

Selkie keeps two parallel position states for every widget: the Widget attributes ($.abs-y, $.abs-x, $.rows, $.cols) updated by parent layouts via set-viewport / handle-resize, and the notcurses plane position updated by ncplane_move_yx and notcurses's internal 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.

Park

park destroys the blit-plane and does nothing else — notably it does not move the Image's own plane, so unlike every other widget an Image's is-parked latch stays clear while its ancestors are parked. Container.park in the ancestor chain handles moving planes off-screen via its reposition cascade; notcurses's 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.

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. !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.

EXAMPLES

Preview in a Split

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);

SEE ALSO

has Buf $!rgba-buf

Raw-RGBA source, the in-memory alternative to $!file. When set, !load builds the visual via ncvisual_from_rgba instead of 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). $!rgba-buf is a tightly-packed 4-band RGBA buffer of $!rgba-w Ɨ $!rgba-h Ɨ 4 bytes. We retain the caller's Buf by reference (no copy) because the clip-only reload-before-rescale path (!ensure-scaled-visual) rebuilds the visual from the pristine source on every rescale; !load passes nativecast(Pointer, $!rgba-buf) straight to ncvisual_from_rgba, which copies the pixels into its own buffer. Memory cost: the one 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 Bool $.clip-only

When True, partial-clip rendering (render-viewport-crop, called by row-scrolling containers like 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 Int $!last-abs-y

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 destroy-blit-plane.

has Str $!last-source-id

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 $!source-id each render. It is NEVER the pixel bytes: comparing two multi-MB buffers (a 4 MB-vs-4 MB eqv every frame) is exactly the cost this token avoids.

has Str $!last-crop-key

Render cache for render-viewport-crop, the counterpart of the $!last-* snapshot 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 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 destroy-blit-plane so park, occlusion, and any other teardown re-blit from scratch. $!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 Bool $!blit-from-crop

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

has Str $!source-id

Identity of the current source. set-file uses the path; set-rgba uses the caller's :id (a stable token like "frame-42") or, absent that, "gen-N" from $!source-gen.

method logical-height

method logical-height() returns UInt

Height in rows. Same as self.rows; provided for the ScrollView contract.

method handle-resize

method handle-resize(
    Int $rows where { ... },
    Int $cols where { ... }
) returns Mu

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 file

method file() returns Str

The currently displayed file path, or Nil.

method has-blit-plane

method has-blit-plane() returns Bool

True when an active blit-plane currently exists.

method source-id

method source-id() returns Str

The render-cache identity of the current source (path / caller id / gen token). Exposed for tests; not part of the rendering contract.

method set-file

method set-file(
    Str $path
) returns Mu

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-rgba

method set-rgba(
    Buf:D $rgba,
    Int:D :$width! where { ... },
    Int:D :$height! where { ... },
    Str :$id
) returns Mu

Display an image straight from decoded RGBA pixels in memory — the privacy-preserving alternative to set-file. $rgba must be a tightly-packed 4-band buffer of exactly $width Ɨ $height Ɨ 4 bytes (R, G, B, A order), the layout consumed by ncvisual_from_rgba. Pass a stable :id identifying the content (e.g. a key like "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. Ownership: the Buf is retained by reference, not copied — !load hands its storage straight to 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 # widget.set-rgba($rgba-bytes, :width(640), :height(480), :id("frame-42")); =end code

method clear-image

method clear-image() returns Mu

Unload the current image and clear the widget (file or RGBA source).

method ensure-scaled-visual

method ensure-scaled-visual(
    $nc,
    Int $blitter,
    Int $cell-px-y where { ... },
    Int $cell-px-x where { ... }
) returns Bool

For the clip-only render path: ensure $!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 $!visual is now at natural dims and ready for NCSCALE_NONE blits. For the clip-only render path: pre-scale $!visual to its cell-aligned natural rendering dimensions — i.e. rcelly Ɨ cell-px-y tall by rcellx Ɨ cell-px-x wide. After this, every image cell row is exactly cell-px-y source pixels tall (and similarly for cols), so the per-frame source-crop math in 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 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 destroy-blit-plane

method destroy-blit-plane() returns Nil

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 without test is on $!blit-plane directly, NOT $!blit-plane.defined — the latter returns a Bool which is always defined, so without Bool never fires the early-return. Subtle Raku gotcha.

method park

method park() returns Mu

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 Selkie::Widget.is-parked.

method destroy

method destroy() returns Mu

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 render

method render() returns Mu

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-viewport-crop

method render-viewport-crop(
    Notcurses::Native::Types::NcplaneHandle :$parent-plane!,
    Int :$dest-y!,
    Int :$dest-x!,
    Int :$source-row!,
    Int :$source-col = 0,
    Int :$rows! where { ... },
    Int :$cols! where { ... }
) returns Bool

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. A zero rows or cols is an explicit empty-slice notification: the Image itself no longer overlaps the viewport even though an ancestor card may still be visible. It immediately destroys any crop-owned blit and returns handled, before loading the source or probing pixel/blitter geometry. Repeated empty notifications are therefore cheap, idempotent no-ops after the first teardown.

method emit-blit

method emit-blit() returns Nil

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.

sub effective-screen-rect-for

sub effective-screen-rect-for(
    Selkie::Widget $w
) returns Selkie::EffectiveBounds

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 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 plane-address

sub plane-address(
    Notcurses::Native::Types::NcplaneHandle $p
) returns Int

The numeric address behind a notcurses plane handle. NcplaneHandle is a CPointer repr, so two handles onto the same plane are distinct Raku objects and === can't compare them — the address is the only stable identity. Returns 0 for an undefined handle.

sub crop-cache-key

sub crop-cache-key(
    Int :$parent-plane!,
    Str :$source-id,
    Bool :$clip-only!,
    Int :$self-rows! where { ... },
    Int :$self-cols! where { ... },
    Int :$cell-px-y! where { ... },
    Int :$cell-px-x! where { ... },
    Int :$dest-y!,
    Int :$dest-x!,
    Int :$source-row!,
    Int :$source-col!,
    Int :$rows! where { ... },
    Int :$cols! where { ... },
    Int :$pixel-impl!
) returns Str

Build the render cache token for 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 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 plane-address), not a handle. =item source-id — the render-cache identity of the picture. Never the pixel bytes; see the $!last-source-id notes. =item clip-only — selects between two entirely different blit parameter derivations. =item self-rows / self-cols — the widget's own cell rect feeds both derivations (image centering, source-pixel scaling). =item cell-px-y / cell-px-x — a font zoom changes the pixel size of the emit without moving a single cell. =item dest-y / dest-x / source-row / source-col / rows / cols — the caller's crop window. =item 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 compute-clip-only-blit

sub compute-clip-only-blit(
    Int :$self-rows! where { ... },
    Int :$self-cols! where { ... },
    Int :$cell-px-y! where { ... },
    Int :$cell-px-x! where { ... },
    Int :$rcelly! where { ... },
    Int :$rcellx! where { ... },
    Int :$dest-y!,
    Int :$dest-x!,
    Int :$source-row!,
    Int :$source-col!,
    Int :$rows! where { ... },
    Int :$cols! where { ... }
) returns Hash

Pure math for Image.render-viewport-crop's :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 begy, begx — source pixel crop origin, in pixels of the 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 cell-px-y source pixels tall. =item leny, lenx — source pixel crop length. =item blit-rows, blit-cols — dest plane cell dims. =item blit-dest-y, blit-dest-x — dest plane position within the parent plane (parent = the caller's $parent-plane), as dest-y + (vis-cell-y-start - source-row) and the analogous x. dest-y/dest-x are the visible-viewport offsets the caller already computed. The output dims have the invariant that lenx = blit-cols Ɨ cell-px-x and leny = blit-rows Ɨ cell-px-y, which is what makes 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 blit-cols and lenx only depend on self-cols, rcellx, source-col and cols — never on source-row or rows. Exported so the test suite can exercise the math without spinning up notcurses.

Selkie v0.16.0

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::ErrorLogPlatform
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::PosixFD
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalPlatform
  • Selkie::App::Internal::TerminalReport
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Store::Schema
  • Selkie::Store::Schema::UI
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::SnapshotPlatform
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList
  • X::Selkie::WidgetCycle

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.