Selkie.git | lib/Selkie/Widget/ | TextInput.rakumod
=begin pod
=head1 NAME
Selkie::Widget::TextInput - Single-line text input with cursor and editing
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::TextInput;
use Selkie::Sizing;
my $input = Selkie::Widget::TextInput.new(
sizing => Sizing.fixed(1),
placeholder => 'Search...',
);
$input.on-submit.tap: -> $text { run-search($text) };
$input.on-change.tap: -> $text { update-preview($text) };
# Password field: mask characters
my $pw = Selkie::Widget::TextInput.new(
sizing => Sizing.fixed(1),
mask-char => '•',
);
=end code
=head1 DESCRIPTION
A one-line text input. Arrow keys, Home, End, Backspace, and Delete
behave as you'd expect. Characters wider than the visible width
horizontally scroll the view to follow the cursor.
Four Supplies:
=item C<on-submit> — fires once when the user presses Enter, carrying the current text
=item C<on-change> — fires on every keystroke that modifies the buffer
=item C<on-copy> — fires on Ctrl+C, carrying the currently-selected text
=item C<on-cut> — fires on Ctrl+X, carrying the cut text (which is also deleted from the buffer)
For programmatic updates that shouldn't re-dispatch (e.g. syncing from
a store subscription), use C<set-text-silent> — it updates the buffer
without emitting on C<on-change>.
=head2 Mouse and selection
Click positions the caret. Drag selects from the press point to the
current cursor cell — the selection range is rendered with reverse-video.
Double-click selects the word under the cursor; triple-click selects
the entire buffer. C<has-selection>, C<selection-range>, and
C<selected-text> expose the current selection state.
Keyboard cooperates: Shift+Left / Shift+Right jump by word AND extend
the selection (the legacy word-jump is now also a selection-extend);
plain arrows clear any selection before moving. Ctrl+A selects all.
Ctrl+C and Ctrl+X emit on the corresponding supplies — Selkie does
not own the system clipboard, so apps wire OSC 52 / notcurses paste-buffer
in their handlers. Backspace and Delete delete an active selection if
present; typing replaces it.
=head2 Decorations: highlight spans and ghost suggestions
Two optional pull-based hooks let an app decorate the input without
ever desyncing from the buffer:
=item C<highlight-provider> — C<Callable(Str $buffer)> returning a
list of L<Selkie::Widget::TextInput::HighlightSpan> (half-open char
ranges + styles). Each range is repainted over the base text during
render, clipped to the visible window. Span styles merge onto the
input's own style, so fg-only spans keep the field background.
=item C<suggest-provider> — C<Callable(Str $buffer, UInt $cursor --> Str)>
returning a completion tail to paint after end-of-buffer in the
C<ghost-style> (falling back to the theme's C<input-ghost> slot),
fish-shell style. Only consulted while focused with the caret at the
end of the buffer. The ghost is display-only; pressing Right arrow at
end-of-buffer accepts it via C<insert-text>, emitting C<on-change>
exactly once. Right anywhere else keeps its normal cursor-move
behavior.
Both hooks are called during C<render> — they must be pure and cheap
(memoise on the buffer string if not). Exceptions they throw are not
caught. Because the widget pulls rather than being pushed, the
decorations survive C<set-text-silent> store-syncs, and external state
changes (say, a new list of valid tokens) take effect by simply
marking the input dirty — e.g. by subscribing the input widget to the
relevant store path. Both hooks are ignored while C<mask-char> is set:
masked inputs expose no content semantics.
=begin code :lang<raku>
use Selkie::Widget::TextInput::HighlightSpan;
$input.highlight-provider = -> Str $buf {
known-tokens($buf).map: -> $t {
Selkie::Widget::TextInput::HighlightSpan.new(
start => $t<from>, end => $t<to>,
style => Selkie::Style.new(fg => $t<ok> ?? 0x80E060 !! 0xE06060),
)
}
};
$input.suggest-provider = -> Str $buf, UInt $ {
best-completion-tail($buf) // ''
};
=end code
=head2 Modifier bubbling
Modified keys (Ctrl, Alt, Super) bubble past the input so global
keybinds still work — except for Ctrl+A / C / X (selection-related,
handled internally) and except when the OS keyboard layout has already
composed the modifier into a different printable character (e.g. UK Mac
Alt-3 → C<#>, US Mac Alt-2 → C<™>). In that case the composed character
is treated as typed input, since blocking it would make those characters
untypeable on layouts that need a modifier to produce them. Bare
characters are consumed for typing.
=head1 EXAMPLES
=head2 Store-synced input
=begin code :lang<raku>
$app.store.subscribe-with-callback(
'sync-name',
-> $s { ($s.get-in('form', 'name') // '').Str },
-> $v { $name-input.set-text-silent($v) if $name-input.text ne $v },
$name-input,
);
$name-input.on-change.tap: -> $v {
$app.store.dispatch('form/set', field => 'name', value => $v);
};
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::MultiLineInput> — multi-line variant with word wrap
=item L<Selkie::Widget::Button> — for commit-only actions
=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::TextInput does Selkie::Widget does Selkie::Widget::FocusableByDefault;
#|( Find the position of the start of the next word at or after C<$pos>
in C<$s>. Word = run of C<\w> chars. Skips through the current
char's class (word or non-word), then through any trailing
non-word chars, landing at the first word char of the next word
— or C<$s.chars> if there is no next word. Used by shift-right
word-jump and by C<MultiLineInput>'s 2D variant. )
sub next-word-pos(Str:D $s, Int:D $pos --> Int) is export(:words) {
my $len = $s.chars;
my $i = $pos max 0;
return $len if $i >= $len;
my $is-word = $s.substr($i, 1) ~~ /\w/;
while $i < $len && (($s.substr($i, 1) ~~ /\w/) ?? True !! False) == ($is-word ?? True !! False) {
$i++;
}
while $i < $len && !($s.substr($i, 1) ~~ /\w/) {
$i++;
}
$i;
}
#|( Find the position of the start of the previous word at or before
C<$pos> in C<$s>. Skips backwards through any non-word chars,
then backwards through word chars, landing on the index of the
first char of that word — or 0 if we walked off the start. Used
by shift-left and shift-backspace. )
sub prev-word-pos(Str:D $s, Int:D $pos --> Int) is export(:words) {
my $i = $pos - 1;
return 0 if $i <= 0;
while $i > 0 && !($s.substr($i, 1) ~~ /\w/) {
$i--;
}
while $i > 0 && ($s.substr($i - 1, 1) ~~ /\w/) {
$i--;
}
$i;
}
has Str $!buffer = '';
has UInt $!cursor = 0;
has UInt $!scroll-x = 0; # horizontal scroll for long input
#|( Selection anchor offset. -1 means "no selection" — the cursor is a
bare caret. When >= 0, the selection covers the half-open range
from C<min(anchor, cursor)> to C<max(anchor, cursor)>. The cursor
is the movable end; the anchor stays put while extending. )
has Int $!sel-anchor = -1;
has Str $.placeholder is rw = '';
#|( Character painted in place of every buffer character. Setting it
at construction is what makes an input a password field; it is
never changed afterwards. Whether the mask is B<currently> applied
is a separate, runtime question — ask C<masked>, not this. )
has Str $.mask-char;
#|( Reveal switch behind C<masked>. Off by default, so a masked input
starts masked; C<set-revealed> flips it for show-password
affordances. Kept separate from C<mask-char> so a reveal is
reversible without the widget having to remember what it used to
mask with, and so revealing an input that was never masked is a
harmless no-op rather than a special case. )
has Bool $!revealed = False;
#|( Optional pull-based highlight hook: a C<Callable(Str $buffer)>
returning a list of C<Selkie::Widget::TextInput::HighlightSpan>.
Consulted on every C<render>, so it must be pure and cheap —
memoise on the buffer string if the computation is nontrivial.
Because decorations are pulled at paint time they can never go
stale against the buffer, including after C<set-text-silent>
(which deliberately skips C<on-change>). Ignored while
C<mask-char> is set — masked inputs expose no content semantics. )
has &.highlight-provider is rw;
#|( Optional pull-based ghost-suggestion hook: a
C<Callable(Str $buffer, UInt $cursor --> Str)> returning the tail
to paint after the end of the buffer (fish-shell style), or an
undefined/empty string for none. Only consulted while the input is
focused with the caret at end-of-buffer, and never while
C<mask-char> is set. The ghost is display-only — it lives outside
the buffer and never affects editing or C<on-change> — until the
user accepts it with Right arrow at end-of-buffer, which inserts
the tail via C<insert-text> (one C<on-change> emission). )
has &.suggest-provider is rw;
#|( Style for the ghost-suggestion tail. Merged onto the input's
base style, so a foreground-only style keeps the field background.
Falls back to the theme's C<input-ghost> slot when unset. )
has Selkie::Style $.ghost-style is rw;
has Supplier $!submit-supplier = Supplier.new;
has Supplier $!change-supplier = Supplier.new;
has Supplier $!copy-supplier = Supplier.new;
has Supplier $!cut-supplier = Supplier.new;
has Bool $!focused = False;
submethod TWEAK() {
# Click positions the caret; double-click selects the word under
# the cursor; triple-click selects the entire buffer. Drag extends
# the selection from the press anchor to the current cursor cell.
self.on-click: -> $ev {
my $col = self.local-col($ev);
if $col >= 0 {
my $offset = ($!scroll-x + $col).UInt min $!buffer.chars;
given $ev.click-count {
when 2 {
self!select-word-at($offset);
}
when 3 {
self!select-all;
}
default {
# Place the caret and clear any selection. We do
# NOT pre-arm the anchor here — that would make
# subsequent typing produce a spurious 1-char
# selection (cursor advances on insert, anchor
# stays put, the next keystroke would replace).
# Drag arms the anchor lazily on first motion.
$!cursor = $offset;
$!sel-anchor = -1;
self.mark-dirty;
}
}
}
};
self.on-drag: -> $ev {
# Allow drag past the visible edge by clamping the local-col;
# the existing scroll machinery takes care of the rest on the
# next render. local-col returns -1 for out-of-bounds, but
# drag captures keep us as the target — recompute against the
# raw event.x so the caret tracks the cursor outside our cell.
my $raw-col = $ev.x - self.abs-x;
my $clamped = ($raw-col max 0) min (self.cols - 1);
my $offset = ($!scroll-x + $clamped).UInt min $!buffer.chars;
if $offset != $!cursor {
# First motion of a drag: anchor at the press-time cursor
# position. Subsequent motions extend the selection from
# that anchor.
$!sel-anchor = $!cursor.Int if $!sel-anchor < 0;
$!cursor = $offset;
self.mark-dirty;
}
};
}
#| The current buffer contents.
method text(--> Str) { $!buffer }
#|( True iff there is an active selection (anchor differs from cursor).
A bare caret returns False. )
method has-selection(--> Bool) {
$!sel-anchor >= 0 && $!sel-anchor != $!cursor.Int;
}
#|( Half-open offset range of the current selection, normalised to
C<low..^high>. Returns C<0..^0> when there's no selection. )
method selection-range(--> Range) {
return 0..^0 unless self.has-selection;
my $a = $!sel-anchor;
my $c = $!cursor.Int;
($a min $c) ..^ ($a max $c);
}
#| The substring currently selected, or the empty string when there
#| is no selection.
method selected-text(--> Str) {
return '' unless self.has-selection;
my $r = self.selection-range;
$!buffer.substr($r.min, $r.max - $r.min);
}
#| Clear any active selection without moving the caret.
method clear-selection() {
return unless $!sel-anchor >= 0;
$!sel-anchor = -1;
self.mark-dirty;
}
#|( Supply emitting the currently-selected text on Ctrl+C. The Selkie
framework does not own the system clipboard — apps wire this up
themselves via OSC 52 or notcurses paste-buffer. The supply only
fires when there's an active selection. )
method on-copy(--> Supply) { $!copy-supplier.Supply }
#|( Supply emitting on Ctrl+X. Like on-copy but the selection is also
deleted from the buffer. )
method on-cut(--> Supply) { $!cut-supplier.Supply }
method !select-word-at(UInt $offset) {
my $start = prev-word-pos($!buffer, ($offset + 1).Int);
my $end = next-word-pos($!buffer, $offset.Int);
# Trim trailing non-word chars next-word-pos lands on.
while $end > $start && !($!buffer.substr($end - 1, 1) ~~ /\w/) {
$end--;
}
return if $end == $start;
$!sel-anchor = $start;
$!cursor = $end.UInt;
self.mark-dirty;
}
method !select-all() {
return unless $!buffer.chars > 0;
$!sel-anchor = 0;
$!cursor = $!buffer.chars;
self.mark-dirty;
}
# Delete the active selection from the buffer, leaving the caret at
# the start of the (now-deleted) range. Returns True if a selection
# was deleted. Caller handles change-supplier emission & dirty flag.
method !delete-selection(--> Bool) {
return False unless self.has-selection;
my $r = self.selection-range;
$!buffer = $!buffer.substr(0, $r.min) ~ $!buffer.substr($r.max);
$!cursor = $r.min.UInt;
$!sel-anchor = -1;
True;
}
# Set the cursor while either clearing or extending the selection
# based on whether Shift is held. Used by every cursor-move keybind
# so the per-direction handlers stay terse.
method !move-cursor(UInt $new, Bool $extend-selection) {
if $extend-selection {
$!sel-anchor = $!cursor.Int if $!sel-anchor < 0;
} else {
$!sel-anchor = -1;
}
$!cursor = $new;
self.mark-dirty;
}
#| Replace the buffer's contents and place the caret at the end. Emits
#| on C<on-change>. Use this for user-driven updates (e.g. a "load
#| from history" button); for programmatic syncs from a store path
#| use C<set-text-silent> instead to avoid feedback loops.
method set-text(Str:D $t) {
$!buffer = $t;
$!cursor = $t.chars;
$!sel-anchor = -1;
$!change-supplier.emit($!buffer);
self.mark-dirty;
}
#| Silent variant of C<set-text> — updates the buffer without emitting
#| on C<on-change>. Wire this into store subscriptions that mirror
#| external state into the input, so the input update doesn't dispatch
#| an event that loops back through the store and re-fires the
#| subscription.
method set-text-silent(Str:D $t) {
$!buffer = $t;
$!cursor = $t.chars;
$!sel-anchor = -1;
self.mark-dirty;
}
#| Empty the buffer. Equivalent to C<set-text('')> — emits on
#| C<on-change>.
method clear() { self.set-text('') }
#|( True while the buffer is being painted as mask characters: the
input was built with a C<mask-char> B<and> has not been revealed.
This — not C<mask-char.defined> — is what gates every
content-revealing behaviour (the painted text, highlight spans,
ghost suggestions). )
method masked(--> Bool) {
$!mask-char.defined && !$!revealed;
}
#|( True while a masked input is showing its contents in the clear.
Always False for an input that has no C<mask-char>: there is
nothing to reveal, so nothing is revealed. )
method revealed(--> Bool) {
$!mask-char.defined && $!revealed;
}
#|( Show or re-hide a masked input's contents — the show-password
affordance. A no-op on an input with no C<mask-char> (there is no
mask to lift) and on a repeat of the current state, so wiring it
to a checkbox that re-emits on every render costs nothing.
Affects B<display only>. The buffer, the caret, C<text> and every
Supply behave identically either way, so a form can be submitted
revealed or hidden with the same result.
=begin code :lang<raku>
my $pw = Selkie::Widget::TextInput.new(
sizing => Sizing.fixed(1), mask-char => '*');
$pw.set-revealed(True); # user ticked "show password"
$pw.masked; # False
$pw.text; # unchanged either way
=end code
)
method set-revealed(Bool:D $on --> Nil) {
return without $!mask-char;
return if $!revealed == $on;
$!revealed = $on;
self.mark-dirty;
Nil;
}
#|( Flip C<revealed> and return the new state. Convenience for a
keybind handler, which usually has nowhere to read the current
state from. Returns False (and does nothing) on an unmasked
input. )
method toggle-revealed(--> Bool) {
return False without $!mask-char;
self.set-revealed(!$!revealed);
$!revealed;
}
#| Supply emitting the current buffer contents when the user presses
#| Enter.
method on-submit(--> Supply) { $!submit-supplier.Supply }
#| Supply emitting the new buffer contents on every user-driven edit
#| (typing, paste, delete, cut, C<set-text>). Does not fire for
#| C<set-text-silent> — the silent variant is intended exactly to
#| break the change-supplier ↔ store feedback loop.
method on-change(--> Supply) { $!change-supplier.Supply }
#| Set the input's focus state. Called by C<Selkie::App>'s focus
#| dispatcher. The caret is only painted while focused.
method set-focused(Bool $f) {
$!focused = $f;
self.mark-dirty;
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
my $style = $!focused ?? self.theme.input-focused !! self.theme.input;
self.apply-style($style);
# Fill background
my UInt $w = self.cols;
ncplane_putstr_yx(self.plane, 0, 0, ' ' x $w);
if $!buffer.chars == 0 && !$!focused && $!placeholder.chars > 0 {
my $ps = self.theme.input-placeholder;
self.apply-style($ps);
my $display = $!placeholder.substr(0, $w);
ncplane_putstr_yx(self.plane, 0, 0, $display);
} else {
self.apply-style($style);
# Ensure cursor is visible
self!adjust-scroll;
my $display-buf = self.masked
?? $!mask-char x $!buffer.chars
!! $!buffer;
my $visible = $display-buf.substr($!scroll-x, $w);
ncplane_putstr_yx(self.plane, 0, 0, $visible);
# Highlight spans: repaint provider-reported ranges over the
# base text, same overlay-repaint trick as selection/caret
# below. Spans merge onto the base style so fg-only span
# styles keep the input background. Clipped to the visible
# window; later spans win on overlap.
if &!highlight-provider.defined && !self.masked {
for &!highlight-provider($!buffer).list -> $span {
next without $span;
my $lo = $span.start max $!scroll-x;
my $hi = $span.end min ($!scroll-x + $w);
next unless $hi > $lo;
self.apply-style($style.merge($span.style));
ncplane_putstr_yx(self.plane, 0, $lo - $!scroll-x,
$display-buf.substr($lo, $hi - $lo));
}
}
# Ghost suggestion tail: painted after end-of-buffer, only
# while focused with the caret at the end. Display-only — it
# is not part of the buffer, so !adjust-scroll ignores it and
# anything past the right edge simply clips.
my Str $ghost = '';
if &!suggest-provider.defined && !self.masked
&& $!focused && $!cursor == $!buffer.chars {
$ghost = &!suggest-provider($!buffer, $!cursor) // '';
if $ghost.chars > 0 {
my $gcol = $!buffer.chars - $!scroll-x;
my $avail = $w - $gcol;
if $avail > 0 {
self.apply-style($style.merge($!ghost-style // self.theme.input-ghost));
ncplane_putstr_yx(self.plane, 0, $gcol, $ghost.substr(0, $avail));
}
}
}
# Selection overlay: redraw cells in the selection range with
# reverse-video. Cheap (one substr per visible run) and keeps
# the base render code unchanged for the no-selection path.
if self.has-selection {
my $r = self.selection-range;
my $vis-start = $r.min - $!scroll-x;
my $vis-end = $r.max - $!scroll-x;
$vis-start = 0 if $vis-start < 0;
$vis-end = $w if $vis-end > $w;
if $vis-end > $vis-start {
my $selection = self.theme.selection;
ncplane_set_fg_rgb(self.plane, $selection.fg // $style.bg) if ($selection.fg // $style.bg).defined;
ncplane_set_bg_rgb(self.plane, $selection.bg // $style.fg) if ($selection.bg // $style.fg).defined;
my $sel-text = $display-buf.substr($!scroll-x + $vis-start, $vis-end - $vis-start);
ncplane_putstr_yx(self.plane, 0, $vis-start, $sel-text);
}
}
# Draw caret (only when the cursor is NOT inside the selection
# — the selection's reverse-video already marks the active end).
if $!focused && !self.has-selection {
my UInt $cx = $!cursor - $!scroll-x;
# At end-of-buffer the caret block sits on the first ghost
# char when a suggestion is showing (fish-style), else on
# a space.
my $under = $!cursor < $display-buf.chars
?? $display-buf.substr($!cursor, 1)
!! ($ghost.chars > 0 ?? $ghost.substr(0, 1) !! ' ');
my $selection = self.theme.selection;
ncplane_set_fg_rgb(self.plane, $selection.fg // $style.bg) if ($selection.fg // $style.bg).defined;
ncplane_set_bg_rgb(self.plane, $selection.bg // $style.fg) if ($selection.bg // $style.fg).defined;
ncplane_putstr_yx(self.plane, 0, $cx, $under);
}
}
self.clear-dirty;
}
method !adjust-scroll() {
my UInt $w = self.cols;
if $!cursor < $!scroll-x {
$!scroll-x = $!cursor;
} elsif $!cursor >= $!scroll-x + $w {
$!scroll-x = $!cursor - $w + 1;
}
}
#|( Insert C<$text> at the current cursor position in one operation.
Equivalent to typing each character in turn, but does ONE buffer
concat instead of one per char — drops paste cost from O(n²) to
O(n). Newlines and other control chars in C<$text> are stripped
(single-line input). Used by the App's paste-batching drain loop;
application code can call it directly to programmatically
insert text.
If a selection is active, it is replaced (deleted then the new
text is inserted at the deletion point) — matches the canonical
"type to overwrite selection" behavior of every text editor. )
method insert-text(Str:D $text --> Nil) {
my $clean = $text.subst(/\n/, '', :g).subst(/<[\x[00]..\x[1F]\x[7F]]>/, '', :g);
return if $clean.chars == 0;
self!delete-selection;
$!buffer = $!buffer.substr(0, $!cursor) ~ $clean ~ $!buffer.substr($!cursor);
$!cursor += $clean.chars;
$!change-supplier.emit($!buffer);
self.mark-dirty;
}
method handle-event(Selkie::Event $ev --> Bool) {
# Mouse routes through the registration API regardless of focus —
# App's click-to-focus has already promoted us on press.
if $ev.event-type ~~ MouseEvent {
return True if self!dispatch-mouse-handlers($ev);
return False;
}
return False unless $!focused;
return False unless $ev.input-type == NCTYPE_PRESS || $ev.input-type == NCTYPE_REPEAT
|| $ev.input-type == NCTYPE_UNKNOWN;
my $shift = $ev.has-modifier(Mod-Shift);
my $ctrl = $ev.has-modifier(Mod-Ctrl);
# Ctrl-chord shortcuts that own selection / copy / cut. Handled
# before the generic Ctrl-bubble-out below so they don't fall
# through to global keybinds. Match on id (case-insensitive on
# the alpha range) — `char` is typically unset for ctrl chords
# outside the kitty-keyboard-protocol composed path.
if $ctrl && !$ev.has-modifier(Mod-Alt) && !$ev.has-modifier(Mod-Super) {
my $lower-id = $ev.id;
$lower-id = $lower-id + 32 if $lower-id >= 'A'.ord && $lower-id <= 'Z'.ord;
given $lower-id {
when 'a'.ord { self!select-all; return True }
when 'c'.ord {
$!copy-supplier.emit(self.selected-text) if self.has-selection;
return True;
}
when 'x'.ord {
if self.has-selection {
$!cut-supplier.emit(self.selected-text);
self!delete-selection;
$!change-supplier.emit($!buffer);
self.mark-dirty;
return True;
}
return False;
}
}
}
# Let other modified keys (except shift) bubble up for global
# keybinds — *unless* the OS keyboard layout already composed the
# modifier into a different printable character (e.g. UK Mac Alt-3
# → '#', US Mac Alt-2 → '™'). When eff_text differs from the
# keysym, the modifier was a composition input rather than a chord
# intent, and blocking it makes those characters untypeable.
my $composed = $ev.char.defined && $ev.char.chars == 1
&& $ev.char.ord >= 32 && $ev.char.ord != $ev.id;
if !$composed && ($ev.has-modifier(Mod-Ctrl) || $ev.has-modifier(Mod-Alt) || $ev.has-modifier(Mod-Super)) {
return self!check-keybinds($ev);
}
given $ev.id {
when NCKEY_ENTER {
$!submit-supplier.emit($!buffer);
return True;
}
when NCKEY_BACKSPACE {
if self.has-selection {
self!delete-selection;
$!change-supplier.emit($!buffer);
self.mark-dirty;
} elsif $shift {
# Delete from cursor back to the previous word boundary.
my $start = prev-word-pos($!buffer, $!cursor.Int);
if $start < $!cursor {
$!buffer = $!buffer.substr(0, $start) ~ $!buffer.substr($!cursor);
$!cursor = $start.UInt;
$!change-supplier.emit($!buffer);
self.mark-dirty;
}
} elsif $!cursor > 0 {
$!buffer = $!buffer.substr(0, $!cursor - 1) ~ $!buffer.substr($!cursor);
$!cursor--;
$!change-supplier.emit($!buffer);
self.mark-dirty;
}
return True;
}
when NCKEY_DEL {
if self.has-selection {
self!delete-selection;
$!change-supplier.emit($!buffer);
self.mark-dirty;
} elsif $!cursor < $!buffer.chars {
$!buffer = $!buffer.substr(0, $!cursor) ~ $!buffer.substr($!cursor + 1);
$!change-supplier.emit($!buffer);
self.mark-dirty;
}
return True;
}
when NCKEY_LEFT {
# Shift+Left preserves the existing word-jump shortcut and
# additionally extends the selection — so the legacy
# behaviour ("jump to prev word") and the new behaviour
# ("extend selection by a word") share one bind, matching
# the standard text-editor convention.
if $shift {
my $new = prev-word-pos($!buffer, $!cursor.Int);
self!move-cursor($new.UInt, True) if $new != $!cursor;
} elsif $!cursor > 0 {
self!move-cursor($!cursor - 1, False);
} else {
self.clear-selection;
}
return True;
}
when NCKEY_RIGHT {
if $shift {
my $new = next-word-pos($!buffer, $!cursor.Int);
self!move-cursor($new.UInt, True) if $new != $!cursor;
} elsif $!cursor < $!buffer.chars {
self!move-cursor($!cursor + 1, False);
} else {
# At end-of-buffer: accept the ghost suggestion if one
# is on offer. insert-text does one buffer concat and
# emits on-change exactly once.
if &!suggest-provider.defined && !self.masked
&& !self.has-selection {
my $tail = &!suggest-provider($!buffer, $!cursor);
if $tail.defined && $tail.chars > 0 {
self.insert-text($tail);
return True;
}
}
self.clear-selection;
}
return True;
}
when NCKEY_HOME {
self!move-cursor(0, $shift);
return True;
}
when NCKEY_END {
self!move-cursor($!buffer.chars, $shift);
return True;
}
default {
# Check registered keybinds (e.g. up/down for external navigation)
return True if self!check-keybinds($ev);
if $ev.char.defined && $ev.char.chars == 1 && $ev.char.ord >= 32 {
self!delete-selection; # type-replaces-selection
$!buffer = $!buffer.substr(0, $!cursor) ~ $ev.char ~ $!buffer.substr($!cursor);
$!cursor++;
$!change-supplier.emit($!buffer);
self.mark-dirty;
return True;
}
}
}
False;
}