v0.1.20 e52a560

input.go RawHistoryFolder

1package tui
2
3import (
4	"strings"
5	"time"
6)
7
8// Key is a parsed keypress.
9type Key struct {
10	Kind  KeyKind
11	Rune  rune   // for KeyRune
12	Paste string // for KeyPaste
13	Ctrl  bool
14	Alt   bool
15}
16
17type KeyKind int
18
19const (
20	KeyRune KeyKind = iota
21	KeyEnter
22	KeyBackspace
23	KeyTab
24	KeyShiftTab
25	KeyEsc
26	KeyUp
27	KeyDown
28	KeyLeft
29	KeyRight
30	KeyHome
31	KeyEnd
32	KeyPageUp
33	KeyPageDown
34	KeyDelete
35	KeyCtrlC
36	KeyCtrlD
37	KeyCtrlL
38	KeyCtrlU
39	KeyCtrlK
40	KeyCtrlA
41	KeyCtrlE
42	KeyCtrlW
43	KeyCtrlO
44	KeyPaste
45	KeyUnknown
46)
47
48// Reader parses a byte stream into Key events. It understands basic
49// xterm escape sequences and bracketed paste.
50type Reader struct {
51	src  func() (byte, error)
52	peek func(time.Duration) (byte, bool, error) // optional; may be nil
53}
54
55// NewReader returns a Reader that pulls bytes from read.
56func NewReader(read func() (byte, error)) *Reader { return &Reader{src: read} }
57
58// NewReaderWithPeek returns a Reader that pulls bytes from read and uses
59// peek to disambiguate bare Esc from the start of an escape sequence.
60func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte, bool, error)) *Reader {
61	return &Reader{src: read, peek: peek}
62}
63
64// Read returns the next parsed Key.
65func (r *Reader) Read() (Key, error) {
66	b, err := r.src()
67	if err != nil {
68		return Key{}, err
69	}
70	switch {
71	case b == 0x03:
72		return Key{Kind: KeyCtrlC}, nil
73	case b == 0x04:
74		return Key{Kind: KeyCtrlD}, nil
75	case b == 0x0c:
76		return Key{Kind: KeyCtrlL}, nil
77	case b == 0x15:
78		return Key{Kind: KeyCtrlU}, nil
79	case b == 0x0b:
80		return Key{Kind: KeyCtrlK}, nil
81	case b == 0x01:
82		return Key{Kind: KeyCtrlA}, nil
83	case b == 0x05:
84		return Key{Kind: KeyCtrlE}, nil
85	case b == 0x17:
86		return Key{Kind: KeyCtrlW}, nil
87	case b == 0x0f:
88		return Key{Kind: KeyCtrlO}, nil
89	case b == '\r', b == '\n':
90		return Key{Kind: KeyEnter}, nil
91	case b == '\t':
92		return Key{Kind: KeyTab}, nil
93	case b == 0x7f, b == 0x08:
94		return Key{Kind: KeyBackspace}, nil
95	case b == 0x1b:
96		return r.readEscape()
97	case b < 0x20:
98		return Key{Kind: KeyUnknown}, nil
99	}
100	// UTF-8 multibyte?
101	if b < 0x80 {
102		return Key{Kind: KeyRune, Rune: rune(b)}, nil
103	}
104	// Decode UTF-8 (up to 4 bytes).
105	n := utf8Len(b)
106	buf := []byte{b}
107	for i := 1; i < n; i++ {
108		bb, err := r.src()
109		if err != nil {
110			return Key{}, err
111		}
112		buf = append(buf, bb)
113	}
114	rn, _ := decodeRune(buf)
115	return Key{Kind: KeyRune, Rune: rn}, nil
116}
117
118func utf8Len(b byte) int {
119	switch {
120	case b&0xe0 == 0xc0:
121		return 2
122	case b&0xf0 == 0xe0:
123		return 3
124	case b&0xf8 == 0xf0:
125		return 4
126	}
127	return 1
128}
129
130func decodeRune(b []byte) (rune, int) {
131	// Minimal decoder; invalid runes become U+FFFD.
132	if len(b) == 1 {
133		return rune(b[0]), 1
134	}
135	var r rune
136	switch len(b) {
137	case 2:
138		r = rune(b[0]&0x1f)<<6 | rune(b[1]&0x3f)
139	case 3:
140		r = rune(b[0]&0x0f)<<12 | rune(b[1]&0x3f)<<6 | rune(b[2]&0x3f)
141	case 4:
142		r = rune(b[0]&0x07)<<18 | rune(b[1]&0x3f)<<12 | rune(b[2]&0x3f)<<6 | rune(b[3]&0x3f)
143	default:
144		r = 0xFFFD
145	}
146	return r, len(b)
147}
148
149// readEscape handles sequences starting with 0x1b.
150func (r *Reader) readEscape() (Key, error) {
151	// Bare ESC: maybe followed by another byte within a short window.
152	b, have, err := r.readEscapeNext(50 * time.Millisecond)
153	if err != nil || !have {
154		return Key{Kind: KeyEsc}, nil
155	}
156	switch b {
157	case '[':
158		return r.readCSI()
159	case 'O':
160		// SS3 sequences (function keys in some terminals).
161		c, err := r.src()
162		if err != nil {
163			return Key{}, err
164		}
165		switch c {
166		case 'H':
167			return Key{Kind: KeyHome}, nil
168		case 'F':
169			return Key{Kind: KeyEnd}, nil
170		}
171		return Key{Kind: KeyUnknown}, nil
172	case 0x7f, 0x08:
173		// Alt+Backspace (Option+Delete on macOS) — most terminals send
174		// ESC + DEL for this. Surface as a dedicated "alt backspace"
175		// so the editor can map it to delete-word.
176		return Key{Kind: KeyBackspace, Alt: true}, nil
177	case 'b':
178		// Emacs-style word-left, also emitted by some terminals for
179		// Option+LeftArrow.
180		return Key{Kind: KeyLeft, Alt: true}, nil
181	case 'f':
182		// Emacs-style word-right, also emitted for Option+RightArrow.
183		return Key{Kind: KeyRight, Alt: true}, nil
184	default:
185		// Alt+<char>
186		if b < 0x80 {
187			return Key{Kind: KeyRune, Rune: rune(b), Alt: true}, nil
188		}
189	}
190	return Key{Kind: KeyUnknown}, nil
191}
192
193// readEscapeNext tries to read one byte within d. If peek is available
194// we use it (true non-blocking). Otherwise we fall back to a blocking
195// read, which means bare Esc is only detected after the next keystroke.
196func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {
197	if r.peek != nil {
198		return r.peek(d)
199	}
200	b, err := r.src()
201	if err != nil {
202		return 0, false, err
203	}
204	return b, true, nil
205}
206
207// readCSI parses a CSI sequence after ESC [.
208func (r *Reader) readCSI() (Key, error) {
209	var params []byte
210	for {
211		c, err := r.src()
212		if err != nil {
213			return Key{}, err
214		}
215		if c >= 0x30 && c <= 0x3f {
216			params = append(params, c)
217			continue
218		}
219		// Final byte.
220		return r.dispatchCSI(string(params), c), nil
221	}
222}
223
224func (r *Reader) dispatchCSI(params string, final byte) Key {
225	// Modified arrow keys come in as CSI 1;<mod><final>. Modifier values
226	// we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt.
227	var alt bool
228	if params != "" {
229		if i := strings.IndexByte(params, ';'); i >= 0 {
230			mod := params[i+1:]
231			if mod == "3" || mod == "4" || mod == "7" || mod == "8" {
232				// 3=Alt, 4=Shift+Alt, 7=Ctrl+Alt, 8=Ctrl+Shift+Alt
233				alt = true
234			}
235		}
236	}
237	switch final {
238	case 'A':
239		return Key{Kind: KeyUp, Alt: alt}
240	case 'B':
241		return Key{Kind: KeyDown, Alt: alt}
242	case 'C':
243		return Key{Kind: KeyRight, Alt: alt}
244	case 'D':
245		return Key{Kind: KeyLeft, Alt: alt}
246	case 'H':
247		return Key{Kind: KeyHome}
248	case 'F':
249		return Key{Kind: KeyEnd}
250	case 'Z':
251		return Key{Kind: KeyShiftTab}
252	case '~':
253		switch params {
254		case "3":
255			return Key{Kind: KeyDelete}
256		case "5":
257			return Key{Kind: KeyPageUp}
258		case "6":
259			return Key{Kind: KeyPageDown}
260		case "200":
261			// Start of bracketed paste.
262			return r.readPaste()
263		}
264	}
265	return Key{Kind: KeyUnknown}
266}
267
268// readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text.
269func (r *Reader) readPaste() Key {
270	var sb strings.Builder
271	const end = "\x1b[201~"
272	tail := make([]byte, 0, len(end))
273	for {
274		b, err := r.src()
275		if err != nil {
276			break
277		}
278		tail = append(tail, b)
279		if len(tail) > len(end) {
280			sb.WriteByte(tail[0])
281			tail = tail[1:]
282		}
283		if string(tail) == end {
284			break
285		}
286	}
287	return Key{Kind: KeyPaste, Paste: sb.String()}
288}

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