Selkie.git | lib/Selkie/Widget/ | TabBar.rakumod
=begin pod
=head1 NAME
Selkie::Widget::TabBar - Horizontal tab strip integrated with ScreenManager
=head1 SYNOPSIS
=begin code :lang<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);
};
=end code
=head1 DESCRIPTION
A one-line horizontal strip of named tabs. The active tab is
highlighted with the theme's C<text-highlight> slot; others render in
the default C<text> slot. Focusable — Left/Right arrows move the
active tab, C<Enter> fires C<on-tab-selected> (which you typically tap
to call C<$app.switch-screen>).
Tabs are identified by an opaque C<name> string and displayed as a
C<label>. The name is what's emitted on C<on-tab-selected> — choose
something that matches your registered screen names for a zero-effort
integration with C<Selkie::ScreenManager>.
C<TabBar> also has convenient integration with C<ScreenManager>: call
C<sync-to-app($app)> to make the active tab reflect C<$app.screen-manager.active-screen>
automatically via a store subscription.
Tabs can carry a B<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
(C<TabActiveStyle> / C<TabFocusIndicator>, exported by this module;
C<use Selkie> does not re-export them). All defaults are byte-for-byte
what TabBar has always drawn.
=head1 BADGES
A badge is any value; it's stored raw and stringified at render time by
C<badge-formatter>, so a store subscription can hand over C<Int> counts
without formatting them first:
=begin code :lang<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
=end code
The stock formatter is C<.Str>, except C<Int>s above 99, which clamp to
C<'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:
=begin code :lang<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) });
=end code
Badges count towards the tab's width, so mouse hit-testing follows them
automatically — see L<LAYOUT INTROSPECTION>.
=head1 ACTIVE-TAB STYLES
=item B<C<TabBrackets>> — the default. The active tab is wrapped in C<[ ]>: C<[ Inbox ]>.
=item B<C<TabUnderline>> — no brackets; the active tab is underlined instead (padding included, so the rule runs the full width of the tab).
=item B<C<TabPill>> — no brackets; the active tab is filled with the theme's C<tab-active> background.
Every style draws the same number of columns for the same tab —
C<"[ L ]"> and C<" L "> are both C<L.chars + 4> — so switching styles
never shifts the strip.
They differ in how an B<unfocused> bar marks its active tab. Brackets
and underline recede to the C<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.
=begin code :lang<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
=end code
=head1 FOCUS INDICATION
C<FocusPrefix> (the default) draws C<'▶ '> 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.
C<FocusColor> drops the prefix entirely (the strip starts at column 0)
and merges the theme's C<tab-focus-accent> slot onto the active tab
instead. That slot defaults to C<border-focused>, so a bar switched to
C<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
C<TabPill>: an accent carrying a C<bg> (C<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 I<in> the accent, give C<tab-focus-accent> a
background of its own:
=begin code :lang<raku>
tab-focus-accent => Selkie::Style.new(fg => 0x1A1A2E, bg => 0xBB99FF, bold => True),
=end code
=head1 LAYOUT INTROSPECTION
C<render> and mouse hit-testing share one width formula, exposed so you
can share it too: C<tab-display($i)> is the exact string tab C<$i>
draws, C<focus-prefix> is the offset before the first tab, and
C<tab-index-at-col($col)> maps a local column back to a tab index
(C<-1> for the prefix or past the last tab).
=begin code :lang<raku>
my $width = $tabs.focus-prefix.chars
+ (^$tabs.tab-names.elems).map({ $tabs.tab-display($_).chars }).sum;
=end code
=head1 EXAMPLES
=head2 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:
=begin code :lang<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);
=end code
=head2 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:
=begin code :lang<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);
};
=end code
=head2 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:
=begin code :lang<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 });
=end code
C<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.
=head1 SEE ALSO
=item L<Selkie::ScreenManager> — the multi-screen registry TabBar typically drives
=item L<Selkie::App> — screen-scoped keybinds complement per-tab views
=item L<Selkie::Theme> — the C<tab-active>, C<tab-inactive> and C<tab-focus-accent> slots
=end pod
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Widget::FocusableByDefault;
use Selkie::Style;
use Selkie::Event;
unit class Selkie::Widget::TabBar does Selkie::Widget does Selkie::Widget::FocusableByDefault;
#|( How the active tab is decorated.
C<TabBrackets> is the historical behaviour and the default: the
active tab is wrapped in C<[ ]>. C<TabUnderline> drops the brackets
and underlines the active tab instead. C<TabPill> drops them too and
leaves the tab filled with the theme's C<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: C<"[ L ]"> and
C<" L "> are both C<L.chars + 4>. )
enum TabActiveStyle is export (
TabBrackets => 'brackets',
TabUnderline => 'underline',
TabPill => 'pill',
);
#|( How a focused bar announces that it has the keyboard.
C<FocusPrefix> is the historical behaviour and the default: a C<▶ >
chevron in front of the strip (blank padding when unfocused, so the
tabs never move). C<FocusColor> drops the prefix entirely — the
strip starts at column 0 — and instead merges the theme's
C<tab-focus-accent> slot onto the active tab while the bar is
focused. )
enum TabFocusIndicator is export (
FocusPrefix => 'prefix',
FocusColor => 'color',
);
# Merged onto the active tab under TabUnderline. Style is immutable, so
# one instance is enough; merge ORs the flags, so whatever the theme's
# tab-active/tab-inactive slot carries survives.
my $UNDERLINE-OVERRIDE = Selkie::Style.new(underline => True);
# Stock badge formatter: stringify, except Ints above 99, which clamp to
# '99+' so a runaway count can't widen the tab (and with it every tab to
# its right) without bound.
my &DEFAULT-BADGE-FORMATTER = sub ($value --> Str) {
return '' without $value;
$value ~~ Int && $value > 99 ?? '99+' !! $value.Str;
};
has @!tabs; # Array of { name => Str, label => Str, badge => Any }
has UInt $!active-idx = 0;
has Bool $!focused = False;
has Supplier $!select-supplier = Supplier.new;
#|( How the active tab is decorated. Defaults to C<TabBrackets>, which
is byte-for-byte what TabBar has always drawn. Change it after
construction with C<set-active-style>. )
has TabActiveStyle $.active-style is rw = TabBrackets;
#|( How the bar shows that it holds focus. Defaults to C<FocusPrefix>,
the historical C<▶ > chevron. Change it after construction with
C<set-focus-indicator>. )
has TabFocusIndicator $.focus-indicator is rw = FocusPrefix;
#|( Separator inserted between a tab's label and its badge. Defaults to
a single space, giving C<[ Inbox 4 ]>. Set it to something like
C<' · '> for a heavier separator — it counts towards the tab's
width, so hit-testing follows automatically. )
has Str $.badge-prefix is rw = ' ';
#|( Callable turning a badge value into the text drawn inside the tab.
Receives the raw value passed to C<add-tab>/C<set-badge> and returns
a C<Str>; return the empty string to draw no badge at all. Defaults
to C<TabBar.default-badge-formatter>. )
has &.badge-formatter is rw = &DEFAULT-BADGE-FORMATTER;
#|( The stock badge formatter: C<.Str> for everything, except C<Int>s
above 99, which become C<'99+'>. Handy to restore after a custom
formatter, or to wrap one around it — see the L<BADGES> section. )
method default-badge-formatter(--> Callable:D) { &DEFAULT-BADGE-FORMATTER }
submethod TWEAK() {
# Primary mouse click activates the tab under the cursor (and
# re-emits if the click hits the already-active tab — same
# contract as Enter/Space on the active index).
self.on-click: -> $ev {
my $col = self.local-col($ev);
if $col >= 0 {
my $idx = self!tab-index-at-col($col);
if $idx >= 0 {
if $idx == $!active-idx {
$!select-supplier.emit(@!tabs[$idx]<name>) if @!tabs;
} else {
self!activate($idx);
}
}
}
};
}
# --- Layout core ----------------------------------------------------
#
# render() and hit-testing MUST agree on every column, or a click lands
# on the wrong tab. They agree because both go through exactly these two
# methods: !focus-prefix owns the leading offset, !tab-display owns each
# tab's cell — brackets, padding, badge and all. Nothing else may
# compute a tab width.
# The leading offset before the first tab. Two cells under FocusPrefix
# (chevron or blank padding — the same width either way, so the tabs
# don't move when focus changes), nothing at all under FocusColor.
method !focus-prefix(--> Str) {
return '' if $!focus-indicator === FocusColor;
$!focused ?? '▶ ' !! ' ';
}
# The badge fragment appended inside a tab's cell, separator included.
# Empty string when there's no badge, or when the formatter declines to
# render one — a bare separator would look like a rendering bug.
method !badge-text($raw --> Str) {
return '' without $raw;
my &fmt = &!badge-formatter // &DEFAULT-BADGE-FORMATTER;
my $text = (&fmt($raw) // '').Str;
$text.chars ?? $!badge-prefix ~ $text !! '';
}
# The exact string tab $i renders as. Brackets only under TabBrackets;
# the other styles pad with the same number of columns so the strip's
# geometry is style-independent.
method !tab-display(UInt $i --> Str) {
return '' unless $i < @!tabs.elems;
my %tab := @!tabs[$i];
my $cell = %tab<label> ~ self!badge-text(%tab<badge>);
$i == $!active-idx && $!active-style === TabBrackets
?? "[ $cell ]"
!! " $cell ";
}
# Map a local column to the tab index whose rendered span covers it.
# Returns -1 when the cursor is in the focus prefix or past the last
# tab.
method !tab-index-at-col(Int $col --> Int) {
return -1 unless @!tabs;
my $x = self!focus-prefix.chars;
return -1 if $col < $x;
for ^@!tabs.elems -> $i {
my $width = self!tab-display($i).chars;
return $i if $col < $x + $width;
$x += $width;
}
-1;
}
#|( The exact string tab C<$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 (C<sum> the displays plus
C<focus-prefix.chars> for its total width) and so tests can assert
that hit-testing and rendering agree. )
method tab-display(UInt $i --> Str) { self!tab-display($i) }
#|( The leading offset drawn before the first tab: C<'▶ '> / C<' '>
under C<FocusPrefix>, C<''> under C<FocusColor>. )
method focus-prefix(--> Str) { self!focus-prefix }
#|( The tab index covering local column C<$col>, or C<-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 tab-index-at-col(Int $col --> Int) { self!tab-index-at-col($col) }
#|( Register a tab. C<name> is the identifier (usually matches a
screen name); C<label> is what's shown to the user. Tabs render
in the order they're added.
Pass C<:badge> to attach a count (or any value the formatter
understands) drawn inside the tab after the label. )
method add-tab(Str:D :$name!, Str:D :$label!, :$badge) {
@!tabs.push({ :$name, :$label, :$badge });
self.mark-dirty;
}
#|( Attach a badge to the named tab, or clear it by passing C<Nil>.
No-op for an unknown name. The value is stored raw and run through
C<badge-formatter> at render time, so a store subscription can push
C<Int> counts straight in. )
method set-badge(Str:D $name, $badge --> Nil) {
my $idx = @!tabs.first(*<name> eq $name, :k);
return without $idx;
return if (@!tabs[$idx]<badge> // Nil) eqv ($badge // Nil);
@!tabs[$idx]<badge> = $badge;
self.mark-dirty;
Nil
}
#| Remove the named tab's badge. No-op for an unknown name.
method clear-badge(Str:D $name --> Nil) { self.set-badge($name, Nil) }
#|( The raw (unformatted) badge value attached to the named tab, or
C<Nil> if it has none — or the name isn't registered. )
method badge(Str:D $name) {
my $idx = @!tabs.first(*<name> eq $name, :k);
return Nil without $idx;
@!tabs[$idx]<badge> // Nil;
}
#| Switch the active-tab decoration. Marks dirty when it changes.
method set-active-style(TabActiveStyle:D $style --> Nil) {
return if $style === $!active-style;
$!active-style = $style;
self.mark-dirty;
Nil
}
#| Switch the focus indicator. Marks dirty when it changes.
method set-focus-indicator(TabFocusIndicator:D $indicator --> Nil) {
return if $indicator === $!focus-indicator;
$!focus-indicator = $indicator;
self.mark-dirty;
Nil
}
#| Set the label/badge separator. Marks dirty when it changes.
method set-badge-prefix(Str:D $prefix --> Nil) {
return if $prefix eq $!badge-prefix;
$!badge-prefix = $prefix;
self.mark-dirty;
Nil
}
#|( Replace the badge formatter. Pass an undefined C<Callable> to fall
back to C<default-badge-formatter>. )
method set-badge-formatter(&formatter --> Nil) {
&!badge-formatter = &formatter;
self.mark-dirty;
Nil
}
#| 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 remove-tab(Str:D $name) {
my $idx = @!tabs.first(*<name> eq $name, :k);
return without $idx;
@!tabs.splice($idx, 1);
$!active-idx = ($!active-idx min (@!tabs.elems - 1)) max 0 if @!tabs;
$!active-idx = 0 unless @!tabs;
self.mark-dirty;
}
#| Remove all tabs.
method clear-tabs() {
@!tabs = ();
$!active-idx = 0;
self.mark-dirty;
}
#| Tab name of the currently active tab, or C<Nil> if the bar is empty.
method active-name(--> Str) {
return Str unless @!tabs;
@!tabs[$!active-idx]<name>;
}
#| Index of the active tab.
method active-index(--> UInt) { $!active-idx }
#| Tab names in order.
method tab-names(--> List) { @!tabs.map(*<name>).List }
#|( Activate the tab with this name. No-op if the name isn't registered
or already active. Emits C<on-tab-selected>. )
method select-by-name(Str:D $name) {
my $idx = @!tabs.first(*<name> eq $name, :k);
return without $idx;
self!activate($idx);
}
#| Activate the tab at this index. No-op if already active or out of range.
method select-index(UInt $idx) {
return unless @!tabs && $idx < @!tabs.elems;
self!activate($idx);
}
method !activate(UInt $idx) {
return if $idx == $!active-idx;
$!active-idx = $idx;
self.mark-dirty;
$!select-supplier.emit(@!tabs[$idx]<name>);
}
#| Silently set the active index (no C<on-tab-selected> emit). Use from
#| a store subscription that syncs the bar to external state — prevents
#| feedback loops.
method set-active-name-silent(Str:D $name) {
my $idx = @!tabs.first(*<name> eq $name, :k);
return without $idx;
return if $idx == $!active-idx;
$!active-idx = $idx;
self.mark-dirty;
}
#| Supply emitting the C<name> of the newly-active tab whenever the
#| user changes it (or a programmatic C<select-by-name> fires).
method on-tab-selected(--> Supply) { $!select-supplier.Supply }
#|( Install a store subscription that keeps this TabBar's active tab
synced to C<$app.screen-manager.active-screen>. Makes the bar
self-consistent: if you call C<$app.switch-screen(...)> elsewhere,
the bar's highlight follows along. )
method sync-to-app($app) {
my $bar = self;
$bar.once-subscribe-computed("tabbar-sync-{self.WHICH}", -> $ {
$app.screen-manager.active-screen // ''
});
# subscribe-computed only marks dirty; we want a side effect, so
# also register a callback subscription:
$app.store.subscribe-with-callback(
"tabbar-sync-cb-{self.WHICH}",
-> $ { $app.screen-manager.active-screen // '' },
-> Str $name { $bar.set-active-name-silent($name) },
$bar,
);
}
#| Set the bar's focus state. Called by C<Selkie::App>'s focus
#| dispatcher; apps don't usually call this directly. Under
#| C<FocusPrefix> the C<▶> chevron appears; under C<FocusColor> the
#| active tab takes the theme's C<tab-focus-accent>.
method set-focused(Bool $f) {
$!focused = $f;
self.mark-dirty;
}
#| Whether the bar currently has keyboard focus.
method is-focused(--> Bool) { $!focused }
# The style tab $i paints with, given the theme's two tab slots.
#
# Under TabBrackets and TabUnderline an unfocused bar's active tab uses
# the inactive style so it visually recedes ("this bar isn't being
# driven right now") — the brackets, or the underline, still say WHICH
# tab is current. TabPill has no such glyph-level marker: its fill IS
# the marker, so it stays lit whether or not the bar is focused.
method !tab-style($i, Selkie::Style $active, Selkie::Style $inactive --> Selkie::Style) {
return $inactive unless $i == $!active-idx;
my $style = $!active-style === TabPill
?? $active
!! ($!focused ?? $active !! $inactive);
$style = $style.merge($UNDERLINE-OVERRIDE)
if $!active-style === TabUnderline;
# FocusColor spends colour, not columns, on the focus signal.
$style = $style.merge(self.theme.tab-focus-accent)
if $!focused && $!focus-indicator === FocusColor;
$style;
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
return unless @!tabs;
my $active-slot = self.theme.tab-active;
my $inactive-slot = self.theme.tab-inactive;
my UInt $x = 0;
my UInt $w = self.cols;
# Focus indicator: under FocusPrefix, prepend a chevron when the bar
# is the focused widget and blank padding otherwise. Lets users
# distinguish focused from unfocused TabBars on the same screen at a
# glance — critical when there are multiple TabBars (e.g. server
# options + character editor on stacked screens). FocusColor returns
# an empty prefix and signals focus on the active tab instead.
my $focus-prefix = self!focus-prefix;
if $focus-prefix.chars {
self.apply-style($!focused ?? $active-slot !! $inactive-slot);
if $x < $w {
my $prefix = $focus-prefix.substr(0, ($w - $x) min $focus-prefix.chars);
ncplane_putstr_yx(self.plane, 0, $x, $prefix);
$x += $prefix.chars;
}
}
for ^@!tabs.elems -> $i {
self.apply-style(self!tab-style($i, $active-slot, $inactive-slot));
my $display = self!tab-display($i);
last if $x >= $w;
my $fits = $w - $x;
$display = $display.substr(0, $fits) if $display.chars > $fits;
ncplane_putstr_yx(self.plane, 0, $x, $display);
$x += $display.chars;
}
self.clear-dirty;
}
method handle-event(Selkie::Event $ev --> Bool) {
if $ev.event-type ~~ MouseEvent {
return True if self!dispatch-mouse-handlers($ev);
return False;
}
return False unless $!focused;
if $ev.event-type ~~ KeyEvent {
given $ev.id {
when NCKEY_LEFT {
if $!active-idx > 0 {
self!activate($!active-idx - 1);
}
return True;
}
when NCKEY_RIGHT {
if $!active-idx + 1 < @!tabs.elems {
self!activate($!active-idx + 1);
}
return True;
}
when NCKEY_HOME {
self!activate(0) if @!tabs;
return True;
}
when NCKEY_END {
self!activate(@!tabs.elems - 1) if @!tabs;
return True;
}
when NCKEY_ENTER | NCKEY_SPACE {
# Re-emit even if unchanged — useful when tapping "activate"
# when the bar itself didn't move. Skipped if empty.
if @!tabs {
$!select-supplier.emit(@!tabs[$!active-idx]<name>);
}
return True;
}
}
}
self!check-keybinds($ev);
}