v0.2.52 4694534

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

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