Selkie.git | examples/ | viewported-card-list.raku


#!/usr/bin/env raku
#
# viewported-card-list.raku — Row-scrolled cards, including Image children.
#
# Demonstrates:
#   - ViewportedCardList for row-by-row scrolling through tall content
#   - Card selection (Up/Down) staying separate from row scrolling
#     (PgUp/PgDown, mouse wheel)
#   - Image cards as siblings in a stack of varied-height cards, scrolling
#     past the viewport edge
#   - Mixed layouts inside cards: pure text, image-only, image+caption HBox
#
# Run with:  raku -I lib examples/viewported-card-list.raku
#
# Image source priority:
#   1. SELKIE_DEMO_IMAGES=/path/a.png:/path/b.png …   (colon-separated list)
#   2. examples/data/ — sample images vendored alongside this example
#      (see examples/data/README.md for attribution).
#   3. A 1x1 PNG written to $TMPDIR as a placeholder, so the example still
#      runs even if examples/data/ has been pruned or moved.

use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Layout::HBox;
use Selkie::Widget::ViewportedCardList;
use Selkie::Widget::Border;
use Selkie::Widget::Image;
use Selkie::Widget::Text;
use Selkie::Sizing;
use Selkie::Style;

sub base64-decode(Str $src --> Blob) {
    my @alphabet = flat 'A'..'Z', 'a'..'z', '0'..'9', '+', '/';
    my %value;
    for @alphabet.kv -> $idx, $ch {
        %value{$ch} = $idx;
    }
    my @bytes;
    my Int $buffer = 0;
    my Int $bits = 0;

    for $src.comb.grep({ %value{$_}:exists }) -> $ch {
        $buffer = ($buffer +< 6) + %value{$ch};
        $bits += 6;
        while $bits >= 8 {
            $bits -= 8;
            @bytes.push(($buffer +> $bits) +& 0xff);
        }
    }

    Blob.new(@bytes);
}

sub placeholder-image-path(--> Str) {
    my $path = $*TMPDIR.add('selkie-viewported-card-list-demo.png');
    unless $path.e {
        my $png = base64-decode(q:to/PNG/.trim);
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=
PNG
        spurt $path, $png;
    }
    $path.Str;
}

sub demo-image-paths(--> List) {
    # Honour an explicit override first.
    with %*ENV<SELKIE_DEMO_IMAGES> {
        my @paths = .split(':').grep({ .IO.f });
        return @paths.List if @paths;
    }

    # Vendored sample images in examples/data/ — see that dir's README
    # for attribution.
    my $here = $?FILE.IO.parent.absolute;
    my @candidates = <
        atma.png
        chunli01.png
        spaceship.png
        eagles.png
        worldmap.png
        natasha-blur.png
        changes.jpg
        notcurses.png
        fonts.jpg
        aidsrobots.jpeg
    >.map: -> $name {
        $here.IO.add('data').add($name).absolute;
    };
    my @existing = @candidates.grep({ .IO.f });
    return @existing.List if @existing;

    # Last resort: a single tiny placeholder.
    (placeholder-image-path(),).List;
}

my $app = Selkie::App.new;
my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$app.add-screen('main', $root);
$app.switch-screen('main');

$root.add: Selkie::Widget::Text.new(
    text   => '  Up/Down select · Shift+Up/Down row scroll · PgUp/PgDown page scroll · wheel scroll · Home/End jump · Ctrl+Q quit',
    sizing => Sizing.fixed(1),
    style  => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);

my $cards = Selkie::Widget::ViewportedCardList.new(
    sizing        => Sizing.flex,
    follow-bottom => False,
);
my $cards-border = Selkie::Widget::Border.new(title => 'Cards', sizing => Sizing.flex);
$cards-border.set-content($cards);
$root.add($cards-border);

sub add-text-card(Str $title, Str $body, UInt $height = 5) {
    my $box = Selkie::Layout::VBox.new(sizing => Sizing.flex);
    $box.add: Selkie::Widget::Text.new(
        text   => $title,
        sizing => Sizing.fixed(1),
        style  => Selkie::Style.new(fg => 0x9ECE6A, bold => True),
    );
    $box.add: Selkie::Widget::Text.new(
        text   => $body,
        sizing => Sizing.flex,
        style  => Selkie::Style.new(fg => 0xD5D6E0),
    );

    my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
    $border.set-content($box);
    $cards.add-item($box, root => $border, height => $height, :$border);
}

sub add-image-with-caption(Str $image-path, Str $title, Str $caption, UInt :$height = 12) {
    my $row = Selkie::Layout::HBox.new(sizing => Sizing.flex);

    my $image = Selkie::Widget::Image.new(sizing => Sizing.fixed(24), :clip-only);
    $image.set-file($image-path);
    $row.add($image);

    my $copy = Selkie::Layout::VBox.new(sizing => Sizing.flex);
    $copy.add: Selkie::Widget::Text.new(
        text   => $title,
        sizing => Sizing.fixed(1),
        style  => Selkie::Style.new(fg => 0xF7768E, bold => True),
    );
    $copy.add: Selkie::Widget::Text.new(
        text   => $caption,
        sizing => Sizing.flex,
        style  => Selkie::Style.new(fg => 0xD5D6E0),
    );
    $row.add($copy);

    my $border = Selkie::Widget::Border.new(title => $title, sizing => Sizing.flex);
    $border.set-content($row);
    $cards.add-item($row, root => $border, height => $height, :$border);
}

sub add-image-only(Str $image-path, Str $title, UInt :$height = 14) {
    my $image = Selkie::Widget::Image.new(sizing => Sizing.flex, :clip-only);
    $image.set-file($image-path);
    my $border = Selkie::Widget::Border.new(title => $title, sizing => Sizing.flex);
    $border.set-content($image);
    $cards.add-item($image, root => $border, height => $height, :$border);
}

my @images = demo-image-paths();
# Round-robin index into @images so the layout below stays valid even
# when only a handful of paths resolved (or a single placeholder).
my $img-idx = 0;
sub next-img(--> Str) {
    my $p = @images[$img-idx % @images.elems];
    $img-idx++;
    $p;
}

add-text-card(
    'Opening note',
    'Stack of mixed cards — scroll past the bottom with PgDown or the mouse wheel. Up/Down still walks selection one card at a time even mid-scroll.',
    5,
);

add-image-with-caption(
    next-img(),
    'First image',
    'Caption next to a 24-cell-wide Image. The image gets cropped at the viewport edge when scrolled partially off-screen instead of bleeding past the card border.',
    height => 11,
);

add-text-card(
    'Selection vs scroll',
    'Pressing Down here selects the card below — its border highlights. PgDown moves the row offset by one viewport, which can leave the selected card entirely off-screen until you scroll back or press Down to chase it.',
    8,
);

add-image-only(next-img(), 'Image-only card', height => 16);

add-text-card(
    'Short status',
    'Selkie 0.6 · ViewportedCardList',
    3,
);

add-image-with-caption(
    next-img(),
    'Second image',
    'Mixed-height cards are intentional: row scrolling has to handle non-uniform stride. PgUp goes the other way.',
    height => 10,
);

add-text-card(
    'Mid-list prose',
    'The interior cells are filled in by the base-cell fallback during merge, so the background stays solid as cards scroll under the viewport. Try scrolling the image cards out from the top — their sprixels get re-emitted as the visible slice shrinks.',
    9,
);

add-image-only(next-img(), 'Tall illustration', height => 18);

add-text-card(
    'Bulleted list',
    "  • Up / Down — select a card\n  • Shift+Up / Shift+Down — scroll one row\n  • PgUp / PgDown — scroll one viewport\n  • Mouse wheel — 3-row scroll per tick\n  • Home / End — jump to first / last\n  • Ctrl+Q — quit",
    9,
);

add-image-with-caption(
    next-img(),
    'Third image',
    'Another image+caption to demonstrate that several Image planes coexist in the same scrollable list — each independent, each with its own crop on partial visibility.',
    height => 11,
);

add-text-card(
    'Longer card',
    'Cards can be tall and still respect row scrolling. The selected card always tries to stay at least partially in view as you walk selection up or down; pure row scrolling (PgUp / PgDown / wheel) is independent and can leave any card fully off the visible region.',
    12,
);

add-image-only(next-img(), 'Late image', height => 14);

add-text-card(
    'Epilogue',
    'Home jumps to the first card and resets the scroll to the top; End walks to the last card and pushes scroll to the maximum offset.',
    6,
);

add-text-card(
    'Tail card',
    'You should be able to scroll this one past the bottom edge of the viewport and back up. PgUp returns one viewport at a time.',
    5,
);

$cards.select-first;
$app.focus($cards);

$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run;