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