v0.1.61 0a85be3

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

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