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