Selkie.git | lib/Selkie/Widget/ | Checkbox.rakumod
=begin pod
=head1 NAME
Selkie::Widget::Checkbox - Focusable boolean toggle
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::Checkbox;
use Selkie::Sizing;
my $cb = Selkie::Widget::Checkbox.new(
label => 'Enable notifications',
sizing => Sizing.fixed(1),
);
$cb.on-change.tap: -> Bool $checked {
$app.store.dispatch('settings/notifications', value => $checked);
};
=end code
=head1 DESCRIPTION
Renders as C<[x] label> when checked, C<[ ] label> when unchecked.
Space or Enter toggles the state, as does a primary mouse click on
any cell of the checkbox row. User activation is debounced by default
so duplicate terminal press events from one physical click do not flip
the checkbox twice.
C<set-checked> is idempotent — passing the current value is a no-op and
doesn't emit on C<on-change>. Safe to call from a store subscription
without causing feedback loops.
=head1 GLYPHS
The C<[x]>/C<[ ]> indicator is configurable via C<checked-glyph> and
C<unchecked-glyph> (constructor args, both C<is rw>) or the
C<set-glyphs> method. Defaults are byte-identical to the historical
hardcoded pair, so existing code renders exactly as before.
B<Keep both glyphs the same C<.chars> length.> The indicator is
rendered directly before the label with no fixed-width padding
between them — if C<checked-glyph> and C<unchecked-glyph> differ in
length, the label visibly shifts left or right every time the
checkbox toggles. Mismatched widths aren't rejected (a narrower
"off" glyph paired with a wrapped/bracketed "on" glyph is a
legitimate design choice), but the jitter is the tradeoff.
B<Avoid C<☑>/C<☐> (U+2611/U+2610).> These render as ambiguous-width
in many terminal/font combinations — some environments draw them as
a single column, others as two, and the mismatch between the two
glyphs compounds the jitter problem above. That ambiguity is why the
ASCII C<[x]>/C<[ ]> pair remains the default rather than a Unicode
checkbox glyph.
Tested alternatives that render consistently:
=item C<[x]> / C<[ ]> — the default, plain ASCII.
=item C<[✓]> / C<[ ]> — checkmark inside brackets; U+2713 is single-width almost everywhere.
=item C<(•)> / C<( )> — bullet radio-style indicator.
=item C<☒> / C<☐> — ballot box with X (U+2612) paired with empty ballot box (U+2610); both single-width in practice, unlike the C<☑> pairing above.
=begin code :lang<raku>
my $cb = Selkie::Widget::Checkbox.new(label => 'Enable notifications');
$cb.set-glyphs(checked => '[✓]', unchecked => '[ ]');
=end code
C<Selkie::Widget::RadioGroup> has the same C<[x]>/C<[ ]> hardcoding
and is a natural follow-up for the same treatment, but that widget
is out of scope here.
=head1 EXAMPLES
=head2 Syncing with the store
=begin code :lang<raku>
# Subscribe: reflect store changes into the widget
$app.store.subscribe-with-callback(
'sync-notif',
-> $s { $s.get-in('settings', 'notifications') // True },
-> Bool $v { $cb.set-checked($v) }, # no-op if unchanged — safe
$cb,
);
# Emit: user toggle dispatches to the store
$cb.on-change.tap: -> Bool $v {
$app.store.dispatch('settings/set', field => 'notifications', value => $v);
};
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::RadioGroup> — one-of-many selection
=item L<Selkie::Widget::Button> — plain action button
=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::Checkbox does Selkie::Widget does Selkie::Widget::FocusableByDefault;
#| The label displayed after the C<[x]> / C<[ ]> indicator. Required.
has Str $.label is required;
has Bool $!checked = False;
has Bool $!focused = False;
has Supplier $!change-supplier = Supplier.new;
#|( Reject user toggles that arrive within this many milliseconds of
the previous accepted user toggle. Defaults to 120ms to collapse
duplicate mouse press events and repeat-key bursts. Set to 0 to
allow every user activation. Direct C<toggle> and C<set-checked>
calls remain immediate for programmatic state changes. )
has UInt $.debounce-ms = 120;
has Instant $!last-user-toggle;
#| Glyph shown when C<checked> is True. Defaults to C<[x]> — see
#| L<GLYPHS> for alternatives and the equal-width caveat.
has Str $.checked-glyph is rw = '[x]';
#| Glyph shown when C<checked> is False. Defaults to C<[ ]>.
has Str $.unchecked-glyph is rw = '[ ]';
submethod TWEAK() {
# Primary mouse click toggles, same path as Enter / Space.
self.on-click: -> $ { self!toggle-from-user };
}
#| Current state.
method checked(--> Bool) { $!checked }
#|( Set the state, emitting on-change only if the value actually
changed. No-op on same-value assignments — safe to call from a
store subscription. )
method set-checked(Bool:D $v) {
return if $v == $!checked;
$!checked = $v;
$!change-supplier.emit($!checked);
self.mark-dirty;
}
#| Flip the state and emit on-change unconditionally.
method toggle() {
$!checked = !$!checked;
$!change-supplier.emit($!checked);
self.mark-dirty;
}
method !toggle-from-user(--> Nil) {
if $!debounce-ms > 0 && $!last-user-toggle.defined {
return if (now - $!last-user-toggle) * 1000 < $!debounce-ms;
}
$!last-user-toggle = now;
self.toggle;
}
#| Supply emitting C<Bool> each time the state changes.
method on-change(--> Supply) { $!change-supplier.Supply }
#| Set focus state. Called by C<Selkie::App>'s focus dispatcher; the
#| focused-styling palette flips on/off accordingly.
method set-focused(Bool $f) {
$!focused = $f;
self.mark-dirty;
}
#| Whether the checkbox currently has keyboard focus.
method is-focused(--> Bool) { $!focused }
#|( Set both indicator glyphs at once. See L<GLYPHS> for guidance on
keeping C<$checked> and C<$unchecked> the same width. )
method set-glyphs(Str:D :$checked!, Str:D :$unchecked! --> Nil) {
$!checked-glyph = $checked;
$!unchecked-glyph = $unchecked;
self.mark-dirty;
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
my UInt $w = self.cols;
my $indicator = $!checked ?? $!checked-glyph !! $!unchecked-glyph;
my $display = "$indicator $!label";
$display = $display.substr(0, $w) if $display.chars > $w;
if $!focused {
self.apply-style(self.theme.control-focused);
} else {
my $style = self.theme.text;
self.apply-style($style);
}
# Pad to full width for consistent background
my $padded = $display ~ (' ' x (($w - $display.chars) max 0));
ncplane_putstr_yx(self.plane, 0, 0, $padded.substr(0, $w));
self.clear-dirty;
}
method handle-event(Selkie::Event $ev --> Bool) {
if $ev.event-type ~~ MouseEvent {
return True if self!dispatch-mouse-handlers($ev);
return False;
}
return False unless $!focused;
if $ev.event-type ~~ KeyEvent {
if $ev.id == NCKEY_ENTER || $ev.id == NCKEY_SPACE {
self!toggle-from-user;
return True;
}
}
self!check-keybinds($ev);
}