v0.31.0 9467429

input.go RawHistoryFolder

1package tui
2
3import (
4	"strconv"
5	"strings"
6	"time"
7	"unicode"
8)
9
10// Key is a parsed keypress.
11type Key struct {
12	Kind  KeyKind
13	Rune  rune   // for KeyRune
14	Paste string // for KeyPaste
15	Ctrl  bool
16	Alt   bool
17	Shift bool
18	Super bool
19}
20
21type KeyKind int
22
23const (
24	KeyRune KeyKind = iota
25	KeyEnter
26	KeyBackspace
27	KeyTab
28	KeyShiftTab
29	KeyEsc
30	KeyUp
31	KeyDown
32	KeyLeft
33	KeyRight
34	KeyHome
35	KeyEnd
36	KeyPageUp
37	KeyPageDown
38	KeyDelete
39	KeyCtrlC
40	KeyCtrlD
41	KeyCtrlL
42	KeyCtrlU
43	KeyCtrlK
44	KeyCtrlA
45	KeyCtrlE
46	KeyCtrlW
47	KeyCtrlO
48	KeyCtrlR
49	KeyPaste
50	KeyPasteClipboard
51	KeyMouseWheelUp
52	KeyMouseWheelDown
53	KeyUnknown
54)
55
56// Reader parses a byte stream into Key events. It understands basic
57// xterm escape sequences and bracketed paste.
58type Reader struct {
59	src  func() (byte, error)
60	peek func(time.Duration) (byte, bool, error) // optional; may be nil
61
62	// pending contains bytes consumed while deciding whether an Esc was
63	// bare. In particular, a second Esc belongs to the next Read, not to
64	// the Alt+<char> case handled by readEscape.
65	pending []byte
66
67	// A Reader without a peek callback still needs to preserve the old
68	// NewReader API and parse Alt/CSI input. The pump lets its bounded
69	// lookahead wait without calling the blocking source on the Read caller's
70	// goroutine. There is never more than one outstanding source call: after a
71	// timeout that call is reused by the next Read rather than replaced.
72	//
73	// The source callback has no cancellation contract, so an implementation
74	// that blocks forever can keep this one goroutine alive forever. That
75	// limitation is intentional; starting another goroutine for each isolated
76	// Esc would leak an unbounded number of blocked goroutines and could also
77	// reorder bytes from an uncancellable source.
78	pump    *readerPump
79	pumpErr error
80}
81
82type readerResult struct {
83	b   byte
84	err error
85}
86
87type readerPump struct {
88	result chan readerResult
89}
90
91func newReaderPump(read func() (byte, error)) *readerPump {
92	p := &readerPump{result: make(chan readerResult, 1)}
93	go func() {
94		b, err := read()
95		p.result <- readerResult{b: b, err: err}
96	}()
97	return p
98}
99
100// NewReader returns a Reader that pulls bytes from read.
101func NewReader(read func() (byte, error)) *Reader { return &Reader{src: read} }
102
103// NewReaderWithPeek returns a Reader that pulls bytes from read and uses
104// peek to disambiguate bare Esc from the start of an escape sequence.
105func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte, bool, error)) *Reader {
106	return &Reader{src: read, peek: peek}
107}
108
109// readByte returns the next source byte, including any byte held back after
110// an escape lookahead.
111func (r *Reader) readByte() (byte, error) {
112	if len(r.pending) != 0 {
113		b := r.pending[0]
114		r.pending = r.pending[1:]
115		return b, nil
116	}
117	if r.pumpErr != nil {
118		return 0, r.pumpErr
119	}
120	if r.pump == nil {
121		return r.src()
122	}
123	result := <-r.pump.result
124	r.pump = nil
125	if result.err != nil {
126		r.pumpErr = result.err
127	}
128	return result.b, result.err
129}
130
131// readByteTimeout returns one source byte without waiting longer than d. A
132// source call left outstanding after a timeout is consumed by the next Read.
133func (r *Reader) readByteTimeout(d time.Duration) (byte, bool, error) {
134	if r.pumpErr != nil {
135		return 0, false, r.pumpErr
136	}
137	if r.pump == nil {
138		r.pump = newReaderPump(r.src)
139	}
140
141	var result readerResult
142	if d <= 0 {
143		select {
144		case result = <-r.pump.result:
145		default:
146			return 0, false, nil
147		}
148	} else {
149		timer := time.NewTimer(d)
150		defer timer.Stop()
151		select {
152		case result = <-r.pump.result:
153		case <-timer.C:
154			return 0, false, nil
155		}
156	}
157	r.pump = nil
158	if result.err != nil {
159		r.pumpErr = result.err
160		return 0, false, result.err
161	}
162	return result.b, true, nil
163}
164
165// Read returns the next parsed Key.
166func (r *Reader) Read() (Key, error) {
167	b, err := r.readByte()
168	if err != nil {
169		return Key{}, err
170	}
171	switch {
172	case b == 0x03:
173		return Key{Kind: KeyCtrlC}, nil
174	case b == 0x04:
175		return Key{Kind: KeyCtrlD}, nil
176	case b == 0x0c:
177		return Key{Kind: KeyCtrlL}, nil
178	case b == 0x15:
179		return Key{Kind: KeyCtrlU}, nil
180	case b == 0x0b:
181		return Key{Kind: KeyCtrlK}, nil
182	case b == 0x01:
183		return Key{Kind: KeyCtrlA}, nil
184	case b == 0x05:
185		return Key{Kind: KeyCtrlE}, nil
186	case b == 0x17:
187		return Key{Kind: KeyCtrlW}, nil
188	case b == 0x0f:
189		return Key{Kind: KeyCtrlO}, nil
190	case b == 0x12:
191		return Key{Kind: KeyCtrlR}, nil
192	case b == 0x16:
193		return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
194	case b == '\r', b == '\n':
195		return Key{Kind: KeyEnter}, nil
196	case b == '\t':
197		return Key{Kind: KeyTab}, nil
198	case b == 0x7f, b == 0x08:
199		return Key{Kind: KeyBackspace}, nil
200	case b == 0x1b:
201		return r.readEscape()
202	case b < 0x20:
203		return Key{Kind: KeyUnknown}, nil
204	}
205	// UTF-8 multibyte?
206	if b < 0x80 {
207		return Key{Kind: KeyRune, Rune: rune(b)}, nil
208	}
209	// Decode UTF-8 (up to 4 bytes).
210	n := utf8Len(b)
211	buf := []byte{b}
212	for i := 1; i < n; i++ {
213		bb, err := r.readByte()
214		if err != nil {
215			return Key{}, err
216		}
217		buf = append(buf, bb)
218	}
219	rn, _ := decodeRune(buf)
220	return Key{Kind: KeyRune, Rune: rn}, nil
221}
222
223func utf8Len(b byte) int {
224	switch {
225	case b&0xe0 == 0xc0:
226		return 2
227	case b&0xf0 == 0xe0:
228		return 3
229	case b&0xf8 == 0xf0:
230		return 4
231	}
232	return 1
233}
234
235func decodeRune(b []byte) (rune, int) {
236	// Minimal decoder; invalid runes become U+FFFD.
237	if len(b) == 1 {
238		return rune(b[0]), 1
239	}
240	var r rune
241	switch len(b) {
242	case 2:
243		r = rune(b[0]&0x1f)<<6 | rune(b[1]&0x3f)
244	case 3:
245		r = rune(b[0]&0x0f)<<12 | rune(b[1]&0x3f)<<6 | rune(b[2]&0x3f)
246	case 4:
247		r = rune(b[0]&0x07)<<18 | rune(b[1]&0x3f)<<12 | rune(b[2]&0x3f)<<6 | rune(b[3]&0x3f)
248	default:
249		r = 0xFFFD
250	}
251	return r, len(b)
252}
253
254// readEscape handles sequences starting with 0x1b.
255func (r *Reader) readEscape() (Key, error) {
256	// Bare ESC: maybe followed by another byte within a short window.
257	b, have, err := r.readEscapeNext(50 * time.Millisecond)
258	if err != nil || !have {
259		return Key{Kind: KeyEsc}, nil
260	}
261	if b == 0x1b {
262		// A second raw ESC is a separate key, not Alt+ESC. Keep it for the
263		// next Read so Esc Esc produces two bare KeyEsc events. This is the
264		// one lookahead case that is not intentionally treated as Alt input.
265		r.pending = append(r.pending, b)
266		return Key{Kind: KeyEsc}, nil
267	}
268	switch b {
269	case '[':
270		return r.readCSI()
271	case 'O':
272		// SS3 sequences (function keys in some terminals).
273		c, err := r.readByte()
274		if err != nil {
275			return Key{}, err
276		}
277		switch c {
278		case 'H':
279			return Key{Kind: KeyHome}, nil
280		case 'F':
281			return Key{Kind: KeyEnd}, nil
282		}
283		return Key{Kind: KeyUnknown}, nil
284	case 0x7f, 0x08:
285		// Alt+Backspace (Option+Delete on macOS) — most terminals send
286		// ESC + DEL for this. Surface as a dedicated "alt backspace"
287		// so the editor can map it to delete-word.
288		return Key{Kind: KeyBackspace, Alt: true}, nil
289	case 'b':
290		// Emacs-style word-left, also emitted by some terminals for
291		// Option+LeftArrow.
292		return Key{Kind: KeyLeft, Alt: true}, nil
293	case 'f':
294		// Emacs-style word-right, also emitted for Option+RightArrow.
295		return Key{Kind: KeyRight, Alt: true}, nil
296	default:
297		// Alt+<char>
298		if b < 0x80 {
299			return Key{Kind: KeyRune, Rune: rune(b), Alt: true}, nil
300		}
301	}
302	return Key{Kind: KeyUnknown}, nil
303}
304
305// readEscapeNext tries to read one byte within d. If peek is available
306// we use it. NewReader's source pump supplies the same bounded behavior
307// when no peek callback is provided, so a bare Esc never waits forever for
308// a second byte. A follow-on printable byte, DEL, b, or f remains
309// intentionally ambiguous with Alt input and keeps the historical mapping
310// in readEscape.
311func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) {
312	if r.peek != nil {
313		return r.peek(d)
314	}
315	return r.readByteTimeout(d)
316}
317
318// readCSI parses a CSI sequence after ESC [.
319func (r *Reader) readCSI() (Key, error) {
320	var params []byte
321	for {
322		c, err := r.readByte()
323		if err != nil {
324			return Key{}, err
325		}
326		if c >= 0x30 && c <= 0x3f {
327			params = append(params, c)
328			continue
329		}
330		// Final byte.
331		return r.dispatchCSI(string(params), c), nil
332	}
333}
334
335func (r *Reader) dispatchCSI(params string, final byte) Key {
336	// SGR mouse mode: CSI < button ; x ; y M/m. Wheel events use
337	// button codes 64 (up) and 65 (down). We ignore coordinates for
338	// now; the chat view only needs scroll direction.
339	if strings.HasPrefix(params, "<") && (final == 'M' || final == 'm') {
340		parts := strings.Split(strings.TrimPrefix(params, "<"), ";")
341		if len(parts) >= 1 {
342			switch parts[0] {
343			case "64":
344				return Key{Kind: KeyMouseWheelUp}
345			case "65":
346				return Key{Kind: KeyMouseWheelDown}
347			}
348		}
349		return Key{Kind: KeyUnknown}
350	}
351
352	shift, alt, super := parseCSIModifiers(params)
353	if final == 'u' {
354		if key, ok := parseCSIU(params); ok {
355			return key
356		}
357	}
358	if final == '~' {
359		if key, ok := parseModifyOtherKeys(params); ok {
360			return key
361		}
362	}
363	switch final {
364	case 'A':
365		return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super}
366	case 'B':
367		return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super}
368	case 'C':
369		return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super}
370	case 'D':
371		return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super}
372	case 'H':
373		return Key{Kind: KeyHome}
374	case 'F':
375		return Key{Kind: KeyEnd}
376	case 'Z':
377		return Key{Kind: KeyShiftTab}
378	case '~':
379		switch params {
380		case "3":
381			return Key{Kind: KeyDelete}
382		case "5":
383			return Key{Kind: KeyPageUp}
384		case "6":
385			return Key{Kind: KeyPageDown}
386		case "200":
387			// Start of bracketed paste.
388			return r.readPaste()
389		}
390	}
391	return Key{Kind: KeyUnknown}
392}
393
394func parseCSIModifiers(params string) (shift, alt, super bool) {
395	if params == "" {
396		return false, false, false
397	}
398	i := strings.LastIndexByte(params, ';')
399	if i < 0 || i+1 >= len(params) {
400		return false, false, false
401	}
402	mod, ok := parseModifierParam(params[i+1:])
403	if !ok {
404		return false, false, false
405	}
406	shift, alt, _, super = modifierBits(mod)
407	return shift, alt, super
408}
409
410func parseCSIU(params string) (Key, bool) {
411	parts := strings.Split(params, ";")
412	if len(parts) == 0 {
413		return Key{}, false
414	}
415	// Kitty may include shifted and base-layout alternatives (code:shifted:base).
416	// The primary code preserves the user's layout, including AZERTY punctuation.
417	primary, _, _ := strings.Cut(parts[0], ":")
418	code, err := strconv.Atoi(primary)
419	if err != nil {
420		return Key{}, false
421	}
422	mod := 1
423	if len(parts) >= 2 {
424		var ok bool
425		mod, ok = parseModifierParam(parts[1])
426		if !ok {
427			return Key{}, false
428		}
429	}
430	return keyFromModifiedCode(code, mod)
431}
432
433func parseModifyOtherKeys(params string) (Key, bool) {
434	parts := strings.Split(params, ";")
435	if len(parts) != 3 || parts[0] != "27" {
436		return Key{}, false
437	}
438	mod, ok := parseModifierParam(parts[1])
439	if !ok {
440		return Key{}, false
441	}
442	code, err := strconv.Atoi(parts[2])
443	if err != nil {
444		return Key{}, false
445	}
446	return keyFromModifiedCode(code, mod)
447}
448
449func parseModifierParam(s string) (int, bool) {
450	if i := strings.IndexByte(s, ':'); i >= 0 {
451		s = s[:i]
452	}
453	mod, err := strconv.Atoi(s)
454	if err != nil {
455		return 0, false
456	}
457	return mod, true
458}
459
460func modifierBits(mod int) (shift, alt, ctrl, super bool) {
461	bits := mod - 1
462	return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0
463}
464
465func keyFromModifiedCode(code, mod int) (Key, bool) {
466	shift, alt, ctrl, super := modifierBits(mod)
467	// Kitty keyboard protocol (CSI ... u) reports control keys as their
468	// codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
469	// enhanced-mode handling these arrive as raw bytes; with it enabled
470	// they come through here, so map them back to their dedicated keys.
471	switch code {
472	case 13:
473		return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
474	case 27:
475		return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
476	case 9:
477		if shift {
478			return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true
479		}
480		return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
481	case 127, 8:
482		return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
483	}
484	if ctrl {
485		switch code {
486		case 'c', 'C':
487			return Key{Kind: KeyCtrlC, Shift: shift, Alt: alt, Ctrl: true}, true
488		case 'd', 'D':
489			return Key{Kind: KeyCtrlD, Shift: shift, Alt: alt, Ctrl: true}, true
490		case 'l', 'L':
491			return Key{Kind: KeyCtrlL, Shift: shift, Alt: alt, Ctrl: true}, true
492		case 'u', 'U':
493			return Key{Kind: KeyCtrlU, Shift: shift, Alt: alt, Ctrl: true}, true
494		case 'k', 'K':
495			return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true
496		case 'a', 'A':
497			return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true
498		case 'e', 'E':
499			return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true
500		case 'w', 'W':
501			return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
502		case 'o', 'O':
503			return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
504		case 'r', 'R':
505			return Key{Kind: KeyCtrlR, Shift: shift, Alt: alt, Ctrl: true}, true
506		case 'v', 'V':
507			return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
508		}
509	}
510	if code >= '0' && code <= '9' {
511		return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
512	}
513	// Only the AZERTY profile shortcuts are printable Ctrl exceptions. Other
514	// Ctrl chords must remain unknown rather than becoming editor text.
515	azertyProfileKey := !alt && strings.ContainsRune("&é\"'(-è_ç", rune(code))
516	if code >= 0x20 && code <= unicode.MaxRune && unicode.IsPrint(rune(code)) && (!ctrl || azertyProfileKey) {
517		return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
518	}
519	return Key{}, false
520}
521
522// readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text.
523func (r *Reader) readPaste() Key {
524	var sb strings.Builder
525	const end = "\x1b[201~"
526	tail := make([]byte, 0, len(end))
527	for {
528		b, err := r.readByte()
529		if err != nil {
530			break
531		}
532		tail = append(tail, b)
533		if len(tail) > len(end) {
534			sb.WriteByte(tail[0])
535			tail = tail[1:]
536		}
537		if string(tail) == end {
538			break
539		}
540	}
541	return Key{Kind: KeyPaste, Paste: sb.String()}
542}

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