NAME
====

Selkie::Widget::Table - Scrollable tabular data with columns, header, and sorting

SYNOPSIS
========

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

my $table = Selkie::Widget::Table.new(sizing => Sizing.flex);

$table.add-column(name => 'id',    label => 'ID',    sizing => Sizing.fixed(6));
$table.add-column(name => 'name',  label => 'Name',  sizing => Sizing.flex,     :sortable);
$table.add-column(name => 'size',  label => 'Size',  sizing => Sizing.fixed(10), :sortable);

$table.set-rows([
    { id => 1, name => 'alpha', size => 42_000 },
    { id => 2, name => 'beta',  size => 1_200_000 },
    { id => 3, name => 'gamma', size => 873 },
]);

$table.on-select.tap:   -> UInt $idx { show-detail($idx) };
$table.on-activate.tap: -> UInt $idx { open-row($idx) };
```

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

A two-section widget: a header row (column labels, plus sort indicators on sortable columns) and a scrollable body of data rows. Rows are supplied as hashes keyed by column `name`. Each cell is rendered as the string form of the hash value unless the column has a custom `render` callback.

Column widths follow the same sizing model as [Selkie::Layout::HBox](Selkie--Layout--HBox.md): fixed, percent, or flex, allocated in three passes.

Navigation
----------

Up / Down / PageUp / PageDown / Home / End move the row cursor; the cursor is always fully visible (the body auto-scrolls). Enter fires `on-activate`. Mouse-wheel scrolls without changing the cursor.

Sizing
------

The header always takes the first row, so a table shows `body-height` data rows at a time — its plane height less one. To show a fixed set without any scrolling, size it to the rows plus that header:

```raku
my @rows = load-rows();
my $table = Selkie::Widget::Table.new(sizing => Sizing.fixed(@rows + 1));
$table.set-rows(@rows);
```

Beyond that height the body scrolls and (unless you pass `:!show-scrollbar`) a one-column scrollbar appears on the right, taken out of the width the columns are allocated from.

Sorting
-------

Set `:sortable` on a column to enable sorting. Call `sort-by($name)` to cycle the given column through ascending → descending → unsorted. The UI shows `▲` / `▼` next to the active sort column's label.

By default, sort comparison uses `cmp` on the raw hash value. Pass `&sort-key` on the column for a custom comparator key (e.g. to sort by length, by a derived property, etc).

Per-row styling
---------------

Pass `row-style` at construction (or call `set-row-style` later) to color rows by their data — status columns, error highlighting, dimmed archive entries. The callback receives the row hash and returns a [Selkie::Style](Selkie--Style.md) to overlay, or `Nil` for the theme default. The override is merged over the row's base style with `Selkie::Style.merge`: override colors win, style flags OR. The cursor row keeps its highlight flags, so a red error row under the cursor stays red and gains the highlight's bold.

**The cursor row's background is the exception**: there, the highlight background (`theme.text-highlight`'s `bg`, or `theme.base`'s when the highlight slot has none) always wins, and a `row-style`'s own background is dropped. Foreground and flags still survive. Otherwise a row style that pins both colours — group headers, banner rows — renders byte-for-byte identically with and without the cursor, and the cursor silently disappears. In the rare case where even that comes out identical (a theme whose highlight background is the row's own background) the theme's `selection` pair takes over for that row. See `effective-row-style`, which is public precisely so this is assertable in tests without a terminal.

EXAMPLES
========

Styling rows by state
---------------------

Render failed jobs red and completed ones dimmed, leaving everything else on the theme default:

```raku
my $err = Selkie::Style.new(fg => 0xF38BA8, bold => True);
my $dim = Selkie::Style.new(fg => 0x6C7086);

$table.set-row-style(-> %row {
    given %row<state> {
        when 'error' { $err }
        when 'done'  { $dim }
        default      { Nil }
    }
});
```

Custom cell rendering
---------------------

Render a size column as a human-readable string while still sorting on the raw bytes:

```raku
sub human(Int $bytes) {
    given $bytes {
        when * < 1024          { "{$bytes} B" }
        when * < 1024 ** 2     { sprintf '%.1f KB', $bytes / 1024 }
        when * < 1024 ** 3     { sprintf '%.1f MB', $bytes / (1024 ** 2) }
        default                { sprintf '%.1f GB', $bytes / (1024 ** 3) }
    }
}

$table.add-column(
    name     => 'size',
    label    => 'Size',
    sizing   => Sizing.fixed(10),
    sortable => True,
    render   => -> $raw { human($raw) },    # display
    sort-key => -> $raw { $raw.Int },       # sort by number, not string
);
```

Store-driven rows
-----------------

Bind the table to a store path so filter/sort changes reflect automatically:

```raku
$app.store.subscribe-with-callback(
    'files-table',
    -> $s { ($s.get-in('files') // []).List },
    -> @rows { $table.set-rows(@rows) },
    $table,
);

$table.on-activate.tap: -> UInt $idx {
    my $row = $table.row-at($idx);
    $app.store.dispatch('file/open', id => $row<id>);
};
```

Keybind-driven sorting
----------------------

```raku
$table.on-key('s', -> $ {
    # Cycle sort column through the sortable ones
    my @sortable = $table.columns.grep(*<sortable>);
    my $current = $table.sort-column;
    my $idx = @sortable.first(*.<name> eq $current, :k) // -1;
    my $next = @sortable[($idx + 1) mod @sortable.elems];
    $table.sort-by($next<name>);
});
```

SEE ALSO
========

  * [Selkie::Widget::ListView](Selkie--Widget--ListView.md) — single-column scrollable list

  * [Selkie::Widget::CardList](Selkie--Widget--CardList.md) — variable-height rich-content list

  * [Selkie::Sizing](Selkie--Sizing.md) — the column-width sizing model

### has Callable &.row-style

Optional per-row style callback: `-` %row { Selkie::Style or Nil }>. Called once per visible row at render time with the row hash; a defined result is merged over the row's base style (cursor highlight or theme text) via `Selkie::Style.merge` — override colors win, style flags OR. Return `Nil` for theme defaults. On the cursor row the background does **not** transfer: the highlight background is authoritative there so the cursor cannot be styled out of existence. See `effective-row-style`.

### method columns

```raku
method columns() returns List
```

The list of registered columns in order. Read-only.

### method rows

```raku
method rows() returns List
```

The underlying row data. Read-only. Note for anyone editing this class: this accessor shadows `Selkie::Widget.rows`, which is the widget's plane **height**. Any code in here that wants the height must say `self.Selkie::Widget::rows` — `self.rows` is this list.

### method cursor

```raku
method cursor() returns UInt
```

Current cursor position within the (possibly sorted) view. `0`-based.

### method sort-column

```raku
method sort-column() returns Str
```

Name of the column currently sorted, or `Nil` if the rows are in insertion order.

### method sort-direction

```raku
method sort-direction() returns Str
```

`'asc'` or `'desc'`.

### method on-select

```raku
method on-select() returns Supply
```

Supply emitting the cursor row index whenever the cursor moves.

### method on-activate

```raku
method on-activate() returns Supply
```

Supply emitting the cursor row index when the user hits Enter.

### method set-row-style

```raku
method set-row-style(
    &cb
) returns Nil
```

Set or replace the per-row style callback (see `row-style`). Pass a `Callable` taking the row hash and returning a `Selkie::Style` to overlay, or `Nil` for theme defaults.

### method row-at

```raku
method row-at(
    Int $idx where { ... }
) returns Mu
```

Fetch the row (hash) at a position in the current view. Honors the current sort. Returns `Nil` if out of range.

### method selected-row

```raku
method selected-row() returns Mu
```

The row currently under the cursor, or `Nil` if the table is empty.

### method add-column

```raku
method add-column(
    Str:D :$name!,
    Str:D :$label!,
    Selkie::Sizing::Sizing :$sizing = Code.new,
    Bool :$sortable = Bool::False,
    :&render,
    :&sort-key
) returns Mu
```

Register a column. See the main pod for parameter meanings. `name` keys into row hashes. `label` is what appears in the header. `sizing` controls column width (Sizing.fixed, .percent, .flex). `sortable` enables sort-by for this column. `render` optional `($raw-value --` Str)> callback — without it the raw value is stringified. `sort-key` optional `($raw-value --` Cool)> callback — used to derive a sort key from the raw value.

### method clear-columns

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

Remove every column.

### method set-rows

```raku
method set-rows(
    @new-rows
) returns Mu
```

Replace the row set. Preserves the current sort (re-applies it to the new rows). Cursor is clamped to bounds. Emits `on-select` if the table is non-empty.

### method sort-by

```raku
method sort-by(
    Str:D $column-name,
    Str :$direction
) returns Mu
```

Sort the view by the given column name. Cycles through ascending → descending → unsorted on repeated calls for the same column. Set `:direction` explicitly to skip the cycle. No-op if the column doesn't exist or isn't `sortable`.

### method clear-sort

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

Clear any active sort and restore insertion order.

### method select-index

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

Move the cursor to row `$idx` (clamped to the last row in the current sort view). Emits on `on-row-selected` when the cursor actually moves; idempotent on no-ops. No-op when the table is empty.

### method body-height

```raku
method body-height() returns UInt
```

How many data rows fit below the header right now — the plane's height less the one row the header takes, and `0` before the table has a plane. Rows beyond it are reached by scrolling. $table.body-height; # 9 in a 10-row plane Public because it is the number a caller needs to decide how tall to make a table ("show all twelve without scrolling" is `Sizing.fixed(@rows + 1)`), and because it makes the geometry testable without standing up notcurses. Note the `self.Selkie::Widget::rows` below, and not `self.rows`: this class's own `rows` accessor is the row *data* and shadows the role's plane-height accessor. Reading it here made the body exactly one row shorter than the data no matter how tall the plane was — the last row was unreachable, the "does it overflow?" test was `elems ` elems - 1> (always true, so the scrollbar was always drawn and always stole a column from the last flex column), and PageUp/PageDown jumped by the row count rather than a screenful.

### method row-base

```raku
method row-base(
    Bool :$cursor = Bool::False
) returns Selkie::Style
```

The un-overridden style a row renders with: `theme.text` over `theme.base`'s background for a body row, `theme.text-highlight` (colours **and** flags) over `text-highlight`'s own background for the cursor row — falling back to the base background when the theme gives the highlight slot no background of its own. Non-cursor rows deliberately carry no style flags: only the foreground of `theme.text`, so a theme with a bold `text-highlight` doesn't embolden the whole body.

### method effective-row-style

```raku
method effective-row-style(
    %row,
    Bool :$is-cursor = Bool::False
) returns Selkie::Style
```

Resolve the final style for a row — the cursor row's included, so cursor visibility is decided here rather than in the renderer. A body row is `theme.text` over `theme.base`'s background with the `row-style` callback's result merged over it (override colours win, style flags OR), exactly as `Selkie::Style.merge` does. The **cursor row** resolves in three steps, in this order: =item Its background is the highlight background — `theme.text-highlight`'s own `bg`, or `theme.base`'s when the highlight slot defines none. This is authoritative: a `row-style` that sets a background loses it on the cursor row. Without that rule a row style that pins both colours renders identically with and without the cursor, and the cursor vanishes. =item The row style's **foreground and flags still win** — an error-red row under the cursor keeps its red foreground and additionally gains the highlight's flags. =item If the result would still render identically to the same row uncursored (a theme whose highlight background equals the row's own, say) or would be unreadable (foreground equal to background), the theme's `selection` background takes over as a last resort, and `selection`'s foreground with it when the row's own would vanish against it. `selection` is the palette's "this is the thing you are driving" pair, so escalating to it is the loudest thing a theme has already agreed to. A theme whose `selection` has no background of its own has nothing left to spend, and the row is left as step 2 produced it. Public so style resolution is testable without a live notcurses plane. The `row-style` callback is invoked exactly once per call, whatever the cursor state.

