Selkie--Layout--Allocate
NAME
Selkie::Layout::Allocate - Shared sizing-allocation pass for box layouts
SYNOPSIS
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);DESCRIPTION
allocate-along-axis runs the three-pass sizing algorithm that both Selkie::Layout::VBox and Selkie::Layout::HBox use to decide how much room each child gets:
Pass 1. Walk the children. Children with
Sizing.fixed($n)take$ncells (clamped by remaining space). Children withSizing.percent($n)take$n%of the original axis total (also clamped). Flex children defer; their flex factors are accumulated into a running total.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.
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 Array[UInt] aligned with @kids; callers are responsible for positioning and propagating viewport bounds, since those depend on which axis is being laid out.
Gaps
Pass :gap($n) to reserve $n cells between children. The reservation is taken off the top: gap-reserve computes the total, and the three passes above then run over the content box ā what's left of $total once the gutters are subtracted.
# 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));Two consequences worth internalising:
Percent means percent-of-content-box.
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 makespercentchildren sum to the space actually available for content rather than overflowing by the gutter total.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 Sizing.fixed(0) ā the "hidden child" idiom, where a widget stays in the tree but collapses to nothing ā do 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.
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).
:gap(0), the default, is bit-identical to calling allocate-along-axis without the argument at all.
The cross axis
allocate-along-axis answers "how much of the 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:
resolve-cross-extent($child, $container)ā resolves the child'scross-sizingagainst the container's cross extent. Fixed is a cell count, percent a share, flex (and undefinedcross-sizing) the whole extent. Always clamped to0 .. $container.effective-cross-align($child, $container-align)ā the child'salign-selfif it has one, otherwise the container'salign-items.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.
# 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); # 15Gap and alignment are orthogonal by construction: the gutter total comes off the main axis before allocation, and these three subs only ever see the cross extent. A VBox with gap and 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.
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 reposition($cy, 0) versus reposition(0, $cx), plus 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.
sub gap-reserve
sub gap-reserve(
@kids,
Int $gap where { ... }
) returns UIntThe total number of cells that $gap-wide gutters between @kids will consume: ((countable - 1) max 0) * $gap. "Countable" excludes children sized 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 $gap == 0.
sub allocate-along-axis
sub allocate-along-axis(
@kids,
Int $total where { ... },
Int :$gap where { ... } = 0
) returns ArrayCompute 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 ($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 ($total - gap-reserve(@kids, $gap)) max 0. With the default :gap(0) that is exactly $total, and everything below behaves as it always has. =item Fixed children take value cells (clamped by remaining). =item Percent children take value% of the content box (also clamped) ā see the Gaps section of the module docs. =item Flex children share whatever remains, weighted by 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 Selkie::Layout::VBox's layout pass for the $placed-flag idiom that keeps gaps strictly between non-collapsed children.
sub resolve-cross-extent
sub resolve-cross-extent(
Selkie::Widget $child,
Int $container where { ... }
) returns UIntHow many cells $child occupies on the container's cross axis ā columns for a VBox, rows for an HBox ā given that the container has $container of them. The child's cross-sizing decides, reusing the Selkie::Sizing vocabulary: =item Undefined cross-sizing (the default) ā the full $container. This is what Selkie's layouts have always done, and why adding cross-axis alignment moves nothing in an existing app. =item Sizing.fixed($n) ā exactly $n, clamped to $container. =item Sizing.percent($n) ā $n% of $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 Sizing.flex ā the full $container. There is nothing to share a cross axis with, so flex and "fill" mean the same thing here; flex is accepted so a widget can carry one Sizing object for both axes. The result is never negative and never exceeds $container. A child that resolves to 0 (Sizing.fixed(0), or a 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 effective-cross-align
sub effective-cross-align(
Selkie::Widget $child,
CrossAlign $container-align
) returns CrossAlignThe CrossAlign that actually governs $child: its own align-self when it has one, the container's align-items otherwise. align-self is undefined by default, which is what makes 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 cross-axis-offset
sub cross-axis-offset(
CrossAlign $a,
Int $extent where { ... },
Int $container where { ... }
) returns UIntThe leading offset ā column in a VBox, row in an HBox ā at which an item of $extent cells sits inside a $container-cell slot under alignment $a: =item CrossStart and CrossFill ā 0. (A CrossFill child normally is the container's extent; when it also declares a cross-sizing, size wins and the leftover goes on the trailing side, exactly like CrossStart.) =item CrossCenter ā ((container - extent) / 2).floor, so an odd slack lands the extra cell on the trailing side. =item CrossEnd ā container - extent. An $extent larger than $container ā which 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 UInt return, fatal) offset. An undefined $a is treated as CrossFill.