diff --git a/doc/2026-11-02T_reference_kitty_disambiguate_v1.md b/doc/2026-11-02T_reference_kitty_disambiguate_v1.md new file mode 100644 index 000000000..0942b3a99 --- /dev/null +++ b/doc/2026-11-02T_reference_kitty_disambiguate_v1.md @@ -0,0 +1,91 @@ +# kitty-disambiguate-v1 profile reference + +- Status: **implemented (Slice A)** in `pkgs/id/examples/roc-world/Key.roc` + (`Key.parse_events`, `Key.to_legacy`); no activation. +- Upstream specification: + [Comprehensive keyboard handling in terminals](https://sw.kovidgoyal.net/kitty/keyboard-protocol/), + verified by direct fetch on **2026-11-02** (sections Quickstart, Key codes, + Modifiers, Event types, Progressive enhancement, Disambiguate escape codes, + Legacy key event encoding, Functional key definitions). +- Design record: `doc/2026-11-01_design_id787.md` §§4.1–4.2, 5–6 (Slice A). + Activation (negotiation, assembler, browser encoder) is slices B–F and is + **gated on F13.4 detection (#786, still open)**. This profile must only be + selected after an explicit opt-in; the push/pop owner is slice E. + +## Wire summary (verified against the upstream spec) + +- Push `CSI > 1 u` (`ESC [ > 1 u`), pop `CSI < u` (default one entry). + Query `CSI ? u`. Detection ownership is F13.4's, not this profile. +- Active-stream grammar (complete tokens; framing is slice B): + plain UTF-8 text runs, legacy bytes `0x0d` (Enter), `0x09` (Tab), + `0x7f`/`0x08` (Backspace), and +- `CSI number [; modifiers] u` — number is a Unicode codepoint + (always the unshifted form, e.g. `97` for `a`, never `65`); CSI-u + functional codes handled here are only `27` Escape, `13` Enter, `9` Tab, + and `127` Backspace; + - `CSI 1 [; modifiers] [A B C D E F H P Q S]` letter forms + (`1` omitted when no modifiers); + - `CSI number [; modifiers] ~` tilde forms; + - bare `CSI Z` (Btab terminfo standard) for Shift+Tab. +- Modifiers: shift=1, alt=2, ctrl=4, super=8, hyper=16, meta=32, + caps_lock=64, num_lock=128; encoded as **1 + bits** (`1` = none). +- Tilde functional numbers: `2` Insert, `3` Delete, `5` PageUp, `6` PageDown, + `7` Home, `8` End, `11`–`15` F1–F5, `17`–`21` F6–F10, `23`–`24` F11–F12; + letters `A/B/C/D` arrows, `F` End, `H` Home, `P/Q` F1/F2, `S` F4. +- Enter/Tab/Backspace keep legacy bytes so a crashed session still accepts + `reset` at a shell prompt (spec Quickstart). + +## Non-obvious resolutions (spec-derived, encoded in `parse_events`) + +- Bare `ESC` (`CSI 27 u`) is Escape; `Alt+x` is `CSI x-code ; 3 u`. + A lone trailing `ESC` byte is `Unknown`: active decoding uses no + Escape timeout, so a bare ESC can never be confirmed as the Escape key. +- `Tab` vs `Ctrl+i`: `0x09` vs `CSI 105 ; 5 u`. + `Enter` vs `Ctrl+m`: `0x0d` vs `CSI 109 ; 5 u`. Bytes `0x0a` and raw + C0 controls (`0x00`–`0x1f` except `0x09`/`0x0d`) are `Unknown`: the + profile never emits them (disambiguated Ctrl arrives as CSI-u). +- Normalization: known US-ASCII shifted printable forms carry Shift consumed + into the resulting scalar (`CSI 97 ; 2 u` → `A`, no Shift bit; shifted + punctuation uses its US-ASCII counterpart); Alt/Ctrl bits are preserved. + For non-ASCII scalars without a profile-supplied shifted alternate, Shift is + retained rather than guessing a layout-specific result. Legacy text cannot + prove physical Shift state, so none is invented. +- `CSI Z` and canonical `CSI 9 ; 2 u` both decode to Shift+Tab + (Btab is universal; the erratum confirms the `9 ; 2` form). +- `CSI R` is `Unknown`: per the spec it collides with the Cursor Position + Report and was removed from the F3 encodings (F3 is `13 ~` only). +- `CSI 1 ~` / `CSI 4 ~` are `Unknown`: Find/Home and End alternatives + encodings differ per terminal; never guess. +- `CSI 29 ~` (Menu) is `Unknown`: no named key; deferred with keypad. +- Event types (`:2`/`:3`, including explicit `:1`), alternate keys + (`:` sub-fields) and associated text (extra `;` fields) are `Unknown`: + this profile requests none of them. +- Keypad (`KP_*` 57399–57427), media keys (57428–57440), modifier-key + reports (57441–57454), `F13`–`F35` (57376–57398), caps/scroll/num-lock + and all other PUA function codes (57344–63743) are `Unknown`: deferred + to a later profile extension, never guessed as application actions. + Surrogates and codepoints above `U+10FFFF` are `Unknown`. +- Key number `0` (text-only form), empty fields, and modifier values + outside `1..=256` are `Unknown`. + +## Executable corpus + +- `Key.roc` `expect` blocks: the authoritative corpus (13 legacy + `Key.parse` expectations unchanged, plus rich `parse_events` fixtures + and `to_legacy` projection checks). Run with pinned + `bash scripts/roc-test.sh` from `pkgs/id` (`roc-test` just recipe). +- `kitty-disambiguate-v1-fixtures.json` (same directory): the + language-neutral mirror of that corpus for the slice C (browser + encoder) and slice D (parity oracle) consumers. Decode with the actual + Roc `parse_events`, never with a re-implemented decoder (invariant 1). +- Captured-terminal fixtures: none yet; slice B/D must record real + terminal bytes (terminal/version, negotiated profile, Escape/Alt + results, reset behavior) before claiming end-to-end support. + +## Compatibility projection + +`Key.parse` is byte-for-byte unchanged, including the modified-arrow +`Unknown` expectation. `Key.to_legacy` projects rich events back only +when representable (unmodified named/printable keys, Ctrl+letter); +anything with modifiers beyond Ctrl+letter, and all function keys, stays +`Unknown` rather than discarding modifiers to make an old app act. diff --git a/doc/kitty-disambiguate-v1-fixtures.json b/doc/kitty-disambiguate-v1-fixtures.json new file mode 100644 index 000000000..cec642724 --- /dev/null +++ b/doc/kitty-disambiguate-v1-fixtures.json @@ -0,0 +1,689 @@ +{ + "profile": "kitty-disambiguate-v1", + "spec": "https://sw.kovidgoyal.net/kitty/keyboard-protocol/", + "spec_verified": "2026-11-02", + "design": "doc/2026-11-01_design_id787.md", + "implementation": "pkgs/id/examples/roc-world/Key.roc (Key.parse_events, Key.to_legacy)", + "note": "Language-neutral mirror of the Key.roc expect corpus. Slices C/D must decode with the actual Roc parse_events, never a re-implemented decoder. Inputs use JSON \\u001b for ESC.", + "cases": [ + { + "name": "escape", + "input": "\u001b[27u", + "expected": [ + { + "press": { + "key": { + "named": "Escape" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "alt-x", + "input": "\u001b[120;3u", + "expected": [ + { + "press": { + "key": { + "scalar": "x" + }, + "modifiers": [ + "Alt" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "tab-byte", + "input": "\t", + "expected": [ + { + "press": { + "key": { + "named": "Tab" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "ctrl-i", + "input": "\u001b[105;5u", + "expected": [ + { + "press": { + "key": { + "scalar": "i" + }, + "modifiers": [ + "Control" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "enter-byte", + "input": "\r", + "expected": [ + { + "press": { + "key": { + "named": "Enter" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "ctrl-m", + "input": "\u001b[109;5u", + "expected": [ + { + "press": { + "key": { + "scalar": "m" + }, + "modifiers": [ + "Control" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "ctrl-right", + "input": "\u001b[1;5C", + "expected": [ + { + "press": { + "key": { + "named": "Right" + }, + "modifiers": [ + "Control" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "up-letter", + "input": "\u001b[A", + "expected": [ + { + "press": { + "key": { + "named": "Up" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "up-letter-explicit-1", + "input": "\u001b[1A", + "expected": [ + { + "press": { + "key": { + "named": "Up" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "shift-tab-canonical", + "input": "\u001b[9;2u", + "expected": [ + { + "press": { + "key": { + "named": "Tab" + }, + "modifiers": [ + "Shift" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "shift-tab-btab", + "input": "\u001b[Z", + "expected": [ + { + "press": { + "key": { + "named": "Tab" + }, + "modifiers": [ + "Shift" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "alt-escape", + "input": "\u001b[27;3u", + "expected": [ + { + "press": { + "key": { + "named": "Escape" + }, + "modifiers": [ + "Alt" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "ctrl-alt-delete", + "input": "\u001b[3;7~", + "expected": [ + { + "press": { + "key": { + "named": "Delete" + }, + "modifiers": [ + "Alt", + "Control" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "super-c", + "input": "\u001b[99;9u", + "expected": [ + { + "press": { + "key": { + "scalar": "c" + }, + "modifiers": [ + "Super" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "plain-ascii", + "input": "hi", + "expected": [ + { + "press": { + "key": { + "scalar": "h" + }, + "modifiers": [], + "kind": "Press" + } + }, + { + "press": { + "key": { + "scalar": "i" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "latin-e-acute", + "input": "é", + "expected": [ + { + "press": { + "key": { + "scalar": "é" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "emoji", + "input": "😀", + "expected": [ + { + "press": { + "key": { + "scalar": "😀" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "shifted-text-plain", + "input": "A", + "expected": [ + { + "press": { + "key": { + "scalar": "A" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "altgr-a-ogonek", + "input": "ą", + "expected": [ + { + "press": { + "key": { + "scalar": "ą" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "shift-a-csi", + "input": "\u001b[97;2u", + "expected": [ + { + "press": { + "key": { + "scalar": "A" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "ctrl-shift-a", + "input": "\u001b[97;6u", + "expected": [ + { + "press": { + "key": { + "scalar": "A" + }, + "modifiers": [ + "Control" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "f5", + "input": "\u001b[15~", + "expected": [ + { + "press": { + "key": { + "named": "F5" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "shift-f5", + "input": "\u001b[15;2~", + "expected": [ + { + "press": { + "key": { + "named": "F5" + }, + "modifiers": [ + "Shift" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "f1-letter", + "input": "\u001b[P", + "expected": [ + { + "press": { + "key": { + "named": "F1" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "ctrl-f1", + "input": "\u001b[1;5P", + "expected": [ + { + "press": { + "key": { + "named": "F1" + }, + "modifiers": [ + "Control" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "insert-tilde", + "input": "\u001b[2~", + "expected": [ + { + "press": { + "key": { + "named": "Insert" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "mixed-text-and-escape", + "input": "a\u001b[27u", + "expected": [ + { + "press": { + "key": { + "scalar": "a" + }, + "modifiers": [], + "kind": "Press" + } + }, + { + "press": { + "key": { + "named": "Escape" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "unknown-cpr-r", + "input": "\u001bR", + "expected": [ + { + "unknown": "\u001bR" + } + ] + }, + { + "name": "unknown-keypad-kp0", + "input": "\u001b[57399u", + "expected": [ + { + "unknown": "\u001b[57399u" + } + ] + }, + { + "name": "unknown-event-type", + "input": "\u001b[97;5:1u", + "expected": [ + { + "unknown": "\u001b[97;5:1u" + } + ] + }, + { + "name": "unknown-alternate-keys", + "input": "\u001b[97:65u", + "expected": [ + { + "unknown": "\u001b[97:65u" + } + ] + }, + { + "name": "unknown-lone-esc", + "input": "\u001b", + "expected": [ + { + "unknown": "\u001b" + } + ] + }, + { + "name": "unknown-truncated", + "input": "\u001b[1;", + "expected": [ + { + "unknown": "\u001b[1;" + } + ] + }, + { + "name": "unknown-raw-c0", + "input": "\u0003", + "expected": [ + { + "unknown": "\u0003" + } + ] + }, + { + "name": "unknown-newline", + "input": "\n", + "expected": [ + { + "unknown": "\n" + } + ] + }, + { + "name": "unknown-ambiguous-tilde-1", + "input": "\u001b[4~", + "expected": [ + { + "unknown": "\u001b[4~" + } + ] + }, + { + "name": "unknown-menu", + "input": "\u001b[29~", + "expected": [ + { + "unknown": "\u001b[29~" + } + ] + }, + { + "name": "shifted-ascii-punctuation", + "input": "\u001b[49;2u", + "expected": [ + { + "press": { + "key": { + "scalar": "!" + }, + "modifiers": [], + "kind": "Press" + } + } + ] + }, + { + "name": "shifted-non-ascii-retains-shift", + "input": "\u001b[261;2u", + "expected": [ + { + "press": { + "key": { + "scalar": "ą" + }, + "modifiers": [ + "Shift" + ], + "kind": "Press" + } + } + ] + }, + { + "name": "unknown-c0-key-number", + "input": "\u001b[3u", + "expected": [ + { + "unknown": "\u001b[3u" + } + ] + } + ], + "projection_cases": [ + { + "name": "legacy-tab", + "event": { + "press": { + "key": { + "named": "Tab" + }, + "modifiers": [], + "kind": "Press" + } + }, + "legacy": { + "tag": "Tab" + } + }, + { + "name": "legacy-escape", + "event": { + "press": { + "key": { + "named": "Escape" + }, + "modifiers": [], + "kind": "Press" + } + }, + "legacy": { + "tag": "Escape" + } + }, + { + "name": "ctrl-letter-projects", + "event": { + "press": { + "key": { + "scalar": "i" + }, + "modifiers": [ + "Control" + ], + "kind": "Press" + } + }, + "legacy": { + "tag": "Ctrl", + "value": "i" + } + }, + { + "name": "plain-scalar-projects", + "event": { + "press": { + "key": { + "scalar": "i" + }, + "modifiers": [], + "kind": "Press" + } + }, + "legacy": { + "tag": "Char", + "value": "i" + } + }, + { + "name": "modifiers-never-discarded", + "event": { + "press": { + "key": { + "named": "Tab" + }, + "modifiers": [ + "Shift" + ], + "kind": "Press" + } + }, + "legacy": { + "tag": "Unknown", + "value": "\u001b[Z" + } + }, + { + "name": "function-keys-stay-unknown", + "event": { + "press": { + "key": { + "named": "F5" + }, + "modifiers": [], + "kind": "Press" + } + }, + "legacy": { + "tag": "Unknown", + "value": "\u001b[15~" + } + } + ] +} diff --git a/pkgs/id/examples/roc-world/Key.roc b/pkgs/id/examples/roc-world/Key.roc index bd3492027..7ebf7685b 100644 --- a/pkgs/id/examples/roc-world/Key.roc +++ b/pkgs/id/examples/roc-world/Key.roc @@ -1,3 +1,57 @@ +Modifier := [Shift, Alt, Control, Super, Hyper, Meta, CapsLock, NumLock].{ + is_eq : Modifier, Modifier -> Bool + is_eq = |a, b| Str.inspect(a) == Str.inspect(b) +} + +NamedKey := [ + NamedEnter, + NamedTab, + NamedBackspace, + NamedEscape, + NamedInsert, + NamedDelete, + NamedUp, + NamedDown, + NamedLeft, + NamedRight, + NamedHome, + NamedEnd, + NamedPageUp, + NamedPageDown, + NamedF1, + NamedF2, + NamedF3, + NamedF4, + NamedF5, + NamedF6, + NamedF7, + NamedF8, + NamedF9, + NamedF10, + NamedF11, + NamedF12, +].{ + is_eq : NamedKey, NamedKey -> Bool + is_eq = |a, b| Str.inspect(a) == Str.inspect(b) +} + +EventKey := [Named(NamedKey), UnicodeScalar(Str)].{ + is_eq : EventKey, EventKey -> Bool + is_eq = |a, b| Str.inspect(a) == Str.inspect(b) +} + +EventKind := [Press].{ + is_eq : EventKind, EventKind -> Bool + is_eq = |a, b| Str.inspect(a) == Str.inspect(b) +} + +KeyEvent := [KeyEvent({ key : EventKey, modifiers : List(Modifier), kind : EventKind, source : Str }), EventUnknown(Str)].{ + is_eq : KeyEvent, KeyEvent -> Bool + is_eq = |a, b| Str.inspect(a) == Str.inspect(b) +} + +DecodeResult(a) := [Ok(a), Err({})] + Key := [ Char(Str), Ctrl(Str), @@ -24,6 +78,404 @@ Key := [ parse : Str -> List(Key) parse = |text| parse_loop(Str.to_utf8(text), []) + ## Decode a kitty-disambiguate-v1 active-mode stream into rich key events. + ## Additive: callers must only use it after selecting that profile (the + ## disambiguation push is owned by slice E, still gated on F13.4 detection + ## in #786). Unsupported control forms stay explicit `EventUnknown` values + ## that retain their source text; a lone trailing ESC is unknown, never + ## guessed, because active decoding uses no Escape timeout. + parse_events : Str -> List(KeyEvent) + parse_events = |text| events_loop(Str.to_utf8(text), []) + + events_loop : List(U8), List(KeyEvent) -> List(KeyEvent) + events_loop = |bytes, acc| if List.is_empty(bytes) { + acc + } else { + step = next_event(bytes) + events_loop(step.rest, List.append(acc, step.event)) + } + + next_event : List(U8) -> { event : KeyEvent, rest : List(U8) } + next_event = |bytes| { + lead = List.first(bytes) ?? 0 + if lead == 27 { + escape_event(bytes) + } else if lead == 13 { + { event: event_named(NamedEnter, [], "\r"), rest: drop(bytes, 1) } + } else if lead == 9 { + { event: event_named(NamedTab, [], "\t"), rest: drop(bytes, 1) } + } else if lead == 8 or lead == 127 { + { event: event_named(NamedBackspace, [], utf8_text([lead])), rest: drop(bytes, 1) } + } else if lead < 32 { + { event: EventUnknown(utf8_text([lead])), rest: drop(bytes, 1) } + } else { + scalar_event(bytes) + } + } + + scalar_event : List(U8) -> { event : KeyEvent, rest : List(U8) } + scalar_event = |bytes| { + lead = List.first(bytes) ?? 0 + len = utf8_len(lead) + if List.len(bytes) < len { + { event: EventUnknown(utf8_text(bytes)), rest: [] } + } else { + chunk = take(bytes, len) + match Str.from_utf8(chunk) { + Ok(text) => { event: event_scalar(text, [], text), rest: drop(bytes, len) } + Err(_) => { event: EventUnknown(utf8_text(chunk)), rest: drop(bytes, len) } + } + } + } + + escape_event : List(U8) -> { event : KeyEvent, rest : List(U8) } + escape_event = |bytes| { + second = List.get(bytes, 1) ?? 0 + if second == 91 { + len = csi_len(bytes) + token = take(bytes, len) + { event: parse_csi(token), rest: drop(bytes, len) } + } else if List.len(bytes) == 1 { + { event: EventUnknown(utf8_text(bytes)), rest: [] } + } else if second == 79 { + n = if List.len(bytes) < 3 { List.len(bytes) } else { 3 } + { event: EventUnknown(utf8_text(take(bytes, n))), rest: drop(bytes, n) } + } else { + want = 1 + utf8_len(second) + n = if List.len(bytes) < want { List.len(bytes) } else { want } + { event: EventUnknown(utf8_text(take(bytes, n))), rest: drop(bytes, n) } + } + } + + event_named : NamedKey, List(Modifier), Str -> KeyEvent + event_named = |key, modifiers, source| KeyEvent({ key: Named(key), modifiers: modifiers, kind: Press, source: source }) + + event_scalar : Str, List(Modifier), Str -> KeyEvent + event_scalar = |scalar, modifiers, source| KeyEvent({ key: UnicodeScalar(scalar), modifiers: modifiers, kind: Press, source: source }) + + parse_csi : List(U8) -> KeyEvent + parse_csi = |token| { + length = List.len(token) + if length < 3 { + EventUnknown(utf8_text(token)) + } else { + final = List.get(token, length - 1) ?? 0 + body = List.sublist(token, { start: 2, len: length - 3 }) + if final == 117 { + parse_csi_u(body, utf8_text(token)) + } else if final == 126 { + parse_csi_tilde(body, utf8_text(token)) + } else if final == 90 { + if List.is_empty(body) { + event_named(NamedTab, [Shift], utf8_text(token)) + } else { + EventUnknown(utf8_text(token)) + } + } else { + parse_csi_letter(body, final, utf8_text(token)) + } + } + } + + parse_csi_u : List(U8), Str -> KeyEvent + parse_csi_u = |body, source| { + fields = split_byte(body, 59) + if List.len(fields) == 1 or List.len(fields) == 2 { + match parse_decimal(List.first(fields) ?? []) { + Err(_) => EventUnknown(source) + Ok(code) => { + mods_field = if List.len(fields) == 1 { Ok(1) } else { parse_decimal(List.get(fields, 1) ?? []) } + match mods_field { + Err(_) => EventUnknown(source) + Ok(modcode) => match modifiers_from_code(modcode) { + Err(_) => EventUnknown(source) + Ok(modifiers) => match key_from_code(code) { + Err(_) => EventUnknown(source) + Ok(Named(key)) => event_named(key, modifiers, source) + Ok(UnicodeScalar(scalar)) => { + normalized = normalize_shift(scalar, modifiers) + normalized_modifiers = if normalized != scalar { remove_shift(modifiers) } else { modifiers } + event_scalar(normalized, normalized_modifiers, source) + } + } + } + } + } + } + } else { + EventUnknown(source) + } + } + + parse_csi_tilde : List(U8), Str -> KeyEvent + parse_csi_tilde = |body, source| { + fields = split_byte(body, 59) + if List.len(fields) == 1 or List.len(fields) == 2 { + match parse_decimal(List.first(fields) ?? []) { + Err(_) => EventUnknown(source) + Ok(number) => { + mods_field = if List.len(fields) == 1 { Ok(1) } else { parse_decimal(List.get(fields, 1) ?? []) } + match mods_field { + Err(_) => EventUnknown(source) + Ok(modcode) => match key_from_tilde(number) { + Err(_) => EventUnknown(source) + Ok(key) => match modifiers_from_code(modcode) { + Err(_) => EventUnknown(source) + Ok(modifiers) => event_named(key, modifiers, source) + } + } + } + } + } + } else { + EventUnknown(source) + } + } + + parse_csi_letter : List(U8), U8, Str -> KeyEvent + parse_csi_letter = |body, final, source| { + key_field = if final == 65 { Ok(NamedUp) } else if final == 66 { Ok(NamedDown) } else if final == 67 { Ok(NamedRight) } else if final == 68 { Ok(NamedLeft) } else if final == 72 { Ok(NamedHome) } else if final == 70 { Ok(NamedEnd) } else if final == 80 { Ok(NamedF1) } else if final == 81 { Ok(NamedF2) } else if final == 83 { Ok(NamedF4) } else { Err({}) } + match key_field { + Err(_) => EventUnknown(source) + Ok(key) => { + fields = split_byte(body, 59) + if List.is_empty(body) { + event_named(key, [], source) + } else if List.len(fields) == 1 and fields == [[49]] { + event_named(key, [], source) + } else if List.len(fields) == 2 and (List.first(fields) ?? []) == [49] { + match parse_decimal(List.get(fields, 1) ?? []) { + Err(_) => EventUnknown(source) + Ok(modcode) => match modifiers_from_code(modcode) { + Err(_) => EventUnknown(source) + Ok(modifiers) => event_named(key, modifiers, source) + } + } + } else { + EventUnknown(source) + } + } + } + } + + key_from_code : U64 -> DecodeResult(EventKey) + key_from_code = |code| if code == 9 { + Ok(Named(NamedTab)) + } else if code == 13 { + Ok(Named(NamedEnter)) + } else if code == 27 { + Ok(Named(NamedEscape)) + } else if code == 127 { + Ok(Named(NamedBackspace)) + } else if code >= 32 and code <= 1114111 and (code < 55296 or code > 57343) and (code < 57344 or code > 63743) { + Ok(UnicodeScalar(utf8_text(u64_to_utf8(code)))) + } else { + Err({}) + } + + key_from_tilde : U64 -> DecodeResult(NamedKey) + key_from_tilde = |code| if code == 2 { + Ok(NamedInsert) + } else if code == 3 { + Ok(NamedDelete) + } else if code == 5 { + Ok(NamedPageUp) + } else if code == 6 { + Ok(NamedPageDown) + } else if code == 7 { + Ok(NamedHome) + } else if code == 8 { + Ok(NamedEnd) + } else if code == 11 { + Ok(NamedF1) + } else if code == 12 { + Ok(NamedF2) + } else if code == 13 { + Ok(NamedF3) + } else if code == 14 { + Ok(NamedF4) + } else if code == 15 { + Ok(NamedF5) + } else if code == 17 { + Ok(NamedF6) + } else if code == 18 { + Ok(NamedF7) + } else if code == 19 { + Ok(NamedF8) + } else if code == 20 { + Ok(NamedF9) + } else if code == 21 { + Ok(NamedF10) + } else if code == 23 { + Ok(NamedF11) + } else if code == 24 { + Ok(NamedF12) + } else { + Err({}) + } + + modifiers_from_code : U64 -> DecodeResult(List(Modifier)) + modifiers_from_code = |code| if code < 1 or code > 256 { + Err({}) + } else { + bits = code - 1 + Ok(List.map(List.keep_if([{ mask: 1, modifier: Shift }, { mask: 2, modifier: Alt }, { mask: 4, modifier: Control }, { mask: 8, modifier: Super }, { mask: 16, modifier: Hyper }, { mask: 32, modifier: Meta }, { mask: 64, modifier: CapsLock }, { mask: 128, modifier: NumLock }], |entry| bits / entry.mask % 2 == 1), |entry| entry.modifier)) + } + + remove_shift : List(Modifier) -> List(Modifier) + remove_shift = |modifiers| List.keep_if(modifiers, |modifier| modifier != Shift) + + normalize_shift : Str, List(Modifier) -> Str + normalize_shift = |scalar, modifiers| { + bytes = Str.to_utf8(scalar) + if List.len(bytes) == 1 and List.any(modifiers, |modifier| modifier == Shift) { + byte = List.first(bytes) ?? 0 + if byte >= 97 and byte <= 122 { + utf8_text([byte - 32]) + } else if byte == 49 { "!" } else if byte == 50 { "@" } else if byte == 51 { "#" } else if byte == 52 { "$" } else if byte == 53 { "%" } else if byte == 54 { "^" } else if byte == 55 { "&" } else if byte == 56 { "*" } else if byte == 57 { "(" } else if byte == 48 { ")" } else if byte == 45 { "_" } else if byte == 61 { "+" } else if byte == 91 { "{" } else if byte == 93 { "}" } else if byte == 92 { "|" } else if byte == 59 { ":" } else if byte == 39 { "\"" } else if byte == 44 { "<" } else if byte == 46 { ">" } else if byte == 47 { "?" } else if byte == 96 { "~" } else { scalar } + } else { + scalar + } + } + + split_byte : List(U8), U8 -> List(List(U8)) + split_byte = |bytes, separator| split_byte_help(bytes, separator, [], []) + + split_byte_help : List(U8), U8, List(U8), List(List(U8)) -> List(List(U8)) + split_byte_help = |bytes, separator, current, acc| if List.is_empty(bytes) { + List.append(acc, current) + } else { + byte = List.first(bytes) ?? 0 + if byte == separator { + split_byte_help(drop(bytes, 1), separator, [], List.append(acc, current)) + } else { + split_byte_help(drop(bytes, 1), separator, List.append(current, byte), acc) + } + } + + parse_decimal : List(U8) -> DecodeResult(U64) + parse_decimal = |bytes| if List.is_empty(bytes) { + Err({}) + } else { + match parse_decimal_help(bytes, 0) { + Err(_) => Err({}) + Ok(value) => if value > 1114111 { Err({}) } else { Ok(value) } + } + } + + parse_decimal_help : List(U8), U64 -> DecodeResult(U64) + parse_decimal_help = |bytes, value| if List.is_empty(bytes) { + Ok(value) + } else { + match digit_value(List.first(bytes) ?? 0) { + Err(_) => Err({}) + Ok(digit) => if value > 111111 { Err({}) } else { parse_decimal_help(drop(bytes, 1), value * 10 + digit) } + } + } + + digit_value : U8 -> DecodeResult(U64) + digit_value = |byte| if byte == 48 { + Ok(0) + } else if byte == 49 { + Ok(1) + } else if byte == 50 { + Ok(2) + } else if byte == 51 { + Ok(3) + } else if byte == 52 { + Ok(4) + } else if byte == 53 { + Ok(5) + } else if byte == 54 { + Ok(6) + } else if byte == 55 { + Ok(7) + } else if byte == 56 { + Ok(8) + } else if byte == 57 { + Ok(9) + } else { + Err({}) + } + + u64_to_utf8 : U64 -> List(U8) + u64_to_utf8 = |code| if code < 128 { + [u64_to_u8(code)] + } else if code < 2048 { + [u64_to_u8(192 + code / 64), u64_to_u8(128 + code % 64)] + } else if code < 65536 { + [u64_to_u8(224 + code / 4096), u64_to_u8(128 + (code / 64) % 64), u64_to_u8(128 + code % 64)] + } else { + [u64_to_u8(240 + code / 262144), u64_to_u8(128 + (code / 4096) % 64), u64_to_u8(128 + (code / 64) % 64), u64_to_u8(128 + code % 64)] + } + + u64_to_u8 : U64 -> U8 + u64_to_u8 = |number| u64_to_u8_help(number, 0) + + u64_to_u8_help : U64, U8 -> U8 + u64_to_u8_help = |remaining, value| if remaining == 0 { value } else { u64_to_u8_help(remaining - 1, value + 1) } + + ## Lossy projection for existing applications. Only semantics representable + ## by the legacy Key union are projected; modifiers are never discarded to + ## make an old application act. + to_legacy : KeyEvent -> Key + to_legacy = |event| match event { + EventUnknown(source) => Unknown(source) + KeyEvent(record) => if record.kind != Press { + Unknown(record.source) + } else { + match record.key { + Named(key) => if List.is_empty(record.modifiers) { named_to_legacy(key, record.source) } else { Unknown(record.source) } + UnicodeScalar(scalar) => if List.is_empty(record.modifiers) { + Char(scalar) + } else if record.modifiers == [Control] and is_plain_lower(scalar) { + Ctrl(scalar) + } else { + Unknown(record.source) + } + } + } + } + + is_plain_lower : Str -> Bool + is_plain_lower = |scalar| { + bytes = Str.to_utf8(scalar) + List.len(bytes) == 1 and (List.first(bytes) ?? 0) >= 97 and (List.first(bytes) ?? 0) <= 122 + } + + named_to_legacy : NamedKey, Str -> Key + named_to_legacy = |key, source| if key == NamedEnter { + Enter + } else if key == NamedTab { + Tab + } else if key == NamedBackspace { + Backspace + } else if key == NamedEscape { + Escape + } else if key == NamedInsert { + Unknown(source) + } else if key == NamedDelete { + Delete + } else if key == NamedUp { + Up + } else if key == NamedDown { + Down + } else if key == NamedLeft { + Left + } else if key == NamedRight { + Right + } else if key == NamedHome { + Home + } else if key == NamedEnd { + End + } else if key == NamedPageUp { + PageUp + } else if key == NamedPageDown { + PageDown + } else { + Unknown(source) + } + parse_loop : List(U8), List(Key) -> List(Key) parse_loop = |bytes, acc| if List.is_empty(bytes) { acc @@ -133,3 +585,50 @@ expect Key.parse("\u(1b)OH") == [Home] expect Key.parse("é") == [Char("é")] expect Key.parse("hi\u(1b)[Bq") == [Char("h"), Char("i"), Down, Char("q")] expect Key.parse("\u(1b)[1;5C") == [Unknown("\u(1b)[1;5C")] + +expect Key.parse_events("\u(1b)[27u") == [Key.event_named(NamedEscape, [], "\u(1b)[27u")] +expect Key.parse_events("\u(1b)[120;3u") == [Key.event_scalar("x", [Alt], "\u(1b)[120;3u")] +expect Key.parse_events("\t") == [Key.event_named(NamedTab, [], "\t")] +expect Key.parse_events("\u(1b)[105;5u") == [Key.event_scalar("i", [Control], "\u(1b)[105;5u")] +expect Key.parse_events("\r") == [Key.event_named(NamedEnter, [], "\r")] +expect Key.parse_events("\u(1b)[109;5u") == [Key.event_scalar("m", [Control], "\u(1b)[109;5u")] +expect Key.parse_events("\u(1b)[1;5C") == [Key.event_named(NamedRight, [Control], "\u(1b)[1;5C")] +expect Key.parse_events("\u(1b)[A") == [Key.event_named(NamedUp, [], "\u(1b)[A")] +expect Key.parse_events("\u(1b)[1A") == [Key.event_named(NamedUp, [], "\u(1b)[1A")] +expect Key.parse_events("\u(1b)[9;2u") == [Key.event_named(NamedTab, [Shift], "\u(1b)[9;2u")] +expect Key.parse_events("\u(1b)[Z") == [Key.event_named(NamedTab, [Shift], "\u(1b)[Z")] +expect Key.parse_events("\u(1b)[27;3u") == [Key.event_named(NamedEscape, [Alt], "\u(1b)[27;3u")] +expect Key.parse_events("\u(1b)[3;7~") == [Key.event_named(NamedDelete, [Alt, Control], "\u(1b)[3;7~")] +expect Key.parse_events("\u(1b)[99;9u") == [Key.event_scalar("c", [Super], "\u(1b)[99;9u")] +expect Key.parse_events("hi") == [Key.event_scalar("h", [], "h"), Key.event_scalar("i", [], "i")] +expect Key.parse_events("é") == [Key.event_scalar("é", [], "é")] +expect Key.parse_events("😀") == [Key.event_scalar("😀", [], "😀")] +expect Key.parse_events("A") == [Key.event_scalar("A", [], "A")] +expect Key.parse_events("ą") == [Key.event_scalar("ą", [], "ą")] +expect Key.parse_events("\u(1b)[97;2u") == [Key.event_scalar("A", [], "\u(1b)[97;2u")] +expect Key.parse_events("\u(1b)[97;6u") == [Key.event_scalar("A", [Control], "\u(1b)[97;6u")] +expect Key.parse_events("\u(1b)[49;2u") == [Key.event_scalar("!", [], "\u(1b)[49;2u")] +expect Key.parse_events("\u(1b)[261;2u") == [Key.event_scalar("ą", [Shift], "\u(1b)[261;2u")] +expect Key.parse_events("\u(1b)[3u") == [EventUnknown("\u(1b)[3u")] +expect Key.parse_events("\u(1b)[15~") == [Key.event_named(NamedF5, [], "\u(1b)[15~")] +expect Key.parse_events("\u(1b)[15;2~") == [Key.event_named(NamedF5, [Shift], "\u(1b)[15;2~")] +expect Key.parse_events("\u(1b)[P") == [Key.event_named(NamedF1, [], "\u(1b)[P")] +expect Key.parse_events("\u(1b)[1;5P") == [Key.event_named(NamedF1, [Control], "\u(1b)[1;5P")] +expect Key.parse_events("\u(1b)[2~") == [Key.event_named(NamedInsert, [], "\u(1b)[2~")] +expect Key.parse_events("a\u(1b)[27u") == [Key.event_scalar("a", [], "a"), Key.event_named(NamedEscape, [], "\u(1b)[27u")] +expect Key.parse_events("\u(1b)R") == [EventUnknown("\u(1b)R")] +expect Key.parse_events("\u(1b)[57399u") == [EventUnknown("\u(1b)[57399u")] +expect Key.parse_events("\u(1b)[97;5:1u") == [EventUnknown("\u(1b)[97;5:1u")] +expect Key.parse_events("\u(1b)[97:65u") == [EventUnknown("\u(1b)[97:65u")] +expect Key.parse_events("\u(1b)") == [EventUnknown("\u(1b)")] +expect Key.parse_events("\u(1b)[1;") == [EventUnknown("\u(1b)[1;")] +expect Key.parse_events("\u(03)") == [EventUnknown("\u(03)")] +expect Key.parse_events("\n") == [EventUnknown("\n")] +expect Key.parse_events("\u(1b)[4~") == [EventUnknown("\u(1b)[4~")] +expect Key.parse_events("\u(1b)[29~") == [EventUnknown("\u(1b)[29~")] +expect Key.to_legacy(Key.event_named(NamedTab, [], "\t")) == Tab +expect Key.to_legacy(Key.event_named(NamedEscape, [], "\u(1b)[27u")) == Escape +expect Key.to_legacy(Key.event_scalar("i", [Control], "\u(1b)[105;5u")) == Ctrl("i") +expect Key.to_legacy(Key.event_scalar("i", [], "i")) == Char("i") +expect Key.to_legacy(Key.event_named(NamedTab, [Shift], "\u(1b)[Z")) == Unknown("\u(1b)[Z") +expect Key.to_legacy(Key.event_named(NamedF5, [], "\u(1b)[15~")) == Unknown("\u(1b)[15~")