v0.2.36 5a4e492

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

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