Selkie.git | t/ | 91-cross-align.rakutest


use Test;
use lib 'lib';

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

# Cross-axis alignment. As with t/64 and t/85, everything here is
# plane-free: the three cross-axis subs only read `.cross-sizing` and
# `.align-self` off a widget, so a stub is enough. The rendering side —
# that VBox/HBox actually feed these numbers to init-plane / reposition
# / set-viewport — is pinned by xt/snapshots/53-vbox-cross-center and
# 54-hbox-cross-end.

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

sub kid(|c) { StubKid.new(|c) }

plan 14;

subtest "an undefined cross-sizing fills the container" => {
    plan 4;
    # The back-compat default: every widget ever written gets the
    # container's full cross extent, exactly as before.
    my $k = kid();
    nok $k.cross-sizing.defined, "cross-sizing starts undefined";
    is resolve-cross-extent($k, 40), 40, "40-column container → 40 columns";
    is resolve-cross-extent($k, 1),  1,  "…and a one-cell container → 1";
    is resolve-cross-extent($k, 0),  0,  "…and a zero-cell one → 0";
};

subtest "fixed cross-sizing is an exact cell count" => {
    plan 4;
    is resolve-cross-extent(kid(cross-sizing => Sizing.fixed(20)), 50), 20,
        "fixed(20) in a 50-cell container";
    is resolve-cross-extent(kid(cross-sizing => Sizing.fixed(50)), 50), 50,
        "…exactly filling it is fine";
    is resolve-cross-extent(kid(cross-sizing => Sizing.fixed(80)), 50), 50,
        "…and an oversized request clamps to the container";
    is resolve-cross-extent(kid(cross-sizing => Sizing.fixed(0)), 50), 0,
        "fixed(0) collapses the child — the box parks it";
};

subtest "percent cross-sizing is a floored share of the container" => {
    plan 6;
    is resolve-cross-extent(kid(cross-sizing => Sizing.percent(50)), 40), 20,
        "50% of 40";
    is resolve-cross-extent(kid(cross-sizing => Sizing.percent(50)), 41), 20,
        "…floored, not rounded";
    is resolve-cross-extent(kid(cross-sizing => Sizing.percent(33)), 10), 3,
        "33% of 10 is 3";
    is resolve-cross-extent(kid(cross-sizing => Sizing.percent(100)), 40), 40,
        "100% is the whole container";
    is resolve-cross-extent(kid(cross-sizing => Sizing.percent(150)), 40), 40,
        "…and more than 100% still clamps to it";
    is resolve-cross-extent(kid(cross-sizing => Sizing.percent(0)), 40), 0,
        "0% collapses the child";
};

subtest "flex cross-sizing means fill — there is nothing to share with" => {
    plan 3;
    # A cross axis has no siblings to distribute leftovers among, so
    # flex and 'undefined' resolve identically. Accepting flex means one
    # Sizing object can be reused for both axes without surprises.
    is resolve-cross-extent(kid(cross-sizing => Sizing.flex), 40), 40,
        "flex fills the container";
    is resolve-cross-extent(kid(cross-sizing => Sizing.flex(3)), 40), 40,
        "…whatever the factor";
    is resolve-cross-extent(kid(cross-sizing => Sizing.flex), 40),
       resolve-cross-extent(kid(), 40),
        "…which is what an undefined cross-sizing does too";
};

subtest "resolved extents never escape 0 .. container" => {
    plan 1;
    my @sizings = Sizing, Sizing.fixed(0), Sizing.fixed(7), Sizing.fixed(999),
                  Sizing.percent(0), Sizing.percent(50), Sizing.percent(100),
                  Sizing.percent(400), Sizing.flex, Sizing.flex(9);
    my $bad = 0;
    for @sizings -> $s {
        for 0, 1, 2, 7, 40, 200 -> $container {
            my $e = resolve-cross-extent(kid(cross-sizing => $s), $container);
            $bad++ unless 0 <= $e <= $container;
        }
    }
    is $bad, 0, "every (sizing, container) pair resolves inside bounds";
};

subtest "cross-axis-offset places the extent within the container" => {
    plan 8;
    is cross-axis-offset(CrossStart,  20, 50), 0,  "start is flush";
    is cross-axis-offset(CrossFill,   20, 50), 0,  "fill starts at 0 as well";
    is cross-axis-offset(CrossCenter, 20, 50), 15, "centre splits the 30 of slack";
    is cross-axis-offset(CrossEnd,    20, 50), 30, "end takes all the slack";
    is cross-axis-offset(CrossCenter, 21, 50), 14,
        "odd slack floors — the extra cell goes on the trailing side";
    is cross-axis-offset(CrossCenter, 49, 50), 0,
        "one cell of slack floors to 0, not 0.5";
    is cross-axis-offset(CrossEnd,    50, 50), 0,
        "a full-width child is flush under every alignment";
    is cross-axis-offset(CrossCenter, 50, 50), 0, "…including centre";
};

subtest "an extent wider than the container clamps to 0" => {
    plan 5;
    # resolve-cross-extent never produces this, but a caller doing its
    # own arithmetic might — and a negative offset would be fatal for
    # the UInt return, not merely wrong.
    for CrossFill, CrossStart, CrossCenter, CrossEnd -> $a {
        is cross-axis-offset($a, 60, 50), 0, "$a: no negative offsets";
    }
    is cross-axis-offset(CrossCenter, 5, 0), 0,
        "…nor in a zero-extent container";
};

subtest "an undefined alignment behaves as fill" => {
    plan 2;
    # A child whose align-self is unset, in a box whose align-items has
    # somehow been cleared: the safe answer is the historical one.
    is cross-axis-offset(CrossAlign, 20, 50), 0, "undefined starts at 0";
    is effective-cross-align(kid(), CrossAlign), CrossFill,
        "…and resolves to CrossFill, not a type object";
};

subtest "align-self overrides the container's align-items" => {
    plan 4;
    my $k = kid(align-self => CrossEnd);
    is $k.align-self, CrossEnd, "align-self set at construction";
    is effective-cross-align($k, CrossStart), CrossEnd,
        "the child's own alignment wins";
    is effective-cross-align($k, CrossFill), CrossEnd,
        "…including over the default CrossFill";
    is cross-axis-offset(effective-cross-align($k, CrossCenter), 10, 50), 40,
        "…all the way through to the offset";
};

subtest "an undefined align-self inherits the container's align-items" => {
    plan 4;
    my $k = kid();
    nok $k.align-self.defined, "align-self starts undefined";
    is effective-cross-align($k, CrossCenter), CrossCenter, "inherits centre";
    is effective-cross-align($k, CrossEnd), CrossEnd, "inherits end";
    is effective-cross-align($k, CrossFill), CrossFill, "inherits the default";
};

subtest "set-align-self updates the child and marks it dirty" => {
    plan 6;
    my $k = kid();
    $k.clear-dirty;
    $k.set-align-self(CrossCenter);
    is $k.align-self, CrossCenter, "alignment updated";
    ok $k.is-dirty, "marked dirty";
    $k.clear-dirty;
    $k.set-align-self(CrossCenter);
    nok $k.is-dirty, "re-setting the same value is a no-op";
    $k.set-align-self(CrossAlign);
    nok $k.align-self.defined, "an undefined value goes back to inheriting";
    ok $k.is-dirty, "…and that counts as a change";

    my $c = kid();
    $c.clear-dirty;
    $c.update-cross-sizing(Sizing.fixed(12));
    ok $c.cross-sizing.defined && $c.is-dirty,
        "update-cross-sizing sets the sizing and marks dirty";
};

subtest "VBox and HBox default to CrossFill" => {
    plan 8;
    # The whole back-compat story: an unconfigured box gives every child
    # the full cross extent at offset 0 — the layout both have always
    # produced.
    for Selkie::Layout::VBox, Selkie::Layout::HBox -> $type {
        my $box = $type.new(sizing => Sizing.flex);
        is $box.align-items, CrossFill, "{$type.^name} starts on CrossFill";
        my $k = kid();
        is resolve-cross-extent($k, 30), 30,
            "{$type.^name}: a default child fills the cross extent";
        is cross-axis-offset(effective-cross-align($k, $box.align-items), 30, 30), 0,
            "{$type.^name}: …at offset 0";

        $box.clear-dirty;
        $box.set-align-items(CrossEnd);
        ok $box.align-items === CrossEnd && $box.is-dirty,
            "{$type.^name}: set-align-items updates and marks dirty";
    }
};

subtest "set-align-items is idempotent" => {
    plan 4;
    for Selkie::Layout::VBox, Selkie::Layout::HBox -> $type {
        my $box = $type.new(sizing => Sizing.flex, align-items => CrossCenter);
        is $box.align-items, CrossCenter, "{$type.^name} takes it from the constructor";
        $box.clear-dirty;
        $box.set-align-items(CrossCenter);
        nok $box.is-dirty, "{$type.^name}: no relayout when nothing changed";
    }
};

subtest "gap and cross alignment are orthogonal" => {
    plan 9;
    # gap is withheld from the *main* axis before allocation; alignment
    # only ever sees the *cross* extent. Combining them must therefore
    # give the gapped main-axis allocation and the ungapped cross-axis
    # placement, with neither number moving because of the other.
    my @kids = kid(sizing => Sizing.fixed(3), cross-sizing => Sizing.fixed(20)),
               kid(sizing => Sizing.fixed(3), cross-sizing => Sizing.fixed(20)),
               kid(sizing => Sizing.fixed(3), cross-sizing => Sizing.fixed(20),
                   align-self => CrossEnd);

    # A 13-row, 50-column VBox with gap => 2: reserve 4, content 9.
    my @rows = allocate-along-axis(@kids, 13, :gap(2));
    is-deeply @rows.List, (3, 3, 3), "the gutters come off the row budget";
    is sum(@rows) + gap-reserve(@kids, 2), 13, "rows and gutters close";

    my @xs = @kids.map({
        cross-axis-offset(effective-cross-align($_, CrossCenter),
                          resolve-cross-extent($_, 50), 50)
    });
    is @xs[0], 15, "first child centred in the full 50 columns";
    is @xs[1], 15, "second child likewise — the gutter is not on this axis";
    is @xs[2], 30, "third child's align-self still wins";

    # The columns are what a gap-free box would produce, cell for cell.
    my @flush = @kids.map({
        cross-axis-offset(effective-cross-align($_, CrossCenter),
                          resolve-cross-extent($_, 50), 50)
    });
    is-deeply @xs.List, @flush.List, "gap changes no cross-axis number";

    # And percent cross-sizing resolves against the full cross extent,
    # not a gap-shrunken one — gutters live on the other axis.
    my $p = kid(sizing => Sizing.flex, cross-sizing => Sizing.percent(50));
    is resolve-cross-extent($p, 50), 25, "percent(50) of the full 50 columns";

    # Same story from the HBox side: gap on columns, alignment on rows.
    my @cols = allocate-along-axis(@kids, 13, :gap(2));
    is-deeply @cols.List, (3, 3, 3), "HBox allocates columns the same way";
    is cross-axis-offset(CrossCenter, resolve-cross-extent(@kids[0], 9), 9), 0,
        "a 20-row child in a 9-row HBox clamps to the top";
};