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