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