Selkie.git | lib/Selkie/Layout/ | VBox.rakumod


=begin pod

=head1 NAME

Selkie::Layout::VBox - Arrange children top to bottom

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Layout::VBox;
use Selkie::Sizing;

my $vbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$vbox.add: $header;    # Sizing.fixed(1)
$vbox.add: $body;      # Sizing.flex
$vbox.add: $footer;    # Sizing.fixed(1)

=end code

=head1 DESCRIPTION

C<VBox> stacks children vertically and allocates rows according to each
child's L<Selkie::Sizing>:

=item B<Fixed> children get exactly the rows they ask for.
=item B<Percent> children get C<n%> of the parent's total rows.
=item B<Flex> children share whatever rows are left over, weighted by flex factor.

Columns are set to the full parent width for every child, unless the
child says otherwise with C<cross-sizing> — see L</Cross-axis alignment>.

VBox is a L<Selkie::Container>, so it inherits C<add>, C<remove>,
C<clear>, and focusable-descendants handling. All children must compose
C<Selkie::Widget>.

=head2 Gaps

C<gap> reserves rows I<between> children — the idiomatic way to give a
stack some breathing room without padding every child by hand:

=begin code :lang<raku>

my $form = Selkie::Layout::VBox.new(sizing => Sizing.flex, gap => 1);
$form.add: $name-row;      # Sizing.fixed(1)
$form.add: $email-row;     # Sizing.fixed(1)
$form.add: $submit;        # Sizing.fixed(1)

$form.set-gap(2);          # marks dirty; relayout on the next render

=end code

The gutters come off the top of the row budget before any child is
sized, so C<Sizing.percent> resolves against the content box (rows
minus gutters) — see L<Selkie::Layout::Allocate>. Three of the rules
are worth knowing by heart:

=item There is never a leading or trailing gap; C<gap> is strictly
I<between> children.
=item A child allocated zero rows is parked, and no gap is placed
beside it. Collapsing a child with C<Sizing.fixed(0)> therefore removes
its gutter as well, instead of leaving a double gap behind.
=item Gap rows are left unpainted, so the VBox's own plane base shows
through. If you want a visible rule between panes rather than empty
space, use L<Selkie::Layout::Split> (which owns a divider) or add a
one-row C<Text> child.

C<gap> defaults to 0, which allocates and positions exactly as VBox
always has.

L<Selkie::Layout::Split> deliberately has no C<gap>: its two panes are
separated by a divider row/column it draws and drags itself, and a
second, invisible gutter next to that would be a footgun rather than a
feature.

=head2 Cross-axis alignment

A VBox stacks rows, so its I<cross> axis is columns. C<align-items>
says where a child sits horizontally; the child's C<cross-sizing> says
how wide it is:

=begin code :lang<raku>

my $page = Selkie::Layout::VBox.new(
    sizing      => Sizing.flex,
    gap         => 1,
    align-items => CrossCenter,
);

# A 40-column card, centred in however wide the page happens to be.
$page.add: Selkie::Widget::Border.new(
    title        => 'Sign in',
    sizing       => Sizing.fixed(9),
    cross-sizing => Sizing.fixed(40),
);

# …and one child that opts out of the container's rule.
$page.add: my $footer = Selkie::Widget::Text.new(
    text         => 'v1.2.0',
    sizing       => Sizing.fixed(1),
    cross-sizing => Sizing.fixed(10),
    align-self   => CrossEnd,
);

=end code

The rules, in full:

=item C<cross-sizing> is a L<Selkie::Sizing> resolved against the box's
width: C<fixed> is a column count, C<percent> a share of the width,
C<flex> (and the default, undefined) the whole width.
=item C<align-items> defaults to C<CrossFill> and C<align-self> is
undefined by default, so a plain VBox lays out exactly as it always
has — full-width children at column 0.
=item C<CrossFill> and C<CrossStart> both place a child at column 0;
they differ only in that C<CrossFill> is the "no opinion" value, and a
child that declares a C<cross-sizing> keeps it under either.
=item A child that resolves to zero columns is parked — a zero-width
plane can't be resized, and a stale one would paint over its
neighbours — but it B<keeps its rows and its gutter>. Collapsing a
child on the cross axis must not reflow the stack on the main one;
C<sizing =E<gt> Sizing.fixed(0)> is still how you take a child out of
the flow entirely.
=item Alignment and C<gap> never interact: the gutters come off the row
budget, the alignment maths only ever sees columns.

=head1 EXAMPLES

=head2 Classic three-pane stack

=begin code :lang<raku>

my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);

$root.add: Selkie::Widget::Text.new(
    text   => ' Selkie App',
    sizing => Sizing.fixed(1),
    style  => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);

$root.add: $main-content;   # sizing => Sizing.flex — fills middle

$root.add: Selkie::Widget::Text.new(
    text   => ' Ctrl+Q: quit',
    sizing => Sizing.fixed(1),
    style  => Selkie::Style.new(fg => 0x666666),
);

=end code

=head2 Weighted distribution

=begin code :lang<raku>

my $vbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$vbox.add: $preview;   # Sizing.flex(2) — gets two-thirds
$vbox.add: $output;    # Sizing.flex    — gets one-third

=end code

=head1 SEE ALSO

=item L<Selkie::Layout::HBox> — horizontal version of the same layout
=item L<Selkie::Layout::Split> — two-pane split with a draggable divider ratio
=item L<Selkie::Sizing> — the fixed/percent/flex sizing model
=item L<Selkie::Align> — the C<CrossAlign> values C<align-items> takes
=item L<Selkie::Layout::Allocate> — the shared allocation and cross-axis maths

=end pod

use Notcurses::Native;
use Notcurses::Native::Types;

use Selkie::Align;
use Selkie::Widget;
use Selkie::Container;
use Selkie::Sizing;
use Selkie::Layout::Allocate;

unit class Selkie::Layout::VBox does Selkie::Container;

#|( Rows of empty space reserved between adjacent children. Defaults to
    0 — no gutter, and layout identical to a gap-free VBox. The total
    reservation is C<gap-reserve> from L<Selkie::Layout::Allocate>:
    children collapsed to C<Sizing.fixed(0)> don't get a gutter. )
has UInt $.gap is rw = 0;

#| Change the inter-child gutter and mark the box dirty so the next
#| render re-runs the allocation. C<0> restores the gap-free layout.
method set-gap(UInt:D $g --> Nil) {
    return if $g == $!gap;
    $!gap = $g;
    self.mark-dirty;
}

#|( Where children sit horizontally — the VBox's B<cross> axis.
    Defaults to C<CrossFill>: every child is given the box's full width
    at column 0, exactly as VBox has always laid out.

    A child overrides this for itself with C<align-self>, and controls
    I<how wide> it is with C<cross-sizing>. See L<Selkie::Align>. )
has CrossAlign $.align-items is rw = CrossFill;

#| Change the cross-axis alignment policy for children that haven't set
#| their own C<align-self>, and mark the box dirty so the next render
#| re-runs the layout.
method set-align-items(CrossAlign:D $a --> Nil) {
    return if $a === $!align-items;
    $!align-items = $a;
    self.mark-dirty;
}

#|( Perform layout and render each child. Called automatically by the
    render cycle. The layout pass allocates rows according to every
    child's C<Sizing>: fixed first, then percent, then flex shares the
    rest. )
method render() {
    self!layout-children;
    self!render-children;
    self.clear-dirty;
}

#| Re-layout children when the parent resizes. Re-runs the same fixed →
#| percent → flex allocation as the initial layout, so children's
#| relative sizing is preserved across resizes. Idempotent on no-size
#| changes.
method handle-resize(UInt $rows, UInt $cols) {
    my $changed = $rows != self.rows || $cols != self.cols;
    return unless $changed;
    self.resize($rows, $cols);
    self!on-resize;
    self!layout-children if self.plane;
}

method !layout-children() {
    my @kids = self.children;
    return unless @kids;

    my UInt $width = self.cols;
    my @allocs = allocate-along-axis(@kids, self.rows, :gap($!gap));

    # Position and resize children, propagate viewport
    my UInt $cy = 0;
    my Int $parent-abs-y = self.abs-y;
    my Int $parent-abs-x = self.abs-x;
    # Gaps go strictly *between* placed children: no leading gutter
    # before the first one, no trailing gutter after the last, and none
    # beside a child that got zero rows (it's parked, so a gutter next
    # to it would read as a gap to nowhere). The flag — rather than
    # `$i > 0` — is what makes the zero-allocation case come out right.
    my Bool $placed = False;
    for @kids.kv -> $i, $child {
        my UInt $h = @allocs[$i];

        # Cross axis (columns). With the defaults — no `cross-sizing`
        # on the child, `align-items` left at CrossFill — this is
        # `$w = $width` and `$cx = 0`, i.e. the layout VBox has always
        # produced, down to the argument values passed to init-plane.
        my UInt $w  = resolve-cross-extent($child, $width);
        my UInt $cx = cross-axis-offset(
            effective-cross-align($child, $!align-items), $w, $width);

        if $h == 0 {
            # Zero-row allocation. A child whose previous layout pass
            # gave it rows but now gets nothing: without an action
            # here the child's plane keeps its old size and position,
            # which can easily fall outside our current bounds (e.g.,
            # avatar-col VBox's flex Text spacer collapsing from 4
            # rows to 0 leaves a 4-row Text plane parked at relative
            # (5, 0) — sitting BELOW our plane, painting empty cells
            # over the next sibling / border / row beneath us).
            # Park the plane off-viewport so its cells render
            # harmlessly. The plane comes back into bounds on the
            # next layout pass that gives the child a non-zero
            # allocation, via the standard reposition + handle-resize
            # path.
            $child.park if $child.plane;
            next;
        }

        # Gap rows are deliberately left unallocated and unpainted, so
        # this container's plane base shows through them.
        $cy += $!gap if $placed;
        $placed = True;

        if $w == 0 && $width > 0 {
            # The child asked for none of the box's width (a
            # `cross-sizing` of fixed(0), or a percent that floors to
            # nothing). Park it: `ncplane_resize_simple` rejects a zero
            # dimension, so resizing would leave a stale, wrongly-sized
            # plane painting over its neighbours — the trap Border's
            # padding collapse hits.
            #
            # Its rows stay reserved and its gutter stays placed: a
            # collapse on the *cross* axis must not reflow the stack on
            # the main one, or a narrow terminal would silently shuffle
            # everything below it. `Sizing.fixed(0)` on `sizing` is
            # still the way to take a child out of the flow entirely.
            #
            # The `$width > 0` guard keeps a zero-width VBox on its
            # historical path: back then every child was created with
            # `cols => 0` and nothing parked, and no cross-sizing can be
            # blamed for a width of 0 when the box itself has none.
            $child.park if $child.plane;
            $cy += $h;
            next;
        }

        if $child.plane {
            $child.reposition($cy, $cx);
            # handle-resize cascades into the child subtree so nested
            # containers also propagate new dims. Short-circuits when
            # dims are unchanged, so this is cheap during normal renders.
            $child.handle-resize($h, $w);
        } else {
            $child.init-plane(self.plane, y => $cy, x => $cx, rows => $h, cols => $w);
        }
        $child.set-viewport(
            abs-y => $parent-abs-y + $cy,
            abs-x => $parent-abs-x + $cx,
            rows  => $h,
            cols  => $w,
        );
        $cy += $h;
    }
}