router.go RawHistoryFolder

1// Package keys routes terminal input bytes into vev Alt/ESC bindings or PTY
2// passthrough bytes.
3package keys
4
5import (
6	"slices"
7	"sync"
8	"sync/atomic"
9	"time"
10	"unicode"
11	"unicode/utf8"
12
13	"github.com/bnema/vev/internal/ports"
14	"github.com/bnema/vev/internal/usecase/keys/kittykey"
15)
16
17const (
18	ESC      byte = 0x1b
19	ESCDelay      = 40 * time.Millisecond
20)
21
22// Action is an intercepted vev key binding.
23type Action int
24
25const (
26	ActionOpenPalette Action = iota
27	ActionSwitchTab1
28	ActionSwitchTab2
29	ActionSwitchTab3
30	ActionSwitchTab4
31	ActionSwitchTab5
32	ActionSwitchTab6
33	ActionSwitchTab7
34	ActionSwitchTab8
35	ActionSwitchTab9
36	ActionJumpAttention
37	ActionFocusPaneLeft
38	ActionFocusPaneRight
39	ActionFocusPaneUp
40	ActionFocusPaneDown
41	ActionToggleFloatingPane
42	ActionGrowPaneWidth
43	ActionShrinkPaneWidth
44	ActionGrowPaneHeight
45	ActionShrinkPaneHeight
46	ActionEqualizePanes
47	ActionConsumeOrExpelPaneLeft
48	ActionConsumeOrExpelPaneRight
49	// ActionSwitchRecent1..9 switch to the Nth previous session of the
50	// client's recent history. They are fixed to Ctrl+1..9, which only a
51	// terminal speaking the kitty keyboard protocol can send, and are not
52	// rebindable.
53	ActionSwitchRecent1
54	ActionSwitchRecent2
55	ActionSwitchRecent3
56	ActionSwitchRecent4
57	ActionSwitchRecent5
58	ActionSwitchRecent6
59	ActionSwitchRecent7
60	ActionSwitchRecent8
61	ActionSwitchRecent9
62)
63
64// Handler receives router outputs. Forward is called only for bytes that should
65// reach the PTY; Action receives intercepted bindings and the exact consumed
66// terminal bytes. Both byte slices are owned copies.
67type Handler interface {
68	Forward([]byte)
69	Action(Action, []byte)
70}
71
72// Router implements vev's Alt/ESC input routing. It retains a trailing ESC for
73// ESCDelay so split Alt-key sequences can still be intercepted without delaying
74// known terminal control prefixes (ESC [ and ESC O), which pass through.
75type Router struct {
76	clock    ports.Clock
77	delay    time.Duration
78	h        Handler
79	bindings *atomic.Pointer[Bindings]
80	// kitty enables kitty keyboard protocol decoding. Only an attachment
81	// whose client enabled the protocol on its outer terminal sets it, so
82	// CSI u from any other source passes through untouched.
83	kitty atomic.Bool
84
85	mu             sync.Mutex
86	pending        bool
87	pendingAlt     []byte
88	pendingHandler Handler
89	timer          ports.Timer
90	pendingDone    chan struct{}
91}
92
93// NewRouter constructs a Router. bindings may be nil, in which case
94// currentBindings falls back to defaultBindings.
95func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router {
96	return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
97}
98
99// Route routes one transport read with the router's default handler.
100func (r *Router) Route(data []byte) { r.RouteWithHandler(data, r.h) }
101
102// RouteWithHandler binds every synchronous or delayed result from data to the
103// supplied handler. A retained ESC therefore cannot gain authority from a
104// later transport frame after the original frame's ownership token is stale.
105func (r *Router) RouteWithHandler(data []byte, h Handler) {
106	if len(data) == 0 {
107		return
108	}
109	if h == nil {
110		h = r.h
111	}
112	r.mu.Lock()
113	var pendingAlt []byte
114	if r.pending {
115		pendingAlt = append([]byte(nil), r.pendingAlt...)
116	}
117	pendingHandler := r.pendingHandler
118	wasPending := r.pending
119	if wasPending {
120		r.stopTimer()
121		r.pending = false
122		r.pendingAlt = nil
123		r.pendingHandler = nil
124	}
125	r.mu.Unlock()
126	if wasPending {
127		combined := append(append([]byte(nil), pendingAlt...), data...)
128		if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) {
129			data = data[consumed-len(pendingAlt):]
130		}
131	}
132	r.route(data, h)
133}
134
135func (r *Router) route(data []byte, h Handler) {
136	buf := make([]byte, 0, len(data))
137	flush := func() {
138		if len(buf) == 0 {
139			return
140		}
141		r.forward(buf, h)
142		buf = buf[:0]
143	}
144	for i := 0; i < len(data); {
145		if data[i] != ESC {
146			buf = append(buf, data[i])
147			i++
148			continue
149		}
150		tok := r.scan(data[i:])
151		if tok.kind == escIncomplete {
152			flush()
153			r.retainESC(h, tok.raw[1:])
154			return
155		}
156		if action, ok := r.tokenAction(tok); ok {
157			flush()
158			r.action(action, tok.raw, h)
159			i += len(tok.raw)
160			continue
161		}
162		switch tok.kind {
163		case escPaste, escAltArrow, escControlPrefix, escKittyKey:
164			// Pastes are forwarded verbatim so embedded Alt lookalikes never
165			// fire; unbound arrows and control prefixes pass through.
166			buf = append(buf, tok.raw...)
167			i += len(tok.raw)
168		default:
169			// Unbound Alt rune or bare ESC: forward the ESC alone and route
170			// the following bytes normally.
171			buf = append(buf, ESC)
172			i++
173		}
174	}
175	flush()
176}
177
178// routeAfterPendingESC routes data that follows a retained ESC. data excludes
179// that ESC; pendingAltLen bytes of data were retained with it. It returns how
180// many bytes of data it consumed.
181func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
182	tok := r.scan(append([]byte{ESC}, data...))
183	if tok.kind == escIncomplete {
184		r.retainESC(h, data)
185		return len(data)
186	}
187	if action, ok := r.tokenAction(tok); ok {
188		r.action(action, tok.raw, h)
189		return len(tok.raw) - 1
190	}
191	if tok.kind == escKittyKey {
192		// Forward the whole sequence so the handler can re-encode it.
193		r.forward(tok.raw, h)
194		return len(tok.raw) - 1
195	}
196	if _, size := utf8.DecodeRune(data); size > 1 {
197		r.forward(append([]byte{ESC}, data[:size]...), h)
198		return size
199	}
200	if pendingAltLen > 0 {
201		r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)
202		return pendingAltLen
203	}
204	if next := data[0]; next == '[' || next == 'O' {
205		r.forward([]byte{ESC, next}, h)
206		return 1
207	}
208	r.forward([]byte{ESC}, h)
209	return 0
210}
211
212// SetKittyKeyboard enables or disables kitty keyboard protocol decoding.
213func (r *Router) SetKittyKeyboard(enabled bool) { r.kitty.Store(enabled) }
214
215// KittyKeyboard reports whether kitty keyboard protocol decoding is enabled.
216// A nil Router reports false.
217func (r *Router) KittyKeyboard() bool { return r != nil && r.kitty.Load() }
218
219// scan lexes one ESC-introduced unit, ignoring kitty key events unless the
220// attachment enabled the protocol.
221func (r *Router) scan(data []byte) escToken {
222	return scanEscape(data, r.kitty.Load())
223}
224
225// tokenAction resolves a lexed unit against the current bindings.
226func (r *Router) tokenAction(tok escToken) (Action, bool) {
227	bindings := r.currentBindings()
228	switch tok.kind {
229	case escAltArrow:
230		return bindings.actionForAltArrow(tok.arrow)
231	case escAltRune:
232		return bindings.actionForAltRune(tok.rune)
233	case escKittyKey:
234		return kittyKeyAction(bindings, tok.key)
235	default:
236		return 0, false
237	}
238}
239
240func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
241	r.mu.Lock()
242	r.pending = true
243	r.pendingAlt = nil
244	r.pendingHandler = h
245	if len(altBytes) > 0 {
246		r.pendingAlt = append([]byte(nil), altBytes[0]...)
247	}
248	r.timer = r.clock.NewTimer(r.delay)
249	r.pendingDone = make(chan struct{})
250	timer, done := r.timer, r.pendingDone
251	r.mu.Unlock()
252	go func(timer ports.Timer, done <-chan struct{}) {
253		select {
254		case <-timer.C():
255		case <-done:
256			return
257		}
258		var (
259			data []byte
260			h    Handler
261		)
262		r.mu.Lock()
263		if r.pending && r.timer == timer {
264			r.pending = false
265			data = append([]byte{ESC}, r.pendingAlt...)
266			h = r.pendingHandler
267			r.pendingAlt = nil
268			r.pendingHandler = nil
269			r.timer = nil
270			r.pendingDone = nil
271		}
272		r.mu.Unlock()
273		if h != nil {
274			r.forward(data, h)
275		}
276	}(timer, done)
277}
278
279func (r *Router) stopTimer() {
280	if r.timer != nil {
281		r.timer.Stop()
282		r.timer = nil
283	}
284	if r.pendingDone != nil {
285		close(r.pendingDone)
286		r.pendingDone = nil
287	}
288}
289
290func (r *Router) forward(data []byte, h Handler) {
291	cp := append([]byte(nil), data...)
292	h.Forward(cp)
293}
294
295func (r *Router) action(action Action, raw []byte, h Handler) {
296	cp := append([]byte(nil), raw...)
297	h.Action(action, cp)
298}
299
300func (r *Router) currentBindings() *Bindings {
301	if r.bindings == nil {
302		return defaultBindings
303	}
304	bindings := r.bindings.Load()
305	if bindings == nil {
306		return defaultBindings
307	}
308	return bindings
309}
310
311var topRowDigitAliases = [][]rune{
312	{'1', '&'},
313	{'2', 'é'},
314	{'3', '"'},
315	{'4', '\''},
316	{'5', '('},
317	{'6', '-', '§'},
318	{'7', 'è'},
319	{'8', '_', '!'},
320	{'9', 'ç'},
321}
322
323// topRowDigitIndex maps symbols emitted by physical top-row digit keys to
324// zero-based digit positions. It is modifier-agnostic so Alt+digit and future
325// Ctrl+digit bindings can share the same layout support.
326func topRowDigitIndex(key rune) (int, bool) {
327	for idx, aliases := range topRowDigitAliases {
328		if slices.Contains(aliases, key) {
329			return idx, true
330		}
331	}
332	return 0, false
333}
334
335// kittyKeyAction resolves a kitty keyboard protocol key event. Alt+key uses
336// the configured Alt bindings; Ctrl+1..9 is the fixed recent-session switch.
337// The PC-101 base key lets layouts such as AZERTY match digit bindings.
338func kittyKeyAction(bindings *Bindings, ev kittykey.Event) (Action, bool) {
339	if ev.Release {
340		return 0, false
341	}
342	switch ev.Mods {
343	case kittykey.ModAlt:
344		if action, ok := bindings.actionForAltRune(ev.Code); ok {
345			return action, true
346		}
347		if ev.Base != 0 {
348			return bindings.actionForAltRune(ev.Base)
349		}
350	case kittykey.ModAlt | kittykey.ModShift:
351		// Legacy terminals send Alt+Shift+key as ESC plus the shifted rune,
352		// which is what alt+<shifted char> bindings match.
353		shifted := ev.Shifted
354		if shifted == 0 {
355			shifted = unicode.ToUpper(ev.Code)
356		}
357		return bindings.actionForAltRune(shifted)
358	case kittykey.ModCtrl:
359		if digit, ok := ev.Digit(); ok {
360			return ActionSwitchRecent1 + Action(digit-1), true
361		}
362	}
363	return 0, false
364}

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 12

Tags 11

↑↓ moveenter openesc close