Selkie.git | lib/Selkie/Layout/ | Allocate.rakumod
=begin pod
=head1 NAME
Selkie::Layout::Allocate - Shared sizing-allocation pass for box layouts
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Layout::Allocate;
use Selkie::Sizing;
# A custom container that arranges children along the row axis:
my @allocs = allocate-along-axis(@kids, self.rows);
# `@allocs` is parallel to `@kids`; @allocs[$i] is the cell-count
# the layout assigns to the corresponding child. Position the children
# yourself — Allocate doesn't know about reposition / set-viewport.
# With a one-cell gutter between children:
my @spaced = allocate-along-axis(@kids, self.rows, :gap(1));
my $gutter = gap-reserve(@kids, 1); # cells the gaps will consume
# The other axis: how wide is the child, and where does it sit?
my $extent = resolve-cross-extent($child, self.cols);
my $align = effective-cross-align($child, self.align-items);
my $offset = cross-axis-offset($align, $extent, self.cols);
=end code
=head1 DESCRIPTION
C<allocate-along-axis> runs the three-pass sizing algorithm that both
L<Selkie::Layout::VBox> and L<Selkie::Layout::HBox> use to decide how
much room each child gets:
=item B<Pass 1.> Walk the children. Children with C<Sizing.fixed($n)>
take C<$n> cells (clamped by remaining space). Children with
C<Sizing.percent($n)> take C<$n%> of the original axis total (also
clamped). Flex children defer; their flex factors are accumulated into
a running total.
=item B<Pass 2.> Distribute whatever space remains among flex children,
weighted by their flex factor relative to the total flex weight. Each
flex share is floored, so several flex children can leave a few
cells unspent.
=item B<Pass 3.> Hand any rounding remainder to the highest-index
flex child. This keeps the box exactly filled and avoids rounding
drift on resizes.
The function returns an C<Array[UInt]> aligned with C<@kids>; callers
are responsible for positioning and propagating viewport bounds, since
those depend on which axis is being laid out.
=head2 Gaps
Pass C<:gap($n)> to reserve C<$n> cells I<between> children. The
reservation is taken off the top: C<gap-reserve> computes the total,
and the three passes above then run over the B<content box> — what's
left of C<$total> once the gutters are subtracted.
=begin code :lang<raku>
# 20 rows, three children, one row of gutter between each:
# gap-reserve = (3 - 1) * 1 = 2
# content box = 20 - 2 = 18
my @allocs = allocate-along-axis(@kids, 20, :gap(1));
=end code
Two consequences worth internalising:
=item B<Percent means percent-of-content-box.> C<Sizing.percent(50)>
in the example above resolves against 18, not 20 — it gets 9 rows, not
10. That is the same rule CSS applies to a flex container's content
box, and it's what makes C<percent> children sum to the space actually
available for content rather than overflowing by the gutter total.
=item B<Gap cells are never allocated to anybody.> They're not in any
child's allocation, so nothing paints them; the container plane's base
cell shows through. That's what makes a gap read as breathing room
rather than as a differently-coloured strip.
Children sized C<Sizing.fixed(0)> — the "hidden child" idiom, where a
widget stays in the tree but collapses to nothing — do B<not> count
towards the gutter total. Hiding a child by zeroing its sizing
therefore removes its gutter too, instead of leaving a mysterious
double gap behind.
C<gap-reserve> is exported separately so containers can reason about
the gutter total without running a full allocation (and so it can be
tested in isolation).
C<:gap(0)>, the default, is bit-identical to calling
C<allocate-along-axis> without the argument at all.
=head2 The cross axis
C<allocate-along-axis> answers "how much of the I<main> axis does each
child get?". Three more free subs answer the two cross-axis questions —
how big is the child on the axis the container doesn't stack on, and
where does that size sit:
=item C<resolve-cross-extent($child, $container)> — resolves the child's
C<cross-sizing> against the container's cross extent. Fixed is a cell
count, percent a share, flex (and undefined C<cross-sizing>) the whole
extent. Always clamped to C<0 .. $container>.
=item C<effective-cross-align($child, $container-align)> — the child's
C<align-self> if it has one, otherwise the container's C<align-items>.
=item C<cross-axis-offset($align, $extent, $container)> — the leading
offset: 0 for start and fill, the floored half of the slack for centre,
all of the slack for end.
=begin code :lang<raku>
# A 20-column child, centred in a 50-column VBox:
my $extent = resolve-cross-extent($child, 50); # 20
my $align = effective-cross-align($child, CrossCenter); # CrossCenter
my $offset = cross-axis-offset($align, $extent, 50); # 15
=end code
Gap and alignment are orthogonal by construction: the gutter total
comes off the I<main> axis before allocation, and these three subs only
ever see the cross extent. A C<VBox> with C<gap> and C<align-items> set
applies both without either affecting the other's arithmetic.
Splitting the rule into free subs — rather than letting each box roll
its own — is what keeps VBox and HBox from drifting apart, and lets the
tests exercise the arithmetic with no notcurses planes in sight.
=head2 Why a free sub and not a base role?
VBox and HBox differ only in axis: VBox stacks rows, HBox stacks
columns. Pass 3 (positioning) is axis-specific — it has to call
C<reposition($cy, 0)> versus C<reposition(0, $cx)>, plus
C<set-viewport> with axis-specific named args. Bridging that into a
shared role would obscure the layout code without saving lines, so
the extraction stops at the axis-agnostic part: the allocation math.
=end pod
unit module Selkie::Layout::Allocate;
use Selkie::Align;
use Selkie::Sizing;
use Selkie::Widget;
#|( The total number of cells that C<$gap>-wide gutters between
C<@kids> will consume: C<((countable - 1) max 0) * $gap>.
"Countable" excludes children sized C<Sizing.fixed(0)>. That's the
hidden-child idiom — a widget kept in the tree but collapsed to
nothing — and a hidden child should take its gutter with it rather
than leave a double gap between its visible neighbours.
Never negative, and always 0 for an empty list, a single child, or
C<$gap == 0>.
)
sub gap-reserve(@kids, UInt $gap --> UInt) is export {
return 0 unless $gap;
my $countable = @kids.grep({
!(.sizing.mode ~~ SizeFixed && .sizing.value == 0)
}).elems;
(($countable - 1) max 0) * $gap;
}
#|( Compute per-child allocations along a single axis, given the total
axis size. Returns an Array[UInt] where `@allocs[$i]` is the cell
count for `@kids[$i]`. Sum of allocations equals the content box
(C<$total> minus the gutter reservation) when flex children are
present and the content box is non-zero; otherwise allocations may
sum to less.
Algorithm:
=item The content box is C<($total - gap-reserve(@kids, $gap)) max 0>.
With the default C<:gap(0)> that is exactly C<$total>, and
everything below behaves as it always has.
=item Fixed children take C<value> cells (clamped by remaining).
=item Percent children take C<value%> of the B<content box> (also
clamped) — see the C<Gaps> section of the module docs.
=item Flex children share whatever remains, weighted by C<value>;
the highest-index flex child collects any rounding remainder.
Positioning the gutters is the caller's job: this function only
withholds the cells. See C<Selkie::Layout::VBox>'s layout pass for
the C<$placed>-flag idiom that keeps gaps strictly I<between>
non-collapsed children.
)
sub allocate-along-axis(@kids, UInt $total, UInt :$gap = 0 --> Array) is export {
my @allocs = @kids.map({ 0 });
return @allocs unless @kids;
# Withhold the gutters up front, then allocate over what's left.
# `max 0` matters: a gap wider than the axis (a 1-row HBox with
# gap => 4, say) would otherwise underflow the UInt.
my UInt $content = ($total - gap-reserve(@kids, $gap)) max 0;
my UInt $available = $content;
my Numeric $total-flex = 0;
# Pass 1: fixed and percent children consume $available.
for @kids.kv -> $i, $child {
given $child.sizing.mode {
when SizeFixed {
@allocs[$i] = $child.sizing.value.UInt min $available;
$available -= @allocs[$i];
}
when SizePercent {
@allocs[$i] = ($content * $child.sizing.value / 100).floor.UInt min $available;
$available -= @allocs[$i];
}
when SizeFlex {
$total-flex += $child.sizing.value;
}
}
}
# Pass 2: distribute the remainder to flex children proportionally.
if $total-flex > 0 && $available > 0 {
my UInt $remaining = $available;
for @kids.kv -> $i, $child {
if $child.sizing.mode ~~ SizeFlex {
my $share = ($available * $child.sizing.value / $total-flex).floor.UInt;
$share = $share min $remaining;
@allocs[$i] = $share;
$remaining -= $share;
}
}
# Pass 3: rounding remainder to the last (highest-index) flex
# child. `.kv.reverse` flips the (idx, val) pairs into
# (val, idx) order, so the pointy block reads as
# `-> $child, $i`, not `-> $i, $child` — easy to misread.
if $remaining > 0 {
for @kids.kv.reverse -> $child, $i {
if $child.sizing.mode ~~ SizeFlex {
@allocs[$i] += $remaining;
last;
}
}
}
}
@allocs;
}
#|( How many cells C<$child> occupies on the container's B<cross> axis
— columns for a C<VBox>, rows for an C<HBox> — given that the
container has C<$container> of them.
The child's C<cross-sizing> decides, reusing the C<Selkie::Sizing>
vocabulary:
=item Undefined C<cross-sizing> (the default) — the full
C<$container>. This is what Selkie's layouts have always done,
and why adding cross-axis alignment moves nothing in an
existing app.
=item C<Sizing.fixed($n)> — exactly C<$n>, clamped to C<$container>.
=item C<Sizing.percent($n)> — C<$n%> of C<$container>, floored, and
also clamped. Percent resolves against the container's cross
extent, which for a gapped box is the full extent: gutters
come off the main axis only.
=item C<Sizing.flex> — the full C<$container>. There is nothing to
share a cross axis with, so flex and "fill" mean the same
thing here; C<flex> is accepted so a widget can carry one
C<Sizing> object for both axes.
The result is never negative and never exceeds C<$container>. A
child that resolves to 0 (C<Sizing.fixed(0)>, or a C<percent> that
floors to nothing) is parked by the box rather than given a
zero-extent plane — but it keeps its main-axis allocation and its
gutter, so collapsing a child on the cross axis never reflows its
siblings on the main one. )
sub resolve-cross-extent(Selkie::Widget $child, UInt $container --> UInt) is export {
my $cs = $child.cross-sizing;
return $container without $cs;
given $cs.mode {
when SizeFixed { (($cs.value.Int max 0) min $container).UInt }
when SizePercent {
((($container * $cs.value / 100).floor max 0) min $container).UInt;
}
# SizeFlex, and any mode a future Sizing gains: fill the axis.
default { $container }
}
}
#|( The C<CrossAlign> that actually governs C<$child>: its own
C<align-self> when it has one, the container's C<align-items>
otherwise.
C<align-self> is undefined by default, which is what makes
C<align-items> a real container-level policy rather than a default
that every child silently overrides. Both boxes route through this
sub so the inheritance rule can't drift between them. )
sub effective-cross-align(Selkie::Widget $child, CrossAlign $container-align --> CrossAlign) is export {
$child.align-self // $container-align // CrossFill;
}
#|( The leading offset — column in a C<VBox>, row in an C<HBox> — at
which an item of C<$extent> cells sits inside a C<$container>-cell
slot under alignment C<$a>:
=item C<CrossStart> and C<CrossFill> — 0. (A C<CrossFill> child
normally I<is> the container's extent; when it also declares a
C<cross-sizing>, size wins and the leftover goes on the
trailing side, exactly like C<CrossStart>.)
=item C<CrossCenter> — C<((container - extent) / 2).floor>, so an
odd slack lands the extra cell on the trailing side.
=item C<CrossEnd> — C<container - extent>.
An C<$extent> larger than C<$container> — which C<resolve-cross-extent>
never produces, but a caller doing its own arithmetic might — clamps
to 0 rather than underflowing into a negative (and, for the C<UInt>
return, fatal) offset. An undefined C<$a> is treated as C<CrossFill>. )
sub cross-axis-offset(CrossAlign $a, UInt $extent, UInt $container --> UInt) is export {
my Int $slack = $container.Int - $extent.Int;
return 0 if $slack <= 0;
given $a {
when CrossCenter { ($slack / 2).floor.UInt }
when CrossEnd { $slack.UInt }
# CrossStart, CrossFill, and undefined all start at the top/left.
default { 0 }
}
}