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


NAME
====

Selkie::Widget::TabBar - Horizontal tab strip integrated with ScreenManager

SYNOPSIS
========

```raku
use Selkie::Widget::TabBar;
use Selkie::Sizing;

my $tabs = Selkie::Widget::TabBar.new(sizing => Sizing.fixed(1));
$tabs.add-tab(name => 'tasks',  label => 'Tasks');
$tabs.add-tab(name => 'notes',  label => 'Notes');
$tabs.add-tab(name => 'stats',  label => 'Stats');

# Tap to react to user selection:
$tabs.on-tab-selected.tap: -> Str $name {
    $app.switch-screen($name);
};
```

DESCRIPTION
===========

A one-line horizontal strip of named tabs. The active tab is highlighted with the theme's `text-highlight` slot; others render in the default `text` slot. Focusable — Left/Right arrows move the active tab, `Enter` fires `on-tab-selected` (which you typically tap to call `$app.switch-screen`).

Tabs are identified by an opaque `name` string and displayed as a `label`. The name is what's emitted on `on-tab-selected` — choose something that matches your registered screen names for a zero-effort integration with `Selkie::ScreenManager`.

`TabBar` also has convenient integration with `ScreenManager`: call `sync-to-app($app)` to make the active tab reflect `$app.screen-manager.active-screen` automatically via a store subscription.

Tabs can carry a **badge** — an unread count, a pending-item tally, anything short — drawn inside the tab after its label. The active-tab decoration and the focus indicator are both configurable (`TabActiveStyle` / `TabFocusIndicator`, exported by this module; `use Selkie` does not re-export them). All defaults are byte-for-byte what TabBar has always drawn.

BADGES
======

A badge is any value; it's stored raw and stringified at render time by `badge-formatter`, so a store subscription can hand over `Int` counts without formatting them first:

```raku
$tabs.add-tab(name => 'inbox', label => 'Inbox', badge => 4);
$tabs.set-badge('inbox', 12);    # → "[ Inbox 12 ]"
$tabs.set-badge('inbox', Nil);   # → "[ Inbox ]"  (same as clear-badge)
$tabs.clear-badge('inbox');
say $tabs.badge('inbox');        # Nil — the raw value, not the drawn text
```

The stock formatter is `.Str`, except `Int`s above 99, which clamp to `'99+'` — an unbounded count would widen its tab and shove every tab to its right along with it. Override it for a different policy; returning the empty string draws no badge at all, which is the idiom for suppressing zeroes:

```raku
$tabs.set-badge-formatter(-> $v { $v ~~ Int && $v == 0 ?? '' !! $v.Str });
$tabs.set-badge-prefix(' · ');   # separator between label and badge

# Wrapping the stock policy rather than replacing it:
my &stock = Selkie::Widget::TabBar.default-badge-formatter;
$tabs.set-badge-formatter(-> $v { $v ~~ Int && $v == 0 ?? '' !! &stock($v) });
```

Badges count towards the tab's width, so mouse hit-testing follows them automatically — see [LAYOUT INTROSPECTION](LAYOUT INTROSPECTION).

ACTIVE-TAB STYLES
=================

  * **`TabBrackets`** — the default. The active tab is wrapped in `[ ]`: `[ Inbox ]`.

  * **`TabUnderline`** — no brackets; the active tab is underlined instead (padding included, so the rule runs the full width of the tab).

  * **`TabPill`** — no brackets; the active tab is filled with the theme's `tab-active` background.

Every style draws the same number of columns for the same tab — `"[ L ]"` and `" L "` are both `L.chars + 4` — so switching styles never shifts the strip.

They differ in how an **unfocused** bar marks its active tab. Brackets and underline recede to the `tab-inactive` colours (the glyph-level decoration still says which tab is current), while a pill stays lit, because its fill is the only marker it has. That's deliberate: a pill bar leans on the focus indicator, not colour, to say whether it's being driven.

```raku
my $tabs = Selkie::Widget::TabBar.new(
    sizing          => Sizing.fixed(1),
    active-style    => TabPill,
    focus-indicator => FocusColor,
);
$tabs.set-active-style(TabUnderline);   # or change it later; marks dirty
```

FOCUS INDICATION
================

`FocusPrefix` (the default) draws `'▶ '` before the first tab while the bar is focused and two blank cells while it isn't — the same width either way, so nothing moves when focus arrives.

`FocusColor` drops the prefix entirely (the strip starts at column 0) and merges the theme's `tab-focus-accent` slot onto the active tab instead. That slot defaults to `border-focused`, so a bar switched to `FocusColor` picks up the same accent the rest of your chrome uses for focus; give the slot its own colour for something distinct.

The accent is merged, so whatever it sets wins. That matters with `TabPill`: an accent carrying a `bg` (`border-focused` does) replaces the pill's fill rather than tinting it, and a focused pill ends up flat-on-base while an unfocused one stays filled. If you want a focused pill that's filled *in* the accent, give `tab-focus-accent` a background of its own:

```raku
tab-focus-accent => Selkie::Style.new(fg => 0x1A1A2E, bg => 0xBB99FF, bold => True),
```

LAYOUT INTROSPECTION
====================

`render` and mouse hit-testing share one width formula, exposed so you can share it too: `tab-display($i)` is the exact string tab `$i` draws, `focus-prefix` is the offset before the first tab, and `tab-index-at-col($col)` maps a local column back to a tab index (`-1` for the prefix or past the last tab).

```raku
my $width = $tabs.focus-prefix.chars
          + (^$tabs.tab-names.elems).map({ $tabs.tab-display($_).chars }).sum;
```

EXAMPLES
========

Wiring to ScreenManager
-----------------------

The canonical pattern: one tab per screen, selection dispatches a screen switch, and the bar keeps itself in sync if the screen changes from elsewhere:

```raku
my $tabs = Selkie::Widget::TabBar.new(sizing => Sizing.fixed(1));
$tabs.add-tab(name => 'inbox',  label => 'Inbox');
$tabs.add-tab(name => 'sent',   label => 'Sent');
$tabs.add-tab(name => 'drafts', label => 'Drafts');

$tabs.on-tab-selected.tap: -> Str $name {
    $app.switch-screen($name);
};

# Keep the bar's active tab in sync with whatever's actually showing
$tabs.sync-to-app($app);
```

Without ScreenManager
---------------------

Tabs don't have to drive screen switches — you can use them as a lightweight "mode" selector for a single screen's content:

```raku
my $tabs = Selkie::Widget::TabBar.new(sizing => Sizing.fixed(1));
$tabs.add-tab(name => 'recent', label => 'Recent');
$tabs.add-tab(name => 'saved',  label => 'Saved');
$tabs.add-tab(name => 'all',    label => 'All');

$tabs.on-tab-selected.tap: -> Str $name {
    $app.store.dispatch('view/mode-changed', mode => $name);
};
```

Count badges from the store
---------------------------

Badges are usually a projection of application state. Recompute them in one callback and let the formatter decide what's worth drawing:

```raku
$app.store.subscribe-with-callback(
    'tab-counts',
    -> $s { $s.get-in('counts') // {} },
    -> %counts {
        $tabs.set-badge($_, %counts{$_} // Nil) for $tabs.tab-names;
    },
    $tabs,
);

# Suppress zeroes rather than drawing "Inbox 0":
$tabs.set-badge-formatter(-> $v { $v ~~ Int && $v == 0 ?? '' !! $v.Str });
```

`set-badge` is a no-op when the value hasn't changed, so a subscription that fires on every store tick doesn't cause a repaint per tick.

SEE ALSO
========

  * [Selkie::ScreenManager](Selkie--ScreenManager.md) — the multi-screen registry TabBar typically drives

  * [Selkie::App](Selkie--App.md) — screen-scoped keybinds complement per-tab views

  * [Selkie::Theme](Selkie--Theme.md) — the `tab-active`, `tab-inactive` and `tab-focus-accent` slots



How the active tab is decorated. `TabBrackets` is the historical behaviour and the default: the active tab is wrapped in `[ ]`. `TabUnderline` drops the brackets and underlines the active tab instead. `TabPill` drops them too and leaves the tab filled with the theme's `tab-active` background — the "pill" look. All three render the same number of columns for the same tab, so switching styles never shifts the strip's layout: `"[ L ]"` and `" L "` are both `L.chars + 4`.



How a focused bar announces that it has the keyboard. `FocusPrefix` is the historical behaviour and the default: a `▶ ` chevron in front of the strip (blank padding when unfocused, so the tabs never move). `FocusColor` drops the prefix entirely — the strip starts at column 0 — and instead merges the theme's `tab-focus-accent` slot onto the active tab while the bar is focused.

### has TabActiveStyle $.active-style

How the active tab is decorated. Defaults to `TabBrackets`, which is byte-for-byte what TabBar has always drawn. Change it after construction with `set-active-style`.

### has TabFocusIndicator $.focus-indicator

How the bar shows that it holds focus. Defaults to `FocusPrefix`, the historical `▶ ` chevron. Change it after construction with `set-focus-indicator`.

### has Str $.badge-prefix

Separator inserted between a tab's label and its badge. Defaults to a single space, giving `[ Inbox 4 ]`. Set it to something like `' · '` for a heavier separator — it counts towards the tab's width, so hit-testing follows automatically.

### has Callable &.badge-formatter

Callable turning a badge value into the text drawn inside the tab. Receives the raw value passed to `add-tab`/`set-badge` and returns a `Str`; return the empty string to draw no badge at all. Defaults to `TabBar.default-badge-formatter`.

### method default-badge-formatter

```raku
method default-badge-formatter() returns Callable:D
```

The stock badge formatter: `.Str` for everything, except `Int`s above 99, which become `'99+'`. Handy to restore after a custom formatter, or to wrap one around it — see the BADGES section.

### method tab-display

```raku
method tab-display(
    Int $i where { ... }
) returns Str
```

The exact string tab `$i` renders as — brackets or padding, label, separator and badge. Empty string for an out-of-range index. Public so callers can measure the strip (`sum` the displays plus `focus-prefix.chars` for its total width) and so tests can assert that hit-testing and rendering agree.

### method focus-prefix

```raku
method focus-prefix() returns Str
```

The leading offset drawn before the first tab: `'▶ '` / `' '` under `FocusPrefix`, `''` under `FocusColor`.

### method tab-index-at-col

```raku
method tab-index-at-col(
    Int $col
) returns Int
```

The tab index covering local column `$col`, or `-1` for the focus prefix, a negative column, or anything past the last tab. This is the same lookup the widget's own click handler uses — reach for it when embedding a TabBar in a region you hit-test yourself.

### method add-tab

```raku
method add-tab(
    Str:D :$name!,
    Str:D :$label!,
    :$badge
) returns Mu
```

Register a tab. `name` is the identifier (usually matches a screen name); `label` is what's shown to the user. Tabs render in the order they're added. Pass `:badge` to attach a count (or any value the formatter understands) drawn inside the tab after the label.

### method set-badge

```raku
method set-badge(
    Str:D $name,
    $badge
) returns Nil
```

Attach a badge to the named tab, or clear it by passing `Nil`. No-op for an unknown name. The value is stored raw and run through `badge-formatter` at render time, so a store subscription can push `Int` counts straight in.

### method clear-badge

```raku
method clear-badge(
    Str:D $name
) returns Nil
```

Remove the named tab's badge. No-op for an unknown name.

### method badge

```raku
method badge(
    Str:D $name
) returns Mu
```

The raw (unformatted) badge value attached to the named tab, or `Nil` if it has none — or the name isn't registered.

### method set-active-style

```raku
method set-active-style(
    TabActiveStyle:D $style
) returns Nil
```

Switch the active-tab decoration. Marks dirty when it changes.

### method set-focus-indicator

```raku
method set-focus-indicator(
    TabFocusIndicator:D $indicator
) returns Nil
```

Switch the focus indicator. Marks dirty when it changes.

### method set-badge-prefix

```raku
method set-badge-prefix(
    Str:D $prefix
) returns Nil
```

Set the label/badge separator. Marks dirty when it changes.

### method set-badge-formatter

```raku
method set-badge-formatter(
    &formatter
) returns Nil
```

Replace the badge formatter. Pass an undefined `Callable` to fall back to `default-badge-formatter`.

### method remove-tab

```raku
method remove-tab(
    Str:D $name
) returns Mu
```

Remove a tab by name. If the removed tab was active, activation falls to the tab that was to its left (or index 0).

### method clear-tabs

```raku
method clear-tabs() returns Mu
```

Remove all tabs.

### method active-name

```raku
method active-name() returns Str
```

Tab name of the currently active tab, or `Nil` if the bar is empty.

### method active-index

```raku
method active-index() returns UInt
```

Index of the active tab.

### method tab-names

```raku
method tab-names() returns List
```

Tab names in order.

### method select-by-name

```raku
method select-by-name(
    Str:D $name
) returns Mu
```

Activate the tab with this name. No-op if the name isn't registered or already active. Emits `on-tab-selected`.

### method select-index

```raku
method select-index(
    Int $idx where { ... }
) returns Mu
```

Activate the tab at this index. No-op if already active or out of range.

### method set-active-name-silent

```raku
method set-active-name-silent(
    Str:D $name
) returns Mu
```

Silently set the active index (no `on-tab-selected` emit). Use from a store subscription that syncs the bar to external state — prevents feedback loops.

### method on-tab-selected

```raku
method on-tab-selected() returns Supply
```

Supply emitting the `name` of the newly-active tab whenever the user changes it (or a programmatic `select-by-name` fires).

### method sync-to-app

```raku
method sync-to-app(
    $app
) returns Mu
```

Install a store subscription that keeps this TabBar's active tab synced to `$app.screen-manager.active-screen`. Makes the bar self-consistent: if you call `$app.switch-screen(...)` elsewhere, the bar's highlight follows along.

### method set-focused

```raku
method set-focused(
    Bool $f
) returns Mu
```

Set the bar's focus state. Called by `Selkie::App`'s focus dispatcher; apps don't usually call this directly. Under `FocusPrefix` the `▶` chevron appears; under `FocusColor` the active tab takes the theme's `tab-focus-accent`.

### method is-focused

```raku
method is-focused() returns Bool
```

Whether the bar currently has keyboard focus.