v0.9.0 e5e4ded

kittykey.go RawHistoryFolder

1// Package kittykey decodes kitty keyboard protocol key events and re-encodes
2// them for a receiver that requested a given set of enhancement flags.
3//
4// vev enables Disambiguate|AlternateKeys on the outer terminal. Keys that the
5// legacy encoding cannot express unambiguously then arrive as
6// CSI code[:shifted[:base]] [; mods] u, and F1-F4 as CSI [1;mods] P/Q/S.
7// Everything else stays legacy. Translate rewrites only those sequences, so
8// legacy input passes through byte-for-byte.
9//
10// The package is pure: no I/O, clocks, or ports.
11package kittykey
12
13import (
14	"bytes"
15	"strconv"
16	"unicode"
17	"unicode/utf8"
18)
19
20// Enhancement flags from the kitty keyboard protocol.
21const (
22	FlagDisambiguate  = 1 << 0
23	FlagReportEvents  = 1 << 1
24	FlagAlternateKeys = 1 << 2
25	FlagAllKeys       = 1 << 3
26	FlagText          = 1 << 4
27
28	// OuterFlags is the key encoding the rest of vev consumes. A Normalizer
29	// rewrites the richer RequestFlags stream into it.
30	OuterFlags = FlagDisambiguate | FlagAlternateKeys
31	// RequestFlags are the flags vev requests from the outer terminal: every
32	// key, modifier presses and releases included, as an escape code.
33	RequestFlags = OuterFlags | FlagReportEvents | FlagAllKeys | FlagText
34)
35
36// Modifier bits, as encoded in the mods parameter minus one.
37const (
38	ModShift = 1 << 0
39	ModAlt   = 1 << 1
40	ModCtrl  = 1 << 2
41	ModSuper = 1 << 3
42	ModHyper = 1 << 4
43	ModMeta  = 1 << 5
44
45	modLocks = 1<<6 | 1<<7 // caps lock, num lock
46)
47
48// Key codes of the functional keys that have legacy single-byte forms.
49const (
50	KeyEscape    = 27
51	KeyEnter     = 13
52	KeyTab       = 9
53	KeyBackspace = 127
54)
55
56// Event is one decoded CSI u key press.
57type Event struct {
58	// Code is the unicode key code: the unshifted key of the current layout.
59	Code rune
60	// Shifted is the shifted key, when reported (AlternateKeys with Shift).
61	Shifted rune
62	// Base is the key in the standard PC-101 layout, when reported.
63	Base rune
64	// Mods is the modifier bit set (ModShift...), lock bits removed.
65	Mods int
66	// Release reports a key-release event (only with FlagReportEvents).
67	Release bool
68}
69
70// OnlyMods reports whether exactly the given modifiers are active.
71func (e Event) OnlyMods(mods int) bool { return e.Mods == mods }
72
73// Digit reports the top-row digit 1-9 the key represents, preferring the
74// PC-101 base key so that layouts like AZERTY (whose unshifted digit keys
75// produce symbols) still map to digits.
76func (e Event) Digit() (int, bool) {
77	for _, r := range [...]rune{e.Base, e.Code} {
78		if r >= '1' && r <= '9' {
79			return int(r - '0'), true
80		}
81	}
82	return 0, false
83}
84
85const maxSeq = 64
86
87var (
88	pasteOpen  = []byte("\x1b[200~")
89	pasteClose = []byte("\x1b[201~")
90)
91
92// Parse decodes one CSI u key sequence at the start of data. It returns the
93// event, the sequence length, and ok. partial reports that data is a strict
94// prefix of a sequence that could still become a CSI u key event.
95func Parse(data []byte) (ev Event, n int, ok, partial bool) {
96	if len(data) < 2 || data[0] != 0x1b || data[1] != '[' {
97		return Event{}, 0, false, len(data) == 1 && data[0] == 0x1b
98	}
99	i := 2
100	for ; i < len(data) && i < maxSeq; i++ {
101		b := data[i]
102		if (b >= '0' && b <= '9') || b == ';' || b == ':' {
103			continue
104		}
105		if b != 'u' || i == 2 {
106			return Event{}, 0, false, false
107		}
108		key, ok := parseParams(data[2:i])
109		if !ok || key.ev.Code == 0 {
110			return Event{}, 0, false, false
111		}
112		return key.ev, i + 1, true, false
113	}
114	return Event{}, 0, false, i < maxSeq
115}
116
117// fullKey is one CSI u key event with the fields Event leaves out.
118type fullKey struct {
119	ev Event
120	// locks holds the Caps Lock and Num Lock bits Event.Mods leaves out.
121	locks int
122	// text holds the associated text (FlagText), control codes removed.
123	text []rune
124}
125
126func parseParams(params []byte) (fullKey, bool) {
127	fields := splitByte(params, ';')
128	if len(fields) > 3 {
129		return fullKey{}, false
130	}
131	keys := splitByte(fields[0], ':')
132	if len(keys) > 3 {
133		return fullKey{}, false
134	}
135	code, ok := atoi(keys[0])
136	if !ok {
137		return fullKey{}, false
138	}
139	key := fullKey{}
140	ev := &key.ev
141	ev.Code = rune(code)
142	if len(keys) > 1 && len(keys[1]) > 0 {
143		v, ok := atoi(keys[1])
144		if !ok {
145			return fullKey{}, false
146		}
147		ev.Shifted = rune(v)
148	}
149	if len(keys) > 2 && len(keys[2]) > 0 {
150		v, ok := atoi(keys[2])
151		if !ok {
152			return fullKey{}, false
153		}
154		ev.Base = rune(v)
155	}
156	if len(fields) > 1 && len(fields[1]) > 0 {
157		mods, event, ok := parseMods(fields[1])
158		if !ok {
159			return fullKey{}, false
160		}
161		ev.Mods = mods &^ modLocks
162		key.locks = mods & modLocks
163		ev.Release = event == eventRelease
164	}
165	// A third field carries associated text (FlagText). Only a Normalizer
166	// needs it; re-encoding a key never does.
167	if len(fields) > 2 && len(fields[2]) > 0 {
168		for _, field := range splitByte(fields[2], ':') {
169			v, ok := atoi(field)
170			if !ok {
171				return fullKey{}, false
172			}
173			if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) {
174				key.text = append(key.text, r)
175			}
176		}
177	}
178	return key, true
179}
180
181// Event types; a repeat (2) is handled as a press.
182const (
183	eventPress   = 1
184	eventRelease = 3
185)
186
187// parseMods decodes a "mods[:event]" field: the modifier bits, lock bits
188// included, and the event type (press when absent).
189func parseMods(field []byte) (mods, event int, ok bool) {
190	sub := splitByte(field, ':')
191	if len(sub) > 2 {
192		return 0, 0, false
193	}
194	encoded, ok := atoi(sub[0])
195	if !ok || encoded < 1 {
196		return 0, 0, false
197	}
198	event = eventPress
199	if len(sub) == 2 && len(sub[1]) > 0 {
200		event, ok = atoi(sub[1])
201		if !ok || event < eventPress || event > eventRelease {
202			return 0, 0, false
203		}
204	}
205	return encoded - 1, event, true
206}
207
208// Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
209// data for a receiver that requested flags. With flags == 0 the result is the
210// legacy encoding; keys without a legacy form are dropped. With
211// FlagDisambiguate set, CSI u is kept and only subfields the receiver did not
212// ask for are removed. Other bytes, including bracketed paste bodies, are
213// copied unchanged. When nothing changes, data itself is returned.
214func Translate(data []byte, flags int) []byte {
215	var out []byte
216	last := 0
217	for i := 0; i+2 < len(data); i++ {
218		if data[i] != 0x1b || data[i+1] != '[' {
219			continue
220		}
221		if bytes.HasPrefix(data[i:], pasteOpen) {
222			end := bytes.Index(data[i:], pasteClose)
223			if end < 0 {
224				break
225			}
226			i += end + len(pasteClose) - 1
227			continue
228		}
229		var repl []byte
230		var n int
231		if ev, size, ok, _ := Parse(data[i:]); ok {
232			repl, n = encode(ev, flags), size
233		} else if fkey, size, ok := parseFunctionKey(data[i:]); ok {
234			repl, n = fkey.encode(flags), size
235		} else {
236			continue
237		}
238		if out == nil {
239			out = make([]byte, 0, len(data))
240		}
241		out = append(out, data[last:i]...)
242		out = append(out, repl...)
243		i += n - 1
244		last = i + 1
245	}
246	if out == nil {
247		return data
248	}
249	return append(out, data[last:]...)
250}
251
252func encode(ev Event, flags int) []byte {
253	if ev.Release && flags&FlagReportEvents == 0 {
254		return nil
255	}
256	if flags&FlagDisambiguate != 0 || flags&FlagAllKeys != 0 {
257		return encodeCSIu(ev, flags)
258	}
259	return encodeLegacy(ev)
260}
261
262func encodeCSIu(ev Event, flags int) []byte {
263	out := []byte("\x1b[")
264	out = strconv.AppendInt(out, int64(ev.Code), 10)
265	if flags&FlagAlternateKeys != 0 && (ev.Shifted != 0 || ev.Base != 0) {
266		out = append(out, ':')
267		if ev.Shifted != 0 {
268			out = strconv.AppendInt(out, int64(ev.Shifted), 10)
269		}
270		if ev.Base != 0 {
271			out = append(out, ':')
272			out = strconv.AppendInt(out, int64(ev.Base), 10)
273		}
274	}
275	release := ev.Release && flags&FlagReportEvents != 0
276	if ev.Mods != 0 || release {
277		out = append(out, ';')
278		out = strconv.AppendInt(out, int64(ev.Mods+1), 10)
279		if release {
280			out = append(out, ":3"...)
281		}
282	}
283	return append(out, 'u')
284}
285
286// encodeLegacy produces the xterm-style bytes for ev, or nil when the key has
287// no legacy representation.
288func encodeLegacy(ev Event) []byte {
289	if ev.Mods&^(ModShift|ModAlt|ModCtrl) != 0 {
290		// Super, Hyper, and Meta have no legacy encoding.
291		return nil
292	}
293	var body []byte
294	switch ev.Code {
295	case KeyEscape:
296		body = []byte{0x1b}
297	case KeyEnter:
298		body = []byte{'\r'}
299	case KeyTab:
300		if ev.Mods&ModShift != 0 {
301			body = []byte("\x1b[Z")
302		} else {
303			body = []byte{'\t'}
304		}
305	case KeyBackspace:
306		if ev.Mods&ModCtrl != 0 {
307			body = []byte{0x08}
308		} else {
309			body = []byte{0x7f}
310		}
311	default:
312		if seq, ok := keypadLegacy[ev.Code]; ok {
313			if mods := ev.Mods &^ ModShift; mods != 0 && len(seq) > 2 && seq[0] == 0x1b {
314				// Modified keypad navigation uses xterm's CSI 1;mods form.
315				return modifiedCSI(seq, ev.Mods)
316			}
317			body = []byte(seq)
318			break
319		}
320		if ev.Code < 0x20 || (ev.Code >= 0xe000 && ev.Code <= 0xf8ff) || !utf8.ValidRune(ev.Code) {
321			// Functional keys in the private use area have no legacy form.
322			return nil
323		}
324		body = legacyText(ev)
325	}
326	if ev.Mods&ModAlt != 0 {
327		return append([]byte{0x1b}, body...)
328	}
329	return body
330}
331
332func legacyText(ev Event) []byte {
333	r := ev.Code
334	if ev.Mods&ModShift != 0 {
335		switch {
336		case ev.Shifted != 0:
337			r = ev.Shifted
338		default:
339			r = unicode.ToUpper(r)
340		}
341	}
342	if ev.Mods&ModCtrl != 0 {
343		if c, ok := ctrlByte(r); ok {
344			return []byte{c}
345		}
346		// Non-Latin layouts: fall back to the PC-101 base key so Ctrl+letter
347		// still produces its control byte.
348		if c, ok := ctrlByte(ev.Base); ok {
349			return []byte{c}
350		}
351	}
352	return utf8.AppendRune(nil, r)
353}
354
355// ctrlByte maps a key to the C0 byte xterm sends for Ctrl+key.
356func ctrlByte(r rune) (byte, bool) {
357	switch {
358	case r >= 'a' && r <= 'z':
359		return byte(r - 'a' + 1), true
360	case r >= 'A' && r <= 'Z':
361		return byte(r - 'A' + 1), true
362	}
363	switch r {
364	case ' ', '@', '2':
365		return 0x00, true
366	case '[', '3':
367		return 0x1b, true
368	case '\\', '4':
369		return 0x1c, true
370	case ']', '5':
371		return 0x1d, true
372	case '^', '6', '~':
373		return 0x1e, true
374	case '_', '/', '7', '-':
375		return 0x1f, true
376	case '8', '?':
377		return 0x7f, true
378	}
379	return 0, false
380}
381
382// keypadLegacy maps kitty keypad private-use codes (reported as separate keys
383// under FlagDisambiguate) to the bytes a legacy terminal sends.
384var keypadLegacy = map[rune]string{
385	57399: "0", 57400: "1", 57401: "2", 57402: "3", 57403: "4",
386	57404: "5", 57405: "6", 57406: "7", 57407: "8", 57408: "9",
387	57409: ".", 57410: "/", 57411: "*", 57412: "-", 57413: "+",
388	57414: "\r", 57415: "=", 57416: ",",
389	57417: "\x1b[D", 57418: "\x1b[C", 57419: "\x1b[A", 57420: "\x1b[B",
390	57421: "\x1b[5~", 57422: "\x1b[6~", 57423: "\x1b[H", 57424: "\x1b[F",
391	57425: "\x1b[2~", 57426: "\x1b[3~", 57427: "\x1b[E",
392}
393
394// modifiedCSI turns a legacy cursor (CSI X) or tilde (CSI n ~) key into its
395// xterm modified form: CSI 1;m X or CSI n;m ~.
396func modifiedCSI(seq string, mods int) []byte {
397	m := strconv.Itoa(mods + 1)
398	final := seq[len(seq)-1]
399	if final == '~' {
400		return []byte(seq[:len(seq)-1] + ";" + m + "~")
401	}
402	return []byte("\x1b[1;" + m + string(final))
403}
404
405// functionKey is a kitty CSI [1;mods] P|Q|S form of F1, F2, or F4. Legacy
406// terminals send SS3 P|Q|S when unmodified.
407type functionKey struct {
408	final byte
409	mods  []byte // the raw "1;mods" parameter bytes, empty when unmodified
410}
411
412func parseFunctionKey(data []byte) (functionKey, int, bool) {
413	for i := 2; i < len(data) && i < maxSeq; i++ {
414		b := data[i]
415		if (b >= '0' && b <= '9') || b == ';' || b == ':' {
416			continue
417		}
418		if b != 'P' && b != 'Q' && b != 'S' {
419			return functionKey{}, 0, false
420		}
421		params := data[2:i]
422		if len(params) != 0 && (len(params) < 3 || params[0] != '1' || params[1] != ';') {
423			return functionKey{}, 0, false
424		}
425		return functionKey{final: b, mods: params}, i + 1, true
426	}
427	return functionKey{}, 0, false
428}
429
430func (f functionKey) encode(flags int) []byte {
431	if len(f.mods) == 0 && flags&FlagDisambiguate == 0 {
432		return []byte{0x1b, 'O', f.final}
433	}
434	out := append([]byte("\x1b["), f.mods...)
435	return append(out, f.final)
436}
437
438func splitByte(b []byte, sep byte) [][]byte {
439	var out [][]byte
440	start := 0
441	for i := 0; i <= len(b); i++ {
442		if i == len(b) || b[i] == sep {
443			out = append(out, b[start:i])
444			start = i + 1
445		}
446	}
447	return out
448}
449
450func atoi(b []byte) (int, bool) {
451	if len(b) == 0 || len(b) > 7 {
452		return 0, false
453	}
454	n := 0
455	for _, c := range b {
456		if c < '0' || c > '9' {
457			return 0, false
458		}
459		n = n*10 + int(c-'0')
460	}
461	return n, true
462}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close