Selkie--Widget--Border

NAME

Selkie::Widget::Border - Decorative frame around a single content widget

SYNOPSIS

use Selkie::Widget::Border;
use Selkie::Sizing;

my $border = Selkie::Widget::Border.new(
    title  => 'Characters',
    sizing => Sizing.fixed(20),
);
$border.set-content($avatar-list);

DESCRIPTION

Draws a box around a single child widget. Auto-highlights when any descendant has focus (via a store subscription on 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.

Opting out of store-driven focus

Set focus-from-store = False to disable both the store subscription and the render-time override. In that mode 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" โ€” CardList, for example, which wants its selected card's Border highlighted regardless of whether keyboard focus has moved elsewhere.

Swapping content

By default, set-content destroys the outgoing widget. Pass :!destroy to swap while keeping the old widget alive โ€” useful for tab-style panes that cycle through persistent views:

$border.set-content($view-a);
$border.set-content($view-b, :!destroy);    # $view-a survives
$border.set-content($view-a, :!destroy);    # swap back, still intact

Glyph sets

border-style takes a BorderKind from Selkie::BorderStyle โ€” BorderSingle (the default, and what Border has always drawn), BorderRounded, BorderDouble, BorderHeavy, or BorderAscii:

use Selkie::BorderStyle;

my $panel = Selkie::Widget::Border.new(
    title        => 'Log',
    border-style => BorderRounded,
);
$panel.set-border-style(BorderHeavy);      # marks dirty

border-style is about glyphs, not colours. The name follows the CSS convention, but it does not overlap with set-style-override โ€” that one takes a Selkie::Style and controls the frame's foreground / background / attributes, exactly as the border and border-focused theme slots do. The two compose freely: a heavy frame in the error palette is border-style => BorderHeavy plus set-style-override($error-style).

For glyphs no stock kind provides, pass a BorderGlyphs table directly. An explicit table always wins over border-style:

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

Selkie never downgrades to ASCII on its own โ€” that would make rendering depend on locale and terminal detection, and snapshots along with it. BorderAscii is the escape hatch you choose deliberately; see Selkie::BorderStyle.

Titles

The top title is placed by title-align, and a second title can be drawn along the bottom edge โ€” the natural home for a key-hint strip:

my $pane = Selkie::Widget::Border.new(
    title              => 'Inbox',
    title-align        => TitleCenter,
    bottom-title       => 'โ†‘โ†“ move ยท โŽ open',
    bottom-title-align => TitleRight,
);

Both titles are wrapped in title-prefix / 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 '' for a flush title, or to something decorative:

$pane.set-title-affixes('โ”ค ', ' โ”œ');       # โ”€โ”€โ”€โ”€โ”€โ”ค Inbox โ”œโ”€โ”€โ”€โ”€โ”€

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 hide-top-border / hide-bottom-border below).

Titles live on the frame, so they are unaffected by padding โ€” the inset applies to the content only.

Which theme slots the frame paints from

By default a Border paints its frame from the border slot, or border-focused while a descendant has focus, and both titles in whatever the frame is painted with. Four is rw knobs redirect that, by slot name rather than by Selkie::Style โ€” names are resolved through Selkie::Theme.slot on every render, so a Border configured this way follows a live theme swap, which a pushed set-style-override would not:

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

Any name slot understands works, app-specific %.custom entries included; an unknown name falls back to 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 style-slot and focused-style-slot at the same name opts a Border out of focus-reactive colouring without touching focus-from-store; 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 mark-dirty to take effect on the next frame.

Padding

padding insets the content from the frame. The constructor takes a uniform shorthand; the four edges are separately addressable after construction:

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

Explicit edges passed to the constructor win over the shorthand, so 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.

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 inner-rect if you want to know the content box in advance:

my ($y, $x, $rows, $cols) = $pane.inner-rect(10, 8);
say "content collapses" if $rows == 0 || $cols == 0;

EXAMPLES

Named panels

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

Stacking borders

Use hide-top-border / hide-bottom-border to share edges between adjacent panels:

$top-panel.hide-bottom-border    = True;
$bottom-panel.hide-top-border    = True;

SEE ALSO

has BorderKind $.border-style

Which stock glyph set the frame is painted with. Defaults to BorderSingle โ€” the โ”Œโ”โ””โ”˜โ”€โ”‚ box Border has always drawn. Ignored while an explicit border-glyphs table is installed.

has Selkie::BorderStyle::BorderGlyphs $.border-glyphs

An explicit glyph table, overriding border-style when defined. Undefined (the default) means "resolve border-style instead". Set it for glyphs no stock BorderKind provides; clear it with clear-border-glyphs to fall back to the kind.

has TitleAlign $.title-align

Placement of title along the top edge. TitleLeft (default) starts it two columns in from the left corner.

has Str $.bottom-title

Optional second title drawn along the bottom edge โ€” typically a key-hint strip. Empty (the default) draws nothing.

has TitleAlign $.bottom-title-align

Placement of bottom-title along the bottom edge. Defaults to TitleLeft for symmetry with title-align; callers that want a right-hand hint strip set TitleRight explicitly.

has Str $.title-prefix

Text placed immediately before a title, separating it from the edge run. A single space by default.

has Str $.title-suffix

Text placed immediately after a title. A single space by default.

has Str $.style-slot

Name of the Selkie::Theme slot the frame is painted from while unfocused. 'border' by default โ€” resolving the slot by name means the frame follows a live theme swap without anyone having to re-push a Selkie::Style, which set-style-override would not. Any name Selkie::Theme.slot understands works, including an app's own %.custom entries; unknown names fall back to base. Set it before the first render (or mark-dirty afterwards) โ€” it's a plain is rw knob, like hide-top-border.

has Str $.focused-style-slot

Name of the theme slot the frame is painted from while focused. 'border-focused' by default. Point both slots at the same name to opt a Border out of focus-reactive colouring while keeping the focus-from-store subscription โ€” that is what Selkie::Widget::Modal's internal frame does.

has Str $.title-slot

Optional theme slot for the 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 $.bottom-title-slot

Optional theme slot for the bottom title. Undefined (the default) paints it in the frame style.

has Bool $.focus-from-store

When True (default), the Border subscribes to ui.focused-widget and its render re-derives $!has-focus from the store on every frame โ€” the normal "highlight when any descendant is focused" pattern. When False, the Border treats 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" โ€” CardList being the canonical case, where the selected card's border should stay highlighted regardless of whether keyboard focus has moved out to another widget.

has UInt $.padding-top

Rows of empty space between the top edge and the content.

has UInt $.padding-right

Columns of empty space between the right edge and the content.

has UInt $.padding-bottom

Rows of empty space between the bottom edge and the content.

has UInt $.padding-left

Columns of empty space between the left edge and the content.

method content

method content() returns Selkie::Widget

The current content widget, or the Selkie::Widget type object when no content is set.

method set-content

method set-content(
    Selkie::Widget $w,
    Bool :$destroy = Bool::True
) returns Mu

Install $w as the wrapped content. Re-callable to swap content (e.g. for a Border that cycles through several views). :destroy (default True) destroys the outgoing widget โ€” the common case when content isn't reused. Pass :!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 set-content call.

method set-title

method set-title(
    Str:D $t
) returns Mu

Update the border's title text. Mark-dirties only; no event emit.

method set-border-style

method set-border-style(
    BorderKind:D $kind
) returns Nil

Switch the frame to another stock glyph set. Has no visible effect while an explicit border-glyphs table is installed.

method set-border-glyphs

method set-border-glyphs(
    Selkie::BorderStyle::BorderGlyphs:D $glyphs
) returns Nil

Install an explicit glyph table, overriding border-style.

method clear-border-glyphs

method clear-border-glyphs() returns Nil

Drop the explicit glyph table; border-style takes over again.

method effective-glyphs

method effective-glyphs() returns Selkie::BorderStyle::BorderGlyphs:D

The glyph table this Border actually paints with: the explicit border-glyphs table when one is installed, otherwise the table border-style resolves to. Cheap โ€” stock tables are cached, so this is safe to call once per render.

method set-title-align

method set-title-align(
    TitleAlign:D $align
) returns Nil

Move the top title along the top edge.

method set-bottom-title

method set-bottom-title(
    Str:D $t
) returns Nil

Set the bottom-edge title. Pass '' to remove it.

method set-bottom-title-align

method set-bottom-title-align(
    TitleAlign:D $align
) returns Nil

Move the bottom title along the bottom edge.

method set-title-affixes

method set-title-affixes(
    Str:D $prefix,
    Str:D $suffix
) returns Nil

Change the text wrapped around both titles. Defaults are a single space each; set-title-affixes('', '') gives flush titles, and set-title-affixes('โ”ค ', ' โ”œ') gives bracketed ones. Counts against the room available for title text.

method set-padding

method set-padding(
    Int:D $all where { ... }
) returns Nil

Inset the content by $all cells on every edge. 0 restores the flush-to-the-frame layout.

method set-padding-edges

method set-padding-edges(
    Int :$top where { ... },
    Int :$right where { ... },
    Int :$bottom where { ... },
    Int :$left where { ... }
) returns Nil

Change individual padding edges; omitted edges keep their current value. Naming only :left and :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 inner-rect

method inner-rect(
    Int $rows where { ... },
    Int $cols where { ... }
) returns List

The content box for a frame $rows x $cols, as ($y, $x, $rows, $cols) relative to the Border's own plane. Reads hide-top-border / 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; render parks the content widget in that case rather than attempting a zero-dimension resize (see the Padding section of the class docs).

method set-has-focus

method set-has-focus(
    Bool $f
) returns Mu

Set the border's focus state explicitly. Idempotent on no-ops. Used by containers (notably CardList) that drive border highlighting from their own selection rather than the framework's keyboard-focus tracking โ€” pair with focus-from-store = False in those cases.

method has-focus

method has-focus() returns Bool

Whether the border is currently rendered in its focused style.

method set-style-override

method set-style-override(
    Selkie::Style:D $style
) returns Nil

Temporarily force the border style regardless of focus state.

method clear-style-override

method clear-style-override() returns Nil

Return to the theme's normal border / focused-border selection.

method on-store-attached

method on-store-attached(
    $store
) returns Mu

Hook called when the widget is attached to a store. Auto-subscribes #| to the focused-widget path when focus-from-store is True (the #| default) so the border highlights itself whenever the keyboard #| focus is one of its descendants. once-* variants are idempotent #| โ€” reparenting and repeated set-store calls won't create duplicate #| subscriptions. Skipped entirely when focus-from-store is False #| (see attribute docs).

method handle-resize

method handle-resize(
    Int $rows where { ... },
    Int $cols where { ... }
) returns Mu

Resize own plane. Content is sized inside 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 paint-frame

method paint-frame(
    Selkie::BorderStyle::BorderGlyphs:D $g,
    Int $rows where { ... },
    Int $cols where { ... },
    Int $content-top where { ... },
    Int $content-bot where { ... },
    Selkie::Style $frame-style,
    Bool :$edges-only = Bool::False
) returns Nil

Paint the frame onto our own plane. Assumes the caller has already applied the frame style and validated Cยซcols >= 3ยป. $content-top / $content-bot are the half-open row range the vertical edges span โ€” precomputed by render from the hide-top-border / hide-bottom-border flags, which is why they're passed in rather than recomputed here. $frame-style is the style the caller has already applied; it is passed along so !paint-title can restore it after painting a title in a slot style of its own. :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-title

method paint-title(
    Int $y where { ... },
    Str $text,
    TitleAlign:D $align,
    Int $cols where { ... },
    Str $slot,
    Selkie::Style $frame-style
) returns Nil

Stamp one title onto edge row $y. Truncates the text to the room left between the corners once the affixes are accounted for, then places the decorated string per $align. Draws nothing for an empty title, or when the affixes alone leave no room for text. $slot is the title's theme slot, or an undefined 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 is named, its style is applied for the write and $frame-style is put back immediately after, so the following title (or the next paint pass) starts from the frame style either way.

method title-column

method title-column(
    TitleAlign:D $align,
    Int $cols where { ... },
    Str $decorated
) returns Int

The column a decorated title starts at, for a frame $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. $decorated is the title including its prefix and suffix. TitleLeft yields column 2, TitleCenter centres the string across the full width, TitleRight ends it two columns short of the right corner. Every result is clamped into [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 focusable-descendants

method focusable-descendants() returns Seq

Focusable descendants of the wrapped content subtree. Used by 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 destroy

method destroy() returns Mu

Destroy the wrapped content and the border's own plane. Always destroys the content unconditionally โ€” for "swap and keep alive" flows, use set-content(:!destroy) instead.

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.