Selkie.git | t/ | 85-layout-gap.rakutest


use Test;
use lib 'lib';

use Selkie::Widget;
use Selkie::Sizing;
use Selkie::Layout::Allocate;
use Selkie::Layout::VBox;
use Selkie::Layout::HBox;

# Gap support in the shared allocation pass. Everything here is
# plane-free: allocate-along-axis and gap-reserve only ever read
# `.sizing`, so a stub widget is enough (same trick as
# t/64-layout-allocate).

class StubKid does Selkie::Widget {
    method render() { self.clear-dirty }
}

sub kids(*@sizings) { @sizings.map({ StubKid.new(sizing => $_) }).List }

plan 12;

subtest "gap-reserve counts the gutters between children" => {
    plan 5;
    # (n - 1) gutters, never n.
    is gap-reserve(kids(), 3), 0,
        "no children, no gutters";
    is gap-reserve(kids(Sizing.flex), 3), 0,
        "a single child has nothing to be separated from";
    is gap-reserve(kids(Sizing.flex, Sizing.flex), 3), 3,
        "two children share one gutter";
    is gap-reserve(kids(Sizing.flex, Sizing.flex, Sizing.flex), 3), 6,
        "three children, two gutters";
    is gap-reserve(kids(Sizing.flex xx 10), 1), 9,
        "ten children, nine gutters";
};

subtest "gap 0 reserves nothing" => {
    plan 2;
    is gap-reserve(kids(Sizing.flex xx 5), 0), 0,
        "zero-width gutters take no space";
    is gap-reserve(kids(), 0), 0,
        "…including for an empty child list";
};

subtest "fixed(0) children are excluded from the gutter count" => {
    plan 4;
    # The hidden-child idiom: a widget stays in the tree but collapses
    # to nothing. It must take its gutter with it, or hiding a child
    # would leave a suspicious double gap between its neighbours.
    is gap-reserve(kids(Sizing.flex, Sizing.fixed(0), Sizing.flex), 2), 2,
        "a hidden child between two visible ones still leaves one gutter";
    is gap-reserve(kids(Sizing.fixed(0), Sizing.flex, Sizing.flex), 2), 2,
        "a leading hidden child doesn't buy a gutter either";
    is gap-reserve(kids(Sizing.fixed(0), Sizing.fixed(0)), 2), 0,
        "two hidden children need no gutter at all";
    is gap-reserve(kids(Sizing.fixed(0)), 2), 0,
        "a lone hidden child is still zero, not negative";
};

subtest "only fixed(0) is excluded — percent(0) and flex(0) still count" => {
    plan 2;
    # The exclusion is deliberately narrow: Sizing.fixed(0) is the
    # documented hide idiom. percent(0) / flex(0) can still resolve to
    # a non-zero allocation via the rounding remainder, so they keep
    # their gutter.
    is gap-reserve(kids(Sizing.flex, Sizing.percent(0)), 4), 4,
        "percent(0) counts";
    is gap-reserve(kids(Sizing.flex, Sizing.flex(0)), 4), 4,
        "flex(0) counts";
};

subtest "allocations plus gutters fill the axis exactly" => {
    plan 4;
    # 20 rows, three flex children, 2 rows of gutter:
    #   reserve = 4, content box = 16 → 5 / 5 / 6 (remainder last).
    my @k = kids(Sizing.flex, Sizing.flex, Sizing.flex);
    my @a = allocate-along-axis(@k, 20, :gap(2));
    is @a[0], 5, "first flex gets a third of the content box";
    is @a[1], 5, "second flex likewise";
    is @a[2], 6, "last flex absorbs the rounding remainder";
    is sum(@a) + gap-reserve(@k, 2), 20,
        "content + gutters is the whole axis";
};

subtest "gap 0 is identical to omitting the argument" => {
    plan 8;
    # The back-compat guarantee that the whole feature rests on: every
    # existing caller passes no gap, and the default must be a literal
    # no-op — not "close enough".
    my @scenarios =
        'all fixed'            => (kids(Sizing.fixed(3), Sizing.fixed(4)), 10),
        'fixed overflow'       => (kids(Sizing.fixed(6), Sizing.fixed(8), Sizing.fixed(4)), 10),
        'fixed+percent+flex'   => (kids(Sizing.fixed(2), Sizing.percent(20),
                                        Sizing.flex, Sizing.flex), 50),
        'three-way flex'       => (kids(Sizing.flex, Sizing.flex, Sizing.flex), 10),
        'weighted flex'        => (kids(Sizing.flex(1), Sizing.flex(2)), 12),
        'hidden child'         => (kids(Sizing.flex, Sizing.fixed(0), Sizing.flex), 15),
        'zero total'           => (kids(Sizing.fixed(3), Sizing.percent(50), Sizing.flex), 0),
        'no children'          => (kids(), 8),
    ;
    for @scenarios -> (:key($name), :value(($kids, $total))) {
        is-deeply allocate-along-axis($kids, $total, :gap(0)),
                  allocate-along-axis($kids, $total),
                  "$name: :gap(0) matches the gap-free allocation";
    }
};

subtest "percent resolves against the content box, not the axis total" => {
    plan 5;
    # 20 cells, one gutter of 2 → content box 18. percent(50) is 9,
    # not 10. Same rule CSS applies inside a flex container.
    my @k = kids(Sizing.percent(50), Sizing.flex);
    my @gapped = allocate-along-axis(@k, 20, :gap(2));
    my @flush  = allocate-along-axis(@k, 20);
    is @gapped[0], 9,  "percent(50) of the 18-cell content box";
    is @gapped[1], 9,  "flex takes the rest of the content box";
    is @flush[0],  10, "…where the gap-free call still sees 20";
    is @flush[1],  10, "…and splits it evenly";
    is sum(@gapped) + 2, 20, "gapped allocation plus the gutter is the axis";
};

subtest "a gap wider than the axis clamps everything to zero" => {
    plan 6;
    # The UInt underflow trap: reserve (200) exceeds the total (10), so
    # the content box has to floor at 0 rather than wrap around.
    my @k = kids(Sizing.fixed(3), Sizing.percent(50), Sizing.flex);
    my @a;
    lives-ok { @a = allocate-along-axis(@k, 10, :gap(100)) },
        "an absurd gap doesn't blow up";
    is-deeply @a.List, (0, 0, 0), "every child gets nothing";
    ok @a.all >= 0, "no allocation went negative";

    # Exactly-consumed boundary: 3 children, gap 5 → reserve 10 = total.
    my @exact = allocate-along-axis(@k, 10, :gap(5));
    is-deeply @exact.List, (0, 0, 0), "a reserve equal to the total leaves nothing";

    # One cell short of that boundary: a single cell of content exists,
    # and the flex child collects it.
    my @sliver = allocate-along-axis(kids(Sizing.flex, Sizing.flex), 11, :gap(10));
    is sum(@sliver), 1, "one cell of content box survives";
    is @sliver[1], 1, "and the rounding remainder puts it on the last flex child";
};

subtest "fixed children are clamped by the shrunken content box" => {
    plan 3;
    # Gutters are withheld *before* pass 1, so fixed children overflow
    # earlier than they would without a gap — the gutter is not
    # something the last child can eat into.
    my @k = kids(Sizing.fixed(4), Sizing.fixed(4), Sizing.fixed(4));
    my @a = allocate-along-axis(@k, 12, :gap(1));   # reserve 2, content 10
    is @a[0], 4, "first fixed child fits";
    is @a[1], 4, "second fixed child fits";
    is @a[2], 2, "third is clipped to what the content box has left";
};

subtest "VBox and HBox default to no gap" => {
    plan 4;
    # The whole back-compat story in two assertions: an unconfigured
    # box reserves nothing, so its layout is the one it has always
    # produced.
    for Selkie::Layout::VBox, Selkie::Layout::HBox -> $type {
        my $box = $type.new(sizing => Sizing.flex);
        is $box.gap, 0, "{$type.^name} starts flush";
        is gap-reserve(kids(Sizing.flex, Sizing.flex), $box.gap), 0,
            "{$type.^name}'s default reserves no cells";
    }
};

subtest "set-gap updates the gutter and marks the box dirty" => {
    plan 8;
    for Selkie::Layout::VBox, Selkie::Layout::HBox -> $type {
        my $box = $type.new(sizing => Sizing.flex, gap => 1);
        is $box.gap, 1, "{$type.^name} takes gap from the constructor";
        $box.clear-dirty;
        $box.set-gap(3);
        is $box.gap, 3, "{$type.^name} gap updated";
        ok $box.is-dirty,
            "{$type.^name} dirty after set-gap — the next render re-lays out";
        # Idempotent: re-setting the same value must not schedule a
        # pointless relayout on every frame.
        $box.clear-dirty;
        $box.set-gap(3);
        nok $box.is-dirty, "{$type.^name} no-op when the gap is unchanged";
    }
};

subtest "hidden children neither take space nor buy a gutter" => {
    plan 4;
    # End-to-end version of the fixed(0) rule: allocation and
    # reservation have to agree, or a VBox positions past its own
    # bounds.
    my @k = kids(Sizing.fixed(4), Sizing.fixed(0), Sizing.flex);
    my @a = allocate-along-axis(@k, 20, :gap(3));   # reserve 3, content 17
    is @a[0], 4,  "the fixed child is untouched";
    is @a[1], 0,  "the hidden child stays hidden";
    is @a[2], 13, "flex fills the rest of the content box";
    is sum(@a) + gap-reserve(@k, 3), 20,
        "one gutter, not two — the totals still close";
};