Selkie.git | lib/Selkie/Widget/ | RadioGroup.rakumod
=begin pod
=head1 NAME
Selkie::Widget::RadioGroup - Focusable single-selection list
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::RadioGroup;
use Selkie::Sizing;
my $radio = Selkie::Widget::RadioGroup.new(sizing => Sizing.fixed(3));
$radio.set-items(<Small Medium Large>);
$radio.on-change.tap: -> UInt $idx {
say "Selected: {$radio.selected-label}";
};
=end code
=head1 DESCRIPTION
A vertical list with C<(●)>/C<( )> indicators showing which option is
selected. Cursor navigation (Up/Down) is decoupled from selection — the
user can browse without committing. C<Enter> or C<Space> commits the
cursor position as the new selection.
Across C<set-items> calls, selection is preserved by label when
possible: if the previously-selected label is still in the new list,
the selection follows it to its new index. Falls back to index clamp
otherwise.
Includes a scrollbar on the right edge if the item count exceeds the
viewport height.
=head1 EXAMPLES
=head2 Sync with store state
=begin code :lang<raku>
$app.store.subscribe-with-callback(
'sync-density',
-> $s { ($s.get-in('settings', 'density') // 0).Int },
-> Int $v { $radio.select-index($v) }, # no-op if unchanged — safe
$radio,
);
$radio.on-change.tap: -> $v {
$app.store.dispatch('settings/set', field => 'density', value => $v);
};
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::Select> — compact dropdown equivalent
=item L<Selkie::Widget::Checkbox> — boolean toggle
=item L<Selkie::Widget::ListView> — similar UI but for navigation, not 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::RadioGroup does Selkie::Widget does Selkie::Widget::FocusableByDefault;
has @!items;
has UInt $!cursor = 0;
has UInt $!selected = 0;
has UInt $!scroll-offset = 0;
has Bool $.show-scrollbar = True;
has Supplier $!change-supplier = Supplier.new;
submethod TWEAK() {
# Primary click on a row commits that row as the new selection
# — radio buttons short-circuit the cursor-then-Enter dance the
# keyboard path uses, since mouse intent is unambiguous. Clicks
# on the scrollbar column fall through (no scrollbar-thumb drag
# in v1 for RadioGroup; scroll-wheel covers the common case).
self.on-click: -> $ev {
my $row = self.local-row($ev);
if $row >= 0 {
my $idx = $!scroll-offset + $row;
self.select-index($idx) if @!items && $idx < @!items.elems;
}
};
}
#| The current option labels as a List.
method items(--> List) { @!items.List }
#| Index of the cursor (the row Up / Down has navigated to). The
#| cursor and the selection are tracked separately — moving the cursor
#| with arrow keys does not change the selection until the user
#| presses Enter or Space.
method cursor(--> UInt) { $!cursor }
#| Index of the currently-selected option. Stable across cursor
#| movement; only changes on commit (Enter / Space / mouse click).
method selected(--> UInt) { $!selected }
#| Label of the currently-selected option, or the C<Str> type object
#| if there are no items.
method selected-label(--> Str) { @!items[$!selected] // Str }
#| Supply that emits the new selected index whenever the selection
#| changes. Does not fire on cursor-only movement.
method on-change(--> Supply) { $!change-supplier.Supply }
#| Replace the option labels. Preserves the current selection by label
#| if it's still present in the new list (so a re-build of the same
#| options doesn't snap selection back to 0); otherwise clamps to the
#| new bounds. Does B<not> emit on C<on-change> — the selection is
#| considered unchanged from the user's perspective when the same label
#| is still selected. Mark-dirties only.
method set-items(@new-items) {
# Preserve the currently-selected option by label if it's still present
# in the new items. Falls back to clamp on index. Keeps the cursor on
# the same option across list rebuilds.
my Str $prev = @!items ?? (@!items[$!selected] // Str) !! Str;
@!items = @new-items;
if @!items.elems == 0 {
$!cursor = 0;
$!selected = 0;
$!scroll-offset = 0;
} else {
my $found = $prev.defined ?? @!items.first($prev, :k) !! Nil;
$!selected = $found // ($!selected min (@!items.elems - 1));
$!cursor = $!selected;
self!ensure-visible;
}
self.mark-dirty;
}
#| Commit a new selection. C<$idx> is clamped to the last item; the
#| cursor jumps to match. Emits on C<on-change> only when the selection
#| actually changed (idempotent on no-ops). No-op when the list is
#| empty.
method select-index(UInt $idx) {
return unless @!items;
my UInt $clamped = $idx min (@!items.elems - 1);
return if $clamped == $!selected;
$!selected = $clamped;
$!cursor = $clamped;
self!ensure-visible;
$!change-supplier.emit($!selected);
self.mark-dirty;
}
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 Bool $is-cursor = $idx == $!cursor;
my Bool $is-selected = $idx == $!selected;
my $indicator = $is-selected ?? '(●)' !! '( )';
my $text = "$indicator @!items[$idx]";
$text = $text.substr(0, $content-w) if $text.chars > $content-w;
$text = $text ~ (' ' x (($content-w - $text.chars) max 0));
if $is-cursor {
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);
}
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) {
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;
}
return True;
}
when NCKEY_DOWN {
if $!cursor < @!items.elems - 1 {
$!cursor++;
self!ensure-visible;
self.mark-dirty;
}
return True;
}
when NCKEY_PGUP {
my $jump = self.rows max 1;
$!cursor = $!cursor >= $jump ?? $!cursor - $jump !! 0;
self!ensure-visible;
self.mark-dirty;
return True;
}
when NCKEY_PGDOWN {
my $jump = self.rows max 1;
$!cursor = ($!cursor + $jump) min (@!items.elems - 1);
self!ensure-visible;
self.mark-dirty;
return True;
}
when NCKEY_HOME {
$!cursor = 0;
self!ensure-visible;
self.mark-dirty;
return True;
}
when NCKEY_END {
$!cursor = @!items.elems - 1;
self!ensure-visible;
self.mark-dirty;
return True;
}
when NCKEY_ENTER | NCKEY_SPACE {
if $!cursor != $!selected {
$!selected = $!cursor;
$!change-supplier.emit($!selected);
self.mark-dirty;
}
return True;
}
}
# Mouse: route scroll-wheel into cursor movement, click handlers
# registered in TWEAK pick up press events.
if $ev.event-type ~~ MouseEvent {
given $ev.id {
when NCKEY_SCROLL_UP {
if $!cursor > 0 { $!cursor--; self!ensure-visible; self.mark-dirty }
return True;
}
when NCKEY_SCROLL_DOWN {
if $!cursor < @!items.elems - 1 { $!cursor++; self!ensure-visible; self.mark-dirty }
return True;
}
}
return True if self!dispatch-mouse-handlers($ev);
}
False;
}