termd/internal/keymap/keymap.go
2026-07-02 22:13:49 -03:00

189 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package keymap parses tmux-style key names ("Enter", "C-c", "C-M-Up") into
// terminal input. A parsed Key is either a key event handed to the vt
// emulator's mode-aware encoder, or — for modified special keys, which the vt
// encoder does not support — pre-encoded xterm escape bytes.
package keymap
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
uv "github.com/charmbracelet/ultraviolet"
)
// Key is a parsed key name.
type Key struct {
Name string
event *uv.KeyPressEvent
raw []byte
}
// Event returns the key event to pass to the emulator's encoder, if this key
// is represented as one.
func (k Key) Event() (uv.KeyPressEvent, bool) {
if k.event == nil {
return uv.KeyPressEvent{}, false
}
return *k.event, true
}
// Raw returns the pre-encoded escape bytes, if this key is represented as raw
// bytes instead of an event.
func (k Key) Raw() ([]byte, bool) {
if k.raw == nil {
return nil, false
}
return k.raw, true
}
// specialKey is a named non-character key and how it can be encoded when
// modified. csiChar covers CSI 1;m<ch> keys (arrows, Home/End, F1F4);
// csiNum covers CSI <n>;m~ keys (Insert/Delete/PgUp/PgDn, F5F12).
type specialKey struct {
code rune // uv key code
csiChar byte // 0 if not a CSI-char key
csiNum int // 0 if not a CSI-number key
}
var specials = map[string]specialKey{
"enter": {code: uv.KeyEnter},
"escape": {code: uv.KeyEscape},
"esc": {code: uv.KeyEscape},
"tab": {code: uv.KeyTab},
"backspace": {code: uv.KeyBackspace},
"bspace": {code: uv.KeyBackspace},
"up": {code: uv.KeyUp, csiChar: 'A'},
"down": {code: uv.KeyDown, csiChar: 'B'},
"right": {code: uv.KeyRight, csiChar: 'C'},
"left": {code: uv.KeyLeft, csiChar: 'D'},
"home": {code: uv.KeyHome, csiChar: 'H'},
"end": {code: uv.KeyEnd, csiChar: 'F'},
"insert": {code: uv.KeyInsert, csiNum: 2},
"ic": {code: uv.KeyInsert, csiNum: 2},
"delete": {code: uv.KeyDelete, csiNum: 3},
"dc": {code: uv.KeyDelete, csiNum: 3},
"pgup": {code: uv.KeyPgUp, csiNum: 5},
"ppage": {code: uv.KeyPgUp, csiNum: 5},
"pageup": {code: uv.KeyPgUp, csiNum: 5},
"pgdn": {code: uv.KeyPgDown, csiNum: 6},
"npage": {code: uv.KeyPgDown, csiNum: 6},
"pagedown": {code: uv.KeyPgDown, csiNum: 6},
"f1": {code: uv.KeyF1, csiChar: 'P'},
"f2": {code: uv.KeyF2, csiChar: 'Q'},
"f3": {code: uv.KeyF3, csiChar: 'R'},
"f4": {code: uv.KeyF4, csiChar: 'S'},
"f5": {code: uv.KeyF5, csiNum: 15},
"f6": {code: uv.KeyF6, csiNum: 17},
"f7": {code: uv.KeyF7, csiNum: 18},
"f8": {code: uv.KeyF8, csiNum: 19},
"f9": {code: uv.KeyF9, csiNum: 20},
"f10": {code: uv.KeyF10, csiNum: 21},
"f11": {code: uv.KeyF11, csiNum: 23},
"f12": {code: uv.KeyF12, csiNum: 24},
// Space is handled as the rune ' ' but gets a name for convenience.
"space": {code: uv.KeySpace},
// BTab = Shift-Tab; the emulator encodes {Tab, Shift} as CSI Z.
"btab": {code: uv.KeyTab},
}
// ctrlEncodable are the non-letter runes the vt encoder maps with Ctrl.
const ctrlEncodable = " [\\]^_"
// Parse turns a tmux-style key name into a Key. Modifier prefixes C- (Ctrl),
// M- (Alt/Meta) and S- (Shift) stack in any order; the base is either a named
// key or a single character. The final character may itself be '-' (C--).
func Parse(name string) (Key, error) {
rest := name
var mod uv.KeyMod
for len(rest) > 2 && rest[1] == '-' {
switch rest[0] {
case 'C':
mod |= uv.ModCtrl
case 'M':
mod |= uv.ModAlt
case 'S':
mod |= uv.ModShift
default:
return Key{}, fmt.Errorf("unknown key %q: bad modifier prefix %q", name, rest[:2])
}
rest = rest[2:]
}
if rest == "" {
return Key{}, fmt.Errorf("unknown key %q: empty base key", name)
}
if sp, ok := specials[strings.ToLower(rest)]; ok {
if strings.EqualFold(rest, "btab") {
mod |= uv.ModShift
}
return encodeSpecial(name, sp, mod)
}
r, size := utf8.DecodeRuneInString(rest)
if r == utf8.RuneError || size != len(rest) {
return Key{}, fmt.Errorf("unknown key %q", name)
}
return encodeRune(name, r, mod)
}
func encodeSpecial(name string, sp specialKey, mod uv.KeyMod) (Key, error) {
// Shift-Tab is the one modified special the emulator knows (CSI Z).
if sp.code == uv.KeyTab && mod == uv.ModShift {
return Key{Name: name, event: &uv.KeyPressEvent{Code: uv.KeyTab, Mod: uv.ModShift}}, nil
}
if sp.code == uv.KeySpace {
return encodeRune(name, ' ', mod)
}
if mod == 0 || (mod == uv.ModAlt && sp.csiChar == 0 && sp.csiNum == 0) {
// Unmodified — or Alt on a key with no CSI form (M-Enter = ESC CR),
// which the emulator encodes as an ESC prefix.
return Key{Name: name, event: &uv.KeyPressEvent{Code: sp.code, Mod: mod}}, nil
}
// Modified special keys use the xterm encoding CSI 1;m<ch> / CSI n;m~
// (m = 1 + Shift(1) + Alt(2) + Ctrl(4)). The vt encoder has no support
// for these, so we pre-encode the bytes ourselves. xterm emits the CSI
// form for modified arrows regardless of DECCKM, so bypassing the
// emulator's DECCKM handling here matches real terminals.
m := 1
if mod&uv.ModShift != 0 {
m += 1
}
if mod&uv.ModAlt != 0 {
m += 2
}
if mod&uv.ModCtrl != 0 {
m += 4
}
switch {
case sp.csiChar != 0:
return Key{Name: name, raw: fmt.Appendf(nil, "\x1b[1;%d%c", m, sp.csiChar)}, nil
case sp.csiNum != 0:
return Key{Name: name, raw: fmt.Appendf(nil, "\x1b[%d;%d~", sp.csiNum, m)}, nil
default:
return Key{}, fmt.Errorf("cannot encode %q: no escape sequence exists for that modifier combination", name)
}
}
func encodeRune(name string, r rune, mod uv.KeyMod) (Key, error) {
if mod&uv.ModShift != 0 {
// Shift on a character is the shifted character itself; letters are
// the only case we can shift reliably.
up := unicode.ToUpper(r)
if up == r {
return Key{}, fmt.Errorf("cannot encode %q: S- is only meaningful on letters, write the shifted character directly", name)
}
r = up
mod &^= uv.ModShift
}
if mod&uv.ModCtrl != 0 {
lo := unicode.ToLower(r)
if !(lo >= 'a' && lo <= 'z') && !strings.ContainsRune(ctrlEncodable, lo) {
return Key{}, fmt.Errorf("cannot encode %q: C-%c has no control character", name, r)
}
// The vt encoder matches Ctrl combos on the lowercase rune.
r = lo
}
return Key{Name: name, event: &uv.KeyPressEvent{Code: r, Mod: mod}}, nil
}