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


=begin pod

=head1 NAME

Selkie::Layout::Split - Two-pane layout with a divider

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Layout::Split;
use Selkie::Sizing;

my $split = Selkie::Layout::Split.new(
    orientation => 'horizontal',   # left | right
    ratio       => 0.3,            # 30% | 70%
    sizing      => Sizing.flex,
);
$split.set-first($sidebar);
$split.set-second($main);

=end code

=head1 DESCRIPTION

Split divides its area into exactly two panes with a one-cell divider
between them. The C<ratio> attribute controls the split — C<0.3> means
the first pane takes 30% of the space, the second gets the rest (minus
one cell for the divider).

Two orientations:

=item C<'horizontal'> — left and right panes, divided by a vertical bar
=item C<'vertical'> — top and bottom panes, divided by a horizontal bar

Unlike VBox/HBox which take a list of children, Split takes exactly two
content widgets via C<set-first> and C<set-second>. Each assignment
destroys the previous occupant of that slot — use a container widget
(like another VBox) on each side if you need more than one widget per
pane.

=head1 EXAMPLES

=head2 Sidebar + main content

A classic two-pane layout with a 25/75 split:

=begin code :lang<raku>

my $split = Selkie::Layout::Split.new(
    orientation => 'horizontal',
    ratio       => 0.25,
    sizing      => Sizing.flex,
);
$split.set-first($sidebar-list);
$split.set-second($detail-view);

=end code

=head2 Editor + preview (vertical split)

Top half is the editor, bottom half is the live preview:

=begin code :lang<raku>

my $split = Selkie::Layout::Split.new(
    orientation => 'vertical',
    ratio       => 0.5,
    sizing      => Sizing.flex,
);
$split.set-first($editor);
$split.set-second($preview);

=end code

=head2 Multiple widgets per pane

Wrap each side in its own layout:

=begin code :lang<raku>

my $left = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$left.add($search-input);     # fixed(1)
$left.add($result-list);      # flex

$split.set-first($left);
$split.set-second($details);

=end code

=head1 SEE ALSO

=item L<Selkie::Layout::VBox>, L<Selkie::Layout::HBox> — N-child stacked layouts
=item L<Selkie::Theme> — C<divider> slot controls divider appearance

=end pod

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

use Selkie::Widget;
use Selkie::Container;
use Selkie::Sizing;

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

#| The fraction of space given to the first pane. C<0.5> is an even
#| split; C<0.3> gives 30% to the first pane, 70% to the second. Can
#| be changed at runtime — just mark the Split dirty and re-layout.
has Rat $.ratio = 0.5;

#| Either C<'horizontal'> (left+right panes, vertical divider) or
#| C<'vertical'> (top+bottom panes, horizontal divider).
has Str $.orientation = 'horizontal';

has NcplaneHandle $!divider-plane;

#| The first (left or top) pane's widget. Set via C<set-first>.
has Selkie::Widget $.first;

#| The second (right or bottom) pane's widget. Set via C<set-second>.
has Selkie::Widget $.second;

#|( Install a widget in the first pane. The previous occupant (if any)
    is destroyed. Returns the new widget for chaining. )
method set-first(Selkie::Widget $w --> Selkie::Widget) {
    $!first.destroy if $!first;
    $!first = $w;
    $w.parent = self;
    self.mark-dirty;
    $w;
}

#|( Install a widget in the second pane. The previous occupant is
    destroyed. Returns the new widget for chaining. )
method set-second(Selkie::Widget $w --> Selkie::Widget) {
    $!second.destroy if $!second;
    $!second = $w;
    $w.parent = self;
    self.mark-dirty;
    $w;
}

method render() {
    self!layout-split;
    $!first.render  if $!first  && $!first.is-dirty;
    $!second.render if $!second && $!second.is-dirty;
    self!render-divider;
    self.clear-dirty;
}

#| Re-layout the two panes and the divider when the parent resizes.
#| The split ratio is honoured exactly — first pane gets
#| C<floor(total * ratio)>, the divider takes 1 cell, the second gets
#| the remainder. Idempotent on no-size-change calls.
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-split if self.plane;
}

#|( Pure-math helper that computes the split allocation for a given
    total length and ratio. Returns a Hash with C<first>, C<divider>,
    C<second> keys — all UInt, all guaranteed non-negative. Exposed at
    class scope so the boundary math is unit-testable without needing
    a notcurses context. Used by C<!layout-split>. )
method compute-split-sizes(UInt $total, Rat $ratio --> Hash) {
    return %(first => 0, divider => 0, second => 0) if $total == 0;
    my UInt $first   = (($total * $ratio).floor.UInt max 1) min $total;
    my UInt $divider = $first min $total;
    my UInt $second  = $total > $first + 1
                          ?? ($total - $first - 1)
                          !! 0;
    %(:$first, :$divider, :$second)
}

method !layout-split() {
    # Bail out before any UInt arithmetic when the parent has been
    # squeezed to nothing. This can fire during the tear-down window
    # where the parent is resized to 0×N before destruction.
    return if self.rows == 0 || self.cols == 0;

    if $!orientation eq 'horizontal' {
        my %s = self.compute-split-sizes(self.cols, $!ratio);
        my UInt $h = self.rows;
        self!ensure-child-plane($!first, 0, 0, $h, %s<first>) if $!first;
        self!ensure-divider(0, %s<divider>, $h, 1);
        self!ensure-child-plane($!second, 0, %s<divider> + 1, $h, %s<second>) if $!second;
    } else {
        my %s = self.compute-split-sizes(self.rows, $!ratio);
        my UInt $w = self.cols;
        self!ensure-child-plane($!first, 0, 0, %s<first>, $w) if $!first;
        self!ensure-divider(%s<divider>, 0, 1, $w);
        self!ensure-child-plane($!second, %s<divider> + 1, 0, %s<second>, $w) if $!second;
    }
}

method !ensure-child-plane(Selkie::Widget $child, UInt $y, UInt $x, UInt $rows, UInt $cols) {
    if $child.plane {
        $child.reposition($y, $x);
        $child.handle-resize($rows, $cols);
    } else {
        $child.init-plane(self.plane, :$y, :$x, :$rows, :$cols);
    }
    $child.set-viewport(
        abs-y => self.abs-y + $y,
        abs-x => self.abs-x + $x,
        :$rows, :$cols,
    );
}

method !ensure-divider(UInt $y, UInt $x, UInt $rows, UInt $cols) {
    if $!divider-plane {
        ncplane_move_yx($!divider-plane, $y, $x);
        ncplane_resize_simple($!divider-plane, $rows, $cols);
    } else {
        my $opts = NcplaneOptions.new(:$y, :$x, :$rows, :$cols);
        $!divider-plane = ncplane_create(self.plane, $opts);
    }
}

method !render-divider() {
    return without $!divider-plane;
    my $style = self.theme.divider;
    ncplane_set_styles($!divider-plane, $style.styles);
    ncplane_set_fg_rgb($!divider-plane, $style.fg) if $style.fg.defined;
    ncplane_set_bg_rgb($!divider-plane, $style.bg) if $style.bg.defined;
    ncplane_erase($!divider-plane);

    if $!orientation eq 'horizontal' {
        my UInt $h = self.rows;
        for ^$h -> $row {
            ncplane_putstr_yx($!divider-plane, $row, 0, '│');
        }
    } else {
        my UInt $w = self.cols;
        for ^$w -> $col {
            ncplane_putstr_yx($!divider-plane, 0, $col, '─');
        }
    }
}

#|( Expose the panes as `children` so that Container-level cascade
    helpers (notably `!unsubscribe-tree`) reach them. Split stores its
    panes in `$!first` / `$!second` rather than the inherited
    `@!children` array, so without this override the cascade walks
    an empty list and leaks subscriptions / bookkeeping for anything
    inside a pane. )
method children(--> List) {
    ($!first, $!second).grep(*.defined).List;
}

#| Focusable descendants in stable left-to-right (or top-to-bottom)
#| order — Tab cycles through everything in the first pane, then
#| everything in the second pane. Omits the divider (which is
#| chrome).
method focusable-descendants(--> Seq) {
    gather {
        if $!first {
            take $!first if $!first.focusable;
            if $!first ~~ Selkie::Container {
                .take for $!first.focusable-descendants;
            }
        }
        if $!second {
            take $!second if $!second.focusable;
            if $!second ~~ Selkie::Container {
                .take for $!second.focusable-descendants;
            }
        }
    }
}

#| Destroy both panes, the divider plane, and the split's own plane.
method destroy() {
    $!first.destroy if $!first;
    $!second.destroy if $!second;
    if $!divider-plane {
        ncplane_destroy($!divider-plane);
        $!divider-plane = NcplaneHandle;
    }
    self!destroy-plane;
}