Selkie.git | lib/Selkie/Layout/ | HBox.rakumod
=begin pod
=head1 NAME
Selkie::Layout::HBox - Arrange children left to right
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Layout::HBox;
use Selkie::Sizing;
my $row = Selkie::Layout::HBox.new(sizing => Sizing.fixed(1));
$row.add: $label; # Sizing.fixed(8)
$row.add: $input; # Sizing.flex
$row.add: $button; # Sizing.fixed(10)
=end code
=head1 DESCRIPTION
C<HBox> arranges children horizontally. Allocation follows the same
three-pass sizing rule as L<Selkie::Layout::VBox>, but operates on
columns instead of rows.
Children get the full parent height unless they say otherwise with
C<cross-sizing> — see L</Cross-axis alignment>.
=head2 Gaps
C<gap> reserves columns I<between> children — the least fiddly way to
space out a button row or keep a sidebar off its neighbour:
=begin code :lang<raku>
my $buttons = Selkie::Layout::HBox.new(sizing => Sizing.fixed(1), gap => 2);
$buttons.add: Selkie::Widget::Button.new(label => 'Cancel', sizing => Sizing.flex);
$buttons.add: Selkie::Widget::Button.new(label => 'OK', sizing => Sizing.flex);
$buttons.set-gap(4); # marks dirty; relayout on the next render
=end code
Note that this replaces the old spacer-child idiom (an empty C<Text>
with C<Sizing.fixed(2)> between the buttons): a gap is not a widget, so
it costs no plane and paints nothing.
Gutters come off the top of the column budget before any child is
sized, so C<Sizing.percent> resolves against the content box (columns
minus gutters) — see L<Selkie::Layout::Allocate>. The rules match
L<Selkie::Layout::VBox> exactly: no leading or trailing gap, no gap
beside a child allocated zero columns (it's parked), and gap cells stay
unpainted so the HBox's own plane base shows through.
C<gap> defaults to 0, which allocates and positions exactly as HBox
always has. L<Selkie::Layout::Split> deliberately has no C<gap> — it
separates its panes with a divider it draws and drags itself.
=head2 Cross-axis alignment
An HBox lays out columns, so its I<cross> axis is rows: C<align-items>
places children vertically, and a child's C<cross-sizing> decides how
tall it is. This is the natural way to vertically centre a one-row
label beside a taller pane:
=begin code :lang<raku>
my $row = Selkie::Layout::HBox.new(
sizing => Sizing.flex,
gap => 2,
align-items => CrossCenter,
);
$row.add: Selkie::Widget::Text.new(
text => 'Status:',
sizing => Sizing.fixed(10),
cross-sizing => Sizing.fixed(1), # one row, vertically centred
);
$row.add: $detail-pane; # full height — no cross-sizing
# One child pinned to the bottom regardless of the row's policy.
$row.add: my $stamp = Selkie::Widget::Text.new(
text => '12:04',
sizing => Sizing.fixed(5),
cross-sizing => Sizing.fixed(1),
align-self => CrossEnd,
);
=end code
The rules are L<Selkie::Layout::VBox>'s with the axes swapped:
C<CrossFill> (the default) plus an undefined C<cross-sizing> is the
historical full-height layout at row 0; C<align-self> beats
C<align-items>; a child resolving to zero rows is parked but keeps its
columns and its gutter, so a cross-axis collapse never reflows the row;
and C<gap>, which only ever touches columns here, is entirely
orthogonal.
=head1 EXAMPLES
=head2 Three-column main layout
The classic file-manager pattern: sidebar + main + details.
=begin code :lang<raku>
my $columns = Selkie::Layout::HBox.new(sizing => Sizing.flex);
$columns.add: $sidebar; # Sizing.fixed(20)
$columns.add: $main-content; # Sizing.flex
$columns.add: $details; # Sizing.fixed(30)
=end code
=head2 A button row
=begin code :lang<raku>
my $buttons = Selkie::Layout::HBox.new(sizing => Sizing.fixed(1));
$buttons.add: Selkie::Widget::Button.new(label => 'Cancel', sizing => Sizing.flex);
$buttons.add: Selkie::Widget::Text.new(text => '', sizing => Sizing.fixed(2)); # spacer
$buttons.add: Selkie::Widget::Button.new(label => 'OK', sizing => Sizing.flex);
=end code
=head1 SEE ALSO
=item L<Selkie::Layout::VBox> — vertical version of the same layout
=item L<Selkie::Layout::Split> — two-pane split with a divider
=item L<Selkie::Sizing> — the 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::HBox does Selkie::Container;
#|( Columns of empty space reserved between adjacent children. Defaults
to 0 — no gutter, and layout identical to a gap-free HBox. 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 vertically — the HBox's B<cross> axis. Defaults
to C<CrossFill>: every child is given the box's full height at row
0, exactly as HBox has always laid out.
A child overrides this for itself with C<align-self>, and controls
I<how tall> 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. Columns are allocated using the same three-pass
strategy as C<VBox>, applied to the width axis. )
method render() {
self!layout-children;
self!render-children;
self.clear-dirty;
}
#| Resize this container's own plane. Children are re-laid-out in
#| C<render>, not here. See VBox for the rationale.
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;
}
method !layout-children() {
my @kids = self.children;
return unless @kids;
my UInt $height = self.rows;
my @allocs = allocate-along-axis(@kids, self.cols, :gap($!gap));
# Position and resize children, propagate viewport
my UInt $cx = 0;
my Int $parent-abs-y = self.abs-y;
my Int $parent-abs-x = self.abs-x;
# Gaps go strictly *between* placed children — see VBox for the
# full rationale behind the flag (it, not `$i > 0`, is what keeps a
# zero-allocation child from leaving a gutter to nowhere).
my Bool $placed = False;
for @kids.kv -> $i, $child {
my UInt $w = @allocs[$i];
# Cross axis (rows) — the mirror of VBox's columns. With the
# defaults this is `$h = $height` and `$cy = 0`: the layout HBox
# has always produced, argument for argument.
my UInt $h = resolve-cross-extent($child, $height);
my UInt $cy = cross-axis-offset(
effective-cross-align($child, $!align-items), $h, $height);
if $w == 0 {
# Zero-col allocation. See VBox for the full rationale —
# a child collapsing from non-zero to zero allocation
# leaves its plane at the previous size + position, which
# can fall outside our bounds and paint stale cells over
# adjacent siblings. Park to render harmlessly off-viewport;
# the next non-zero allocation reposition + resizes via
# the standard path.
$child.park if $child.plane;
next;
}
# Gap columns are deliberately left unallocated and unpainted,
# so this container's plane base shows through them.
$cx += $!gap if $placed;
$placed = True;
if $h == 0 && $height > 0 {
# None of the box's height — a `cross-sizing` that resolved
# to nothing. Park rather than resize to a zero dimension,
# but keep the child's columns and its gutter: a cross-axis
# collapse must not reflow the row. See VBox for the full
# rationale, including the `$height > 0` guard that keeps a
# zero-height HBox on its historical path.
$child.park if $child.plane;
$cx += $w;
next;
}
if $child.plane {
$child.reposition($cy, $cx);
$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,
);
$cx += $w;
}
}