v0.4.3 2a19df1

input.go RawHistoryFolder

1package tui
2
3import (
4	"strconv"
5	"strings"
6	"time"
7)
8
9// Key is a parsed keypress.
10type Key struct {
11	Kind  KeyKind
12	Rune  rune   // for KeyRune
13	Paste string // for KeyPaste
14	Ctrl  bool
15	Alt   bool
16	Shift bool
17	Super bool
18}
19
20type KeyKind int
21
22const (
23	KeyRune KeyKind = iota
24	KeyEnter
25	KeyBackspace
26	KeyTab
27	KeyShiftTab
28	KeyEsc
29	KeyUp
30	KeyDown
31	KeyLeft
32	KeyRight
33	KeyHome
34	KeyEnd
35	KeyPageUp
36	KeyPageDown
37	KeyDelete
38	KeyCtrlC
39	KeyCtrlD
40	KeyCtrlL
41	KeyCtrlU
42	KeyCtrlK
43	KeyCtrlA
44	KeyCtrlE
45	KeyCtrlW
46	KeyCtrlO
47	KeyPaste
48	KeyPasteClipboard
49	KeyMouseWheelUp
50	KeyMouseWheelDown
51	KeyUnknown
52)
53
54// Reader parses a byte stream into Key events. It understands basic
55// xterm escape sequences and bracketed paste.
56type Reader struct {
57	src  func() (byte, error)
58	peek func(time.Duration) (byte, bool, error) // optional; may be nil
59}
60
61// NewReader returns a Reader that pulls bytes from read.
62func NewReader(read func() (byte, error)) *Reader { return &Reader{src: read} }
63
64// NewReaderWithPeek returns a Reader that pulls bytes from read and uses
65// peek to disambiguate bare Esc from the start of an escape sequence.
66func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte, bool, error)) *Reader {
67	return &Reader{src: read, peek: peek}
68}
69
70// Read returns the next parsed Key.
71func (r *Reader) Read() (Key, error) {
72	b, err := r.src()
73	if err != nil {
74		return Key{}, err
75	}
76	switch {
77	case b == 0x03:
78		return Key{Kind: KeyCtrlC}, nil
79	case b == 0x04:
80		return Key{Kind: KeyCtrlD}, nil
81	case b == 0x0c:
82		return Key{Kind: KeyCtrlL}, nil
83	case b == 0x15:
84		return Key{Kind: KeyCtrlU}, nil
85	case b == 0x0b:
86		return Key{Kind: KeyCtrlK}, nil
87	case b == 0x01:
88		return Key{Kind: KeyCtrlA}, nil
89	case b == 0x05:
90		return Key{Kind: KeyCtrlE}, nil
91	case b == 0x17:
92		return Key{Kind: KeyCtrlW}, nil
93	case b == 0x0f:
94		return Key{Kind: KeyCtrlO}, nil
95	case b == 0x16:
96		return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
97	case b == '\r', b == '\n':
98		return Key{Kind: KeyEnter}, nil
99	case b == '\t':
100		return Key{Kind: KeyTab}, nil
101	case b == 0x7f, b == 0x08:
102		return Key{Kind: KeyBackspace}, nil
103	case b >= 0x01 && b <= 0x1a:
104		// Legacy terminals encode Ctrl+A..Ctrl+Z as the control bytes
105		// 0x01..0x1a. The bindings above have dedicated KeyKinds; the
106		// remaining letters surface as Ctrl-modified runes so keymaps
107		// can bind them. Legacy encodings cannot carry Shift, so
108		// Ctrl+Shift+letter arrives identically; only enhanced keyboard
109		// protocols distinguish the two. Consumers must treat a Ctrl
110		// rune as a chord, never as typed text.
111		return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil
112	case b == 0x1b:
113		return r.readEscape()
114	case b < 0x20:
115		return Key{Kind: KeyUnknown}, nil
116	}
117	// UTF-8 multibyte?
118	if b < 0x80 {
119		return Key{Kind: KeyRune, Rune: rune(b)}, nil
120	}
121	// Decode UTF-8 (up to 4 bytes).
122	n := utf8Len(b)
123	buf := []byte{b}
124	for i := 1; i < n; i++ {
125		bb, err := r.src()
126		if err != nil {
127			return Key{}, err
128		}
129		buf = append(buf, bb)
130	}
131	rn, _ := decodeRune(buf)
132	return Key{Kind: KeyRune, Rune: rn}, nil
133}
134
135func utf8Len(b byte) int {
136	switch {
137	case b&0xe0 == 0xc0:
138		return 2
139	case b&0xf0 == 0xe0:
140		return 3
141	case b&0xf8 == 0xf0:
142		return 4
143	}
144	return 1
145}
146
147func decodeRune(b []byte) (rune, int) {
148	// Minimal decoder; invalid runes become U+FFFD.
149	if len(b) == 1 {
150		return rune(b[0]), 1
151	}
152	var r rune
153	switch len(b) {
154	case 2:
155		r = rune(b[0]&0x1f)<<6 | rune(b[1]&0x3f)
156	case 3:
157		r = rune(b[0]&0x0f)<<12 | rune(b[1]&0x3f)<<6 | rune(b[2]&0x3f)
158	case 4:
159		r = rune(b[0]&0x07)<<18 | rune(b[1]&0x3f)<<12 | rune(b[2]&0x3f)<<6 | rune(b[3]&0x3f)
160	default:
161		r = 0xFFFD
162	}
163	return r, len(b)
164}
165
166// readEscape handles sequences starting with 0x1b.
167func (r *Reader) readEscape() (Key, error) {
168	// Bare ESC: maybe followed by another byte within a short window.
169	b, have, err := r.readEscapeNext(50 * time.Millisecond)
170	if err != nil || !have {
171		return Key{Kind: KeyEsc}, nil
172	}
173	switch b {
174	case '[':
175		return r.readCSI()
176	case 'O':
177		// SS3 sequences. Terminals in application cursor mode use these
178		// for arrows, while some terminals also use them for Home and End.
179		c, err := r.src()
180		if err != nil {
181			return Key{}, err
182		}
183		switch c {
184		case 'A':
185			return Key{Kind: KeyUp}, nil
186		case 'B':
187			return Key{Kind: KeyDown}, nil
188		case 'C':
189			return Key{Kind: KeyRight}, nil
190		case 'D':
191			return Key{Kind: KeyLeft}, nil
192		case 'H':
193			return Key{Kind: KeyHome}, nil
194		case 'F':
195			return Key{Kind: KeyEnd}, nil
196		}
197		return Key{Kind: KeyUnknown}, nil
198	case 0x7f, 0x08:
199		// Alt+Backspace (Option+Delete on macOS) — most terminals send
200		// ESC + DEL for this. Surface as a dedicated "alt backspace"
201		// so the editor can map it to delete-word.
202		return Key{Kind: KeyBackspace, Alt: true}, nil
203	case 'b':
204		// Emacs-style word-left, also emitted by some terminals for
205		// Option+LeftArrow.
206		return Key{Kind: KeyLeft, Alt: true}, nil
207	case 'f':
208		// Emacs-style word-right, also emitted for Option+RightArrow.
209		return Key{Kind: KeyRight, Alt: true}, nil
210	default:
211		// Alt+<char>
212		if b < 0x80 {
213			return Key{Kind: KeyRune, Rune: rune(b), Alt: true}, nil
214		}
215	}
216	return Key{Kind: KeyUnknown}, nil
217}
218
219// readEscapeNext tries to read one byte within d. If peek is available
220// we use it (true non-blocking). Otherwise we fall back to a blocking
221// read, which means bare Esc is only detected after the next keystroke.
222func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {
223	if r.peek != nil {
224		return r.peek(d)
225	}
226	b, err := r.src()
227	if err != nil {
228		return 0, false, err
229	}
230	return b, true, nil
231}
232
233// readCSI parses a CSI sequence after ESC [.
234func (r *Reader) readCSI() (Key, error) {
235	var params []byte
236	for {
237		c, err := r.src()
238		if err != nil {
239			return Key{}, err
240		}
241		if c >= 0x30 && c <= 0x3f {
242			params = append(params, c)
243			continue
244		}
245		// Final byte.
246		return r.dispatchCSI(string(params), c), nil
247	}
248}
249
250func (r *Reader) dispatchCSI(params string, final byte) Key {
251	// SGR mouse mode: CSI < button ; x ; y M/m. Wheel events use
252	// button codes 64 (up) and 65 (down). We ignore coordinates for
253	// now; the chat view only needs scroll direction.
254	if strings.HasPrefix(params, "<") && (final == 'M' || final == 'm') {
255		parts := strings.Split(strings.TrimPrefix(params, "<"), ";")
256		if len(parts) >= 1 {
257			switch parts[0] {
258			case "64":
259				return Key{Kind: KeyMouseWheelUp}
260			case "65":
261				return Key{Kind: KeyMouseWheelDown}
262			}
263		}
264		return Key{Kind: KeyUnknown}
265	}
266
267	shift, alt, super := parseCSIModifiers(params)
268	if final == 'u' {
269		if key, ok := parseCSIU(params); ok {
270			return key
271		}
272	}
273	if final == '~' {
274		if key, ok := parseModifyOtherKeys(params); ok {
275			return key
276		}
277	}
278	switch final {
279	case 'A':
280		return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super}
281	case 'B':
282		return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super}
283	case 'C':
284		return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super}
285	case 'D':
286		return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super}
287	case 'H':
288		return Key{Kind: KeyHome}
289	case 'F':
290		return Key{Kind: KeyEnd}
291	case 'Z':
292		return Key{Kind: KeyShiftTab}
293	case '~':
294		switch params {
295		case "3":
296			return Key{Kind: KeyDelete}
297		case "5":
298			return Key{Kind: KeyPageUp}
299		case "6":
300			return Key{Kind: KeyPageDown}
301		case "200":
302			// Start of bracketed paste.
303			return r.readPaste()
304		}
305	}
306	return Key{Kind: KeyUnknown}
307}
308
309func parseCSIModifiers(params string) (shift, alt, super bool) {
310	if params == "" {
311		return false, false, false
312	}
313	i := strings.LastIndexByte(params, ';')
314	if i < 0 || i+1 >= len(params) {
315		return false, false, false
316	}
317	mod, ok := parseModifierParam(params[i+1:])
318	if !ok {
319		return false, false, false
320	}
321	shift, alt, _, super = modifierBits(mod)
322	return shift, alt, super
323}
324
325func parseCSIU(params string) (Key, bool) {
326	parts := strings.Split(params, ";")
327	if len(parts) == 0 {
328		return Key{}, false
329	}
330	code, err := strconv.Atoi(parts[0])
331	if err != nil {
332		return Key{}, false
333	}
334	mod := 1
335	if len(parts) >= 2 {
336		var ok bool
337		mod, ok = parseModifierParam(parts[1])
338		if !ok {
339			return Key{}, false
340		}
341	}
342	return keyFromModifiedCode(code, mod)
343}
344
345func parseModifyOtherKeys(params string) (Key, bool) {
346	parts := strings.Split(params, ";")
347	if len(parts) != 3 || parts[0] != "27" {
348		return Key{}, false
349	}
350	mod, ok := parseModifierParam(parts[1])
351	if !ok {
352		return Key{}, false
353	}
354	code, err := strconv.Atoi(parts[2])
355	if err != nil {
356		return Key{}, false
357	}
358	return keyFromModifiedCode(code, mod)
359}
360
361func parseModifierParam(s string) (int, bool) {
362	if i := strings.IndexByte(s, ':'); i >= 0 {
363		s = s[:i]
364	}
365	mod, err := strconv.Atoi(s)
366	if err != nil {
367		return 0, false
368	}
369	return mod, true
370}
371
372func modifierBits(mod int) (shift, alt, ctrl, super bool) {
373	bits := mod - 1
374	return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0
375}
376
377func keyFromModifiedCode(code, mod int) (Key, bool) {
378	shift, alt, ctrl, super := modifierBits(mod)
379	// Kitty keyboard protocol (CSI ... u) reports control keys as their
380	// codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
381	// enhanced-mode handling these arrive as raw bytes; with it enabled
382	// they come through here, so map them back to their dedicated keys.
383	switch code {
384	case 13:
385		return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
386	case 27:
387		return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
388	case 9:
389		if shift {
390			return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true
391		}
392		return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
393	case 127, 8:
394		return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
395	}
396	if ctrl {
397		switch code {
398		case 'c', 'C':
399			return Key{Kind: KeyCtrlC, Shift: shift, Alt: alt, Ctrl: true}, true
400		case 'd', 'D':
401			return Key{Kind: KeyCtrlD, Shift: shift, Alt: alt, Ctrl: true}, true
402		case 'l', 'L':
403			return Key{Kind: KeyCtrlL, Shift: shift, Alt: alt, Ctrl: true}, true
404		case 'u', 'U':
405			return Key{Kind: KeyCtrlU, Shift: shift, Alt: alt, Ctrl: true}, true
406		case 'k', 'K':
407			return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true
408		case 'a', 'A':
409			return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true
410		case 'e', 'E':
411			return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true
412		case 'w', 'W':
413			return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
414		case 'o', 'O':
415			return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
416		case 'v', 'V':
417			return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
418		}
419		// Enhanced keyboard protocols preserve Ctrl for letters that do
420		// not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S.
421		if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) {
422			return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true
423		}
424	}
425	if code >= '0' && code <= '9' {
426		return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
427	}
428	if code >= 0x20 && code <= 0x7e && (shift || alt || super) {
429		return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
430	}
431	return Key{}, false
432}
433
434// readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text.
435func (r *Reader) readPaste() Key {
436	var sb strings.Builder
437	const end = "\x1b[201~"
438	tail := make([]byte, 0, len(end))
439	for {
440		b, err := r.src()
441		if err != nil {
442			break
443		}
444		tail = append(tail, b)
445		if len(tail) > len(end) {
446			sb.WriteByte(tail[0])
447			tail = tail[1:]
448		}
449		if string(tail) == end {
450			break
451		}
452	}
453	return Key{Kind: KeyPaste, Paste: sb.String()}
454}

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 1

Tags 476

↑↓ moveenter openesc close