Selkie.git | docs/api/ | Selkie--Widget--Border.md


NAME
====

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

SYNOPSIS
========

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

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:

```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
```

Glyph sets
----------

`border-style` takes a `BorderKind` from [Selkie::BorderStyle](Selkie--BorderStyle.md) — `BorderSingle` (the default, and what Border has always drawn), `BorderRounded`, `BorderDouble`, `BorderHeavy`, or `BorderAscii`:

```raku
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](Selkie--Style.md) 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`:

```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
```

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](Selkie--BorderStyle.md).

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:

```raku
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:

```raku
$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:

```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
```

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](Selkie--Widget--Modal.md)'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:

```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
```

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:

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

EXAMPLES
========

Named panels
------------

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

Stacking borders
----------------

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

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

SEE ALSO
========

  * [Selkie::BorderStyle](Selkie--BorderStyle.md) — the `BorderKind` / `TitleAlign` enums and the `BorderGlyphs` table

  * [Selkie::Widget::Modal](Selkie--Widget--Modal.md) — centered overlay; also has `set-content(:!destroy)`

  * [Selkie::Theme](Selkie--Theme.md) — `border` / `border-focused` slots control appearance

### 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](Selkie--Theme.md) 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](Selkie--Widget--Modal.md)'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

```raku
method content() returns Selkie::Widget
```

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

### method set-content

```raku
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

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

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

### method set-border-style

```raku
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

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

Install an explicit glyph table, overriding `border-style`.

### method clear-border-glyphs

```raku
method clear-border-glyphs() returns Nil
```

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

### method effective-glyphs

```raku
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

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

Move the top title along the top edge.

### method set-bottom-title

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

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

### method set-bottom-title-align

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

Move the bottom title along the bottom edge.

### method set-title-affixes

```raku
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

```raku
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

```raku
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

```raku
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

```raku
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

```raku
method has-focus() returns Bool
```

Whether the border is currently rendered in its focused style.

### method set-style-override

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

Temporarily force the border style regardless of focus state.

### method clear-style-override

```raku
method clear-style-override() returns Nil
```

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

### method on-store-attached

```raku
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

```raku
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

```raku
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«$rows >= 3 && $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

```raku
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

```raku
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

```raku
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

```raku
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.