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

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