Selkie--Layout--Allocate

NAME

Selkie::Layout::Allocate - Shared sizing-allocation pass for box layouts

SYNOPSIS

use Selkie::Layout::Allocate;
use Selkie::Sizing;

# A custom container that arranges children along the row axis:
my @allocs = allocate-along-axis(@kids, self.rows);

# `@allocs` is parallel to `@kids`; @allocs[$i] is the cell-count
# the layout assigns to the corresponding child. Position the children
# yourself — Allocate doesn't know about reposition / set-viewport.

# With a one-cell gutter between children:
my @spaced = allocate-along-axis(@kids, self.rows, :gap(1));
my $gutter = gap-reserve(@kids, 1);   # cells the gaps will consume

# The other axis: how wide is the child, and where does it sit?
my $extent = resolve-cross-extent($child, self.cols);
my $align  = effective-cross-align($child, self.align-items);
my $offset = cross-axis-offset($align, $extent, self.cols);

DESCRIPTION

allocate-along-axis runs the three-pass sizing algorithm that both Selkie::Layout::VBox and Selkie::Layout::HBox use to decide how much room each child gets:

  • Pass 1. Walk the children. Children with Sizing.fixed($n) take $n cells (clamped by remaining space). Children with Sizing.percent($n) take $n% of the original axis total (also clamped). Flex children defer; their flex factors are accumulated into a running total.

  • Pass 2. Distribute whatever space remains among flex children, weighted by their flex factor relative to the total flex weight. Each flex share is floored, so several flex children can leave a few cells unspent.

  • Pass 3. Hand any rounding remainder to the highest-index flex child. This keeps the box exactly filled and avoids rounding drift on resizes.

The function returns an Array[UInt] aligned with @kids; callers are responsible for positioning and propagating viewport bounds, since those depend on which axis is being laid out.

Gaps

Pass :gap($n) to reserve $n cells between children. The reservation is taken off the top: gap-reserve computes the total, and the three passes above then run over the content box — what's left of $total once the gutters are subtracted.

# 20 rows, three children, one row of gutter between each:
#   gap-reserve  = (3 - 1) * 1 = 2
#   content box  = 20 - 2      = 18
my @allocs = allocate-along-axis(@kids, 20, :gap(1));

Two consequences worth internalising:

  • Percent means percent-of-content-box. Sizing.percent(50) in the example above resolves against 18, not 20 — it gets 9 rows, not 10. That is the same rule CSS applies to a flex container's content box, and it's what makes percent children sum to the space actually available for content rather than overflowing by the gutter total.

  • Gap cells are never allocated to anybody. They're not in any child's allocation, so nothing paints them; the container plane's base cell shows through. That's what makes a gap read as breathing room rather than as a differently-coloured strip.

Children sized Sizing.fixed(0) — the "hidden child" idiom, where a widget stays in the tree but collapses to nothing — do not count towards the gutter total. Hiding a child by zeroing its sizing therefore removes its gutter too, instead of leaving a mysterious double gap behind.

gap-reserve is exported separately so containers can reason about the gutter total without running a full allocation (and so it can be tested in isolation).

:gap(0), the default, is bit-identical to calling allocate-along-axis without the argument at all.

The cross axis

allocate-along-axis answers "how much of the main axis does each child get?". Three more free subs answer the two cross-axis questions — how big is the child on the axis the container doesn't stack on, and where does that size sit:

  • resolve-cross-extent($child, $container) — resolves the child's cross-sizing against the container's cross extent. Fixed is a cell count, percent a share, flex (and undefined cross-sizing) the whole extent. Always clamped to 0 .. $container.

  • effective-cross-align($child, $container-align) — the child's align-self if it has one, otherwise the container's align-items.

  • cross-axis-offset($align, $extent, $container) — the leading offset: 0 for start and fill, the floored half of the slack for centre, all of the slack for end.

# A 20-column child, centred in a 50-column VBox:
my $extent = resolve-cross-extent($child, 50);          # 20
my $align  = effective-cross-align($child, CrossCenter); # CrossCenter
my $offset = cross-axis-offset($align, $extent, 50);    # 15

Gap and alignment are orthogonal by construction: the gutter total comes off the main axis before allocation, and these three subs only ever see the cross extent. A VBox with gap and align-items set applies both without either affecting the other's arithmetic.

Splitting the rule into free subs — rather than letting each box roll its own — is what keeps VBox and HBox from drifting apart, and lets the tests exercise the arithmetic with no notcurses planes in sight.

Why a free sub and not a base role?

VBox and HBox differ only in axis: VBox stacks rows, HBox stacks columns. Pass 3 (positioning) is axis-specific — it has to call reposition($cy, 0) versus reposition(0, $cx), plus set-viewport with axis-specific named args. Bridging that into a shared role would obscure the layout code without saving lines, so the extraction stops at the axis-agnostic part: the allocation math.

sub gap-reserve

sub gap-reserve(
    @kids,
    Int $gap where { ... }
) returns UInt

The total number of cells that $gap-wide gutters between @kids will consume: ((countable - 1) max 0) * $gap. "Countable" excludes children sized Sizing.fixed(0). That's the hidden-child idiom — a widget kept in the tree but collapsed to nothing — and a hidden child should take its gutter with it rather than leave a double gap between its visible neighbours. Never negative, and always 0 for an empty list, a single child, or $gap == 0.

sub allocate-along-axis

sub allocate-along-axis(
    @kids,
    Int $total where { ... },
    Int :$gap where { ... } = 0
) returns Array

Compute per-child allocations along a single axis, given the total axis size. Returns an Array[UInt] where @allocs[$i] is the cell count for @kids[$i]. Sum of allocations equals the content box ($total minus the gutter reservation) when flex children are present and the content box is non-zero; otherwise allocations may sum to less. Algorithm: =item The content box is ($total - gap-reserve(@kids, $gap)) max 0. With the default :gap(0) that is exactly $total, and everything below behaves as it always has. =item Fixed children take value cells (clamped by remaining). =item Percent children take value% of the content box (also clamped) — see the Gaps section of the module docs. =item Flex children share whatever remains, weighted by value; the highest-index flex child collects any rounding remainder. Positioning the gutters is the caller's job: this function only withholds the cells. See Selkie::Layout::VBox's layout pass for the $placed-flag idiom that keeps gaps strictly between non-collapsed children.

sub resolve-cross-extent

sub resolve-cross-extent(
    Selkie::Widget $child,
    Int $container where { ... }
) returns UInt

How many cells $child occupies on the container's cross axis — columns for a VBox, rows for an HBox — given that the container has $container of them. The child's cross-sizing decides, reusing the Selkie::Sizing vocabulary: =item Undefined cross-sizing (the default) — the full $container. This is what Selkie's layouts have always done, and why adding cross-axis alignment moves nothing in an existing app. =item Sizing.fixed($n) — exactly $n, clamped to $container. =item Sizing.percent($n) — $n% of $container, floored, and also clamped. Percent resolves against the container's cross extent, which for a gapped box is the full extent: gutters come off the main axis only. =item Sizing.flex — the full $container. There is nothing to share a cross axis with, so flex and "fill" mean the same thing here; flex is accepted so a widget can carry one Sizing object for both axes. The result is never negative and never exceeds $container. A child that resolves to 0 (Sizing.fixed(0), or a percent that floors to nothing) is parked by the box rather than given a zero-extent plane — but it keeps its main-axis allocation and its gutter, so collapsing a child on the cross axis never reflows its siblings on the main one.

sub effective-cross-align

sub effective-cross-align(
    Selkie::Widget $child,
    CrossAlign $container-align
) returns CrossAlign

The CrossAlign that actually governs $child: its own align-self when it has one, the container's align-items otherwise. align-self is undefined by default, which is what makes align-items a real container-level policy rather than a default that every child silently overrides. Both boxes route through this sub so the inheritance rule can't drift between them.

sub cross-axis-offset

sub cross-axis-offset(
    CrossAlign $a,
    Int $extent where { ... },
    Int $container where { ... }
) returns UInt

The leading offset — column in a VBox, row in an HBox — at which an item of $extent cells sits inside a $container-cell slot under alignment $a: =item CrossStart and CrossFill — 0. (A CrossFill child normally is the container's extent; when it also declares a cross-sizing, size wins and the leftover goes on the trailing side, exactly like CrossStart.) =item CrossCenter — ((container - extent) / 2).floor, so an odd slack lands the extra cell on the trailing side. =item CrossEnd — container - extent. An $extent larger than $container — which resolve-cross-extent never produces, but a caller doing its own arithmetic might — clamps to 0 rather than underflowing into a negative (and, for the UInt return, fatal) offset. An undefined $a is treated as CrossFill.

Selkie v0.11.1

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.4.1+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.