Raxol. UI. Harness. InputEvent
(Raxol v2.6.1)
View Source
Canonical input-event normalization for harness components.
The problem
The terminal driver reaches component handle_event/3 callbacks through
THREE incompatible key-event shapes depending on which layer produced the
event, plus a fourth paste shape. Two harness components (the composer,
the picker) independently hit this gap and each grew inline defensive
reshaping. Raxol.UI.Components.Input.TextInput also copes with it ad
hoc (data[:modifiers] || []). This module is the single normalizer all
of them should converge on.
The three real %Raxol.Core.Events.Event{type: :key, data: data} shapes
for data (verified against source, not assumed):
| Source | data shape | char keys | modifiers |
|---|---|---|---|
packages/raxol_terminal/.../driver/event_translator.ex (native termbox NIF) | %{shift: bool, ctrl: bool, alt: bool, meta: bool, char: binary | nil, key: atom | nil} (char/key mutually exclusive; booleans always present) | char: "a", key: nil | boolean fields, no modifiers list |
packages/raxol_terminal/.../ansi/input_parser.ex (raw ANSI/VT parser) | %{key: atom} plus char:/shift:/alt:/ctrl: only present when true/non-nil | key: :char, char: "a" | boolean fields, omitted entirely when false; no modifiers list |
Raxol.Core.Events.Event.key_event/3 (test/component API) | %{key: atom | binary, state: :pressed | :released | :repeat, modifiers: [atom]} | bare key: "a" (no :char field) | modifiers: list of atoms (:ctrl, :alt, :shift) |
Plus the paste shape (identical across the ANSI parser and
Event.paste_event/2): %Raxol.Core.Events.Event{type: :paste, data: %{text: binary}}.
The canonical shape
normalize/1 reduces all of the above (and bare, unwrapped data maps of
the same shapes) to one map:
%{
kind: :char | :key | :paste | :other,
char: String.t() | nil, # the grapheme to insert, when kind == :char
key: atom() | nil, # the special key atom, when kind == :key
text: String.t() | nil, # the pasted text, when kind == :paste
mods: %{ctrl: boolean(), alt: boolean(), shift: boolean(), meta: boolean()},
state: :pressed | :released | :repeat | nil, # nil == press (see below)
raw: term() # the original input, untouched
}Design notes:
charandkeyare never both non-nil --kindpicks one lane.charis never sliced or byte-counted. A multi-codepoint grapheme (emoji, ZWJ sequences) survives intact; normalize never assumesbyte_size(char) == 1the way some upstream insertion paths do. It also is never a control byte or more than one grapheme cluster (see "Content validation" below) -- a malformedchar:/barekey:field routes the WHOLE event tokind: :otherinstead of lying that it's insertable text.modsalways has all four keys (ctrl/alt/shift/meta), each aboolean(), regardless of which upstream shape was missing which fields.event_translator.exemitsmeta:(Cmd/Super); it is a shortcut modifier just like ctrl/alt, so Cmd+char is a shortcut, NOT insertable text. Shift-only is stilltext?/1(capital letters, etc).statecarriesEvent.key_event/3's press/release/repeat lifecycle through normalization instead of silently dropping it. Onlyevent_translator.ex/Event.key_event/3have a:state-bearing shape at all right now --input_parser.exevents (and the bare driverdatamap) have no state field, so they normalize tostate: nil, whichtext?/1/key/1treat as "press" (nil is NOT "unknown, so drop it" -- most driver shapes have no notion of release at all, and treating absence as "not a press" would make ordinary typing stop inserting text). Only an explicitstate: :releasedsuppressestext?/1/key/1-- see "Release does not insert or dispatch" below.kind: :otheris the total fallback -- unrecognized event shapes, non-map input, mouse/resize/focus events, all land here rather than raising.normalize/1never crashes.
Content validation
char: (or the bare-binary key: fallback) is only accepted as
insertable text when it is exactly one grapheme cluster AND contains no
C0 control byte (0x00-0x1F) or DEL (0x7F). A raw control sequence
smuggled into char: (e.g. %{char: "\e[2J"}, %{key: "enter"} typo'd
as a bare multi-grapheme string instead of the atom :enter) normalizes
to kind: :other, NOT kind: :char -- it is not insertable, and it does
not silently become a special key either (there is no key atom to
recover; the caller passed a malformed shape). text?/1 and
printable_char/1 re-check this even on a hand-built t() map that
didn't go through normalize/1, so their names stay honest. Pasted
text (kind: :paste) is stripped of C0 control bytes and DEL EXCEPT
tab/newline/carriage-return, which are legitimate content in a
multi-line paste.
Release does not insert or dispatch
text?/1 and key/1 only return truthy for state in [nil, :pressed, :repeat] -- an explicit state: :released normalizes the classification
fields as usual (so norm.key/norm.char still reflect what was
released, useful for key-up-triggered UI) but text?/1 is false and
key/1 is nil, so the documented cond below naturally falls through
to the no-op branch on release instead of double-inserting a character
or re-dispatching a special key on both press AND release.
Cross-shape agreement: scope
The cross-shape-agreement guarantee (see
Raxol.UI.Harness.InputEventTest's "cross-shape agreement" property) is
real, but it is NOT unconditional -- driven off the REAL
EventTranslator.translate/1 and InputParser.parse/1 functions, not
synthetic hand-built maps, it only holds over the set that is actually
representable on BOTH the termbox and raw-ANSI wires:
- Meta (Cmd/Super) is UNREPRESENTABLE over raw ANSI --
input_parser.exnever emitsmeta: truefor any byte sequence. The agreement property therefore only drives ctrl/alt/shift combinations (alone or combined) through both real emitters; a translator-shape event withmeta: truehas no ANSI counterpart to agree with and is exercised only in the single-shape unit tests above. - Enter/Tab/Escape/Backspace have NO modifier-carrying ANSI encoding
(Ctrl+Enter is indistinguishable on the wire from plain Enter) --
those keys are only agreement-tested unmodified. Shift+Tab
(backtab) is the one exception: both wires have a dedicated,
distinguishable encoding for it (termbox's
TB_KEY_BACK_TAB, ANSI'sCSI Z), so it IS covered. - Arrows, Home/End, and F1-F4 support the CSI
"1;<mod>"modifier encoding on the ANSI side and are agreement-tested across the full ctrl/alt/shift power set (8 combinations) via real bytes/keycodes. F5-F12/Insert/Delete/PageUp/PageDown are agreement-tested unmodified only (their ANSI tilde encoding here has no modifier parameter).
The shared shortcut/text-input predicate
Composer (lib/raxol/ui/components/harness/composer.ex) and picker
(lib/raxol/ui/components/harness/picker.ex) both use the pattern below
instead of hand-rolling a text_input?/1-style predicate that reads
data[:modifiers] || [] alongside data[:ctrl]/data[:alt] booleans.
Note the
ordering: shortcut?/1 is checked FIRST, because a shortcut is
ALSO a kind: :char (Ctrl+A) or kind: :key (Ctrl+Up) -- routing on
text?/1/key/1 first would drop char-shortcuts into the no-op branch
and strip the modifiers off modifier-qualified special keys. The key
branch passes the WHOLE norm (or its mods), never the bare atom, so
Ctrl+Up stays Ctrl+Up rather than collapsing to Up:
alias Raxol.UI.Harness.InputEvent
def handle_event(%Event{} = event, state, _context) do
norm = InputEvent.normalize(event)
cond do
norm.kind == :paste ->
insert(state, norm.text)
# ctrl/alt/meta held -- a shortcut. Handle it WITH the mods so
# both Ctrl+A (kind: :char) and Ctrl+Up (kind: :key) reach the
# shortcut handler; neither is text, neither is a plain nav key.
InputEvent.shortcut?(norm) ->
handle_shortcut(state, norm)
# printable text (shift-only still counts). insert the grapheme.
InputEvent.text?(norm) ->
insert(state, InputEvent.printable_char(norm))
# a plain special key. dispatch WITH the norm so a handler that
# cares about e.g. Shift+Tab still sees norm.mods.shift.
InputEvent.key(norm) ->
dispatch_key(state, norm)
true ->
{state, []}
end
endtext_input.ex's KeyHandler.handle_key/3 dispatch and its
data[:modifiers] || [] read are the same class of fix.
Summary
Functions
The special key atom (:up, :enter, :backspace, ...), or nil if
this normalized event isn't a special key or state is :released
(see moduledoc -- release does not (re-)dispatch).
Normalizes any of the driver key-event shapes, the paste shape, or
Raxol.Core.Events.Event.key_event/3's shape (wrapped in an Event
struct OR passed as a bare data map) into the canonical form above.
The grapheme to insert, or nil if this normalized event isn't
insertable text (see text?/1). Never truncated to a single codepoint.
Ctrl, Alt, or Meta (Cmd/Super) is held, regardless of kind -- a
printable char with a modifier (Ctrl+A, Cmd+S) or a special key with a
modifier (Ctrl+Up) both count. Shift alone is not a shortcut.
Printable text with no ctrl/alt/meta held. Shift-only (capital letters,
shifted punctuation) is still text -- only ctrl/alt/meta promote a char
to a shortcut. meta is Cmd/Super (macOS Cmd+key); an event with it set
is a shortcut, not insertable text.
Types
@type key_state() :: :pressed | :released | :repeat | nil
@type kind() :: :char | :key | :paste | :other
Functions
The special key atom (:up, :enter, :backspace, ...), or nil if
this normalized event isn't a special key or state is :released
(see moduledoc -- release does not (re-)dispatch).
Normalizes any of the driver key-event shapes, the paste shape, or
Raxol.Core.Events.Event.key_event/3's shape (wrapped in an Event
struct OR passed as a bare data map) into the canonical form above.
Total: never raises. Anything unrecognized normalizes to kind: :other.
IDEMPOTENT by contract (the SessionPump's PumpContract §4): the live
pump normalizes at its boundary, and HarnessApp.Model.handle_key/2
normalizes again for its own Keymap routing -- so a second pass must
return the input UNCHANGED. Without idempotence, re-normalizing an
already-normalized map reads mods as all-false (extract_mods looks
for top-level :ctrl/:alt fields, which the canonical shape nests
under :mods) -- silently un-pressing Ctrl on every live chord,
breaking the quit protocol -- and buries the original %Event{} one
:raw level deeper than component dispatch can find it.
The grapheme to insert, or nil if this normalized event isn't
insertable text (see text?/1). Never truncated to a single codepoint.
Ctrl, Alt, or Meta (Cmd/Super) is held, regardless of kind -- a
printable char with a modifier (Ctrl+A, Cmd+S) or a special key with a
modifier (Ctrl+Up) both count. Shift alone is not a shortcut.
Printable text with no ctrl/alt/meta held. Shift-only (capital letters,
shifted punctuation) is still text -- only ctrl/alt/meta promote a char
to a shortcut. meta is Cmd/Super (macOS Cmd+key); an event with it set
is a shortcut, not insertable text.
Also false when char fails content validation (control byte, or more
than one grapheme -- see moduledoc) or state is :released, even on a
hand-built map that didn't go through normalize/1 -- so the name
"text?" doesn't lie about what's actually safe to insert.