Selkie--Widget--TabBar

NAME

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

SYNOPSIS

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:

$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 Ints 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:

$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.

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:

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

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:

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:

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:

$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

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

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

The stock badge formatter: .Str for everything, except Ints 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

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

method focus-prefix() returns Str

The leading offset drawn before the first tab: 'โ–ถ ' / ' ' under FocusPrefix, '' under FocusColor.

method tab-index-at-col

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

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

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

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

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

method badge

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

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

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

method set-focus-indicator

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

Switch the focus indicator. Marks dirty when it changes.

method set-badge-prefix

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

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

method set-badge-formatter

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

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

method clear-tabs() returns Mu

Remove all tabs.

method active-name

method active-name() returns Str

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

method active-index

method active-index() returns UInt

Index of the active tab.

method tab-names

method tab-names() returns List

Tab names in order.

method select-by-name

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

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

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

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

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

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

method is-focused() returns Bool

Whether the bar currently has keyboard focus.

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.