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";
};