feat: route alt escape keys
- commit
- 98c7add9b20416a3cb0a4eec3c7caa135ae511fc
- author
- brice · · 2026-07-02 14:45 UTC
- parent
- 2b8f235
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +431 -36
M internal/usecase/daemon/daemon.go +65 -36
| @@ -39,6 +39,7 @@ import ( | |||
| 39 | 39 | ||
| 40 | 40 | "github.com/bnema/vev/internal/domain" | |
| 41 | 41 | "github.com/bnema/vev/internal/ports" | |
| 42 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 42 | 43 | "github.com/bnema/vev/pkg/renderer" | |
| 43 | 44 | "github.com/bnema/vev/pkg/vt" | |
| 44 | 45 | ) | |
| @@ -138,6 +139,7 @@ type attachedClient struct { | |||
| 138 | 139 | tr ports.Transport | |
| 139 | 140 | rend *renderer.Renderer | |
| 140 | 141 | size domain.Size | |
| 142 | + | keys *keys.Router | |
| 141 | 143 | sendMu sync.Mutex | |
| 142 | 144 | } | |
| 143 | 145 | ||
| @@ -347,16 +349,15 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 347 | 349 | infos := make([]ports.SessionInfo, 0, len(d.sessions)) | |
| 348 | 350 | for _, s := range d.sessions { | |
| 349 | 351 | s.mu.Lock() | |
| 350 | - | attached := s.client != nil | |
| 351 | - | s.mu.Unlock() | |
| 352 | - | windows := len(s.windows) | |
| 353 | - | infos = append(infos, ports.SessionInfo{ | |
| 352 | + | info := ports.SessionInfo{ | |
| 354 | 353 | SessionID: string(s.id), | |
| 355 | 354 | Name: s.name, | |
| 356 | 355 | Ephemeral: s.ephemeral, | |
| 357 | - | Windows: uint16(windows), | |
| 358 | - | Attached: attached, | |
| 359 | - | }) | |
| 356 | + | Windows: uint16(len(s.windows)), | |
| 357 | + | Attached: s.client != nil, | |
| 358 | + | } | |
| 359 | + | s.mu.Unlock() | |
| 360 | + | infos = append(infos, info) | |
| 360 | 361 | } | |
| 361 | 362 | d.mu.Unlock() | |
| 362 | 363 | ||
| @@ -551,6 +552,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 551 | 552 | rend: renderer.New(renderer.Capabilities{}), | |
| 552 | 553 | size: sz, | |
| 553 | 554 | } | |
| 555 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 554 | 556 | sess.mu.Lock() | |
| 555 | 557 | old := sess.client | |
| 556 | 558 | sess.client = ac | |
| @@ -655,42 +657,61 @@ func (s *session) activeWindow() *window { | |||
| 655 | 657 | } | |
| 656 | 658 | ||
| 657 | 659 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |
| 658 | - | switch string(data) { | |
| 659 | - | case "\x1bc": | |
| 660 | - | if err := d.createWindow(sess, ac.size); err != nil { | |
| 661 | - | d.log.Error("create window failed", "err", err, "session", sess.name) | |
| 662 | - | return | |
| 663 | - | } | |
| 664 | - | d.paint(sess, ac, true) | |
| 660 | + | if ac.keys == nil { | |
| 661 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 662 | + | } | |
| 663 | + | ac.keys.Route(data) | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | type daemonKeyHandler struct { | |
| 667 | + | d *Daemon | |
| 668 | + | sess *session | |
| 669 | + | ac *attachedClient | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func (h daemonKeyHandler) Forward(data []byte) { | |
| 673 | + | win := h.sess.activeWindow() | |
| 674 | + | if win == nil { | |
| 665 | 675 | return | |
| 666 | - | case "\x1b1", "\x1b2", "\x1b3", "\x1b4", "\x1b5", "\x1b6", "\x1b7", "\x1b8", "\x1b9": | |
| 667 | - | idx := int(data[1] - '1') | |
| 668 | - | if sess.switchWindow(idx) { | |
| 669 | - | d.paint(sess, ac, true) | |
| 676 | + | } | |
| 677 | + | _, _ = win.pty.Write(data) | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func (h daemonKeyHandler) Action(action keys.Action) { | |
| 681 | + | switch action { | |
| 682 | + | case keys.ActionCreateWindow: | |
| 683 | + | if err := h.d.createWindow(h.sess, h.ac.size); err != nil { | |
| 684 | + | h.d.log.Error("create window failed", "err", err, "session", h.sess.name) | |
| 685 | + | return | |
| 670 | 686 | } | |
| 671 | - | return | |
| 672 | - | case "\x1bn": | |
| 673 | - | if sess.switchRelative(1) { | |
| 674 | - | d.paint(sess, ac, true) | |
| 687 | + | h.d.paint(h.sess, h.ac, true) | |
| 688 | + | case keys.ActionNextWindow: | |
| 689 | + | if h.sess.switchRelative(1) { | |
| 690 | + | h.d.paint(h.sess, h.ac, true) | |
| 675 | 691 | } | |
| 676 | - | return | |
| 677 | - | case "\x1bp": | |
| 678 | - | if sess.switchRelative(-1) { | |
| 679 | - | d.paint(sess, ac, true) | |
| 692 | + | case keys.ActionPreviousWindow: | |
| 693 | + | if h.sess.switchRelative(-1) { | |
| 694 | + | h.d.paint(h.sess, h.ac, true) | |
| 680 | 695 | } | |
| 681 | - | return | |
| 682 | - | case "\x1bx": | |
| 683 | - | win := sess.activeWindow() | |
| 696 | + | case keys.ActionDetach: | |
| 697 | + | h.d.clientGone(h.sess, h.ac, true) | |
| 698 | + | case keys.ActionCloseWindow: | |
| 699 | + | win := h.sess.activeWindow() | |
| 684 | 700 | if win != nil { | |
| 685 | - | d.closeWindow(sess, win, true) | |
| 701 | + | h.d.closeWindow(h.sess, win, true) | |
| 702 | + | } | |
| 703 | + | case keys.ActionCopyMode: | |
| 704 | + | // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted. | |
| 705 | + | case keys.ActionRenameSession: | |
| 706 | + | h.sess.promoteEphemeral() | |
| 707 | + | case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3, | |
| 708 | + | keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6, | |
| 709 | + | keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9: | |
| 710 | + | idx := int(action - keys.ActionSwitchWindow1) | |
| 711 | + | if h.sess.switchWindow(idx) { | |
| 712 | + | h.d.paint(h.sess, h.ac, true) | |
| 686 | 713 | } | |
| 687 | - | return | |
| 688 | - | } | |
| 689 | - | win := sess.activeWindow() | |
| 690 | - | if win == nil { | |
| 691 | - | return | |
| 692 | 714 | } | |
| 693 | - | _, _ = win.pty.Write(data) | |
| 694 | 715 | } | |
| 695 | 716 | ||
| 696 | 717 | func (s *session) switchWindow(idx int) bool { | |
| @@ -713,6 +734,14 @@ func (s *session) switchRelative(delta int) bool { | |||
| 713 | 734 | return true | |
| 714 | 735 | } | |
| 715 | 736 | ||
| 737 | + | // promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet, | |
| 738 | + | // the current ephemeral numeric name is kept and the session is made persistent. | |
| 739 | + | func (s *session) promoteEphemeral() { | |
| 740 | + | s.mu.Lock() | |
| 741 | + | defer s.mu.Unlock() | |
| 742 | + | s.ephemeral = false | |
| 743 | + | } | |
| 744 | + | ||
| 716 | 745 | func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | |
| 717 | 746 | sess.mu.Lock() | |
| 718 | 747 | idx := -1 | |
| @@ -39,6 +39,7 @@ import ( | |||||
| 39 | 39 | ||||
| 40 | "github.com/bnema/vev/internal/domain" | 40 | "github.com/bnema/vev/internal/domain" | ||
| 41 | "github.com/bnema/vev/internal/ports" | 41 | "github.com/bnema/vev/internal/ports" | ||
| 42 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 42 | "github.com/bnema/vev/pkg/renderer" | 43 | "github.com/bnema/vev/pkg/renderer" | ||
| 43 | "github.com/bnema/vev/pkg/vt" | 44 | "github.com/bnema/vev/pkg/vt" | ||
| 44 | ) | 45 | ) | ||
| @@ -138,6 +139,7 @@ type attachedClient struct { | |||||
| 138 | tr ports.Transport | 139 | tr ports.Transport | ||
| 139 | rend *renderer.Renderer | 140 | rend *renderer.Renderer | ||
| 140 | size domain.Size | 141 | size domain.Size | ||
| 142 | + | keys *keys.Router | |||
| 141 | sendMu sync.Mutex | 143 | sendMu sync.Mutex | ||
| 142 | } | 144 | } | ||
| 143 | 145 | ||||
| @@ -347,16 +349,15 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||||
| 347 | infos := make([]ports.SessionInfo, 0, len(d.sessions)) | 349 | infos := make([]ports.SessionInfo, 0, len(d.sessions)) | ||
| 348 | for _, s := range d.sessions { | 350 | for _, s := range d.sessions { | ||
| 349 | s.mu.Lock() | 351 | s.mu.Lock() | ||
| 350 | - | attached := s.client != nil | 352 | + | info := ports.SessionInfo{ |
| 351 | - | s.mu.Unlock() | |||
| 352 | - | windows := len(s.windows) | |||
| 353 | - | infos = append(infos, ports.SessionInfo{ | |||
| 354 | SessionID: string(s.id), | 353 | SessionID: string(s.id), | ||
| 355 | Name: s.name, | 354 | Name: s.name, | ||
| 356 | Ephemeral: s.ephemeral, | 355 | Ephemeral: s.ephemeral, | ||
| 357 | - | Windows: uint16(windows), | 356 | + | Windows: uint16(len(s.windows)), |
| 358 | - | Attached: attached, | 357 | + | Attached: s.client != nil, |
| 359 | - | }) | 358 | + | } |
| 359 | + | s.mu.Unlock() | |||
| 360 | + | infos = append(infos, info) | |||
| 360 | } | 361 | } | ||
| 361 | d.mu.Unlock() | 362 | d.mu.Unlock() | ||
| 362 | 363 | ||||
| @@ -551,6 +552,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||||
| 551 | rend: renderer.New(renderer.Capabilities{}), | 552 | rend: renderer.New(renderer.Capabilities{}), | ||
| 552 | size: sz, | 553 | size: sz, | ||
| 553 | } | 554 | } | ||
| 555 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |||
| 554 | sess.mu.Lock() | 556 | sess.mu.Lock() | ||
| 555 | old := sess.client | 557 | old := sess.client | ||
| 556 | sess.client = ac | 558 | sess.client = ac | ||
| @@ -655,42 +657,61 @@ func (s *session) activeWindow() *window { | |||||
| 655 | } | 657 | } | ||
| 656 | 658 | ||||
| 657 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | 659 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | ||
| 658 | - | switch string(data) { | 660 | + | if ac.keys == nil { |
| 659 | - | case "\x1bc": | 661 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) |
| 660 | - | if err := d.createWindow(sess, ac.size); err != nil { | 662 | + | } |
| 661 | - | d.log.Error("create window failed", "err", err, "session", sess.name) | 663 | + | ac.keys.Route(data) |
| 662 | - | return | 664 | + | } |
| 663 | - | } | 665 | + | |
| 664 | - | d.paint(sess, ac, true) | 666 | + | type daemonKeyHandler struct { |
| 667 | + | d *Daemon | |||
| 668 | + | sess *session | |||
| 669 | + | ac *attachedClient | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | func (h daemonKeyHandler) Forward(data []byte) { | |||
| 673 | + | win := h.sess.activeWindow() | |||
| 674 | + | if win == nil { | |||
| 665 | return | 675 | return | ||
| 666 | - | case "\x1b1", "\x1b2", "\x1b3", "\x1b4", "\x1b5", "\x1b6", "\x1b7", "\x1b8", "\x1b9": | 676 | + | } |
| 667 | - | idx := int(data[1] - '1') | 677 | + | _, _ = win.pty.Write(data) |
| 668 | - | if sess.switchWindow(idx) { | 678 | + | } |
| 669 | - | d.paint(sess, ac, true) | 679 | + | |
| 680 | + | func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 681 | + | switch action { | |||
| 682 | + | case keys.ActionCreateWindow: | |||
| 683 | + | if err := h.d.createWindow(h.sess, h.ac.size); err != nil { | |||
| 684 | + | h.d.log.Error("create window failed", "err", err, "session", h.sess.name) | |||
| 685 | + | return | |||
| 670 | } | 686 | } | ||
| 671 | - | return | 687 | + | h.d.paint(h.sess, h.ac, true) |
| 672 | - | case "\x1bn": | 688 | + | case keys.ActionNextWindow: |
| 673 | - | if sess.switchRelative(1) { | 689 | + | if h.sess.switchRelative(1) { |
| 674 | - | d.paint(sess, ac, true) | 690 | + | h.d.paint(h.sess, h.ac, true) |
| 675 | } | 691 | } | ||
| 676 | - | return | 692 | + | case keys.ActionPreviousWindow: |
| 677 | - | case "\x1bp": | 693 | + | if h.sess.switchRelative(-1) { |
| 678 | - | if sess.switchRelative(-1) { | 694 | + | h.d.paint(h.sess, h.ac, true) |
| 679 | - | d.paint(sess, ac, true) | |||
| 680 | } | 695 | } | ||
| 681 | - | return | 696 | + | case keys.ActionDetach: |
| 682 | - | case "\x1bx": | 697 | + | h.d.clientGone(h.sess, h.ac, true) |
| 683 | - | win := sess.activeWindow() | 698 | + | case keys.ActionCloseWindow: |
| 699 | + | win := h.sess.activeWindow() | |||
| 684 | if win != nil { | 700 | if win != nil { | ||
| 685 | - | d.closeWindow(sess, win, true) | 701 | + | h.d.closeWindow(h.sess, win, true) |
| 702 | + | } | |||
| 703 | + | case keys.ActionCopyMode: | |||
| 704 | + | // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted. | |||
| 705 | + | case keys.ActionRenameSession: | |||
| 706 | + | h.sess.promoteEphemeral() | |||
| 707 | + | case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3, | |||
| 708 | + | keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6, | |||
| 709 | + | keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9: | |||
| 710 | + | idx := int(action - keys.ActionSwitchWindow1) | |||
| 711 | + | if h.sess.switchWindow(idx) { | |||
| 712 | + | h.d.paint(h.sess, h.ac, true) | |||
| 686 | } | 713 | } | ||
| 687 | - | return | |||
| 688 | - | } | |||
| 689 | - | win := sess.activeWindow() | |||
| 690 | - | if win == nil { | |||
| 691 | - | return | |||
| 692 | } | 714 | } | ||
| 693 | - | _, _ = win.pty.Write(data) | |||
| 694 | } | 715 | } | ||
| 695 | 716 | ||||
| 696 | func (s *session) switchWindow(idx int) bool { | 717 | func (s *session) switchWindow(idx int) bool { | ||
| @@ -713,6 +734,14 @@ func (s *session) switchRelative(delta int) bool { | |||||
| 713 | return true | 734 | return true | ||
| 714 | } | 735 | } | ||
| 715 | 736 | ||||
| 737 | + | // promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet, | |||
| 738 | + | // the current ephemeral numeric name is kept and the session is made persistent. | |||
| 739 | + | func (s *session) promoteEphemeral() { | |||
| 740 | + | s.mu.Lock() | |||
| 741 | + | defer s.mu.Unlock() | |||
| 742 | + | s.ephemeral = false | |||
| 743 | + | } | |||
| 744 | + | ||||
| 716 | func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | 745 | func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | ||
| 717 | sess.mu.Lock() | 746 | sess.mu.Lock() | ||
| 718 | idx := -1 | 747 | idx := -1 | ||
M internal/usecase/daemon/daemon_test.go +25 -0
| @@ -462,6 +462,31 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) { | |||
| 462 | 462 | require.Equal(t, ports.ReasonSessionKilled, det.Reason) | |
| 463 | 463 | } | |
| 464 | 464 | ||
| 465 | + | func TestAltDDetachesCurrentClient(t *testing.T) { | |
| 466 | + | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | |
| 467 | + | defer releases[0]() | |
| 468 | + | ||
| 469 | + | d.handleInput(sess, ac, []byte("\x1bd")) | |
| 470 | + | ||
| 471 | + | require.Nil(t, sess.client) | |
| 472 | + | f := awaitFrame(t, sends, ports.MsgDetached) | |
| 473 | + | det, err := ports.UnmarshalDetached(f.Payload) | |
| 474 | + | require.NoError(t, err) | |
| 475 | + | require.Equal(t, ports.ReasonDetach, det.Reason) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |
| 479 | + | d, sess, ac, _, releases := newManualWindowSession(t, 1) | |
| 480 | + | defer releases[0]() | |
| 481 | + | sess.ephemeral = true | |
| 482 | + | sess.name = "0" | |
| 483 | + | ||
| 484 | + | d.handleInput(sess, ac, []byte("\x1br")) | |
| 485 | + | ||
| 486 | + | require.False(t, sess.ephemeral) | |
| 487 | + | require.Equal(t, "0", sess.name) | |
| 488 | + | } | |
| 489 | + | ||
| 465 | 490 | func TestAttachReplaceDetachesOld(t *testing.T) { | |
| 466 | 491 | p, releasePTY := newBlockingPTY(t) | |
| 467 | 492 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| @@ -462,6 +462,31 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) { | |||||
| 462 | require.Equal(t, ports.ReasonSessionKilled, det.Reason) | 462 | require.Equal(t, ports.ReasonSessionKilled, det.Reason) | ||
| 463 | } | 463 | } | ||
| 464 | 464 | ||||
| 465 | + | func TestAltDDetachesCurrentClient(t *testing.T) { | |||
| 466 | + | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | |||
| 467 | + | defer releases[0]() | |||
| 468 | + | ||||
| 469 | + | d.handleInput(sess, ac, []byte("\x1bd")) | |||
| 470 | + | ||||
| 471 | + | require.Nil(t, sess.client) | |||
| 472 | + | f := awaitFrame(t, sends, ports.MsgDetached) | |||
| 473 | + | det, err := ports.UnmarshalDetached(f.Payload) | |||
| 474 | + | require.NoError(t, err) | |||
| 475 | + | require.Equal(t, ports.ReasonDetach, det.Reason) | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |||
| 479 | + | d, sess, ac, _, releases := newManualWindowSession(t, 1) | |||
| 480 | + | defer releases[0]() | |||
| 481 | + | sess.ephemeral = true | |||
| 482 | + | sess.name = "0" | |||
| 483 | + | ||||
| 484 | + | d.handleInput(sess, ac, []byte("\x1br")) | |||
| 485 | + | ||||
| 486 | + | require.False(t, sess.ephemeral) | |||
| 487 | + | require.Equal(t, "0", sess.name) | |||
| 488 | + | } | |||
| 489 | + | ||||
| 465 | func TestAttachReplaceDetachesOld(t *testing.T) { | 490 | func TestAttachReplaceDetachesOld(t *testing.T) { | ||
| 466 | p, releasePTY := newBlockingPTY(t) | 491 | p, releasePTY := newBlockingPTY(t) | ||
| 467 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | 492 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | ||
A internal/usecase/keys/router.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | // Package keys routes terminal input bytes into vev Alt/ESC bindings or PTY | |
| 2 | + | // passthrough bytes. | |
| 3 | + | package keys | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | ESC byte = 0x1b | |
| 14 | + | ESCDelay = 500 * time.Millisecond | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Action is an intercepted vev key binding. | |
| 18 | + | type Action int | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | ActionCreateWindow Action = iota | |
| 22 | + | ActionNextWindow | |
| 23 | + | ActionPreviousWindow | |
| 24 | + | ActionDetach | |
| 25 | + | ActionCloseWindow | |
| 26 | + | ActionCopyMode | |
| 27 | + | ActionRenameSession | |
| 28 | + | ActionSwitchWindow1 | |
| 29 | + | ActionSwitchWindow2 | |
| 30 | + | ActionSwitchWindow3 | |
| 31 | + | ActionSwitchWindow4 | |
| 32 | + | ActionSwitchWindow5 | |
| 33 | + | ActionSwitchWindow6 | |
| 34 | + | ActionSwitchWindow7 | |
| 35 | + | ActionSwitchWindow8 | |
| 36 | + | ActionSwitchWindow9 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // Handler receives router outputs. Forward is called only for bytes that should | |
| 40 | + | // reach the PTY; Action is called for intercepted bindings. | |
| 41 | + | type Handler interface { | |
| 42 | + | Forward([]byte) | |
| 43 | + | Action(Action) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Router implements vev's Alt/ESC input routing. It retains a trailing ESC for | |
| 47 | + | // ESCDelay so split Alt-key sequences can still be intercepted without delaying | |
| 48 | + | // known terminal control prefixes (ESC [ and ESC O), which pass through. | |
| 49 | + | type Router struct { | |
| 50 | + | clock ports.Clock | |
| 51 | + | delay time.Duration | |
| 52 | + | h Handler | |
| 53 | + | ||
| 54 | + | mu sync.Mutex | |
| 55 | + | pending bool | |
| 56 | + | timer ports.Timer | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func NewRouter(clock ports.Clock, h Handler) *Router { | |
| 60 | + | return &Router{clock: clock, delay: ESCDelay, h: h} | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Route routes one transport read. | |
| 64 | + | func (r *Router) Route(data []byte) { | |
| 65 | + | if len(data) == 0 { | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | r.mu.Lock() | |
| 69 | + | defer r.mu.Unlock() | |
| 70 | + | if r.pending { | |
| 71 | + | r.stopTimer() | |
| 72 | + | r.pending = false | |
| 73 | + | if consumed := r.routeAfterPendingESC(data); consumed > 0 { | |
| 74 | + | data = data[consumed:] | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | r.route(data) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // PartialInputSuffixLen reports the suffix length that must be retained for a | |
| 81 | + | // later read. The current protocol only has one partial suffix: a lone trailing | |
| 82 | + | // ESC. This mirrors the YMUX partial-input pattern in a deliberately small form. | |
| 83 | + | func PartialInputSuffixLen(data []byte) int { | |
| 84 | + | if len(data) > 0 && data[len(data)-1] == ESC { | |
| 85 | + | return 1 | |
| 86 | + | } | |
| 87 | + | return 0 | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (r *Router) route(data []byte) { | |
| 91 | + | buf := make([]byte, 0, len(data)) | |
| 92 | + | flush := func() { | |
| 93 | + | if len(buf) == 0 { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | r.forward(buf) | |
| 97 | + | buf = buf[:0] | |
| 98 | + | } | |
| 99 | + | for i := 0; i < len(data); { | |
| 100 | + | if data[i] != ESC { | |
| 101 | + | buf = append(buf, data[i]) | |
| 102 | + | i++ | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | if i == len(data)-1 { | |
| 106 | + | flush() | |
| 107 | + | r.retainESC() | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | next := data[i+1] | |
| 111 | + | if passThroughPrefix(next) { | |
| 112 | + | buf = append(buf, ESC, next) | |
| 113 | + | i += 2 | |
| 114 | + | continue | |
| 115 | + | } | |
| 116 | + | if action, ok := binding(next); ok { | |
| 117 | + | flush() | |
| 118 | + | r.h.Action(action) | |
| 119 | + | i += 2 | |
| 120 | + | continue | |
| 121 | + | } | |
| 122 | + | buf = append(buf, ESC) | |
| 123 | + | i++ | |
| 124 | + | } | |
| 125 | + | flush() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (r *Router) routeAfterPendingESC(data []byte) int { | |
| 129 | + | next := data[0] | |
| 130 | + | if passThroughPrefix(next) { | |
| 131 | + | r.forward([]byte{ESC, next}) | |
| 132 | + | return 1 | |
| 133 | + | } | |
| 134 | + | if action, ok := binding(next); ok { | |
| 135 | + | r.h.Action(action) | |
| 136 | + | return 1 | |
| 137 | + | } | |
| 138 | + | r.forward([]byte{ESC}) | |
| 139 | + | return 0 | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (r *Router) retainESC() { | |
| 143 | + | r.pending = true | |
| 144 | + | r.timer = r.clock.NewTimer(r.delay) | |
| 145 | + | go func(timer ports.Timer) { | |
| 146 | + | <-timer.C() | |
| 147 | + | r.mu.Lock() | |
| 148 | + | defer r.mu.Unlock() | |
| 149 | + | if r.pending && r.timer == timer { | |
| 150 | + | r.pending = false | |
| 151 | + | r.forward([]byte{ESC}) | |
| 152 | + | } | |
| 153 | + | }(r.timer) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (r *Router) stopTimer() { | |
| 157 | + | if r.timer != nil { | |
| 158 | + | r.timer.Stop() | |
| 159 | + | r.timer = nil | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (r *Router) forward(data []byte) { | |
| 164 | + | cp := append([]byte(nil), data...) | |
| 165 | + | r.h.Forward(cp) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |
| 169 | + | ||
| 170 | + | func binding(b byte) (Action, bool) { | |
| 171 | + | switch b { | |
| 172 | + | case 'c': | |
| 173 | + | return ActionCreateWindow, true | |
| 174 | + | case 'n': | |
| 175 | + | return ActionNextWindow, true | |
| 176 | + | case 'p': | |
| 177 | + | return ActionPreviousWindow, true | |
| 178 | + | case 'd': | |
| 179 | + | return ActionDetach, true | |
| 180 | + | case 'x': | |
| 181 | + | return ActionCloseWindow, true | |
| 182 | + | case 'u': | |
| 183 | + | return ActionCopyMode, true | |
| 184 | + | case 'r': | |
| 185 | + | return ActionRenameSession, true | |
| 186 | + | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | |
| 187 | + | return ActionSwitchWindow1 + Action(b-'1'), true | |
| 188 | + | default: | |
| 189 | + | return 0, false | |
| 190 | + | } | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 1 | + | // Package keys routes terminal input bytes into vev Alt/ESC bindings or PTY | |||
| 2 | + | // passthrough bytes. | |||
| 3 | + | package keys | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "sync" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | ESC byte = 0x1b | |||
| 14 | + | ESCDelay = 500 * time.Millisecond | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Action is an intercepted vev key binding. | |||
| 18 | + | type Action int | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | ActionCreateWindow Action = iota | |||
| 22 | + | ActionNextWindow | |||
| 23 | + | ActionPreviousWindow | |||
| 24 | + | ActionDetach | |||
| 25 | + | ActionCloseWindow | |||
| 26 | + | ActionCopyMode | |||
| 27 | + | ActionRenameSession | |||
| 28 | + | ActionSwitchWindow1 | |||
| 29 | + | ActionSwitchWindow2 | |||
| 30 | + | ActionSwitchWindow3 | |||
| 31 | + | ActionSwitchWindow4 | |||
| 32 | + | ActionSwitchWindow5 | |||
| 33 | + | ActionSwitchWindow6 | |||
| 34 | + | ActionSwitchWindow7 | |||
| 35 | + | ActionSwitchWindow8 | |||
| 36 | + | ActionSwitchWindow9 | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // Handler receives router outputs. Forward is called only for bytes that should | |||
| 40 | + | // reach the PTY; Action is called for intercepted bindings. | |||
| 41 | + | type Handler interface { | |||
| 42 | + | Forward([]byte) | |||
| 43 | + | Action(Action) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Router implements vev's Alt/ESC input routing. It retains a trailing ESC for | |||
| 47 | + | // ESCDelay so split Alt-key sequences can still be intercepted without delaying | |||
| 48 | + | // known terminal control prefixes (ESC [ and ESC O), which pass through. | |||
| 49 | + | type Router struct { | |||
| 50 | + | clock ports.Clock | |||
| 51 | + | delay time.Duration | |||
| 52 | + | h Handler | |||
| 53 | + | ||||
| 54 | + | mu sync.Mutex | |||
| 55 | + | pending bool | |||
| 56 | + | timer ports.Timer | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func NewRouter(clock ports.Clock, h Handler) *Router { | |||
| 60 | + | return &Router{clock: clock, delay: ESCDelay, h: h} | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Route routes one transport read. | |||
| 64 | + | func (r *Router) Route(data []byte) { | |||
| 65 | + | if len(data) == 0 { | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | r.mu.Lock() | |||
| 69 | + | defer r.mu.Unlock() | |||
| 70 | + | if r.pending { | |||
| 71 | + | r.stopTimer() | |||
| 72 | + | r.pending = false | |||
| 73 | + | if consumed := r.routeAfterPendingESC(data); consumed > 0 { | |||
| 74 | + | data = data[consumed:] | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | r.route(data) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // PartialInputSuffixLen reports the suffix length that must be retained for a | |||
| 81 | + | // later read. The current protocol only has one partial suffix: a lone trailing | |||
| 82 | + | // ESC. This mirrors the YMUX partial-input pattern in a deliberately small form. | |||
| 83 | + | func PartialInputSuffixLen(data []byte) int { | |||
| 84 | + | if len(data) > 0 && data[len(data)-1] == ESC { | |||
| 85 | + | return 1 | |||
| 86 | + | } | |||
| 87 | + | return 0 | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (r *Router) route(data []byte) { | |||
| 91 | + | buf := make([]byte, 0, len(data)) | |||
| 92 | + | flush := func() { | |||
| 93 | + | if len(buf) == 0 { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | r.forward(buf) | |||
| 97 | + | buf = buf[:0] | |||
| 98 | + | } | |||
| 99 | + | for i := 0; i < len(data); { | |||
| 100 | + | if data[i] != ESC { | |||
| 101 | + | buf = append(buf, data[i]) | |||
| 102 | + | i++ | |||
| 103 | + | continue | |||
| 104 | + | } | |||
| 105 | + | if i == len(data)-1 { | |||
| 106 | + | flush() | |||
| 107 | + | r.retainESC() | |||
| 108 | + | return | |||
| 109 | + | } | |||
| 110 | + | next := data[i+1] | |||
| 111 | + | if passThroughPrefix(next) { | |||
| 112 | + | buf = append(buf, ESC, next) | |||
| 113 | + | i += 2 | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | if action, ok := binding(next); ok { | |||
| 117 | + | flush() | |||
| 118 | + | r.h.Action(action) | |||
| 119 | + | i += 2 | |||
| 120 | + | continue | |||
| 121 | + | } | |||
| 122 | + | buf = append(buf, ESC) | |||
| 123 | + | i++ | |||
| 124 | + | } | |||
| 125 | + | flush() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (r *Router) routeAfterPendingESC(data []byte) int { | |||
| 129 | + | next := data[0] | |||
| 130 | + | if passThroughPrefix(next) { | |||
| 131 | + | r.forward([]byte{ESC, next}) | |||
| 132 | + | return 1 | |||
| 133 | + | } | |||
| 134 | + | if action, ok := binding(next); ok { | |||
| 135 | + | r.h.Action(action) | |||
| 136 | + | return 1 | |||
| 137 | + | } | |||
| 138 | + | r.forward([]byte{ESC}) | |||
| 139 | + | return 0 | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (r *Router) retainESC() { | |||
| 143 | + | r.pending = true | |||
| 144 | + | r.timer = r.clock.NewTimer(r.delay) | |||
| 145 | + | go func(timer ports.Timer) { | |||
| 146 | + | <-timer.C() | |||
| 147 | + | r.mu.Lock() | |||
| 148 | + | defer r.mu.Unlock() | |||
| 149 | + | if r.pending && r.timer == timer { | |||
| 150 | + | r.pending = false | |||
| 151 | + | r.forward([]byte{ESC}) | |||
| 152 | + | } | |||
| 153 | + | }(r.timer) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (r *Router) stopTimer() { | |||
| 157 | + | if r.timer != nil { | |||
| 158 | + | r.timer.Stop() | |||
| 159 | + | r.timer = nil | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (r *Router) forward(data []byte) { | |||
| 164 | + | cp := append([]byte(nil), data...) | |||
| 165 | + | r.h.Forward(cp) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |||
| 169 | + | ||||
| 170 | + | func binding(b byte) (Action, bool) { | |||
| 171 | + | switch b { | |||
| 172 | + | case 'c': | |||
| 173 | + | return ActionCreateWindow, true | |||
| 174 | + | case 'n': | |||
| 175 | + | return ActionNextWindow, true | |||
| 176 | + | case 'p': | |||
| 177 | + | return ActionPreviousWindow, true | |||
| 178 | + | case 'd': | |||
| 179 | + | return ActionDetach, true | |||
| 180 | + | case 'x': | |||
| 181 | + | return ActionCloseWindow, true | |||
| 182 | + | case 'u': | |||
| 183 | + | return ActionCopyMode, true | |||
| 184 | + | case 'r': | |||
| 185 | + | return ActionRenameSession, true | |||
| 186 | + | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | |||
| 187 | + | return ActionSwitchWindow1 + Action(b-'1'), true | |||
| 188 | + | default: | |||
| 189 | + | return 0, false | |||
| 190 | + | } | |||
| 191 | + | } | |||
A internal/usecase/keys/router_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package keys | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type fakeClock struct{ timers []*fakeTimer } | |
| 13 | + | ||
| 14 | + | func (c *fakeClock) Now() time.Time { return time.Time{} } | |
| 15 | + | func (c *fakeClock) NewTimer(d time.Duration) ports.Timer { | |
| 16 | + | t := &fakeTimer{ch: make(chan time.Time, 1), d: d} | |
| 17 | + | c.timers = append(c.timers, t) | |
| 18 | + | return t | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type fakeTimer struct { | |
| 22 | + | ch chan time.Time | |
| 23 | + | d time.Duration | |
| 24 | + | stopped bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (t *fakeTimer) C() <-chan time.Time { return t.ch } | |
| 28 | + | func (t *fakeTimer) Reset(d time.Duration) bool { t.d = d; return t.stopped } | |
| 29 | + | func (t *fakeTimer) Stop() bool { t.stopped = true; return true } | |
| 30 | + | func (t *fakeTimer) fire() { t.ch <- time.Time{} } | |
| 31 | + | ||
| 32 | + | type captureHandler struct { | |
| 33 | + | forwards [][]byte | |
| 34 | + | actions []Action | |
| 35 | + | notify chan struct{} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (h *captureHandler) Forward(data []byte) { | |
| 39 | + | h.forwards = append(h.forwards, append([]byte(nil), data...)) | |
| 40 | + | if h.notify != nil { | |
| 41 | + | h.notify <- struct{}{} | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | |
| 45 | + | ||
| 46 | + | func TestRouterInterceptsSameReadAltBindings(t *testing.T) { | |
| 47 | + | cases := []struct { | |
| 48 | + | name string | |
| 49 | + | in []byte | |
| 50 | + | want Action | |
| 51 | + | }{ | |
| 52 | + | {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow}, | |
| 53 | + | {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow}, | |
| 54 | + | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow}, | |
| 55 | + | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | |
| 56 | + | {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow}, | |
| 57 | + | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | |
| 58 | + | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | |
| 59 | + | } | |
| 60 | + | for _, tc := range cases { | |
| 61 | + | t.Run(tc.name, func(t *testing.T) { | |
| 62 | + | clk := &fakeClock{} | |
| 63 | + | h := &captureHandler{} | |
| 64 | + | NewRouter(clk, h).Route(tc.in) | |
| 65 | + | require.Equal(t, []Action{tc.want}, h.actions) | |
| 66 | + | require.Empty(t, h.forwards) | |
| 67 | + | require.Empty(t, clk.timers) | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestRouterInterceptsAltDigits(t *testing.T) { | |
| 73 | + | clk := &fakeClock{} | |
| 74 | + | h := &captureHandler{} | |
| 75 | + | r := NewRouter(clk, h) | |
| 76 | + | for b := byte('1'); b <= '9'; b++ { | |
| 77 | + | r.Route([]byte{ESC, b}) | |
| 78 | + | } | |
| 79 | + | require.Equal(t, []Action{ | |
| 80 | + | ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3, | |
| 81 | + | ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6, | |
| 82 | + | ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9, | |
| 83 | + | }, h.actions) | |
| 84 | + | require.Empty(t, h.forwards) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | |
| 88 | + | clk := &fakeClock{} | |
| 89 | + | h := &captureHandler{} | |
| 90 | + | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | |
| 91 | + | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | |
| 92 | + | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) { | |
| 96 | + | cases := [][]byte{{ESC, '[', 'A'}, {ESC, 'O', 'P'}} | |
| 97 | + | for _, in := range cases { | |
| 98 | + | clk := &fakeClock{} | |
| 99 | + | h := &captureHandler{} | |
| 100 | + | NewRouter(clk, h).Route(in) | |
| 101 | + | require.Empty(t, h.actions) | |
| 102 | + | require.Equal(t, [][]byte{in}, h.forwards) | |
| 103 | + | require.Empty(t, clk.timers) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |
| 108 | + | clk := &fakeClock{} | |
| 109 | + | h := &captureHandler{} | |
| 110 | + | r := NewRouter(clk, h) | |
| 111 | + | r.Route([]byte{ESC}) | |
| 112 | + | require.Len(t, clk.timers, 1) | |
| 113 | + | require.Equal(t, ESCDelay, clk.timers[0].d) | |
| 114 | + | require.Empty(t, h.forwards) | |
| 115 | + | ||
| 116 | + | r.Route([]byte{'n'}) | |
| 117 | + | require.True(t, clk.timers[0].stopped) | |
| 118 | + | require.Equal(t, []Action{ActionNextWindow}, h.actions) | |
| 119 | + | require.Empty(t, h.forwards) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | |
| 123 | + | clk := &fakeClock{} | |
| 124 | + | h := &captureHandler{notify: make(chan struct{}, 1)} | |
| 125 | + | r := NewRouter(clk, h) | |
| 126 | + | r.Route([]byte{ESC}) | |
| 127 | + | clk.timers[0].fire() | |
| 128 | + | select { | |
| 129 | + | case <-h.notify: | |
| 130 | + | case <-time.After(time.Second): | |
| 131 | + | t.Fatal("timed out waiting for flushed ESC") | |
| 132 | + | } | |
| 133 | + | require.Equal(t, [][]byte{{ESC}}, h.forwards) | |
| 134 | + | require.Empty(t, h.actions) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |
| 138 | + | clk := &fakeClock{} | |
| 139 | + | h := &captureHandler{} | |
| 140 | + | r := NewRouter(clk, h) | |
| 141 | + | r.Route([]byte{ESC}) | |
| 142 | + | r.Route([]byte{'z'}) | |
| 143 | + | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | |
| 144 | + | require.Empty(t, h.actions) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestPartialInputSuffixLen(t *testing.T) { | |
| 148 | + | require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b"))) | |
| 149 | + | require.Equal(t, 0, PartialInputSuffixLen([]byte("abc"))) | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package keys | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type fakeClock struct{ timers []*fakeTimer } | |||
| 13 | + | ||||
| 14 | + | func (c *fakeClock) Now() time.Time { return time.Time{} } | |||
| 15 | + | func (c *fakeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 16 | + | t := &fakeTimer{ch: make(chan time.Time, 1), d: d} | |||
| 17 | + | c.timers = append(c.timers, t) | |||
| 18 | + | return t | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | type fakeTimer struct { | |||
| 22 | + | ch chan time.Time | |||
| 23 | + | d time.Duration | |||
| 24 | + | stopped bool | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (t *fakeTimer) C() <-chan time.Time { return t.ch } | |||
| 28 | + | func (t *fakeTimer) Reset(d time.Duration) bool { t.d = d; return t.stopped } | |||
| 29 | + | func (t *fakeTimer) Stop() bool { t.stopped = true; return true } | |||
| 30 | + | func (t *fakeTimer) fire() { t.ch <- time.Time{} } | |||
| 31 | + | ||||
| 32 | + | type captureHandler struct { | |||
| 33 | + | forwards [][]byte | |||
| 34 | + | actions []Action | |||
| 35 | + | notify chan struct{} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (h *captureHandler) Forward(data []byte) { | |||
| 39 | + | h.forwards = append(h.forwards, append([]byte(nil), data...)) | |||
| 40 | + | if h.notify != nil { | |||
| 41 | + | h.notify <- struct{}{} | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | |||
| 45 | + | ||||
| 46 | + | func TestRouterInterceptsSameReadAltBindings(t *testing.T) { | |||
| 47 | + | cases := []struct { | |||
| 48 | + | name string | |||
| 49 | + | in []byte | |||
| 50 | + | want Action | |||
| 51 | + | }{ | |||
| 52 | + | {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow}, | |||
| 53 | + | {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow}, | |||
| 54 | + | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow}, | |||
| 55 | + | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | |||
| 56 | + | {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow}, | |||
| 57 | + | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | |||
| 58 | + | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | |||
| 59 | + | } | |||
| 60 | + | for _, tc := range cases { | |||
| 61 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 62 | + | clk := &fakeClock{} | |||
| 63 | + | h := &captureHandler{} | |||
| 64 | + | NewRouter(clk, h).Route(tc.in) | |||
| 65 | + | require.Equal(t, []Action{tc.want}, h.actions) | |||
| 66 | + | require.Empty(t, h.forwards) | |||
| 67 | + | require.Empty(t, clk.timers) | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestRouterInterceptsAltDigits(t *testing.T) { | |||
| 73 | + | clk := &fakeClock{} | |||
| 74 | + | h := &captureHandler{} | |||
| 75 | + | r := NewRouter(clk, h) | |||
| 76 | + | for b := byte('1'); b <= '9'; b++ { | |||
| 77 | + | r.Route([]byte{ESC, b}) | |||
| 78 | + | } | |||
| 79 | + | require.Equal(t, []Action{ | |||
| 80 | + | ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3, | |||
| 81 | + | ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6, | |||
| 82 | + | ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9, | |||
| 83 | + | }, h.actions) | |||
| 84 | + | require.Empty(t, h.forwards) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | |||
| 88 | + | clk := &fakeClock{} | |||
| 89 | + | h := &captureHandler{} | |||
| 90 | + | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | |||
| 91 | + | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | |||
| 92 | + | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) { | |||
| 96 | + | cases := [][]byte{{ESC, '[', 'A'}, {ESC, 'O', 'P'}} | |||
| 97 | + | for _, in := range cases { | |||
| 98 | + | clk := &fakeClock{} | |||
| 99 | + | h := &captureHandler{} | |||
| 100 | + | NewRouter(clk, h).Route(in) | |||
| 101 | + | require.Empty(t, h.actions) | |||
| 102 | + | require.Equal(t, [][]byte{in}, h.forwards) | |||
| 103 | + | require.Empty(t, clk.timers) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||
| 108 | + | clk := &fakeClock{} | |||
| 109 | + | h := &captureHandler{} | |||
| 110 | + | r := NewRouter(clk, h) | |||
| 111 | + | r.Route([]byte{ESC}) | |||
| 112 | + | require.Len(t, clk.timers, 1) | |||
| 113 | + | require.Equal(t, ESCDelay, clk.timers[0].d) | |||
| 114 | + | require.Empty(t, h.forwards) | |||
| 115 | + | ||||
| 116 | + | r.Route([]byte{'n'}) | |||
| 117 | + | require.True(t, clk.timers[0].stopped) | |||
| 118 | + | require.Equal(t, []Action{ActionNextWindow}, h.actions) | |||
| 119 | + | require.Empty(t, h.forwards) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | |||
| 123 | + | clk := &fakeClock{} | |||
| 124 | + | h := &captureHandler{notify: make(chan struct{}, 1)} | |||
| 125 | + | r := NewRouter(clk, h) | |||
| 126 | + | r.Route([]byte{ESC}) | |||
| 127 | + | clk.timers[0].fire() | |||
| 128 | + | select { | |||
| 129 | + | case <-h.notify: | |||
| 130 | + | case <-time.After(time.Second): | |||
| 131 | + | t.Fatal("timed out waiting for flushed ESC") | |||
| 132 | + | } | |||
| 133 | + | require.Equal(t, [][]byte{{ESC}}, h.forwards) | |||
| 134 | + | require.Empty(t, h.actions) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |||
| 138 | + | clk := &fakeClock{} | |||
| 139 | + | h := &captureHandler{} | |||
| 140 | + | r := NewRouter(clk, h) | |||
| 141 | + | r.Route([]byte{ESC}) | |||
| 142 | + | r.Route([]byte{'z'}) | |||
| 143 | + | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | |||
| 144 | + | require.Empty(t, h.actions) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestPartialInputSuffixLen(t *testing.T) { | |||
| 148 | + | require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b"))) | |||
| 149 | + | require.Equal(t, 0, PartialInputSuffixLen([]byte("abc"))) | |||
| 150 | + | } | |||