Selkie.git | lib/Selkie/Widget/ | ListView.rakumod
=begin pod
=head1 NAME
Selkie::Widget::ListView - Scrollable single-select list of strings
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::ListView;
use Selkie::Sizing;
my $list = Selkie::Widget::ListView.new(sizing => Sizing.flex);
$list.set-items(<Alpha Beta Gamma Delta>);
$list.on-select.tap: -> $name { say "cursor on: $name" };
$list.on-activate.tap: -> $name { say "selected: $name" };
$list.on-key('d', -> $ { delete-item });
=end code
=head1 DESCRIPTION
A vertical list of string entries with a cursor. Arrow keys (and
PageUp/PageDown/Home/End/mouse wheel) move the cursor; C<Enter>
activates. The selected item is always fully visible; the list
auto-scrolls as the cursor moves.
Two Supplies:
=item C<on-select> — fires whenever the cursor moves. Use for "show details of highlighted"
=item C<on-activate> — fires when the user presses Enter. Use for "open this item"
Across C<set-items> calls, cursor position is preserved by label when
possible. If the previously-selected string is still in the new list,
the cursor follows it. Otherwise the cursor index is clamped to
bounds. Only resets to 0 when the list becomes empty.
Includes a scrollbar on the right edge when items exceed the viewport.
=head1 EXAMPLES
=head2 Store-driven list
=begin code :lang<raku>
$app.store.subscribe-with-callback(
'file-list',
-> $s { ($s.get-in('files') // []).map(*<name>).List },
-> @items { $list.set-items(@items) }, # cursor preserved by value
$list,
);
$list.on-activate.tap: -> $name {
$app.store.dispatch('files/open', :$name);
};
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::CardList> — same pattern for variable-height cards
=item L<Selkie::Widget::RadioGroup> — similar UI but for one-of-many selection
=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::ListView does Selkie::Widget does Selkie::Widget::FocusableByDefault;
has @!items;
has UInt $!cursor = 0;
has UInt $!scroll-offset = 0;
has Bool $.show-scrollbar = True;
has Supplier $!select-supplier = Supplier.new;
has Supplier $!activate-supplier = Supplier.new;
submethod TWEAK() {
# Single-click moves the cursor to the row under the pointer (same
# semantics as Up/Down — selection only). Double-click activates,
# matching the on-activate keyboard path (Enter). Clicks on the
# scrollbar column fall through; scroll-wheel covers the common
# navigation case for v1.
self.on-click: -> $ev {
my $row = self.local-row($ev);
my $col = self.local-col($ev);
if $row >= 0 && $col >= 0 {
my UInt $vw = self.cols;
my Bool $need-scrollbar = $!show-scrollbar && @!items.elems > self.rows;
my UInt $content-w = $need-scrollbar ?? $vw - 1 !! $vw;
if $col < $content-w {
my $idx = $!scroll-offset + $row;
if @!items && $idx < @!items.elems {
if $idx != $!cursor {
$!cursor = $idx;
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
}
$!activate-supplier.emit(self.selected) if $ev.click-count >= 2;
}
}
}
};
}
#| The current items as a List.
method items(--> List) { @!items.List }
#| Index of the cursor (the highlighted row). Always 0 when the list
#| is empty.
method cursor(--> UInt) { $!cursor }
#| The string at the cursor, or the C<Str> type object when empty.
method selected(--> Str) { @!items[$!cursor] // Str }
#| Supply that emits the selected string whenever the cursor moves
#| (Up / Down / mouse click / C<select-index>). Fires once on
#| C<set-items> if the new list is non-empty.
method on-select(--> Supply) { $!select-supplier.Supply }
#| Supply that emits the selected string when the user activates a
#| row (Enter, Space, double-click). C<on-select> fires for cursor
#| movement; C<on-activate> only fires for explicit activation.
method on-activate(--> Supply) { $!activate-supplier.Supply }
#| Replace the items. The cursor tracks the previously-selected string
#| if it's still present in the new list (so a list refresh doesn't
#| jump the user back to row 0); otherwise clamps to the new bounds.
#| Emits on C<on-select> when the resulting list is non-empty.
method set-items(@new-items) {
# Preserve the cursor's relative position when possible. If the
# previously-selected string is still in the new list, move the cursor
# to its new index. Otherwise clamp to bounds. Avoids the surprising
# "cursor jumps back to 0 every time the list is rebuilt" behaviour.
my Str $prev-selected = @!items ?? (@!items[$!cursor] // Str) !! Str;
@!items = @new-items;
if @!items.elems == 0 {
$!cursor = 0;
$!scroll-offset = 0;
} elsif $prev-selected.defined {
my $found = @!items.first($prev-selected, :k);
$!cursor = $found // ($!cursor min (@!items.elems - 1));
self!ensure-visible;
} else {
$!cursor = $!cursor min (@!items.elems - 1);
self!ensure-visible;
}
self.mark-dirty;
$!select-supplier.emit(self.selected) if @!items;
}
#| Move the cursor to C<$idx> (clamped to the last item) and emit on
#| C<on-select>. No-op when the list is empty.
method select-index(UInt $idx) {
return unless @!items;
$!cursor = $idx min (@!items.elems - 1);
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
}
method !ensure-visible() {
my UInt $vh = self.rows max 1;
if $!cursor < $!scroll-offset {
$!scroll-offset = $!cursor;
} elsif $!cursor >= $!scroll-offset + $vh {
$!scroll-offset = $!cursor - $vh + 1;
}
}
method !max-offset(--> UInt) {
my UInt $vh = self.rows;
@!items.elems > $vh ?? @!items.elems - $vh !! 0;
}
method render() {
return without self.plane;
my UInt $max = self!max-offset;
$!scroll-offset = $max if $!scroll-offset > $max;
ncplane_erase(self.plane);
my UInt $vh = self.rows;
my UInt $vw = self.cols;
my Bool $need-scrollbar = $!show-scrollbar && @!items.elems > $vh;
my UInt $content-w = $need-scrollbar ?? $vw - 1 !! $vw;
my $normal = self.theme.text;
my $highlight = self.theme.text-highlight;
my $base = self.theme.base;
my UInt $visible = $vh min @!items.elems;
for ^$visible -> $row {
my UInt $idx = $!scroll-offset + $row;
last if $idx >= @!items.elems;
my $is-selected = $idx == $!cursor;
my $style = $is-selected ?? $highlight !! $normal;
# Selected line gets highlight bg, others get base bg
if $is-selected {
ncplane_set_fg_rgb(self.plane, $highlight.fg) if $highlight.fg.defined;
ncplane_set_bg_rgb(self.plane, $base.bg) if $base.bg.defined;
ncplane_set_styles(self.plane, $highlight.styles);
} else {
ncplane_set_fg_rgb(self.plane, $normal.fg) if $normal.fg.defined;
ncplane_set_bg_rgb(self.plane, $base.bg) if $base.bg.defined;
ncplane_set_styles(self.plane, 0);
}
my $text = @!items[$idx] // '';
$text = $text.substr(0, $content-w) if $text.chars > $content-w;
# Pad to full width for consistent background
$text = $text ~ (' ' x ($content-w - $text.chars)) if $text.chars < $content-w;
ncplane_putstr_yx(self.plane, $row, 0, $text);
}
self!render-scrollbar if $need-scrollbar;
self.clear-dirty;
}
method !render-scrollbar() {
my UInt $vh = self.rows;
my UInt $sx = self.cols - 1;
my $max = self!max-offset;
return unless $max > 0;
my $track-style = self.theme.scrollbar-track;
my $thumb-style = self.theme.scrollbar-thumb;
my Rat $thumb-ratio = $vh / @!items.elems;
my UInt $thumb-h = ($vh * $thumb-ratio).ceiling.UInt max 1;
my UInt $thumb-y = (($!scroll-offset / $max) * ($vh - $thumb-h)).floor.UInt;
for ^$vh -> $row {
if $row >= $thumb-y && $row < $thumb-y + $thumb-h {
ncplane_set_fg_rgb(self.plane, $thumb-style.fg) if $thumb-style.fg.defined;
ncplane_set_bg_rgb(self.plane, $thumb-style.bg) if $thumb-style.bg.defined;
ncplane_putstr_yx(self.plane, $row, $sx, '┃');
} else {
ncplane_set_fg_rgb(self.plane, $track-style.fg) if $track-style.fg.defined;
ncplane_set_bg_rgb(self.plane, $track-style.bg) if $track-style.bg.defined;
ncplane_putstr_yx(self.plane, $row, $sx, '│');
}
}
}
method handle-event(Selkie::Event $ev --> Bool) {
# Check widget keybinds first (e.g. 'a' for import) — works even with empty list
if $ev.event-type ~~ KeyEvent {
return True if self!check-keybinds($ev);
}
return False unless @!items;
given $ev.id {
when NCKEY_UP {
if $!cursor > 0 {
$!cursor--;
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
}
return True;
}
when NCKEY_DOWN {
if $!cursor < @!items.elems - 1 {
$!cursor++;
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
}
return True;
}
when NCKEY_PGUP {
my $jump = self.rows max 1;
$!cursor = $!cursor >= $jump ?? $!cursor - $jump !! 0;
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
return True;
}
when NCKEY_PGDOWN {
my $jump = self.rows max 1;
$!cursor = ($!cursor + $jump) min (@!items.elems - 1);
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
return True;
}
when NCKEY_HOME {
$!cursor = 0;
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
return True;
}
when NCKEY_END {
$!cursor = @!items.elems - 1;
self!ensure-visible;
self.mark-dirty;
$!select-supplier.emit(self.selected);
return True;
}
when NCKEY_ENTER {
$!activate-supplier.emit(self.selected);
return True;
}
}
# Mouse: scroll-wheel moves the cursor; click handlers registered
# in TWEAK select / activate by row.
if $ev.event-type ~~ MouseEvent {
given $ev.id {
when NCKEY_SCROLL_UP {
if $!cursor > 0 { $!cursor--; self!ensure-visible; self.mark-dirty; $!select-supplier.emit(self.selected) }
return True;
}
when NCKEY_SCROLL_DOWN {
if $!cursor < @!items.elems - 1 { $!cursor++; self!ensure-visible; self.mark-dirty; $!select-supplier.emit(self.selected) }
return True;
}
}
return True if self!dispatch-mouse-handlers($ev);
}
False;
}