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