Selkie.git | lib/Selkie/Widget/ | Select.rakumod
=begin pod
=head1 NAME
Selkie::Widget::Select - Compact dropdown picker
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::Select;
use Selkie::Sizing;
my $select = Selkie::Widget::Select.new(
sizing => Sizing.fixed(1),
placeholder => 'Choose a model',
max-visible => 8,
);
$select.set-items(<gpt-4 claude-opus local-model>);
$select.on-change.tap: -> UInt $idx {
say $select.selected-value;
};
=end code
=head1 DESCRIPTION
A single-line control showing the currently selected value with a
C<▼> marker. C<Enter> or C<Space> opens a dropdown list as a child
plane rendered on top of the surrounding layout. The dropdown opens
below the control when it fits, flips above when bottom-screen space is
tighter, and caps its visible rows to the terminal so long lists remain
scrollable. User activation is debounced by default so duplicate
terminal press events from one physical click do not open and
immediately close the menu. Esc cancels; Enter commits the highlighted
option.
While open, the Select acts as a local focus trap — arrow keys and
Enter navigate the dropdown, not the surrounding app. Losing focus
auto-closes the dropdown.
Use C<RadioGroup> instead when you want the options always visible;
use C<Select> when you want compact real estate.
=head1 EXAMPLES
=head2 Inside a form
=begin code :lang<raku>
my $theme-select = Selkie::Widget::Select.new(
sizing => Sizing.fixed(1),
);
$theme-select.set-items(<Auto Light Dark>);
$app.store.subscribe-with-callback(
'sync-theme-select',
-> $s { ($s.get-in('settings', 'theme') // 0).Int },
-> Int $v { $theme-select.select-index($v) },
$theme-select,
);
$theme-select.on-change.tap: -> $v {
$app.store.dispatch('settings/set', field => 'theme', value => $v);
};
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::RadioGroup> — always-visible equivalent
=item L<Selkie::Widget::ListView> — full-height scrollable list
=end pod
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Notcurses::Native::Channel;
use Selkie::Widget;
use Selkie::Widget::FocusableByDefault;
use Selkie::Style;
use Selkie::Event;
use Selkie::EffectiveBounds;
unit class Selkie::Widget::Select does Selkie::Widget does Selkie::Widget::FocusableByDefault;
has @!items;
has UInt $!selected = 0;
has UInt $!cursor = 0;
has UInt $!scroll-offset = 0;
has Str $.placeholder is rw = '';
has UInt $.max-visible = 8;
has UInt $.debounce-ms = 120;
has Bool $!open = False;
has Bool $!focused = False;
has NcplaneHandle $!dropdown-plane;
has Supplier $!change-supplier = Supplier.new;
has Instant $!last-mouse-toggle;
has Instant $!last-mouse-commit;
has Instant $!last-key-activation;
submethod TWEAK() {
# Click on the closed-display row (y == abs-y) toggles the dropdown.
# Click on a dropdown row when open commits that item.
# Clicks elsewhere close the dropdown — the contains-point override
# extends our hit-test rect to cover the dropdown so they reach us.
self.on-click: -> $ev {
my $row = $ev.y - self.abs-y;
my $col = $ev.x - self.abs-x;
if $col >= 0 {
if $row == 0 {
self!toggle-dropdown-from-mouse;
} elsif $!open {
my $dropdown-row = self!dropdown-row-at($ev.y);
if $dropdown-row.defined {
my $idx = $!scroll-offset + $dropdown-row.UInt;
if @!items && $idx < @!items.elems {
self!commit-index-from-mouse($idx.UInt);
}
} else {
self!close-dropdown;
}
} else {
self!close-dropdown;
}
} elsif $!open {
self!close-dropdown;
}
};
}
method !dropdown-row-at(Int $screen-y --> Int) {
my %layout = self!dropdown-layout;
my UInt $h = %layout<height>.UInt;
return Int unless $h;
my Int $top = %layout<abs-y>.Int;
my Int $row = $screen-y - $top;
($row >= 0 && $row < $h) ?? $row !! Int;
}
method !allow-user-activation(Instant $last --> Bool) {
return True unless $!debounce-ms > 0 && $last.defined;
(now - $last) * 1000 >= $!debounce-ms;
}
method !toggle-dropdown-from-mouse(--> Nil) {
return unless self!allow-user-activation($!last-mouse-toggle);
$!last-mouse-toggle = now;
$!open ?? self!close-dropdown !! self.open;
}
method !commit-index-from-mouse(UInt:D $idx --> Nil) {
return unless self!allow-user-activation($!last-mouse-commit);
$!last-mouse-commit = now;
self!commit-index($idx);
}
method !open-from-key(--> Nil) {
return unless self!allow-user-activation($!last-key-activation);
self.open;
$!last-key-activation = now;
}
method !commit-cursor-from-key(--> Nil) {
return unless self!allow-user-activation($!last-key-activation);
$!last-key-activation = now;
self!commit-index($!cursor);
}
method !commit-index(UInt:D $idx --> Nil) {
return unless @!items && $idx < @!items.elems;
$!cursor = $idx;
if $idx != $!selected {
$!selected = $idx;
$!change-supplier.emit($!selected);
}
self!close-dropdown;
}
method !dropdown-layout(--> Hash) {
my UInt $wanted = self!preferred-dropdown-height;
return %(height => 0, y-offset => 1, abs-y => self.abs-y + 1, opens-up => False)
unless $wanted;
my %viewport = self!dropdown-viewport;
my Int $below = (%viewport<bottom> - (self.abs-y + 1)) max 0;
my Int $above = (self.abs-y - %viewport<top>) max 0;
my Bool $opens-up = $below < $wanted && $above > $below;
my Int $space = $opens-up ?? $above !! $below;
my UInt $height = ($wanted min ($space max 0)).UInt;
my Int $y-offset = $opens-up ?? -$height.Int !! 1;
%(height => $height, y-offset => $y-offset,
abs-y => self.abs-y + $y-offset, opens-up => $opens-up);
}
method !dropdown-viewport(--> Hash) {
my ($vp-rows, $vp-cols) = terminal-viewport();
# Select's menu is an overlay plane. Ordinary layout containers do
# not clip child planes in notcurses, so bounding the menu to every
# ancestor makes selects inside one-row/two-row form cells render a
# one-line "dropdown" (often only the blank sentinel option). Keep
# the overlay within the screen; let it escape local layout cells.
%(
top => 0,
left => 0,
bottom => $vp-rows.Int max 0,
right => $vp-cols.Int max 0,
);
}
method !preferred-dropdown-height(--> UInt) {
return 0 unless @!items.elems;
my UInt $limit = $!max-visible max 1;
(@!items.elems min $limit).UInt;
}
method !dropdown-height(--> UInt) {
self!dropdown-layout<height>.UInt;
}
method !ensure-cursor-visible() {
my UInt $vh = self!dropdown-height;
return unless $vh;
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!dropdown-height;
return 0 unless $vh;
@!items.elems > $vh ?? @!items.elems - $vh !! 0;
}
#|( When the dropdown is open, claim overlay rights for the
dropdown rows. The framework's C<widget-at-in> does an overlay
pass against the entire tree before normal containment walk, so
clicks on the dropdown reach the Select even though the parent
layout's bounds end at our closed-display row.
The closed-display row itself stays under standard
contains-point — when the dropdown isn't open, we behave like
any other 1-row widget. )
method claims-overlay-at(Int $y, Int $x --> Bool) {
return False unless $!open;
my %layout = self!dropdown-layout;
my UInt $h = %layout<height>.UInt;
my $w = self.viewport-cols || self.cols;
return False if $h <= 0 || $w <= 0;
my Int $top = %layout<abs-y>.Int;
$y >= $top && $y < $top + $h
&& $x >= self.abs-x && $x < self.abs-x + $w;
}
#| The current option labels as a List.
method items(--> List) { @!items.List }
#| Index of the committed selection.
method selected(--> UInt) { $!selected }
#| Label of the committed selection, or the C<Str> type object when
#| no items are set.
method selected-value(--> Str) { @!items[$!selected] // Str }
#| Whether the dropdown is currently open.
method is-open(--> Bool) { $!open }
#| Supply that emits the new selected index whenever the selection
#| changes (Enter / Space / mouse pick / C<select-index> /
#| C<select-by-value>). Cursor-only movement inside the open dropdown
#| does not emit until the user commits.
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. Closes the dropdown if it was open. Mark-dirties only
#| — does not emit on C<on-change>.
method set-items(@new-items) {
# Preserve the currently-selected value by label if it's still present.
# Otherwise clamp to bounds.
my Str $prev = @!items ?? (@!items[$!selected] // Str) !! Str;
@!items = @new-items;
$!scroll-offset = 0;
self!close-dropdown;
if @!items.elems == 0 {
$!selected = 0;
$!cursor = 0;
} else {
my $found = $prev.defined ?? @!items.first($prev, :k) !! Nil;
$!selected = $found // ($!selected min (@!items.elems - 1));
$!cursor = $!selected;
}
self.mark-dirty;
}
#| Commit the option at C<$idx> as the new selection (clamped to the
#| last item). Emits on C<on-change> only when the selection actually
#| changes (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;
$!change-supplier.emit($!selected);
self.mark-dirty;
}
#|( Programmatically select the entry matching C<$value> (string
equality on the items list). No-op when the value isn't present
or when it's already selected, so callers don't have to guard
against absent items themselves. Fires C<on-change> only when the
selection actually moves. )
method select-by-value(Str:D $value) {
my $idx = @!items.first($value, :k);
return without $idx;
self.select-index($idx.UInt);
}
#| Set the input's focus state. Losing focus auto-closes any open
#| dropdown — Select is a local focus trap while open, so leaving
#| focus shouldn't strand the dropdown on screen.
method set-focused(Bool $f) {
$!focused = $f;
self!close-dropdown unless $f;
self.mark-dirty;
}
#| Whether the widget currently has focus.
method is-focused(--> Bool) { $!focused }
#| Open the dropdown. No-op when already open or when there are no
#| items. Resets the dropdown cursor to the committed selection so
#| the highlight starts there. Does not emit on C<on-change>.
method open() {
return unless @!items;
return if $!open;
$!open = True;
$!last-key-activation = Instant;
$!cursor = $!selected;
$!scroll-offset = 0;
self!ensure-cursor-visible;
self.mark-dirty;
}
#| Close the dropdown without committing the cursor. Used by Esc and
#| by C<set-focused(False)>; programmatic callers that want to commit
#| should call C<select-index> first.
method close() {
self!close-dropdown;
}
method !close-dropdown() {
return unless $!open;
$!open = False;
if $!dropdown-plane {
ncplane_destroy($!dropdown-plane);
$!dropdown-plane = NcplaneHandle;
}
self.mark-dirty;
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
my UInt $w = self.cols;
# Render the closed display (always visible)
my $display = @!items ?? @!items[$!selected] // $!placeholder !! $!placeholder;
my $arrow = $!open ?? '▲' !! '▼';
my $label = "$arrow $display";
$label = $label.substr(0, $w) if $label.chars > $w;
$label = $label ~ (' ' x (($w - $label.chars) max 0));
my $style = $!focused ?? self.theme.input-focused !! self.theme.input;
self.apply-style($style);
ncplane_putstr_yx(self.plane, 0, 0, $label);
# Render dropdown if open
self!render-dropdown if $!open;
self.clear-dirty;
}
method !render-dropdown() {
return without self.plane;
return unless @!items;
my %layout = self!dropdown-layout;
my UInt $dh = %layout<height>.UInt;
unless $dh {
if $!dropdown-plane {
ncplane_destroy($!dropdown-plane);
$!dropdown-plane = NcplaneHandle;
}
return;
}
my Int $dy = %layout<y-offset>.Int;
my UInt $dw = self.cols;
# Create or resize dropdown plane. It is usually below the select
# row (y=1), but moves above with a negative y offset when bottom
# terminal space is tighter.
if $!dropdown-plane {
ncplane_move_yx($!dropdown-plane, $dy, 0);
ncplane_resize_simple($!dropdown-plane, $dh, $dw);
} else {
my $opts = NcplaneOptions.new(y => $dy, x => 0, rows => $dh, cols => $dw);
$!dropdown-plane = ncplane_create(self.plane, $opts);
}
return without $!dropdown-plane;
self.raise-active-overlay;
ncplane_erase($!dropdown-plane);
# Ensure scroll bounds
my UInt $max = self!max-offset;
$!scroll-offset = $max if $!scroll-offset > $max;
# Theme the dropdown plane's base cell so unwritten regions
# (right-edge padding past the longest item, etc.) carry the
# dropdown surface rather than the terminal default. The plane was
# raw-created via ncplane_create above and so misses the
# init-plane → !sync-plane-base hook every other widget gets.
my $surface = self.theme.base.merge(self.theme.dropdown);
if $surface.bg.defined || $surface.fg.defined {
my uint64 $base-channels = 0;
ncchannels_set_fg_rgb($base-channels, $surface.fg) if $surface.fg.defined;
ncchannels_set_bg_rgb($base-channels, $surface.bg) if $surface.bg.defined;
ncplane_set_base($!dropdown-plane, ' ', 0, $base-channels);
}
my $normal = $surface;
my $highlight = $surface.merge(self.theme.dropdown-highlight);
for ^$dh -> $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 $marker = $is-selected ?? '●' !! ' ';
my $text = "$marker @!items[$idx]";
$text = $text.substr(0, $dw) if $text.chars > $dw;
$text = $text ~ (' ' x (($dw - $text.chars) max 0));
if $is-cursor {
ncplane_set_styles($!dropdown-plane, $highlight.styles);
ncplane_set_fg_rgb($!dropdown-plane, $highlight.fg) if $highlight.fg.defined;
ncplane_set_bg_rgb($!dropdown-plane, $highlight.bg) if $highlight.bg.defined;
} else {
ncplane_set_styles($!dropdown-plane, $normal.styles);
ncplane_set_fg_rgb($!dropdown-plane, $normal.fg) if $normal.fg.defined;
ncplane_set_bg_rgb($!dropdown-plane, $normal.bg) if $normal.bg.defined;
}
ncplane_putstr_yx($!dropdown-plane, $row, 0, $text);
}
}
#| Raise the open dropdown above later sibling/content planes. Select's
#| dropdown intentionally escapes ordinary layout cells, so the app
#| render loop calls this after the normal widget tree has rendered.
method raise-active-overlay() {
return unless $!open && $!dropdown-plane;
ncplane_move_top($!dropdown-plane);
}
method handle-event(Selkie::Event $ev --> Bool) {
# Mouse routes through the registration API regardless of current
# focus — App's click-to-focus has already promoted us on press,
# and clicks-while-open need to reach the dropdown handler even
# if focus is being recomputed mid-event.
if $ev.event-type ~~ MouseEvent {
return True if self!dispatch-mouse-handlers($ev);
# Scroll wheel + any other mouse event falls through to the
# focus-gated branch below.
}
return False unless $!focused;
if $ev.event-type ~~ KeyEvent {
if $!open {
return self!handle-open-event($ev);
} else {
return self!handle-closed-event($ev);
}
}
if $ev.event-type ~~ MouseEvent {
if $!open {
given $ev.id {
when NCKEY_SCROLL_UP {
if $!cursor > 0 { $!cursor--; self!ensure-cursor-visible; self.mark-dirty }
return True;
}
when NCKEY_SCROLL_DOWN {
if $!cursor < @!items.elems - 1 { $!cursor++; self!ensure-cursor-visible; self.mark-dirty }
return True;
}
}
}
return True if self!dispatch-mouse-handlers($ev);
}
self!check-keybinds($ev);
}
method !handle-closed-event(Selkie::Event $ev --> Bool) {
given $ev.id {
when NCKEY_ENTER | NCKEY_SPACE {
self!open-from-key;
return True;
}
}
self!check-keybinds($ev);
}
method !handle-open-event(Selkie::Event $ev --> Bool) {
given $ev.id {
when NCKEY_UP {
if $!cursor > 0 { $!cursor--; self!ensure-cursor-visible; self.mark-dirty }
return True;
}
when NCKEY_DOWN {
if $!cursor < @!items.elems - 1 { $!cursor++; self!ensure-cursor-visible; self.mark-dirty }
return True;
}
when NCKEY_PGUP {
my $jump = self!dropdown-height max 1;
$!cursor = $!cursor >= $jump ?? $!cursor - $jump !! 0;
self!ensure-cursor-visible;
self.mark-dirty;
return True;
}
when NCKEY_PGDOWN {
my $jump = self!dropdown-height max 1;
$!cursor = ($!cursor + $jump) min (@!items.elems - 1);
self!ensure-cursor-visible;
self.mark-dirty;
return True;
}
when NCKEY_HOME {
$!cursor = 0;
self!ensure-cursor-visible;
self.mark-dirty;
return True;
}
when NCKEY_END {
$!cursor = @!items.elems - 1;
self!ensure-cursor-visible;
self.mark-dirty;
return True;
}
when NCKEY_ENTER | NCKEY_SPACE {
self!commit-cursor-from-key;
return True;
}
when NCKEY_ESC {
self!close-dropdown;
return True;
}
}
# When open, consume all key events to act as focus trap
return True;
}
#| Tear down the dropdown plane and the widget's own plane. Called on
#| app shutdown.
method destroy() {
if $!dropdown-plane {
ncplane_destroy($!dropdown-plane);
$!dropdown-plane = NcplaneHandle;
}
self!destroy-plane;
}