v0.3.0 99dc266

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	"bytes"
7	"slices"
8	"sync"
9	"sync/atomic"
10	"time"
11	"unicode/utf8"
12
13	"github.com/bnema/vev/internal/ports"
14)
15
16const (
17	ESC      byte = 0x1b
18	ESCDelay      = 40 * time.Millisecond
19)
20
21// Action is an intercepted vev key binding.
22type Action int
23
24const (
25	ActionOpenPalette Action = iota
26	ActionSwitchTab1
27	ActionSwitchTab2
28	ActionSwitchTab3
29	ActionSwitchTab4
30	ActionSwitchTab5
31	ActionSwitchTab6
32	ActionSwitchTab7
33	ActionSwitchTab8
34	ActionSwitchTab9
35	ActionJumpAttention
36	ActionFocusPaneLeft
37	ActionFocusPaneRight
38	ActionFocusPaneUp
39	ActionFocusPaneDown
40	ActionToggleFloatingPane
41	ActionGrowPaneWidth
42	ActionShrinkPaneWidth
43	ActionGrowPaneHeight
44	ActionShrinkPaneHeight
45	ActionEqualizePanes
46	ActionConsumeOrExpelPaneLeft
47	ActionConsumeOrExpelPaneRight
48)
49
50// Handler receives router outputs. Forward is called only for bytes that should
51// reach the PTY; Action receives intercepted bindings and the exact consumed
52// terminal bytes. Both byte slices are owned copies.
53type Handler interface {
54	Forward([]byte)
55	Action(Action, []byte)
56}
57
58// Router implements vev's Alt/ESC input routing. It retains a trailing ESC for
59// ESCDelay so split Alt-key sequences can still be intercepted without delaying
60// known terminal control prefixes (ESC [ and ESC O), which pass through.
61type Router struct {
62	clock    ports.Clock
63	delay    time.Duration
64	h        Handler
65	bindings *atomic.Pointer[Bindings]
66
67	mu             sync.Mutex
68	pending        bool
69	pendingAlt     []byte
70	pendingHandler Handler
71	timer          ports.Timer
72	pendingDone    chan struct{}
73}
74
75// NewRouter constructs a Router. bindings may be nil, in which case
76// currentBindings falls back to defaultBindings.
77func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router {
78	return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings}
79}
80
81// Route routes one transport read with the router's default handler.
82func (r *Router) Route(data []byte) { r.RouteWithHandler(data, r.h) }
83
84// RouteWithHandler binds every synchronous or delayed result from data to the
85// supplied handler. A retained ESC therefore cannot gain authority from a
86// later transport frame after the original frame's ownership token is stale.
87func (r *Router) RouteWithHandler(data []byte, h Handler) {
88	if len(data) == 0 {
89		return
90	}
91	if h == nil {
92		h = r.h
93	}
94	r.mu.Lock()
95	var pendingAlt []byte
96	if r.pending {
97		pendingAlt = append([]byte(nil), r.pendingAlt...)
98	}
99	pendingHandler := r.pendingHandler
100	wasPending := r.pending
101	if wasPending {
102		r.stopTimer()
103		r.pending = false
104		r.pendingAlt = nil
105		r.pendingHandler = nil
106	}
107	r.mu.Unlock()
108	if wasPending {
109		combined := append(append([]byte(nil), pendingAlt...), data...)
110		if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) {
111			data = data[consumed-len(pendingAlt):]
112		}
113	}
114	r.route(data, h)
115}
116
117func (r *Router) route(data []byte, h Handler) {
118	buf := make([]byte, 0, len(data))
119	flush := func() {
120		if len(buf) == 0 {
121			return
122		}
123		r.forward(buf, h)
124		buf = buf[:0]
125	}
126	for i := 0; i < len(data); {
127		if data[i] != ESC {
128			buf = append(buf, data[i])
129			i++
130			continue
131		}
132		if i == len(data)-1 {
133			flush()
134			r.retainESC(h)
135			return
136		}
137		remaining := data[i+1:]
138		if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
139			// A bracketed paste whose closing marker is in this same frame is
140			// forwarded verbatim: pasted content bytes (including embedded
141			// ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
142			// fire an Action mid-paste. Without a closing marker in-frame we
143			// fall through to today's per-byte routing (the '[' passes through
144			// as a control prefix); Part A keeps pastes single-frame in
145			// practice, so no cross-frame paste state is tracked here.
146			if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
147				end := i + rel + len(ports.BracketedPasteCloseMarker)
148				buf = append(buf, data[i:end]...)
149				i = end
150				continue
151			}
152		}
153		if action, size, ok, partial := r.altArrowCSI(remaining); ok {
154			flush()
155			r.action(action, data[i:i+1+size], h)
156			i += 1 + size
157			continue
158		} else if partial {
159			flush()
160			r.retainESC(h, remaining)
161			return
162		}
163		next := data[i+1]
164		if passThroughPrefix(next) {
165			buf = append(buf, ESC, next)
166			i += 2
167			continue
168		}
169		if action, size, ok := r.binding(remaining); ok {
170			flush()
171			r.action(action, data[i:i+1+size], h)
172			i += 1 + size
173			continue
174		}
175		if partialUTF8Rune(remaining) {
176			flush()
177			r.retainESC(h, remaining)
178			return
179		}
180		buf = append(buf, ESC)
181		i++
182	}
183	flush()
184}
185
186func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
187	if action, size, ok, partial := r.altArrowCSI(data); ok {
188		r.action(action, append([]byte{ESC}, data[:size]...), h)
189		return size
190	} else if partial {
191		r.retainESC(h, data)
192		return len(data)
193	}
194	next := data[0]
195	if action, size, ok := r.binding(data); ok {
196		r.action(action, append([]byte{ESC}, data[:size]...), h)
197		return size
198	}
199	if partialUTF8Rune(data) {
200		r.retainESC(h, data)
201		return len(data)
202	}
203	_, size := utf8.DecodeRune(data)
204	if size > 1 {
205		r.forward(append([]byte{ESC}, data[:size]...), h)
206		return size
207	}
208	if pendingAltLen > 0 {
209		r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h)
210		return pendingAltLen
211	}
212	if passThroughPrefix(next) {
213		r.forward([]byte{ESC, next}, h)
214		return 1
215	}
216	r.forward([]byte{ESC}, h)
217	return 0
218}
219
220func (r *Router) retainESC(h Handler, altBytes ...[]byte) {
221	r.mu.Lock()
222	r.pending = true
223	r.pendingAlt = nil
224	r.pendingHandler = h
225	if len(altBytes) > 0 {
226		r.pendingAlt = append([]byte(nil), altBytes[0]...)
227	}
228	r.timer = r.clock.NewTimer(r.delay)
229	r.pendingDone = make(chan struct{})
230	timer, done := r.timer, r.pendingDone
231	r.mu.Unlock()
232	go func(timer ports.Timer, done <-chan struct{}) {
233		select {
234		case <-timer.C():
235		case <-done:
236			return
237		}
238		var (
239			data []byte
240			h    Handler
241		)
242		r.mu.Lock()
243		if r.pending && r.timer == timer {
244			r.pending = false
245			data = append([]byte{ESC}, r.pendingAlt...)
246			h = r.pendingHandler
247			r.pendingAlt = nil
248			r.pendingHandler = nil
249			r.timer = nil
250			r.pendingDone = nil
251		}
252		r.mu.Unlock()
253		if h != nil {
254			r.forward(data, h)
255		}
256	}(timer, done)
257}
258
259func (r *Router) stopTimer() {
260	if r.timer != nil {
261		r.timer.Stop()
262		r.timer = nil
263	}
264	if r.pendingDone != nil {
265		close(r.pendingDone)
266		r.pendingDone = nil
267	}
268}
269
270func partialUTF8Rune(data []byte) bool {
271	if len(data) == 0 || utf8.FullRune(data) {
272		return false
273	}
274	key, size := utf8.DecodeRune(data)
275	return key == utf8.RuneError && size == 1
276}
277
278func (r *Router) forward(data []byte, h Handler) {
279	cp := append([]byte(nil), data...)
280	h.Forward(cp)
281}
282
283func (r *Router) action(action Action, raw []byte, h Handler) {
284	cp := append([]byte(nil), raw...)
285	h.Action(action, cp)
286}
287
288func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
289
290func (r *Router) currentBindings() *Bindings {
291	if r.bindings == nil {
292		return defaultBindings
293	}
294	bindings := r.bindings.Load()
295	if bindings == nil {
296		return defaultBindings
297	}
298	return bindings
299}
300
301func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) {
302	const seqLen = len("[1;3A")
303	if len(data) < seqLen {
304		return 0, 0, false, hasAltArrowCSIPrefix(data)
305	}
306	seq := data[:seqLen]
307	if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
308		return 0, 0, false, false
309	}
310	if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok {
311		return action, seqLen, true, false
312	}
313	return 0, 0, false, false
314}
315
316func hasAltArrowCSIPrefix(data []byte) bool {
317	if len(data) == 0 || len(data) >= len("[1;3A") {
318		return false
319	}
320	return matchesPrefix(data, "[1;3") || matchesPrefix(data, "[1;9")
321}
322
323func matchesPrefix(data []byte, want string) bool {
324	if len(data) > len(want) {
325		return false
326	}
327	for i := range data {
328		if data[i] != want[i] {
329			return false
330		}
331	}
332	return true
333}
334
335func (r *Router) binding(data []byte) (Action, int, bool) {
336	return r.currentBindings().actionForAltBytes(data)
337}
338
339var topRowDigitAliases = [][]rune{
340	{'1', '&'},
341	{'2', 'é'},
342	{'3', '"'},
343	{'4', '\''},
344	{'5', '('},
345	{'6', '-', '§'},
346	{'7', 'è'},
347	{'8', '_', '!'},
348	{'9', 'ç'},
349}
350
351// topRowDigitIndex maps symbols emitted by physical top-row digit keys to
352// zero-based digit positions. It is modifier-agnostic so Alt+digit and future
353// Ctrl+digit bindings can share the same layout support.
354func topRowDigitIndex(key rune) (int, bool) {
355	for idx, aliases := range topRowDigitAliases {
356		if slices.Contains(aliases, key) {
357			return idx, true
358		}
359	}
360	return 0, false
361}

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