v0.3.58 532d7f6

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 == 0x1b:
104		return r.readEscape()
105	case b < 0x20:
106		return Key{Kind: KeyUnknown}, nil
107	}
108	// UTF-8 multibyte?
109	if b < 0x80 {
110		return Key{Kind: KeyRune, Rune: rune(b)}, nil
111	}
112	// Decode UTF-8 (up to 4 bytes).
113	n := utf8Len(b)
114	buf := []byte{b}
115	for i := 1; i < n; i++ {
116		bb, err := r.src()
117		if err != nil {
118			return Key{}, err
119		}
120		buf = append(buf, bb)
121	}
122	rn, _ := decodeRune(buf)
123	return Key{Kind: KeyRune, Rune: rn}, nil
124}
125
126func utf8Len(b byte) int {
127	switch {
128	case b&0xe0 == 0xc0:
129		return 2
130	case b&0xf0 == 0xe0:
131		return 3
132	case b&0xf8 == 0xf0:
133		return 4
134	}
135	return 1
136}
137
138func decodeRune(b []byte) (rune, int) {
139	// Minimal decoder; invalid runes become U+FFFD.
140	if len(b) == 1 {
141		return rune(b[0]), 1
142	}
143	var r rune
144	switch len(b) {
145	case 2:
146		r = rune(b[0]&0x1f)<<6 | rune(b[1]&0x3f)
147	case 3:
148		r = rune(b[0]&0x0f)<<12 | rune(b[1]&0x3f)<<6 | rune(b[2]&0x3f)
149	case 4:
150		r = rune(b[0]&0x07)<<18 | rune(b[1]&0x3f)<<12 | rune(b[2]&0x3f)<<6 | rune(b[3]&0x3f)
151	default:
152		r = 0xFFFD
153	}
154	return r, len(b)
155}
156
157// readEscape handles sequences starting with 0x1b.
158func (r *Reader) readEscape() (Key, error) {
159	// Bare ESC: maybe followed by another byte within a short window.
160	b, have, err := r.readEscapeNext(50 * time.Millisecond)
161	if err != nil || !have {
162		return Key{Kind: KeyEsc}, nil
163	}
164	switch b {
165	case '[':
166		return r.readCSI()
167	case 'O':
168		// SS3 sequences. Terminals in application cursor mode use these
169		// for arrows, while some terminals also use them for Home and End.
170		c, err := r.src()
171		if err != nil {
172			return Key{}, err
173		}
174		switch c {
175		case 'A':
176			return Key{Kind: KeyUp}, nil
177		case 'B':
178			return Key{Kind: KeyDown}, nil
179		case 'C':
180			return Key{Kind: KeyRight}, nil
181		case 'D':
182			return Key{Kind: KeyLeft}, nil
183		case 'H':
184			return Key{Kind: KeyHome}, nil
185		case 'F':
186			return Key{Kind: KeyEnd}, nil
187		}
188		return Key{Kind: KeyUnknown}, nil
189	case 0x7f, 0x08:
190		// Alt+Backspace (Option+Delete on macOS) — most terminals send
191		// ESC + DEL for this. Surface as a dedicated "alt backspace"
192		// so the editor can map it to delete-word.
193		return Key{Kind: KeyBackspace, Alt: true}, nil
194	case 'b':
195		// Emacs-style word-left, also emitted by some terminals for
196		// Option+LeftArrow.
197		return Key{Kind: KeyLeft, Alt: true}, nil
198	case 'f':
199		// Emacs-style word-right, also emitted for Option+RightArrow.
200		return Key{Kind: KeyRight, Alt: true}, nil
201	default:
202		// Alt+<char>
203		if b < 0x80 {
204			return Key{Kind: KeyRune, Rune: rune(b), Alt: true}, nil
205		}
206	}
207	return Key{Kind: KeyUnknown}, nil
208}
209
210// readEscapeNext tries to read one byte within d. If peek is available
211// we use it (true non-blocking). Otherwise we fall back to a blocking
212// read, which means bare Esc is only detected after the next keystroke.
213func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {
214	if r.peek != nil {
215		return r.peek(d)
216	}
217	b, err := r.src()
218	if err != nil {
219		return 0, false, err
220	}
221	return b, true, nil
222}
223
224// readCSI parses a CSI sequence after ESC [.
225func (r *Reader) readCSI() (Key, error) {
226	var params []byte
227	for {
228		c, err := r.src()
229		if err != nil {
230			return Key{}, err
231		}
232		if c >= 0x30 && c <= 0x3f {
233			params = append(params, c)
234			continue
235		}
236		// Final byte.
237		return r.dispatchCSI(string(params), c), nil
238	}
239}
240
241func (r *Reader) dispatchCSI(params string, final byte) Key {
242	// SGR mouse mode: CSI < button ; x ; y M/m. Wheel events use
243	// button codes 64 (up) and 65 (down). We ignore coordinates for
244	// now; the chat view only needs scroll direction.
245	if strings.HasPrefix(params, "<") && (final == 'M' || final == 'm') {
246		parts := strings.Split(strings.TrimPrefix(params, "<"), ";")
247		if len(parts) >= 1 {
248			switch parts[0] {
249			case "64":
250				return Key{Kind: KeyMouseWheelUp}
251			case "65":
252				return Key{Kind: KeyMouseWheelDown}
253			}
254		}
255		return Key{Kind: KeyUnknown}
256	}
257
258	shift, alt, super := parseCSIModifiers(params)
259	if final == 'u' {
260		if key, ok := parseCSIU(params); ok {
261			return key
262		}
263	}
264	if final == '~' {
265		if key, ok := parseModifyOtherKeys(params); ok {
266			return key
267		}
268	}
269	switch final {
270	case 'A':
271		return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super}
272	case 'B':
273		return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super}
274	case 'C':
275		return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super}
276	case 'D':
277		return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super}
278	case 'H':
279		return Key{Kind: KeyHome}
280	case 'F':
281		return Key{Kind: KeyEnd}
282	case 'Z':
283		return Key{Kind: KeyShiftTab}
284	case '~':
285		switch params {
286		case "3":
287			return Key{Kind: KeyDelete}
288		case "5":
289			return Key{Kind: KeyPageUp}
290		case "6":
291			return Key{Kind: KeyPageDown}
292		case "200":
293			// Start of bracketed paste.
294			return r.readPaste()
295		}
296	}
297	return Key{Kind: KeyUnknown}
298}
299
300func parseCSIModifiers(params string) (shift, alt, super bool) {
301	if params == "" {
302		return false, false, false
303	}
304	i := strings.LastIndexByte(params, ';')
305	if i < 0 || i+1 >= len(params) {
306		return false, false, false
307	}
308	mod, ok := parseModifierParam(params[i+1:])
309	if !ok {
310		return false, false, false
311	}
312	shift, alt, _, super = modifierBits(mod)
313	return shift, alt, super
314}
315
316func parseCSIU(params string) (Key, bool) {
317	parts := strings.Split(params, ";")
318	if len(parts) == 0 {
319		return Key{}, false
320	}
321	code, err := strconv.Atoi(parts[0])
322	if err != nil {
323		return Key{}, false
324	}
325	mod := 1
326	if len(parts) >= 2 {
327		var ok bool
328		mod, ok = parseModifierParam(parts[1])
329		if !ok {
330			return Key{}, false
331		}
332	}
333	return keyFromModifiedCode(code, mod)
334}
335
336func parseModifyOtherKeys(params string) (Key, bool) {
337	parts := strings.Split(params, ";")
338	if len(parts) != 3 || parts[0] != "27" {
339		return Key{}, false
340	}
341	mod, ok := parseModifierParam(parts[1])
342	if !ok {
343		return Key{}, false
344	}
345	code, err := strconv.Atoi(parts[2])
346	if err != nil {
347		return Key{}, false
348	}
349	return keyFromModifiedCode(code, mod)
350}
351
352func parseModifierParam(s string) (int, bool) {
353	if i := strings.IndexByte(s, ':'); i >= 0 {
354		s = s[:i]
355	}
356	mod, err := strconv.Atoi(s)
357	if err != nil {
358		return 0, false
359	}
360	return mod, true
361}
362
363func modifierBits(mod int) (shift, alt, ctrl, super bool) {
364	bits := mod - 1
365	return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0
366}
367
368func keyFromModifiedCode(code, mod int) (Key, bool) {
369	shift, alt, ctrl, super := modifierBits(mod)
370	// Kitty keyboard protocol (CSI ... u) reports control keys as their
371	// codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
372	// enhanced-mode handling these arrive as raw bytes; with it enabled
373	// they come through here, so map them back to their dedicated keys.
374	switch code {
375	case 13:
376		return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
377	case 27:
378		return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
379	case 9:
380		if shift {
381			return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true
382		}
383		return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
384	case 127, 8:
385		return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
386	}
387	if ctrl {
388		switch code {
389		case 'c', 'C':
390			return Key{Kind: KeyCtrlC, Shift: shift, Alt: alt, Ctrl: true}, true
391		case 'd', 'D':
392			return Key{Kind: KeyCtrlD, Shift: shift, Alt: alt, Ctrl: true}, true
393		case 'l', 'L':
394			return Key{Kind: KeyCtrlL, Shift: shift, Alt: alt, Ctrl: true}, true
395		case 'u', 'U':
396			return Key{Kind: KeyCtrlU, Shift: shift, Alt: alt, Ctrl: true}, true
397		case 'k', 'K':
398			return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true
399		case 'a', 'A':
400			return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true
401		case 'e', 'E':
402			return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true
403		case 'w', 'W':
404			return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
405		case 'o', 'O':
406			return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
407		case 'v', 'V':
408			return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
409		}
410	}
411	if code >= '0' && code <= '9' {
412		return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
413	}
414	return Key{}, false
415}
416
417// readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text.
418func (r *Reader) readPaste() Key {
419	var sb strings.Builder
420	const end = "\x1b[201~"
421	tail := make([]byte, 0, len(end))
422	for {
423		b, err := r.src()
424		if err != nil {
425			break
426		}
427		tail = append(tail, b)
428		if len(tail) > len(end) {
429			sb.WriteByte(tail[0])
430			tail = tail[1:]
431		}
432		if string(tail) == end {
433			break
434		}
435	}
436	return Key{Kind: KeyPaste, Paste: sb.String()}
437}

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