v0.2.44 4bec50a

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

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