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