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}