Selkie.git | lib/Selkie/Widget/ | Border.rakumod
=begin pod
=head1 NAME
Selkie::Widget::Border - Decorative frame around a single content widget
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::Border;
use Selkie::Sizing;
my $border = Selkie::Widget::Border.new(
title => 'Characters',
sizing => Sizing.fixed(20),
);
$border.set-content($avatar-list);
=end code
=head1 DESCRIPTION
Draws a box around a single child widget. Auto-highlights when any
descendant has focus (via a store subscription on C<ui.focused-widget>
— it's the canonical example of the "widget reacts to store state"
pattern).
Requires at least 3x3 dimensions. Redraws its edges after content renders
to cover pixel bleed from image blits — useful when wrapping an Image.
=head2 Opting out of store-driven focus
Set C<focus-from-store = False> to disable both the store subscription
and the render-time override. In that mode C<set-has-focus> is the only
writer and its value persists across renders. Intended for Borders
managed by a parent container with richer selection semantics than
"focused descendant" — C<CardList>, for example, which wants its
I<selected> card's Border highlighted regardless of whether keyboard
focus has moved elsewhere.
=head2 Swapping content
By default, C<set-content> destroys the outgoing widget. Pass
C<:!destroy> to swap while keeping the old widget alive — useful for
tab-style panes that cycle through persistent views:
=begin code :lang<raku>
$border.set-content($view-a);
$border.set-content($view-b, :!destroy); # $view-a survives
$border.set-content($view-a, :!destroy); # swap back, still intact
=end code
=head2 Glyph sets
C<border-style> takes a C<BorderKind> from L<Selkie::BorderStyle> —
C<BorderSingle> (the default, and what Border has always drawn),
C<BorderRounded>, C<BorderDouble>, C<BorderHeavy>, or C<BorderAscii>:
=begin code :lang<raku>
use Selkie::BorderStyle;
my $panel = Selkie::Widget::Border.new(
title => 'Log',
border-style => BorderRounded,
);
$panel.set-border-style(BorderHeavy); # marks dirty
=end code
B<C<border-style> is about glyphs, not colours.> The name follows the
CSS convention, but it does not overlap with C<set-style-override> —
that one takes a L<Selkie::Style> and controls the frame's foreground /
background / attributes, exactly as the C<border> and C<border-focused>
theme slots do. The two compose freely: a heavy frame in the error
palette is C«border-style => BorderHeavy» plus
C<set-style-override($error-style)>.
For glyphs no stock kind provides, pass a C<BorderGlyphs> table
directly. An explicit table always wins over C<border-style>:
=begin code :lang<raku>
my $dotted = Selkie::BorderStyle::BorderGlyphs.new(
top-left => '.', top-right => '.',
bottom-left => "'", bottom-right => "'",
horizontal => '.', vertical => ':',
);
$panel.set-border-glyphs($dotted); # border-style now ignored
$panel.clear-border-glyphs; # back to border-style's set
=end code
Selkie never downgrades to ASCII on its own — that would make rendering
depend on locale and terminal detection, and snapshots along with it.
C<BorderAscii> is the escape hatch you choose deliberately; see
L<Selkie::BorderStyle>.
=head2 Titles
The top title is placed by C<title-align>, and a second title can be
drawn along the bottom edge — the natural home for a key-hint strip:
=begin code :lang<raku>
my $pane = Selkie::Widget::Border.new(
title => 'Inbox',
title-align => TitleCenter,
bottom-title => '↑↓ move · ⏎ open',
bottom-title-align => TitleRight,
);
=end code
Both titles are wrapped in C<title-prefix> / C<title-suffix> (a single
space each by default) so they sit in a gap in the edge run rather than
butting up against it. Set them to C<''> for a flush title, or to
something decorative:
=begin code :lang<raku>
$pane.set-title-affixes('┤ ', ' ├'); # ─────┤ Inbox ├─────
=end code
Titles are truncated to the room left between the corners after the
affixes, are never drawn over a corner glyph, and are skipped entirely
on a hidden edge (see C<hide-top-border> / C<hide-bottom-border> below).
Titles live on the frame, so they are unaffected by C<padding> — the
inset applies to the content only.
=head2 Which theme slots the frame paints from
By default a Border paints its frame from the C<border> slot, or
C<border-focused> while a descendant has focus, and both titles in
whatever the frame is painted with. Four C<is rw> knobs redirect that,
by B<slot name> rather than by C<Selkie::Style> — names are resolved
through C<Selkie::Theme.slot> on every render, so a Border configured
this way follows a live theme swap, which a pushed
C<set-style-override> would not:
=begin code :lang<raku>
my $panel = Selkie::Widget::Border.new(title => 'Chat');
$panel.style-slot = 'panel'; # unfocused frame
$panel.focused-style-slot = 'panel-active'; # focused frame
$panel.title-slot = 'panel-title'; # top title only
$panel.bottom-title-slot = 'panel-keys'; # bottom title only
=end code
Any name C<slot> understands works, app-specific C<%.custom> entries
included; an unknown name falls back to C<base>. The two title slots are
undefined by default, which means "paint in the frame style" — the
behaviour Border has always had, down to the native calls it makes.
Pointing C<style-slot> and C<focused-style-slot> at the I<same> name
opts a Border out of focus-reactive colouring without touching
C<focus-from-store>; L<Selkie::Widget::Modal>'s internal frame does
exactly that, because something inside a modal is always focused.
These are plain attributes: changing one after the first paint wants a
C<mark-dirty> to take effect on the next frame.
=head2 Padding
C<padding> insets the content from the frame. The constructor takes a
uniform shorthand; the four edges are separately addressable after
construction:
=begin code :lang<raku>
my $pane = Selkie::Widget::Border.new(
title => 'Notes',
padding => 1, # one cell on all four edges
);
$pane.set-padding(2); # uniform
$pane.set-padding-edges(left => 2, right => 2); # only the named edges
=end code
Explicit edges passed to the constructor win over the shorthand, so
C«Border.new(padding => 1, padding-top => 0)» gives a pane with no top
inset — handy when a title already provides the visual separation.
Padding cells are left unpainted: the Border's own plane base shows
through, exactly like the frame's interior does today.
B<Padding can collapse the content.> A narrow Border with generous
horizontal padding can leave zero columns (or rows) for the content, at
which point there's nothing to resize the content plane to — notcurses
rejects a zero-width resize, which would leave the content at its
previous, larger size, painting straight through the frame. Border
handles that by parking the content widget for as long as either inner
dimension is zero; it comes back automatically on the first render that
has room for it. Use C<inner-rect> if you want to know the content box
in advance:
=begin code :lang<raku>
my ($y, $x, $rows, $cols) = $pane.inner-rect(10, 8);
say "content collapses" if $rows == 0 || $cols == 0;
=end code
=head1 EXAMPLES
=head2 Named panels
=begin code :lang<raku>
my $left = Selkie::Widget::Border.new(
title => 'Characters',
sizing => Sizing.fixed(20),
);
$left.set-content($char-list);
my $right = Selkie::Widget::Border.new(
title => 'Chat',
sizing => Sizing.flex,
);
$right.set-content($chat-view);
=end code
=head2 Stacking borders
Use C<hide-top-border> / C<hide-bottom-border> to share edges between
adjacent panels:
=begin code :lang<raku>
$top-panel.hide-bottom-border = True;
$bottom-panel.hide-top-border = True;
=end code
=head1 SEE ALSO
=item L<Selkie::BorderStyle> — the C<BorderKind> / C<TitleAlign> enums and the C<BorderGlyphs> table
=item L<Selkie::Widget::Modal> — centered overlay; also has C<set-content(:!destroy)>
=item L<Selkie::Theme> — C<border> / C<border-focused> slots control appearance
=end pod
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Container;
use Selkie::Style;
use Selkie::Event;
use Selkie::BorderStyle;
unit class Selkie::Widget::Border does Selkie::Container;
has Selkie::Widget $!content;
has Str $.title = '';
has Bool $!has-focus = False;
has Bool $.hide-top-border is rw = False;
has Bool $.hide-bottom-border is rw = False;
has Selkie::Style $!style-override;
#| Which stock glyph set the frame is painted with. Defaults to
#| C<BorderSingle> — the C<┌┐└┘─│> box Border has always drawn.
#| Ignored while an explicit C<border-glyphs> table is installed.
has BorderKind $.border-style = BorderSingle;
#|( An explicit glyph table, overriding C<border-style> when defined.
Undefined (the default) means "resolve C<border-style> instead".
Set it for glyphs no stock C<BorderKind> provides; clear it with
C<clear-border-glyphs> to fall back to the kind. )
has BorderGlyphs $.border-glyphs;
#| Placement of C<title> along the top edge. C<TitleLeft> (default)
#| starts it two columns in from the left corner.
has TitleAlign $.title-align = TitleLeft;
#| Optional second title drawn along the bottom edge — typically a
#| key-hint strip. Empty (the default) draws nothing.
has Str $.bottom-title = '';
#| Placement of C<bottom-title> along the bottom edge. Defaults to
#| C<TitleLeft> for symmetry with C<title-align>; callers that want a
#| right-hand hint strip set C<TitleRight> explicitly.
has TitleAlign $.bottom-title-align = TitleLeft;
#| Text placed immediately before a title, separating it from the edge
#| run. A single space by default.
has Str $.title-prefix = ' ';
#| Text placed immediately after a title. A single space by default.
has Str $.title-suffix = ' ';
#|( Name of the L<Selkie::Theme> slot the frame is painted from while
B<unfocused>. C<'border'> by default — resolving the slot by name
means the frame follows a live theme swap without anyone having to
re-push a C<Selkie::Style>, which C<set-style-override> would not.
Any name C<Selkie::Theme.slot> understands works, including an app's
own C<%.custom> entries; unknown names fall back to C<base>. Set it
before the first render (or C<mark-dirty> afterwards) — it's a plain
C<is rw> knob, like C<hide-top-border>. )
has Str $.style-slot is rw = 'border';
#|( Name of the theme slot the frame is painted from while B<focused>.
C<'border-focused'> by default. Point both slots at the same name to
opt a Border out of focus-reactive colouring while keeping the
C<focus-from-store> subscription — that is what
L<Selkie::Widget::Modal>'s internal frame does. )
has Str $.focused-style-slot is rw = 'border-focused';
#|( Optional theme slot for the B<top> title. Undefined (the default)
paints it in the frame style, exactly as Border always has. When
set, the title is painted in that slot's style and the frame style
is restored immediately afterwards. )
has Str $.title-slot is rw;
#|( Optional theme slot for the B<bottom> title. Undefined (the
default) paints it in the frame style. )
has Str $.bottom-title-slot is rw;
#|( When True (default), the Border subscribes to C<ui.focused-widget>
and its C<render> re-derives C<$!has-focus> from the store on every
frame — the normal "highlight when any descendant is focused"
pattern.
When False, the Border treats C<set-has-focus> as the single source
of truth: no subscription, no render-time override. This is the
right mode for Borders whose focus state is managed by a parent
container that has richer selection semantics than "focused
descendant" — C<CardList> being the canonical case, where the
I<selected> card's border should stay highlighted regardless of
whether keyboard focus has moved out to another widget. )
has Bool $.focus-from-store is rw = True;
#| Rows of empty space between the top edge and the content.
has UInt $.padding-top is rw = 0;
#| Columns of empty space between the right edge and the content.
has UInt $.padding-right is rw = 0;
#| Rows of empty space between the bottom edge and the content.
has UInt $.padding-bottom is rw = 0;
#| Columns of empty space between the left edge and the content.
has UInt $.padding-left is rw = 0;
# Constructor-only uniform-padding shorthand: `padding => 1` fans out
# to all four padding-* attributes (documented under "Padding" in the
# class Pod). There is deliberately no `padding` accessor — once
# construction is over the four edges are the state, and a scalar
# accessor would go stale the moment set-padding-edges touched one of
# them. An explicitly-passed edge wins over the shorthand, so
# `Border.new(padding => 1, padding-top => 0)` insets three edges.
#
# Plain comment rather than a #|( ) declarator block on purpose: the
# generated API docs render a submethod signature with its object
# address, which would churn docs/ on every mi6 build.
submethod TWEAK(UInt :$padding, :$padding-top, :$padding-right,
:$padding-bottom, :$padding-left) {
return without $padding;
$!padding-top = $padding without $padding-top;
$!padding-right = $padding without $padding-right;
$!padding-bottom = $padding without $padding-bottom;
$!padding-left = $padding without $padding-left;
}
#| The current content widget, or the C<Selkie::Widget> type object
#| when no content is set.
method content(--> Selkie::Widget) { $!content }
#| Install C<$w> as the wrapped content. Re-callable to swap content
#| (e.g. for a Border that cycles through several views).
#|
#| C<:destroy> (default True) destroys the outgoing widget — the
#| common case when content isn't reused. Pass C<:!destroy> to keep
#| the outgoing widget alive (its plane is parked far off-screen so
#| its last-rendered cells don't bleed through behind the new
#| content); reinstall it later with another C<set-content> call.
method set-content(Selkie::Widget $w, Bool :$destroy = True) {
# Destroying the outgoing content is the safe default — most callers
# won't reuse it. Pass :!destroy when swapping between widgets that
# you want to keep alive (e.g. a tab-style pane that cycles through
# several persistent views).
#
# In the non-destroy case we park the outgoing plane far off-screen
# so its last-rendered contents don't bleed through behind the new
# content. Widget state (plane, subscriptions, cursor positions) is
# preserved, and C<reposition> puts it back in place on the next
# install. Values > screen height are safe — notcurses tolerates
# out-of-bounds plane positions and simply clips them.
if $!content && $destroy {
$!content.destroy;
} elsif $!content && $!content.plane {
# park() recurses through containers and cleans up sprixels on
# Image widgets (which otherwise stay visible on the terminal
# even when their parent plane moves off-screen).
$!content.park;
}
$!content = $w;
$w.parent = self;
$w.set-store(self.store) if self.store;
self.mark-dirty;
}
#| Update the border's title text. Mark-dirties only; no event emit.
method set-title(Str:D $t) {
$!title = $t;
self.mark-dirty;
}
#| Switch the frame to another stock glyph set. Has no visible effect
#| while an explicit C<border-glyphs> table is installed.
method set-border-style(BorderKind:D $kind --> Nil) {
$!border-style = $kind;
self.mark-dirty;
}
#| Install an explicit glyph table, overriding C<border-style>.
method set-border-glyphs(BorderGlyphs:D $glyphs --> Nil) {
$!border-glyphs = $glyphs;
self.mark-dirty;
}
#| Drop the explicit glyph table; C<border-style> takes over again.
method clear-border-glyphs(--> Nil) {
$!border-glyphs = BorderGlyphs;
self.mark-dirty;
}
#|( The glyph table this Border actually paints with: the explicit
C<border-glyphs> table when one is installed, otherwise the table
C<border-style> resolves to. Cheap — stock tables are cached, so
this is safe to call once per render. )
method effective-glyphs(--> BorderGlyphs:D) {
$!border-glyphs // BorderGlyphs.for($!border-style);
}
#| Move the top title along the top edge.
method set-title-align(TitleAlign:D $align --> Nil) {
$!title-align = $align;
self.mark-dirty;
}
#| Set the bottom-edge title. Pass C<''> to remove it.
method set-bottom-title(Str:D $t --> Nil) {
$!bottom-title = $t;
self.mark-dirty;
}
#| Move the bottom title along the bottom edge.
method set-bottom-title-align(TitleAlign:D $align --> Nil) {
$!bottom-title-align = $align;
self.mark-dirty;
}
#|( Change the text wrapped around both titles. Defaults are a single
space each; C<set-title-affixes('', '')> gives flush titles, and
C<set-title-affixes('┤ ', ' ├')> gives bracketed ones. Counts
against the room available for title text. )
method set-title-affixes(Str:D $prefix, Str:D $suffix --> Nil) {
$!title-prefix = $prefix;
$!title-suffix = $suffix;
self.mark-dirty;
}
#| Inset the content by C<$all> cells on every edge. C<0> restores the
#| flush-to-the-frame layout.
method set-padding(UInt:D $all --> Nil) {
$!padding-top = $!padding-right = $!padding-bottom = $!padding-left = $all;
self.mark-dirty;
}
#|( Change individual padding edges; omitted edges keep their current
value. Naming only C<:left> and C<:right> gives a pane horizontal
breathing room without pushing the content off the top edge, which
is usually what a titled panel wants. An explicit 0 clears one edge
without touching the others. )
method set-padding-edges(UInt :$top, UInt :$right,
UInt :$bottom, UInt :$left --> Nil) {
$!padding-top = $_ with $top;
$!padding-right = $_ with $right;
$!padding-bottom = $_ with $bottom;
$!padding-left = $_ with $left;
self.mark-dirty;
}
#|( The content box for a frame C<$rows> x C<$cols>, as
C<($y, $x, $rows, $cols)> relative to the Border's own plane.
Reads C<hide-top-border> / C<hide-bottom-border> and the four
padding edges, but touches no plane — so it's the plane-free way to
ask "what will the content actually get?", both from tests and from
callers sizing content ahead of a render.
Both extents are clamped at 0. A zero in either one means the
content collapses entirely; C<render> parks the content widget in
that case rather than attempting a zero-dimension resize (see the
C<Padding> section of the class docs). )
method inner-rect(UInt $rows, UInt $cols --> List) {
my Int $content-top = $!hide-top-border ?? 0 !! 1;
my Int $content-bot = $!hide-bottom-border ?? $rows !! $rows - 1;
my Int $y = $content-top + $!padding-top;
my Int $x = 1 + $!padding-left;
my Int $inner-rows = ($content-bot - $!padding-bottom - $y) max 0;
my Int $inner-cols = ($cols - 2 - $!padding-left - $!padding-right) max 0;
($y, $x, $inner-rows, $inner-cols);
}
#| Set the border's focus state explicitly. Idempotent on no-ops.
#| Used by containers (notably C<CardList>) that drive border
#| highlighting from their own selection rather than the framework's
#| keyboard-focus tracking — pair with C<focus-from-store = False> in
#| those cases.
method set-has-focus(Bool $f) {
return if $f == $!has-focus;
$!has-focus = $f;
self.mark-dirty;
}
#| Whether the border is currently rendered in its focused style.
method has-focus(--> Bool) { $!has-focus }
#| Temporarily force the border style regardless of focus state.
method set-style-override(Selkie::Style:D $style --> Nil) {
$!style-override = $style;
self.mark-dirty;
}
#| Return to the theme's normal border / focused-border selection.
method clear-style-override(--> Nil) {
$!style-override = Selkie::Style;
self.mark-dirty;
}
#|( Hook called when the widget is attached to a store. Auto-subscribes
#| to the focused-widget path when C<focus-from-store> is True (the
#| default) so the border highlights itself whenever the keyboard
#| focus is one of its descendants. C<once-*> variants are idempotent
#| — reparenting and repeated set-store calls won't create duplicate
#| subscriptions. Skipped entirely when C<focus-from-store> is False
#| (see attribute docs).
)
method on-store-attached($store) {
return unless $!focus-from-store;
my $border = self;
self.once-subscribe-computed("border-focus-{self.WHICH}", -> $s {
my $focused = $s.get-in('ui', 'focused-widget');
$focused.defined ?? $border!is-descendant($focused) !! False;
});
}
method !is-descendant(Selkie::Widget $widget --> Bool) {
# Walk up from widget to see if we're an ancestor
my $w = $widget;
while $w.defined {
return True if $w === $!content;
return True if $w.parent.defined && $w.parent === self;
$w = $w.parent;
}
False;
}
#| Resize own plane. Content is sized inside C<render> (after the
#| inner-top / inner-rows / inner-cols computation that accounts for
#| hide-top/bottom-border). No cascade here — one layout pass per
#| frame, top-down via render.
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 render() {
return without self.plane;
# Update focus state directly from the store on each render. Cheap
# and avoids stale state if the subscription-driven update hasn't
# landed yet. Skipped when C<focus-from-store> is False — in that
# mode a parent (e.g. CardList) owns the authoritative has-focus
# state and set-has-focus is the single writer.
if self.store && $!focus-from-store {
my $focused = self.store.get-in('ui', 'focused-widget');
$!has-focus = $focused.defined && self!is-descendant($focused);
}
my UInt $rows = self.rows;
my UInt $cols = self.cols;
if $rows < 3 || $cols < 3 {
# Too small to draw a useful border. Still resize the content
# plane to our footprint (treating both borders as hidden) so
# its cells stay inside our bounds — without this, the content's
# plane keeps its previous larger size and notcurses paints its
# cells past our edge into siblings, since notcurses doesn't
# clip child planes to parents. effective-bounds protects the
# blit-plane (sprixel pixels), but the content's own cell-grid
# paint needs the plane itself to be the right size.
if $!content && $!content.plane {
if $rows > 0 && $cols > 0 {
$!content.reposition(0, 0);
$!content.handle-resize($rows, $cols);
} else {
# Same guard as the padding-collapse case below: a zero
# row or column count is a resize notcurses refuses, so
# the plane would keep its old geometry while the widget
# believed it was empty. Park instead.
$!content.park;
}
}
ncplane_erase(self.plane);
self.clear-dirty;
return;
}
my $frame-style = $!style-override // self.theme.slot(
$!has-focus ?? $!focused-style-slot !! $!style-slot);
my $glyphs = self.effective-glyphs;
self.apply-style($frame-style);
ncplane_erase(self.plane);
my UInt $content-top = $!hide-top-border ?? 0 !! 1;
my UInt $content-bot = $!hide-bottom-border ?? $rows !! $rows - 1;
self!paint-frame($glyphs, $rows, $cols, $content-top, $content-bot,
$frame-style);
# Position and render content inside the border
if $!content {
my ($inner-top, $inner-left, $inner-rows, $inner-cols)
= self.inner-rect($rows, $cols);
if $inner-rows > 0 && $inner-cols > 0 {
if $!content.plane {
$!content.reposition($inner-top, $inner-left);
$!content.handle-resize($inner-rows, $inner-cols);
} else {
$!content.init-plane(self.plane,
y => $inner-top, x => $inner-left,
rows => $inner-rows, cols => $inner-cols);
}
$!content.set-viewport(
abs-y => self.abs-y + $inner-top,
abs-x => self.abs-x + $inner-left,
rows => $inner-rows,
cols => $inner-cols,
);
$!content.mark-dirty unless $!content.is-dirty;
$!content.render;
# Greyscale post-pass for a disabled content subtree. Only
# the content is greyed here — the frame lives on the
# Border's own plane and keeps its colours, which is the
# right reading of "the panel's contents are unavailable".
# A disabled *Border* greys frame and content together, via
# the hook in whatever renders the Border.
$!content.apply-disabled-effect if $!content.disabled;
} elsif $!content.plane {
# Padding (or a hidden edge plus a one-row frame) has
# squeezed the content box to nothing on at least one axis.
# We cannot resize the content plane to fit: notcurses
# rejects a zero row or column count, so the plane would
# keep its previous, larger geometry and paint straight
# through our frame into whatever sits beyond it — the same
# failure VBox guards against for zero-allocation children.
# Park it instead; the next render with room for content
# repositions and resizes it back through the branch above.
$!content.park;
}
}
# Redraw border edges after content render to cover any pixel bleed
# from an image blit. :edges-only leaves the top edge alone — the
# bleed only ever comes from below-and-inside — but the bottom edge
# is repainted, so its title has to go back on top of it.
self.apply-style($frame-style);
self!paint-frame($glyphs, $rows, $cols, $content-top, $content-bot,
$frame-style, :edges-only);
self.clear-dirty;
}
#|( Paint the frame onto our own plane. Assumes the caller has already
applied the frame style and validated C«$rows >= 3 && $cols >= 3».
C<$content-top> / C<$content-bot> are the half-open row range the
vertical edges span — precomputed by C<render> from the
hide-top-border / hide-bottom-border flags, which is why they're
passed in rather than recomputed here.
C<$frame-style> is the style the caller has already applied; it is
passed along so C<!paint-title> can restore it after painting a
title in a slot style of its own.
C<:edges-only> is the post-content pass: it skips the top edge and
the top title (nothing an inner widget paints can reach row 0) but
still redraws the verticals and the bottom edge, which an Image
blit's pixel bleed does reach. The bottom title is stamped after
that redraw, or repainting the edge would erase it. )
method !paint-frame(BorderGlyphs:D $g, UInt $rows, UInt $cols,
UInt $content-top, UInt $content-bot,
Selkie::Style $frame-style,
Bool :$edges-only = False --> Nil) {
my $plane = self.plane;
my UInt $bot-y = $rows - 1;
# One putstr for the whole run rather than one per column: same
# cells, a fraction of the FFI crossings.
my Str $run = $g.horizontal x ($cols - 2);
unless $edges-only || $!hide-top-border {
ncplane_putstr_yx($plane, 0, 0, $g.top-left);
ncplane_putstr_yx($plane, 0, $cols - 1, $g.top-right);
ncplane_putstr_yx($plane, 0, 1, $run);
}
unless $!hide-bottom-border {
ncplane_putstr_yx($plane, $bot-y, 0, $g.bottom-left);
ncplane_putstr_yx($plane, $bot-y, $cols - 1, $g.bottom-right);
ncplane_putstr_yx($plane, $bot-y, 1, $run);
}
for $content-top ..^ $content-bot -> $y {
ncplane_putstr_yx($plane, $y, 0, $g.vertical);
ncplane_putstr_yx($plane, $y, $cols - 1, $g.vertical);
}
unless $edges-only || $!hide-top-border {
self!paint-title(0, $!title, $!title-align, $cols,
$!title-slot, $frame-style);
}
unless $!hide-bottom-border {
self!paint-title($bot-y, $!bottom-title, $!bottom-title-align, $cols,
$!bottom-title-slot, $frame-style);
}
}
#|( Stamp one title onto edge row C<$y>. Truncates the text to the room
left between the corners once the affixes are accounted for, then
places the decorated string per C<$align>. Draws nothing for an
empty title, or when the affixes alone leave no room for text.
C<$slot> is the title's theme slot, or an undefined C<Str> for "no
slot of its own". Undefined is the overwhelmingly common case and
costs nothing extra: the frame style is already on the plane, so the
write goes out exactly as it always has. When a slot I<is> named,
its style is applied for the write and C<$frame-style> is put back
immediately after, so the following title (or the next paint pass)
starts from the frame style either way. )
method !paint-title(UInt $y, Str $text, TitleAlign:D $align, UInt $cols,
Str $slot, Selkie::Style $frame-style --> Nil) {
return if $text.chars == 0;
my Int $room = $cols - 2 - $!title-prefix.chars - $!title-suffix.chars;
return if $room < 1;
my $decorated = $!title-prefix ~ $text.substr(0, $room) ~ $!title-suffix;
my $x = Selkie::Widget::Border.title-column($align, $cols, $decorated);
if $slot.defined {
self.apply-style(self.theme.slot($slot));
ncplane_putstr_yx(self.plane, $y, $x, $decorated);
self.apply-style($frame-style) if $frame-style.defined;
} else {
ncplane_putstr_yx(self.plane, $y, $x, $decorated);
}
}
#|( The column a decorated title starts at, for a frame C<$cols> wide.
A class method with no plane and no widget state, so title placement
is testable — and reusable by widgets that draw their own chrome —
without a notcurses context.
C<$decorated> is the title I<including> its prefix and suffix.
C<TitleLeft> yields column 2, C<TitleCenter> centres the string
across the full width, C<TitleRight> ends it two columns short of
the right corner. Every result is clamped into
C<[1, cols - 1 - chars]> so the corner glyphs survive; when the
string is too long to fit between the corners at all the clamp
bottoms out at 1 and the caller's truncation is what keeps the
right corner intact. )
method title-column(TitleAlign:D $align, UInt $cols, Str $decorated --> Int) {
my Int $len = $decorated.chars;
my Int $x = do given $align {
when TitleCenter { (($cols - $len) / 2).floor }
when TitleRight { $cols - 2 - $len }
default { 2 }
};
my Int $max = $cols - 1 - $len;
$x = $max if $x > $max;
$x = 1 if $x < 1;
$x;
}
#| Focusable descendants of the wrapped content subtree. Used by
#| C<Selkie::App>'s Tab cycle to skip the Border itself (which is
#| chrome) and reach the inner widget. Disabled content contributes
#| nothing — neither itself nor anything below it.
method focusable-descendants(--> Seq) {
return ().Seq without $!content;
return ().Seq if $!content.disabled;
gather {
take $!content if $!content.focusable;
if $!content ~~ Selkie::Container {
.take for $!content.focusable-descendants;
}
}
}
#| Destroy the wrapped content and the border's own plane. Always
#| destroys the content unconditionally — for "swap and keep alive"
#| flows, use C<set-content(:!destroy)> instead.
method destroy() {
$!content.destroy if $!content;
$!content = Selkie::Widget;
self!destroy-plane;
}