1package keys
2
3import (
4 "runtime"
5 "strings"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/vev/internal/ports"
13)
14
15type fakeClock struct{ timers []*fakeTimer }
16
17func (c *fakeClock) Now() time.Time { return time.Time{} }
18func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
19 t := &fakeTimer{ch: make(chan time.Time, 1), d: d}
20 c.timers = append(c.timers, t)
21 return t
22}
23
24type fakeTimer struct {
25 ch chan time.Time
26 d time.Duration
27 stopped bool
28}
29
30func (t *fakeTimer) C() <-chan time.Time { return t.ch }
31func (t *fakeTimer) Reset(d time.Duration) bool { t.d = d; return t.stopped }
32func (t *fakeTimer) Stop() bool { t.stopped = true; return true }
33func (t *fakeTimer) fire() { t.ch <- time.Time{} }
34
35type captureHandler struct {
36 forwards [][]byte
37 actions []Action
38 actionRaw [][]byte
39 notify chan struct{}
40}
41
42func (h *captureHandler) Forward(data []byte) {
43 h.forwards = append(h.forwards, append([]byte(nil), data...))
44 if h.notify != nil {
45 h.notify <- struct{}{}
46 }
47}
48func (h *captureHandler) Action(a Action, raw []byte) {
49 h.actions = append(h.actions, a)
50 h.actionRaw = append(h.actionRaw, append([]byte(nil), raw...))
51}
52
53func TestRouterRawActionPreservesExactConsumedBytes(t *testing.T) {
54 custom := DefaultBindings().clone()
55 custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'ø'}})
56 var bindings atomic.Pointer[Bindings]
57 bindings.Store(custom)
58
59 tests := []struct {
60 name string
61 reads [][]byte
62 action Action
63 raw []byte
64 }{
65 {name: "single frame", reads: [][]byte{{ESC, 'j'}}, action: ActionFocusPaneDown, raw: []byte{ESC, 'j'}},
66 {name: "delayed escape", reads: [][]byte{{ESC}, {'1'}}, action: ActionSwitchTab1, raw: []byte{ESC, '1'}},
67 {name: "split UTF-8 custom binding", reads: [][]byte{{ESC, 0xc3}, {0xb8}}, action: ActionEqualizePanes, raw: []byte{ESC, 0xc3, 0xb8}},
68 {name: "split Alt arrow", reads: [][]byte{{ESC, '[', '1'}, {';', '9', 'D'}}, action: ActionFocusPaneLeft, raw: []byte{ESC, '[', '1', ';', '9', 'D'}},
69 }
70
71 for _, tt := range tests {
72 t.Run(tt.name, func(t *testing.T) {
73 clk := &fakeClock{}
74 h := &captureHandler{}
75 router := NewRouter(clk, h, &bindings)
76 for _, read := range tt.reads {
77 router.Route(read)
78 }
79 require.Equal(t, []Action{tt.action}, h.actions)
80 require.Equal(t, [][]byte{tt.raw}, h.actionRaw)
81 require.Empty(t, h.forwards)
82 })
83 }
84}
85
86func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
87 cases := []struct {
88 name string
89 in []byte
90 want Action
91 }{
92 {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},
93 {name: "floating pane", in: []byte{ESC, 'f'}, want: ActionToggleFloatingPane},
94 {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},
95 {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},
96 {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},
97 {name: "focus up", in: []byte{ESC, 'k'}, want: ActionFocusPaneUp},
98 {name: "focus right", in: []byte{ESC, 'l'}, want: ActionFocusPaneRight},
99 {name: "arrow left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
100 {name: "arrow right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
101 {name: "arrow up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
102 {name: "arrow down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
103 {name: "digit", in: []byte{ESC, '1'}, want: ActionSwitchTab1},
104 {name: "digit alias", in: append([]byte{ESC}, []byte("é")...), want: ActionSwitchTab2},
105 }
106
107 var bindings atomic.Pointer[Bindings]
108 bindings.Store(DefaultBindings())
109 for _, tc := range cases {
110 t.Run(tc.name, func(t *testing.T) {
111 clk := &fakeClock{}
112 h := &captureHandler{}
113 NewRouter(clk, h, &bindings).Route(tc.in)
114 require.Equal(t, []Action{tc.want}, h.actions)
115 require.Empty(t, h.forwards)
116 })
117 }
118}
119
120func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
121 clk := &fakeClock{}
122 h := &captureHandler{}
123 NewRouter(clk, h, nil).Route([]byte{ESC, ' '})
124 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
125 require.Empty(t, h.forwards)
126 require.Empty(t, clk.timers)
127}
128
129func TestRouterInterceptsAltDigits(t *testing.T) {
130 clk := &fakeClock{}
131 h := &captureHandler{}
132 r := NewRouter(clk, h, nil)
133 for b := byte('1'); b <= '9'; b++ {
134 r.Route([]byte{ESC, b})
135 }
136 require.Equal(t, switchTabActions(), h.actions)
137 require.Empty(t, h.forwards)
138}
139
140func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
141 cases := []struct {
142 name string
143 keys []string
144 }{
145 {name: "QWERTY", keys: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}},
146 {name: "French AZERTY", keys: []string{"&", "é", "\"", "'", "(", "-", "è", "_", "ç"}},
147 {name: "Belgian AZERTY", keys: []string{"&", "é", "\"", "'", "(", "§", "è", "!", "ç"}},
148 }
149
150 for _, tc := range cases {
151 t.Run(tc.name, func(t *testing.T) {
152 clk := &fakeClock{}
153 h := &captureHandler{}
154 r := NewRouter(clk, h, nil)
155 for _, key := range tc.keys {
156 r.Route(append([]byte{ESC}, []byte(key)...))
157 }
158 require.Equal(t, switchTabActions(), h.actions)
159 require.Empty(t, h.forwards)
160 })
161 }
162}
163
164func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
165 clk := &fakeClock{}
166 h := &captureHandler{}
167 r := NewRouter(clk, h, nil)
168
169 r.Route([]byte{ESC, 0xc3})
170 require.Empty(t, h.actions)
171 require.Empty(t, h.forwards)
172 require.Len(t, clk.timers, 1)
173
174 r.Route([]byte{0xa9})
175 require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
176 require.Empty(t, h.forwards)
177 assertRouterPendingCleared(t, r)
178
179 r.Route([]byte{ESC, ' '})
180 require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
181 require.Empty(t, h.forwards)
182}
183
184func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
185 clk := &fakeClock{}
186 h := &captureHandler{}
187 r := NewRouter(clk, h, nil)
188
189 r.Route([]byte{ESC})
190 r.Route([]byte{0xc3})
191 require.Empty(t, h.actions)
192 require.Empty(t, h.forwards)
193 require.Len(t, clk.timers, 2)
194
195 r.Route([]byte{0xa9})
196 require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
197 require.Empty(t, h.forwards)
198 assertRouterPendingCleared(t, r)
199
200 r.Route([]byte{ESC, ' '})
201 require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
202 require.Empty(t, h.forwards)
203}
204
205func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
206 clk := &fakeClock{}
207 h := &captureHandler{}
208 r := NewRouter(clk, h, nil)
209
210 r.Route([]byte{ESC, 0xc3})
211 r.Route([]byte{0xb1})
212
213 require.Empty(t, h.actions)
214 require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
215 assertRouterPendingCleared(t, r)
216
217 r.Route([]byte("Z"))
218 require.Empty(t, h.actions)
219 require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}, []byte("Z")}, h.forwards)
220}
221
222func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
223 clk := &fakeClock{}
224 h := &captureHandler{}
225 r := NewRouter(clk, h, nil)
226
227 r.Route([]byte{ESC, 0xc3})
228 r.Route([]byte("X"))
229
230 require.Empty(t, h.actions)
231 require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
232 assertRouterPendingCleared(t, r)
233
234 r.Route([]byte{ESC, ' '})
235 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
236 require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
237}
238
239func assertRouterPendingCleared(t *testing.T, r *Router) {
240 t.Helper()
241 require.False(t, r.pending)
242 require.Nil(t, r.pendingAlt)
243 require.Nil(t, r.timer)
244 require.Nil(t, r.pendingDone)
245}
246
247func TestTopRowDigitIndexAcceptsLayoutAliases(t *testing.T) {
248 for want, aliases := range [][]rune{
249 {'1', '&'},
250 {'2', 'é'},
251 {'3', '"'},
252 {'4', '\''},
253 {'5', '('},
254 {'6', '-', '§'},
255 {'7', 'è'},
256 {'8', '_', '!'},
257 {'9', 'ç'},
258 } {
259 for _, key := range aliases {
260 got, ok := topRowDigitIndex(key)
261 require.True(t, ok, "key %q", key)
262 require.Equal(t, want, got, "key %q", key)
263 }
264 }
265
266 _, ok := topRowDigitIndex('0')
267 require.False(t, ok)
268}
269
270func switchTabActions() []Action {
271 return []Action{
272 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
273 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
274 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
275 }
276}
277
278func TestSwitchTabActionsRemainContiguous(t *testing.T) {
279 for i, action := range switchTabActions() {
280 require.Equal(t, ActionSwitchTab1+Action(i), action)
281 }
282 require.False(t, ActionToggleFloatingPane >= ActionSwitchTab1 && ActionToggleFloatingPane <= ActionSwitchTab9)
283}
284
285func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
286 clk := &fakeClock{}
287 h := &captureHandler{}
288 NewRouter(clk, h, nil).Route([]byte{ESC, 'a'})
289 require.Equal(t, []Action{ActionJumpAttention}, h.actions)
290 require.Empty(t, h.forwards)
291 require.Empty(t, clk.timers)
292}
293
294func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
295 cases := []struct {
296 name string
297 key byte
298 want Action
299 }{
300 {name: "left", key: 'h', want: ActionFocusPaneLeft},
301 {name: "down", key: 'j', want: ActionFocusPaneDown},
302 {name: "up", key: 'k', want: ActionFocusPaneUp},
303 {name: "right", key: 'l', want: ActionFocusPaneRight},
304 }
305 for _, tc := range cases {
306 t.Run(tc.name, func(t *testing.T) {
307 clk := &fakeClock{}
308 h := &captureHandler{}
309 NewRouter(clk, h, nil).Route([]byte{ESC, tc.key})
310 require.Equal(t, []Action{tc.want}, h.actions)
311 require.Empty(t, h.forwards)
312 require.Empty(t, clk.timers)
313 })
314 }
315}
316
317func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
318 for _, b := range []byte("cnpdxurt") {
319 t.Run(string(b), func(t *testing.T) {
320 clk := &fakeClock{}
321 h := &captureHandler{}
322 NewRouter(clk, h, nil).Route([]byte{ESC, b})
323 require.Empty(t, h.actions)
324 require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
325 })
326 }
327}
328
329func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
330 clk := &fakeClock{}
331 h := &captureHandler{}
332 NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'})
333 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
334 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
335}
336
337func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
338 cases := [][]byte{{ESC, '[', 'A'}, {ESC, 'O', 'P'}}
339 for _, in := range cases {
340 clk := &fakeClock{}
341 h := &captureHandler{}
342 NewRouter(clk, h, nil).Route(in)
343 require.Empty(t, h.actions)
344 require.Equal(t, [][]byte{in}, h.forwards)
345 require.Empty(t, clk.timers)
346 }
347}
348
349func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
350 cases := []struct {
351 name string
352 in []byte
353 want Action
354 }{
355 {name: "alt up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
356 {name: "alt down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
357 {name: "alt right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
358 {name: "alt left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
359 {name: "meta up", in: []byte{ESC, '[', '1', ';', '9', 'A'}, want: ActionFocusPaneUp},
360 {name: "meta down", in: []byte{ESC, '[', '1', ';', '9', 'B'}, want: ActionFocusPaneDown},
361 {name: "meta right", in: []byte{ESC, '[', '1', ';', '9', 'C'}, want: ActionFocusPaneRight},
362 {name: "meta left", in: []byte{ESC, '[', '1', ';', '9', 'D'}, want: ActionFocusPaneLeft},
363 }
364
365 for _, tc := range cases {
366 t.Run(tc.name, func(t *testing.T) {
367 clk := &fakeClock{}
368 h := &captureHandler{}
369 NewRouter(clk, h, nil).Route(tc.in)
370 require.Equal(t, []Action{tc.want}, h.actions)
371 require.Empty(t, h.forwards)
372 require.Empty(t, clk.timers)
373 })
374 }
375}
376
377func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
378 cases := []struct {
379 name string
380 reads [][]byte
381 want Action
382 timers int
383 }{
384 {name: "esc then rest", reads: [][]byte{{ESC}, []byte("[1;3D")}, want: ActionFocusPaneLeft, timers: 1},
385 {name: "csi prefix then rest", reads: [][]byte{{ESC, '['}, []byte("1;3C")}, want: ActionFocusPaneRight, timers: 1},
386 {name: "modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '3'}, []byte("A")}, want: ActionFocusPaneUp, timers: 1},
387 {name: "meta modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '9'}, []byte("B")}, want: ActionFocusPaneDown, timers: 1},
388 }
389
390 for _, tc := range cases {
391 t.Run(tc.name, func(t *testing.T) {
392 clk := &fakeClock{}
393 h := &captureHandler{}
394 r := NewRouter(clk, h, nil)
395 for _, read := range tc.reads {
396 r.Route(read)
397 }
398 require.Equal(t, []Action{tc.want}, h.actions)
399 require.Empty(t, h.forwards)
400 require.Len(t, clk.timers, tc.timers)
401 assertRouterPendingCleared(t, r)
402 })
403 }
404}
405
406func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
407 cases := []struct {
408 name string
409 reads [][]byte
410 wantActions []Action
411 wantForwards [][]byte
412 }{
413 {
414 name: "two alt arrows in one read",
415 reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', ESC, '[', '1', ';', '3', 'A'}},
416 wantActions: []Action{ActionFocusPaneUp, ActionFocusPaneUp},
417 },
418 {
419 name: "alt arrow followed by normal byte",
420 reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', 'x'}},
421 wantActions: []Action{ActionFocusPaneUp},
422 wantForwards: [][]byte{[]byte("x")},
423 },
424 {
425 name: "bare arrow passthrough",
426 reads: [][]byte{{ESC, '[', 'A'}},
427 wantForwards: [][]byte{{ESC, '[', 'A'}},
428 },
429 {
430 name: "retained escape alt arrow across reads",
431 reads: [][]byte{{ESC}, []byte("[1;3A")},
432 wantActions: []Action{ActionFocusPaneUp},
433 },
434 }
435
436 for _, tc := range cases {
437 t.Run(tc.name, func(t *testing.T) {
438 clk := &fakeClock{}
439 h := &captureHandler{}
440 r := NewRouter(clk, h, nil)
441 for _, read := range tc.reads {
442 r.Route(read)
443 }
444 require.Equal(t, tc.wantActions, h.actions)
445 require.Equal(t, tc.wantForwards, h.forwards)
446 })
447 }
448}
449
450func TestRouterPassesThroughBareArrows(t *testing.T) {
451 for _, in := range [][]byte{
452 {ESC, '[', 'A'},
453 {ESC, '[', 'B'},
454 {ESC, '[', 'C'},
455 {ESC, '[', 'D'},
456 } {
457 t.Run(string(in), func(t *testing.T) {
458 clk := &fakeClock{}
459 h := &captureHandler{}
460 NewRouter(clk, h, nil).Route(in)
461 require.Empty(t, h.actions)
462 require.Equal(t, [][]byte{in}, h.forwards)
463 require.Empty(t, clk.timers)
464 })
465 }
466}
467
468func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
469 clk := &fakeClock{}
470 h := &captureHandler{}
471 r := NewRouter(clk, h, nil)
472 r.Route([]byte{ESC})
473 require.Len(t, clk.timers, 1)
474 require.Equal(t, ESCDelay, clk.timers[0].d)
475 require.Empty(t, h.forwards)
476
477 r.Route([]byte{' '})
478 require.True(t, clk.timers[0].stopped)
479 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
480 require.Empty(t, h.forwards)
481}
482
483func TestRouterForwardsBareT(t *testing.T) {
484 clk := &fakeClock{}
485 h := &captureHandler{}
486 NewRouter(clk, h, nil).Route([]byte{'t'})
487 require.Empty(t, h.actions)
488 require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
489}
490
491func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
492 cases := []struct {
493 name string
494 secondInput []byte
495 want []byte
496 }{
497 {name: "CSI up", secondInput: []byte("[A"), want: []byte{ESC, '[', 'A'}},
498 {name: "SS3 function key", secondInput: []byte("OP"), want: []byte{ESC, 'O', 'P'}},
499 }
500
501 for _, tc := range cases {
502 t.Run(tc.name, func(t *testing.T) {
503 clk := &fakeClock{}
504 h := &captureHandler{}
505 r := NewRouter(clk, h, nil)
506 r.Route([]byte{ESC})
507 require.Len(t, clk.timers, 1)
508 require.Empty(t, h.forwards)
509
510 r.Route(tc.secondInput)
511
512 require.True(t, clk.timers[0].stopped)
513 require.Empty(t, h.actions)
514 require.Equal(t, [][]byte{tc.want[:2], tc.want[2:]}, h.forwards)
515 })
516 }
517}
518
519func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
520 clk := &fakeClock{}
521 h := &captureHandler{}
522 r := NewRouter(clk, h, nil)
523 r.Route([]byte{ESC})
524
525 r.Route([]byte{'[', 'A', ESC, ' '})
526
527 require.True(t, clk.timers[0].stopped)
528 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
529 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
530}
531
532func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
533 clk := &fakeClock{}
534 h := &captureHandler{}
535 r := NewRouter(clk, h, nil)
536
537 before := retainESCWaiters()
538 r.Route([]byte{ESC})
539 require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
540
541 r.Route([]byte{' '})
542 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
543 require.True(t, clk.timers[0].stopped)
544 require.Equal(t, []Action{ActionOpenPalette}, h.actions)
545 require.Empty(t, h.forwards)
546}
547
548func retainESCWaiters() int {
549 buf := make([]byte, 1<<20)
550 n := runtime.Stack(buf, true)
551 return strings.Count(string(buf[:n]), "github.com/bnema/vev/internal/usecase/keys.(*Router).retainESC.func1")
552}
553
554func TestRouterFlushesLoneESCAfterTimer(t *testing.T) {
555 clk := &fakeClock{}
556 h := &captureHandler{notify: make(chan struct{}, 1)}
557 r := NewRouter(clk, h, nil)
558 r.Route([]byte{ESC})
559 clk.timers[0].fire()
560 select {
561 case <-h.notify:
562 case <-time.After(time.Second):
563 t.Fatal("timed out waiting for flushed ESC")
564 }
565 require.Equal(t, [][]byte{{ESC}}, h.forwards)
566 require.Empty(t, h.actions)
567}
568
569func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
570 clk := &fakeClock{}
571 h := &captureHandler{}
572 r := NewRouter(clk, h, nil)
573 r.Route([]byte{ESC})
574 r.Route([]byte{'z'})
575 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
576 require.Empty(t, h.actions)
577}
578
579// pasteBindingLookalikes are content bytes that, seen outside a paste, each map
580// to a binding: Alt-j, Alt-space, Alt-1, and an Alt-arrow CSI.
581var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
582
583func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
584 clk := &fakeClock{}
585 h := &captureHandler{}
586 r := NewRouter(clk, h, nil)
587
588 paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
589 r.Route(paste)
590
591 require.Empty(t, h.actions, "paste content must not fire any binding")
592 require.Equal(t, [][]byte{paste}, h.forwards, "the whole paste must be forwarded byte-identical")
593 require.Empty(t, clk.timers, "a fully bracketed paste retains no trailing ESC")
594}
595
596func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
597 clk := &fakeClock{}
598 h := &captureHandler{}
599 r := NewRouter(clk, h, nil)
600
601 r.Route(pasteBindingLookalikes)
602
603 require.Equal(t, []Action{
604 ActionFocusPaneDown, // Alt-j
605 ActionOpenPalette, // Alt-space
606 ActionSwitchTab1, // Alt-1
607 ActionFocusPaneUp, // Alt-arrow up
608 }, h.actions)
609 require.Empty(t, h.forwards)
610}
611
612func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
613 clk := &fakeClock{}
614 h := &captureHandler{}
615 r := NewRouter(clk, h, nil)
616
617 // Opening marker + content in one frame, closing marker in the next: with
618 // no closing marker in-frame the router keeps its historical per-byte
619 // routing, forwarding each frame's bytes as they arrive.
620 r.Route([]byte("\x1b[200~hello"))
621 r.Route([]byte("\x1b[201~"))
622
623 require.Empty(t, h.actions)
624 require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)
625}
626
627func TestRouterKittyKeyboardEvents(t *testing.T) {
628 tests := []struct {
629 name string
630 reads []string
631 wantActions []Action
632 wantForwards []string
633 }{
634 {name: "ctrl 1", reads: []string{"\x1b[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
635 {name: "ctrl 9", reads: []string{"\x1b[57;5u"}, wantActions: []Action{ActionSwitchRecent9}},
636 {name: "azerty ctrl 2 base key", reads: []string{"\x1b[233::50;5u"}, wantActions: []Action{ActionSwitchRecent2}},
637 {name: "ctrl 0 forwarded", reads: []string{"\x1b[48;5u"}, wantForwards: []string{"\x1b[48;5u"}},
638 {name: "ctrl shift 1 forwarded", reads: []string{"\x1b[49;6u"}, wantForwards: []string{"\x1b[49;6u"}},
639 {name: "ctrl 1 release ignored", reads: []string{"\x1b[49;5:3u"}, wantForwards: []string{"\x1b[49;5:3u"}},
640 {name: "alt j binding", reads: []string{"\x1b[106;3u"}, wantActions: []Action{ActionFocusPaneDown}},
641 {name: "alt space binding", reads: []string{"\x1b[32;3u"}, wantActions: []Action{ActionOpenPalette}},
642 {name: "alt azerty 1 via symbol", reads: []string{"\x1b[38::49;3u"}, wantActions: []Action{ActionSwitchTab1}},
643 {name: "alt unbound forwarded", reads: []string{"\x1b[122;3u"}, wantForwards: []string{"\x1b[122;3u"}},
644 {name: "ctrl a forwarded", reads: []string{"\x1b[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
645 {name: "text around", reads: []string{"ab\x1b[49;5ucd"}, wantActions: []Action{ActionSwitchRecent1}, wantForwards: []string{"ab", "cd"}},
646 {name: "split across reads", reads: []string{"\x1b[49", ";5u"}, wantActions: []Action{ActionSwitchRecent1}},
647 {name: "split after esc", reads: []string{"\x1b", "[49;5u"}, wantActions: []Action{ActionSwitchRecent1}},
648 {name: "unbound split after esc stays whole", reads: []string{"\x1b", "[97;5u"}, wantForwards: []string{"\x1b[97;5u"}},
649 {name: "unbound split mid sequence stays whole", reads: []string{"\x1b[97", ";5ux"}, wantForwards: []string{"\x1b[97;5u", "x"}},
650 {name: "alt shift h uses shifted key", reads: []string{"\x1b[104:72;4u"}, wantForwards: []string{"\x1b[104:72;4u"}},
651 {name: "inside paste untouched", reads: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}, wantForwards: []string{"\x1b[200~\x1b[49;5u\x1b[201~"}},
652 }
653 for _, tt := range tests {
654 t.Run(tt.name, func(t *testing.T) {
655 clk := &fakeClock{}
656 h := &captureHandler{}
657 r := NewRouter(clk, h, nil)
658 r.SetKittyKeyboard(true)
659 for _, read := range tt.reads {
660 r.Route([]byte(read))
661 }
662 require.Equal(t, tt.wantActions, h.actions)
663 var forwards []string
664 for _, f := range h.forwards {
665 forwards = append(forwards, string(f))
666 }
667 require.Equal(t, tt.wantForwards, forwards)
668 assertRouterPendingCleared(t, r)
669 })
670 }
671}
672
673func TestRouterKittyKeyboardAltShiftBindings(t *testing.T) {
674 custom := DefaultBindings().clone()
675 custom.bind(ActionConsumeOrExpelPaneLeft, KeySpec{altRunes: []rune{'H'}})
676 custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'?'}})
677 var bindings atomic.Pointer[Bindings]
678 bindings.Store(custom)
679 tests := []struct {
680 name string
681 in string
682 want Action
683 }{
684 {name: "alt shift h with shifted key", in: "\x1b[104:72;4u", want: ActionConsumeOrExpelPaneLeft},
685 {name: "alt shift h without shifted key", in: "\x1b[104;4u", want: ActionConsumeOrExpelPaneLeft},
686 {name: "alt shift slash", in: "\x1b[47:63;4u", want: ActionEqualizePanes},
687 }
688 for _, tt := range tests {
689 t.Run(tt.name, func(t *testing.T) {
690 h := &captureHandler{}
691 r := NewRouter(&fakeClock{}, h, &bindings)
692 r.SetKittyKeyboard(true)
693 r.Route([]byte(tt.in))
694 require.Equal(t, []Action{tt.want}, h.actions)
695 require.Empty(t, h.forwards)
696 })
697 }
698}
699
700func TestRouterIgnoresKittyKeysUnlessEnabled(t *testing.T) {
701 h := &captureHandler{}
702 r := NewRouter(&fakeClock{}, h, nil)
703 r.Route([]byte("\x1b[49;5u"))
704 require.Empty(t, h.actions)
705 require.Equal(t, [][]byte{[]byte("\x1b[49;5u")}, h.forwards)
706}