Selkie.git | lib/Selkie/ | Event.rakumod


=begin pod

=head1 NAME

Selkie::Event - Keyboard, mouse, and resize event abstraction

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Event;
use Notcurses::Native::Types;

# In a widget's handle-event method:
method handle-event(Selkie::Event $ev --> Bool) {
    return False unless $ev.event-type ~~ KeyEvent;

    given $ev.id {
        when NCKEY_UP    { self!cursor-up;   return True }
        when NCKEY_DOWN  { self!cursor-down; return True }
        when NCKEY_ENTER { self!activate;    return True }
    }

    # Printable character?
    if $ev.char.defined && $ev.char.chars == 1 && $ev.char.ord >= 32 {
        self!insert-char($ev.char);
        return True;
    }

    False;   # not consumed — bubble to parent
}

=end code

=head1 DESCRIPTION

Every input event — keystrokes, mouse clicks, terminal resizes — is
wrapped in a C<Selkie::Event> before reaching widgets. The event carries:

=item An C<id> — the keycode (C<NCKEY_*>) or character codepoint
=item A C<char> — the effective printable character, if any (handles Shift correctly: Shift+1 → C<'!'>)
=item The C<modifiers> that were held — a C<Set> of C<Modifier> values
=item The C<input-type> — PRESS, RELEASE, REPEAT (see C<NcInputType>)
=item The C<event-type> — C<KeyEvent>, C<MouseEvent>, or C<ResizeEvent>
=item Mouse coordinates (C<x>, C<y>) for mouse events

Widgets implement C<handle-event(Selkie::Event)> returning Bool. True
means the event was consumed; False lets it bubble to the parent chain
and eventually to the app's global keybinds.

This module also exports L<Keybind> — the parsed form used by
C<on-key> on widgets and C<Selkie::App>.

=head1 EXAMPLES

=head2 Character input

When the user types a printable character on a focused widget, you get
it in C<$ev.char>:

=begin code :lang<raku>

if $ev.char.defined && $ev.char.chars == 1 && $ev.char.ord >= 32 {
    # Printable — insert into buffer
    $!buffer ~= $ev.char;
    $!change-supplier.emit($!buffer);
    return True;
}

=end code

Note the C<.ord >= 32> guard: that filters out control characters (which
arrive with C<id> in the 1–26 range) so Ctrl+X combos aren't mistaken
for typed input.

=head2 Checking modifiers

Use C<has-modifier> to test for a specific modifier key:

=begin code :lang<raku>

if $ev.id == NCKEY_ENTER && $ev.has-modifier(Mod-Ctrl) {
    self!submit;        # Ctrl+Enter submits
    return True;
} elsif $ev.id == NCKEY_ENTER {
    self!insert-newline;   # plain Enter inserts a newline
    return True;
}

=end code

Modifiers are normalised across keyboard encodings before they reach
you, so the test above means the same thing on every terminal:

=item Terminals speaking the B<kitty keyboard protocol> (kitty, foot, ghostty, WezTerm, recent Alacritty) report every modifier explicitly, including on C<Enter>, C<Tab> and C<Escape>.
=item Terminals speaking B<XTMODKEYS> / xterm's C<modifyOtherKeys> report them through C<CSI 27;mod;key~>.
=item B<Legacy> terminals transmit Alt+I<key> as an C<Escape> prefix and Ctrl+I<letter> as a C<0x01>..C<0x1a> control code. Both are decoded back into C<Mod-Alt> / C<Mod-Ctrl> here, so C<has-modifier> answers the same question either way.

The one thing legacy encodings B<cannot> express is a modifier on a key
that already has a control code of its own — Shift+Enter and Ctrl+Enter
are indistinguishable from a plain C<Enter> on such terminals, because
the terminal sends the identical byte. Alt+Enter I<is> distinguishable
(it is C<ESC> C<CR>), which makes Alt the modifier to reach for when a
widget needs a chord on C<Enter> that works everywhere — see
L<Selkie::Widget::MultiLineInput>'s C<enter-submits> mode.

=head2 Mouse events

For mouse events, C<id> is one of the C<NCKEY_SCROLL_UP>, C<NCKEY_BUTTON1>,
etc. constants, and C<x>/C<y> give the click coordinates:

=begin code :lang<raku>

if $ev.event-type ~~ MouseEvent {
    given $ev.id {
        when NCKEY_SCROLL_UP   { self!scroll(-1); return True }
        when NCKEY_SCROLL_DOWN { self!scroll(1);  return True }
    }
}

=end code

=head1 KEYBIND SYNTAX

C<Keybind.parse> and the C<on-key> methods accept a string spec:

=item Single character: C<'a'>, C<'?'>, C<'Q'>, C<'+'>
=item Named keys: C<'enter'>, C<'tab'>, C<'esc'> (or C<'escape'>), C<'space'>, C<'backspace'>, C<'delete'>, C<'insert'>, C<'home'>, C<'end'>, C<'pgup'>, C<'pgdown'>, C<'up'>, C<'down'>, C<'left'>, C<'right'>
=item Function keys: C<'f1'> through C<'f60'>
=item Modifiers: C<'ctrl+'>, C<'alt+'>, C<'shift+'>, C<'super+'>, C<'hyper+'>, C<'meta+'> — combinable, e.g. C<'ctrl+shift+a'>

Letter keybinds are case-insensitive — C<'a'> matches both C<a> and C<A>
(with Shift held).

The literal C<'+'> key is bindable too: write it as C<'+'> on its own,
or as C<'shift++'>, C<'ctrl++'>, C<'ctrl+shift++'>, etc. The parser
recognises a trailing C<'+'> as the key when the rest of the spec
already supplies one or more modifiers.

=head1 SEE ALSO

=item L<Selkie::Widget> — widgets receive events via C<handle-event>
=item L<Selkie::App> — the event loop dispatches to focused widget first, then parent chain, then global keybinds

=end pod

unit class Selkie::Event;

use Notcurses::Native::Types;
use Notcurses::Native::Input;

#| Category of event. C<KeyEvent> for keystrokes, C<MouseEvent> for clicks
#| and scrolls, C<ResizeEvent> for terminal resizes.
enum EventType is export (
    KeyEvent    => 'key',
    MouseEvent  => 'mouse',
    ResizeEvent => 'resize',
);

#| Modifier keys. Test with C<$ev.has-modifier(Mod-Ctrl)>, etc.
enum Modifier is export (
    Mod-Shift => 'shift',
    Mod-Alt   => 'alt',
    Mod-Ctrl  => 'ctrl',
    Mod-Super => 'super',
    Mod-Hyper => 'hyper',
    Mod-Meta  => 'meta',
);

#| The keycode or character codepoint of the event. For named keys this
#| is an C<NCKEY_*> constant; for printable characters it's the ordinal.
has UInt $.id;

#| The effective printable character, if any. Respects Shift (Shift+1 →
#| C<'!'>). Undefined for non-printable keys, synthesised events, and
#| legacy control sequences.
has Str $.char;

#| The set of modifier keys held when the event fired. Test with C<has-modifier>.
has Set $.modifiers;

#| The input type: NCTYPE_PRESS, NCTYPE_RELEASE, NCTYPE_REPEAT, etc.
#| The framework typically filters RELEASE events before dispatching.
has NcInputType $.input-type;

#| Which category this event belongs to — see L<EventType>.
has EventType $.event-type;

#| Mouse Y coordinate for C<MouseEvent>, -1 otherwise.
has Int $.y = -1;

#| Mouse X coordinate for C<MouseEvent>, -1 otherwise.
has Int $.x = -1;

#|( Click multiplicity for C<MouseEvent> presses: 1 for a single click,
    2 for a double-click, 3 for a triple-click. C<0> for everything else
    (motion, drag, release, scroll, keyboard / resize events). Computed
    by C<Selkie::App> from the inter-press timing and target cell —
    widgets read this to distinguish e.g. "select" from "open" in
    L<Selkie::Widget::FileBrowser>. )
has Int $.click-count = 0;

#| True if the given modifier is part of the event's modifier set.
method has-modifier(Modifier $mod --> Bool) {
    $mod ∈ $!modifiers;
}

#|( Return a fresh C<Selkie::Event> identical to this one but with the
    given C<click-count>. Used by C<Selkie::App>'s mouse dispatcher to
    annotate a press event with its multiplicity before delivery. )
method with-click-count(Int $n --> Selkie::Event) {
    Selkie::Event.new(
        id          => $!id,
        char        => $!char,
        modifiers   => $!modifiers,
        input-type  => $!input-type,
        event-type  => $!event-type,
        y           => $!y,
        x           => $!x,
        click-count => $n,
    );
}

#| True if any modifier is held. Useful for "pass bare keys to the
#| widget, bubble modified keys to global keybinds" branches.
method has-any-modifier(--> Bool) {
    ?$!modifiers;
}

#|( Build a C<Selkie::Event> from a raw notcurses C<Ncinput> struct.
    Called by C<Selkie::App> inside the event loop — you don't normally
    call this yourself. Handles: resize detection, mouse vs key
    classification, modifier bit decoding, effective character resolution
    for Shift + key combos, legacy Ctrl+A..Z control-code remapping and
    legacy ESC-prefix Alt for terminals without the kitty keyboard
    protocol. )
method from-ncinput(Ncinput $ni --> Selkie::Event) {
    my $id = $ni.id;

    my $event-type = do given $id {
        when NCKEY_RESIZE { ResizeEvent }
        when { nckey_mouse_p($id) } { MouseEvent }
        default { KeyEvent }
    };

    my @mods;
    my $mod-bits = $ni.modifiers;
    @mods.push(Mod-Shift) if $mod-bits +& NCKEY_MOD_SHIFT;
    @mods.push(Mod-Alt)   if $mod-bits +& NCKEY_MOD_ALT;
    @mods.push(Mod-Ctrl)  if $mod-bits +& NCKEY_MOD_CTRL;
    @mods.push(Mod-Super) if $mod-bits +& NCKEY_MOD_SUPER;
    @mods.push(Mod-Hyper) if $mod-bits +& NCKEY_MOD_HYPER;
    @mods.push(Mod-Meta)  if $mod-bits +& NCKEY_MOD_META;

    # Legacy ESC-prefix Alt. Terminals without the kitty keyboard
    # protocol (and without xterm's modifyOtherKeys / XTMODKEYS)
    # transmit Alt+X as the two bytes ESC then X. notcurses coalesces
    # that pair into a single ncinput when both bytes arrive in one
    # read — but records the fact only in the deprecated `alt`
    # boolean: the escape-root fallback in its input automaton sets
    # `ni->alt = true` and leaves the `modifiers` bitmask at zero.
    # Fold it in here, at the one place raw input becomes a
    # Selkie::Event, so every consumer downstream — widget
    # handle-event branches, Keybind.matches — sees one normalised
    # Mod-Alt no matter which encoding the terminal used.
    @mods.push(Mod-Alt) if $ni.alt != 0 && !($mod-bits +& NCKEY_MOD_ALT);

    my $char = Str;
    my $resolved-id = $id;

    # Detect legacy ctrl sequences: control codes 1-26 map to Ctrl+A through Ctrl+Z
    # Terminals without kitty keyboard protocol send these instead of modifier flags
    # Exclude real keys that happen to be in this range: Tab(9), Enter(13)
    my constant %ctrl-exceptions = 9 => True, 10 => True, 13 => True;
    if $id >= 1 && $id <= 26 && Mod-Ctrl ∉ @mods && !%ctrl-exceptions{$id} {
        @mods.push(Mod-Ctrl);
        $resolved-id = $id + 96;  # map to lowercase letter (1 → 'a', 17 → 'q')
        $char = $resolved-id.chr;
    } elsif !nckey_synthesized_p($id) {
        # Use eff_text (effective text) for the actual character produced
        # This handles Shift+key correctly (e.g., Shift+1 → '!')
        my $eff = $ni.eff_text_0;
        if $eff > 0 && $eff < 0x110000 {
            $char = $eff.chr;
        } elsif $id > 0 && $id < 0x110000 {
            $char = $id.chr;
        }
    }

    Selkie::Event.new(
        id         => $resolved-id,
        :$char,
        modifiers  => @mods.Set,
        input-type => NcInputType($ni.evtype),
        :$event-type,
        y          => $ni.y,
        x          => $ni.x,
    );
}

=begin pod

=head1 Keybind

A parsed keybind specification, produced by C<Keybind.parse> and matched
against events via C<matches>. You don't normally construct or match
these yourself — C<on-key> does it for you — but the class is exposed
so advanced code can inspect registered binds.

=end pod

class Keybind is export {
    #| The target keycode / character codepoint.
    has UInt $.id;

    #| The target character, if the bind was for a single character.
    has Str $.char;

    #| The modifier set that must be held for a match.
    has Set $.modifiers;

    #| The original spec string the bind was parsed from. Useful for
    #| help-overlay rendering ("Ctrl+L  — Lorebooks").
    has Str $.spec;

    #| Optional human-readable description of what the bind does. Set
    #| via the C<:description> arg on C<Widget.on-key>; surfaced by
    #| L<Selkie::Widget::HelpOverlay>.
    has Str $.description = '';

    #| The handler callable invoked on match.
    has &.handler is required;

    #|( Parse a keybind spec string into a C<Keybind>. Spec grammar is
        described under L<KEYBIND SYNTAX> in this module's main pod.
        Throws on unknown modifiers or unknown key names. )
    method parse(Str:D $spec, &handler, Str :$description = '' --> Keybind) {
        my @parts;
        my Str $key;

        # `+` is the modifier separator, so a naive split would leave the
        # key half empty whenever the key itself is `+`. Detect those
        # forms — `'+'`, `'shift++'`, `'ctrl+shift++'`, etc. — first and
        # peel the trailing `+` off as the literal key. Specs that end
        # with a single trailing `+` and nothing after (e.g. `'shift+'`)
        # are still treated as malformed and fail in the key parser
        # below, preserving the prior "no empty key" invariant.
        if $spec eq '+' {
            @parts = ();
            $key = '+';
        } elsif $spec.chars >= 2 && $spec.ends-with('++') {
            @parts = $spec.substr(0, *-2).split('+');
            $key = '+';
        } else {
            @parts = $spec.split('+');
            $key = @parts.pop;
        }

        my @mods;

        for @parts -> $mod {
            @mods.push: do given $mod.lc {
                when 'ctrl'  { Mod-Ctrl  }
                when 'alt'   { Mod-Alt   }
                when 'shift' { Mod-Shift }
                when 'super' { Mod-Super }
                when 'hyper' { Mod-Hyper }
                when 'meta'  { Mod-Meta  }
                default { die "Unknown modifier: $mod" }
            };
        }

        my UInt $id;
        my Str $char;

        given $key.lc {
            when 'enter'     { $id = NCKEY_ENTER     }
            when 'tab'       { $id = NCKEY_TAB       }
            when 'esc'       { $id = NCKEY_ESC       }
            when 'escape'    { $id = NCKEY_ESC       }
            when 'space'     { $id = NCKEY_SPACE     }
            when 'backspace' { $id = NCKEY_BACKSPACE }
            when 'delete'    { $id = NCKEY_DEL       }
            when 'insert'    { $id = NCKEY_INS       }
            when 'home'      { $id = NCKEY_HOME      }
            when 'end'       { $id = NCKEY_END       }
            when 'pgup'      { $id = NCKEY_PGUP      }
            when 'pgdown'    { $id = NCKEY_PGDOWN    }
            when 'up'        { $id = NCKEY_UP        }
            when 'down'      { $id = NCKEY_DOWN      }
            when 'left'      { $id = NCKEY_LEFT      }
            when 'right'     { $id = NCKEY_RIGHT     }
            when /^ 'f' (\d+) $/ {
                my $n = +$0;
                die "Invalid function key: f$n" unless 0 <= $n <= 60;
                $id = NCKEY_F00 + $n;
            }
            when .chars == 1 {
                $char = $key;
                $id = $key.ord;
            }
            default { die "Unknown key: $key" }
        }

        Keybind.new(:$id, :$char, modifiers => @mods.Set, :&handler, :$spec, :$description);
    }

    #|( Does the given event match this keybind? Letter binds are
        case-insensitive — C<'a'> matches a typed C<A> with Shift held.
        All other binds require an exact modifier-set match. )
    method matches(Selkie::Event $ev --> Bool) {
        if $!id >= 'a'.ord && $!id <= 'z'.ord {
            my $ev-mods = $ev.modifiers (-) Set(Mod-Shift);
            my $kb-mods = $!modifiers (-) Set(Mod-Shift);
            return False unless $ev-mods eqv $kb-mods;
            my $ev-lower = $ev.id >= 'A'.ord && $ev.id <= 'Z'.ord
                           ?? $ev.id + 32
                           !! $ev.id;
            return $ev-lower == $!id;
        }
        return False unless $ev.modifiers eqv $!modifiers;
        $ev.id == $!id;
    }
}

=begin pod

=head1 MouseHandler

A registered click / scroll / drag / mouse-down / mouse-up handler,
produced by the C<on-click> / C<on-scroll> / etc. methods on
L<Selkie::Widget>. Surfaced via C<mouse-handlers> so help overlays
can list registered mouse interactions alongside keyboard binds.
Apps don't normally construct these directly.

=end pod

class MouseHandler is export {
    #| The kind of event this handler responds to: one of C<'click'>,
    #| C<'scroll'>, C<'drag'>, C<'mouse-down'>, C<'mouse-up'>.
    has Str $.kind;

    #| The button this handler fires on (1=primary, 2=middle, 3=right).
    #| C<0> is the wildcard "any button" — used by C<on-scroll> (where
    #| the wheel arrives as buttons 4 and 5) and by low-level handlers
    #| that want to see every press.
    has UInt $.button;

    #| The handler callable; receives the C<Selkie::Event>.
    has &.handler is required;

    #| Optional human-readable description, surfaced in help overlays.
    has Str $.description = '';
}

#|( Classify a mouse event into the list of C<MouseHandler.kind>s it
    should fan out to. A press fires both C<'click'> and
    C<'mouse-down'> handlers; a release fires C<'mouse-up'>; drag
    motion fires C<'drag'>; scroll wheel fires C<'scroll'>. Empty list
    for events that don't match any kind (e.g. unknown id). )
sub mouse-event-kinds(Selkie::Event $ev --> List) is export {
    return ().List unless $ev.event-type ~~ MouseEvent;
    given $ev.id {
        when NCKEY_SCROLL_UP | NCKEY_SCROLL_DOWN { return ('scroll',).List }
        when NCKEY_MOTION { return ('drag',).List }
    }
    given $ev.input-type {
        when NCTYPE_PRESS   { return <click mouse-down>.List }
        when NCTYPE_REPEAT  { return ('drag',).List }
        when NCTYPE_RELEASE { return ('mouse-up',).List }
    }
    ().List;
}

#|( Extract the 1-indexed button number from a mouse event id.
    Scroll events are buttons 4 and 5 by encoding; pure motion (NCKEY_MOTION)
    has no associated button and returns C<0>. )
sub mouse-event-button(Selkie::Event $ev --> UInt) is export {
    given $ev.id {
        when NCKEY_BUTTON1  { 1 }
        when NCKEY_BUTTON2  { 2 }
        when NCKEY_BUTTON3  { 3 }
        when NCKEY_BUTTON4  { 4 }   # scroll up
        when NCKEY_BUTTON5  { 5 }   # scroll down
        when NCKEY_BUTTON6  { 6 }
        when NCKEY_BUTTON7  { 7 }
        when NCKEY_BUTTON8  { 8 }
        when NCKEY_BUTTON9  { 9 }
        when NCKEY_BUTTON10 { 10 }
        when NCKEY_BUTTON11 { 11 }
        default             { 0 }
    }
}