feat(daemon): route floating terminal input
- commit
- 540608fb41a70018ef8ab4b4ddeb9bdb5ff1d983
- author
- brice · · 2026-07-10 07:19 UTC
- parent
- eaec9db
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +191 -2
M internal/usecase/daemon/clipboard.go +1 -1
| @@ -91,7 +91,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 91 | 91 | return | |
| 92 | 92 | } | |
| 93 | 93 | tb.mu.Lock() | |
| 94 | - | p := tb.focusedPane() | |
| 94 | + | p := tb.terminalTargetLocked() | |
| 95 | 95 | tb.mu.Unlock() | |
| 96 | 96 | if p == nil { | |
| 97 | 97 | return | |
| @@ -91,7 +91,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||||
| 91 | return | 91 | return | ||
| 92 | } | 92 | } | ||
| 93 | tb.mu.Lock() | 93 | tb.mu.Lock() | ||
| 94 | - | p := tb.focusedPane() | 94 | + | p := tb.terminalTargetLocked() |
| 95 | tb.mu.Unlock() | 95 | tb.mu.Unlock() | ||
| 96 | if p == nil { | 96 | if p == nil { | ||
| 97 | return | 97 | return | ||
M internal/usecase/daemon/clipboard_test.go +19 -0
| @@ -13,11 +13,30 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 22 | + | func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) { | |
| 23 | + | normalWrites := make(chan []byte, 1) | |
| 24 | + | floatingWrites := make(chan []byte, 1) | |
| 25 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |
| 26 | + | floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |
| 27 | + | defer releaseNormal() | |
| 28 | + | defer releaseFloating() | |
| 29 | + | d, sess, _, _ := newManualSessionWithPTYs(t, normal) | |
| 30 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}) | |
| 31 | + | floating.screen.Write([]byte("\x1b[?2004h")) | |
| 32 | + | installTestFloating(sess.activeTab(), floating, true) | |
| 33 | + | ||
| 34 | + | d.injectClipboardPath(sess, "/tmp/image.png") | |
| 35 | + | ||
| 36 | + | requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~")) | |
| 37 | + | requireNoPTYWrite(t, normalWrites) | |
| 38 | + | } | |
| 39 | + | ||
| 21 | 40 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |
| 22 | 41 | writes := make(chan []byte, 1) | |
| 23 | 42 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| @@ -13,11 +13,30 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 22 | + | func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) { | |||
| 23 | + | normalWrites := make(chan []byte, 1) | |||
| 24 | + | floatingWrites := make(chan []byte, 1) | |||
| 25 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 26 | + | floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 27 | + | defer releaseNormal() | |||
| 28 | + | defer releaseFloating() | |||
| 29 | + | d, sess, _, _ := newManualSessionWithPTYs(t, normal) | |||
| 30 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}) | |||
| 31 | + | floating.screen.Write([]byte("\x1b[?2004h")) | |||
| 32 | + | installTestFloating(sess.activeTab(), floating, true) | |||
| 33 | + | ||||
| 34 | + | d.injectClipboardPath(sess, "/tmp/image.png") | |||
| 35 | + | ||||
| 36 | + | requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~")) | |||
| 37 | + | requireNoPTYWrite(t, normalWrites) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 21 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | 40 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | ||
| 22 | writes := make(chan []byte, 1) | 41 | writes := make(chan []byte, 1) | ||
| 23 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 42 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
M internal/usecase/daemon/floating.go +14 -0
| @@ -125,6 +125,20 @@ func (tb *tab) terminalTargetLocked() *pane { | |||
| 125 | 125 | return tb.focusedPane() | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | // visibleFloatingSnapshotLocked captures the visible popup and its current | |
| 129 | + | // geometry under the tab lock, so input cannot mix a pane from one state with | |
| 130 | + | // coordinates from another. The caller must hold tb.mu. | |
| 131 | + | func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) { | |
| 132 | + | if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil { | |
| 133 | + | return nil, floatingGeometry{}, false | |
| 134 | + | } | |
| 135 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |
| 136 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 137 | + | return nil, floatingGeometry{}, false | |
| 138 | + | } | |
| 139 | + | return tb.floating.pane, geometry, true | |
| 140 | + | } | |
| 141 | + | ||
| 128 | 142 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | |
| 129 | 143 | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | |
| 130 | 144 | if d == nil || sess == nil || tb == nil || d.ptys == nil { | |
| @@ -125,6 +125,20 @@ func (tb *tab) terminalTargetLocked() *pane { | |||||
| 125 | return tb.focusedPane() | 125 | return tb.focusedPane() | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | // visibleFloatingSnapshotLocked captures the visible popup and its current | |||
| 129 | + | // geometry under the tab lock, so input cannot mix a pane from one state with | |||
| 130 | + | // coordinates from another. The caller must hold tb.mu. | |||
| 131 | + | func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) { | |||
| 132 | + | if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil { | |||
| 133 | + | return nil, floatingGeometry{}, false | |||
| 134 | + | } | |||
| 135 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |||
| 136 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |||
| 137 | + | return nil, floatingGeometry{}, false | |||
| 138 | + | } | |||
| 139 | + | return tb.floating.pane, geometry, true | |||
| 140 | + | } | |||
| 141 | + | ||||
| 128 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | 142 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | ||
| 129 | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | 143 | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | ||
| 130 | if d == nil || sess == nil || tb == nil || d.ptys == nil { | 144 | if d == nil || sess == nil || tb == nil || d.ptys == nil { | ||
M internal/usecase/daemon/input.go +22 -1
| @@ -120,6 +120,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 120 | 120 | ||
| 121 | 121 | tb.mu.Lock() | |
| 122 | 122 | contentRow := ev.Row - 1 | |
| 123 | + | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 124 | + | if floatingVisible { | |
| 125 | + | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| 126 | + | tb.mu.Unlock() | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | tb.mu.Unlock() | |
| 130 | + | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |
| 131 | + | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | |
| 132 | + | return | |
| 133 | + | } | |
| 123 | 134 | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | |
| 124 | 135 | multi := len(tb.panes) > 1 | |
| 125 | 136 | focusedID := layout.PaneID("") | |
| @@ -177,6 +188,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 177 | 188 | return | |
| 178 | 189 | } | |
| 179 | 190 | } | |
| 191 | + | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |
| 195 | + | if p == nil { | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | rt := ac.overlays | |
| 180 | 199 | p.mu.Lock() | |
| 181 | 200 | childRows := p.screen.Frame.Height | |
| 182 | 201 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| @@ -285,7 +304,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |||
| 285 | 304 | if _, err := p.pty.Write(data); err != nil { | |
| 286 | 305 | name := "" | |
| 287 | 306 | if sess != nil { | |
| 307 | + | sess.mu.Lock() | |
| 288 | 308 | name = sess.name | |
| 309 | + | sess.mu.Unlock() | |
| 289 | 310 | } | |
| 290 | 311 | d.log.Error("pty write failed", "err", err, "session", name) | |
| 291 | 312 | } | |
| @@ -348,7 +369,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 348 | 369 | return | |
| 349 | 370 | } | |
| 350 | 371 | tb.mu.Lock() | |
| 351 | - | p := tb.focusedPane() | |
| 372 | + | p := tb.terminalTargetLocked() | |
| 352 | 373 | tb.mu.Unlock() | |
| 353 | 374 | h.d.writeToPane(sess, p, data) | |
| 354 | 375 | } | |
| @@ -120,6 +120,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 120 | 120 | ||||
| 121 | tb.mu.Lock() | 121 | tb.mu.Lock() | ||
| 122 | contentRow := ev.Row - 1 | 122 | contentRow := ev.Row - 1 | ||
| 123 | + | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 124 | + | if floatingVisible { | |||
| 125 | + | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |||
| 126 | + | tb.mu.Unlock() | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | tb.mu.Unlock() | |||
| 130 | + | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |||
| 131 | + | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 123 | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | 134 | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | ||
| 124 | multi := len(tb.panes) > 1 | 135 | multi := len(tb.panes) > 1 | ||
| 125 | focusedID := layout.PaneID("") | 136 | focusedID := layout.PaneID("") | ||
| @@ -177,6 +188,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 177 | return | 188 | return | ||
| 178 | } | 189 | } | ||
| 179 | } | 190 | } | ||
| 191 | + | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |||
| 195 | + | if p == nil { | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | rt := ac.overlays | |||
| 180 | p.mu.Lock() | 199 | p.mu.Lock() | ||
| 181 | childRows := p.screen.Frame.Height | 200 | childRows := p.screen.Frame.Height | ||
| 182 | mouseMode, mouseSGR := p.screen.MouseMode() | 201 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| @@ -285,7 +304,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |||||
| 285 | if _, err := p.pty.Write(data); err != nil { | 304 | if _, err := p.pty.Write(data); err != nil { | ||
| 286 | name := "" | 305 | name := "" | ||
| 287 | if sess != nil { | 306 | if sess != nil { | ||
| 307 | + | sess.mu.Lock() | |||
| 288 | name = sess.name | 308 | name = sess.name | ||
| 309 | + | sess.mu.Unlock() | |||
| 289 | } | 310 | } | ||
| 290 | d.log.Error("pty write failed", "err", err, "session", name) | 311 | d.log.Error("pty write failed", "err", err, "session", name) | ||
| 291 | } | 312 | } | ||
| @@ -348,7 +369,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||||
| 348 | return | 369 | return | ||
| 349 | } | 370 | } | ||
| 350 | tb.mu.Lock() | 371 | tb.mu.Lock() | ||
| 351 | - | p := tb.focusedPane() | 372 | + | p := tb.terminalTargetLocked() |
| 352 | tb.mu.Unlock() | 373 | tb.mu.Unlock() | ||
| 353 | h.d.writeToPane(sess, p, data) | 374 | h.d.writeToPane(sess, p, data) | ||
| 354 | } | 375 | } | ||
M internal/usecase/daemon/input_test.go +135 -0
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | + | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| @@ -14,6 +15,7 @@ import ( | |||
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | 19 | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -79,6 +81,129 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||
| 79 | 81 | releasePTY() | |
| 80 | 82 | } | |
| 81 | 83 | ||
| 84 | + | func installTestFloating(tb *tab, p *pane, visible bool) { | |
| 85 | + | tb.mu.Lock() | |
| 86 | + | defer tb.mu.Unlock() | |
| 87 | + | tb.floating.pane = p | |
| 88 | + | tb.floating.generation++ | |
| 89 | + | if visible { | |
| 90 | + | tb.floating.state = floatingVisible | |
| 91 | + | } else { | |
| 92 | + | tb.floating.state = floatingHidden | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) { | |
| 97 | + | for _, tc := range []struct { | |
| 98 | + | name string | |
| 99 | + | visible bool | |
| 100 | + | input []byte | |
| 101 | + | }{ | |
| 102 | + | {name: "visible ordinary bytes", visible: true, input: []byte("hello")}, | |
| 103 | + | {name: "visible escape", visible: true, input: []byte("\x1b")}, | |
| 104 | + | {name: "hidden ordinary bytes", visible: false, input: []byte("hello")}, | |
| 105 | + | {name: "hidden escape", visible: false, input: []byte("\x1b")}, | |
| 106 | + | } { | |
| 107 | + | t.Run(tc.name, func(t *testing.T) { | |
| 108 | + | normalWrites := make(chan []byte, 1) | |
| 109 | + | floatingWrites := make(chan []byte, 1) | |
| 110 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |
| 111 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |
| 112 | + | defer releaseNormal() | |
| 113 | + | defer releaseFloating() | |
| 114 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 115 | + | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible) | |
| 116 | + | ||
| 117 | + | daemonKeyHandler{d: d, ac: ac}.Forward(tc.input) | |
| 118 | + | if tc.visible { | |
| 119 | + | requirePTYWrite(t, floatingWrites, tc.input) | |
| 120 | + | requireNoPTYWrite(t, normalWrites) | |
| 121 | + | } else { | |
| 122 | + | requirePTYWrite(t, normalWrites, tc.input) | |
| 123 | + | requireNoPTYWrite(t, floatingWrites) | |
| 124 | + | } | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) { | |
| 130 | + | normalWrites := make(chan []byte, 1) | |
| 131 | + | floatingWrites := make(chan []byte, 1) | |
| 132 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |
| 133 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |
| 134 | + | defer releaseNormal() | |
| 135 | + | defer releaseFloating() | |
| 136 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 137 | + | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |
| 138 | + | ||
| 139 | + | paste := []byte("\x1b[200~paste\x1bf\x1b[201~") | |
| 140 | + | d.handleInput(sess, ac, paste) | |
| 141 | + | requirePTYWrite(t, floatingWrites, paste) | |
| 142 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 143 | + | require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept") | |
| 144 | + | requireNoPTYWrite(t, normalWrites) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |
| 148 | + | writes := make(chan []byte, 1) | |
| 149 | + | floatingWrites := make(chan []byte, 1) | |
| 150 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, writes) | |
| 151 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |
| 152 | + | defer releaseNormal() | |
| 153 | + | defer releaseFloating() | |
| 154 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 155 | + | tb := sess.activeTab() | |
| 156 | + | second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5}) | |
| 157 | + | tb.mu.Lock() | |
| 158 | + | tb.size = domain.Size{Cols: 81, Rows: 5} | |
| 159 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 160 | + | tb.panes["pane-2"] = second | |
| 161 | + | tb.mu.Unlock() | |
| 162 | + | installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |
| 163 | + | ||
| 164 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight) | |
| 165 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 166 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) | |
| 167 | + | requirePTYWrite(t, floatingWrites, []byte("x")) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |
| 171 | + | normal := portsmocks.NewMockPTY(t) | |
| 172 | + | floatingPTY := portsmocks.NewMockPTY(t) | |
| 173 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 174 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 175 | + | tb := sess.activeTab() | |
| 176 | + | tb.mu.Lock() | |
| 177 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 178 | + | tb.mu.Unlock() | |
| 179 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |
| 180 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 181 | + | installTestFloating(tb, floating, true) | |
| 182 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |
| 183 | + | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | |
| 184 | + | ||
| 185 | + | d.handleInput(sess, ac, raw) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |
| 189 | + | normal := portsmocks.NewMockPTY(t) | |
| 190 | + | floatingPTY := portsmocks.NewMockPTY(t) | |
| 191 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 192 | + | tb := sess.activeTab() | |
| 193 | + | tb.mu.Lock() | |
| 194 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 195 | + | tb.mu.Unlock() | |
| 196 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |
| 197 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 198 | + | installTestFloating(tb, floating, true) | |
| 199 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |
| 200 | + | ||
| 201 | + | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | |
| 202 | + | outside := []byte("\x1b[<0;1;2M") | |
| 203 | + | d.handleInput(sess, ac, border) | |
| 204 | + | d.handleInput(sess, ac, outside) | |
| 205 | + | } | |
| 206 | + | ||
| 82 | 207 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |
| 83 | 208 | writes := make(chan []byte, 1) | |
| 84 | 209 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| @@ -196,6 +321,16 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||
| 196 | 321 | } | |
| 197 | 322 | } | |
| 198 | 323 | ||
| 324 | + | func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) { | |
| 325 | + | t.Helper() | |
| 326 | + | select { | |
| 327 | + | case got := <-writes: | |
| 328 | + | require.Equal(t, want, got) | |
| 329 | + | case <-time.After(time.Second): | |
| 330 | + | t.Fatalf("PTY did not receive %q", want) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 199 | 334 | func requireNoPTYWrite(t *testing.T, writes chan []byte) { | |
| 200 | 335 | t.Helper() | |
| 201 | 336 | select { | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/base64" | 4 | "encoding/base64" | ||
| 5 | + | "fmt" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -14,6 +15,7 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 16 | scopy "github.com/bnema/vev/internal/usecase/copy" | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 18 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 19 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| @@ -79,6 +81,129 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||||
| 79 | releasePTY() | 81 | releasePTY() | ||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 84 | + | func installTestFloating(tb *tab, p *pane, visible bool) { | |||
| 85 | + | tb.mu.Lock() | |||
| 86 | + | defer tb.mu.Unlock() | |||
| 87 | + | tb.floating.pane = p | |||
| 88 | + | tb.floating.generation++ | |||
| 89 | + | if visible { | |||
| 90 | + | tb.floating.state = floatingVisible | |||
| 91 | + | } else { | |||
| 92 | + | tb.floating.state = floatingHidden | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) { | |||
| 97 | + | for _, tc := range []struct { | |||
| 98 | + | name string | |||
| 99 | + | visible bool | |||
| 100 | + | input []byte | |||
| 101 | + | }{ | |||
| 102 | + | {name: "visible ordinary bytes", visible: true, input: []byte("hello")}, | |||
| 103 | + | {name: "visible escape", visible: true, input: []byte("\x1b")}, | |||
| 104 | + | {name: "hidden ordinary bytes", visible: false, input: []byte("hello")}, | |||
| 105 | + | {name: "hidden escape", visible: false, input: []byte("\x1b")}, | |||
| 106 | + | } { | |||
| 107 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 108 | + | normalWrites := make(chan []byte, 1) | |||
| 109 | + | floatingWrites := make(chan []byte, 1) | |||
| 110 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 111 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 112 | + | defer releaseNormal() | |||
| 113 | + | defer releaseFloating() | |||
| 114 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 115 | + | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible) | |||
| 116 | + | ||||
| 117 | + | daemonKeyHandler{d: d, ac: ac}.Forward(tc.input) | |||
| 118 | + | if tc.visible { | |||
| 119 | + | requirePTYWrite(t, floatingWrites, tc.input) | |||
| 120 | + | requireNoPTYWrite(t, normalWrites) | |||
| 121 | + | } else { | |||
| 122 | + | requirePTYWrite(t, normalWrites, tc.input) | |||
| 123 | + | requireNoPTYWrite(t, floatingWrites) | |||
| 124 | + | } | |||
| 125 | + | }) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) { | |||
| 130 | + | normalWrites := make(chan []byte, 1) | |||
| 131 | + | floatingWrites := make(chan []byte, 1) | |||
| 132 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 133 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 134 | + | defer releaseNormal() | |||
| 135 | + | defer releaseFloating() | |||
| 136 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 137 | + | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 138 | + | ||||
| 139 | + | paste := []byte("\x1b[200~paste\x1bf\x1b[201~") | |||
| 140 | + | d.handleInput(sess, ac, paste) | |||
| 141 | + | requirePTYWrite(t, floatingWrites, paste) | |||
| 142 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 143 | + | require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept") | |||
| 144 | + | requireNoPTYWrite(t, normalWrites) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||
| 148 | + | writes := make(chan []byte, 1) | |||
| 149 | + | floatingWrites := make(chan []byte, 1) | |||
| 150 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, writes) | |||
| 151 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 152 | + | defer releaseNormal() | |||
| 153 | + | defer releaseFloating() | |||
| 154 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 155 | + | tb := sess.activeTab() | |||
| 156 | + | second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5}) | |||
| 157 | + | tb.mu.Lock() | |||
| 158 | + | tb.size = domain.Size{Cols: 81, Rows: 5} | |||
| 159 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 160 | + | tb.panes["pane-2"] = second | |||
| 161 | + | tb.mu.Unlock() | |||
| 162 | + | installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 163 | + | ||||
| 164 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight) | |||
| 165 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 166 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) | |||
| 167 | + | requirePTYWrite(t, floatingWrites, []byte("x")) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 171 | + | normal := portsmocks.NewMockPTY(t) | |||
| 172 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 173 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |||
| 174 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 175 | + | tb := sess.activeTab() | |||
| 176 | + | tb.mu.Lock() | |||
| 177 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 178 | + | tb.mu.Unlock() | |||
| 179 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |||
| 180 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 181 | + | installTestFloating(tb, floating, true) | |||
| 182 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |||
| 183 | + | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | |||
| 184 | + | ||||
| 185 | + | d.handleInput(sess, ac, raw) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 189 | + | normal := portsmocks.NewMockPTY(t) | |||
| 190 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 191 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 192 | + | tb := sess.activeTab() | |||
| 193 | + | tb.mu.Lock() | |||
| 194 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 195 | + | tb.mu.Unlock() | |||
| 196 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |||
| 197 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 198 | + | installTestFloating(tb, floating, true) | |||
| 199 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |||
| 200 | + | ||||
| 201 | + | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | |||
| 202 | + | outside := []byte("\x1b[<0;1;2M") | |||
| 203 | + | d.handleInput(sess, ac, border) | |||
| 204 | + | d.handleInput(sess, ac, outside) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 82 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | 207 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | ||
| 83 | writes := make(chan []byte, 1) | 208 | writes := make(chan []byte, 1) | ||
| 84 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 209 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
| @@ -196,6 +321,16 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||||
| 196 | } | 321 | } | ||
| 197 | } | 322 | } | ||
| 198 | 323 | ||||
| 324 | + | func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) { | |||
| 325 | + | t.Helper() | |||
| 326 | + | select { | |||
| 327 | + | case got := <-writes: | |||
| 328 | + | require.Equal(t, want, got) | |||
| 329 | + | case <-time.After(time.Second): | |||
| 330 | + | t.Fatalf("PTY did not receive %q", want) | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 199 | func requireNoPTYWrite(t *testing.T, writes chan []byte) { | 334 | func requireNoPTYWrite(t *testing.T, writes chan []byte) { | ||
| 200 | t.Helper() | 335 | t.Helper() | ||
| 201 | select { | 336 | select { | ||