fix(copy): invalidate rejected pointer interactions
- commit
- 99388074345d5aa157306fa61f8224dc5df43c60
- author
- brice · · 2026-07-15 08:06 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- b30c769
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +314 -26
M internal/usecase/daemon/copymode.go +8 -0
| @@ -96,7 +96,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 96 | 96 | } else { | |
| 97 | 97 | rt.invalidateCopyPointerLocked(true) | |
| 98 | 98 | } | |
| 99 | + | pointerEpoch := rt.copyPointerEpoch | |
| 99 | 100 | rt.copyMu.Unlock() | |
| 101 | + | if d.beforeCopyModeRevalidate != nil { | |
| 102 | + | d.beforeCopyModeRevalidate() | |
| 103 | + | } | |
| 100 | 104 | valid := sess.activeTab() == tb | |
| 101 | 105 | if valid { | |
| 102 | 106 | tb.mu.Lock() | |
| @@ -108,6 +112,10 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 108 | 112 | if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document { | |
| 109 | 113 | return false | |
| 110 | 114 | } | |
| 115 | + | if pointerEpoch != rt.copyPointerEpoch { | |
| 116 | + | rt.discardCopyCandidateLocked(mode) | |
| 117 | + | return false | |
| 118 | + | } | |
| 111 | 119 | if !valid { | |
| 112 | 120 | rt.clearCopyModeLocked() | |
| 113 | 121 | return false | |
| @@ -96,7 +96,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 96 | } else { | 96 | } else { | ||
| 97 | rt.invalidateCopyPointerLocked(true) | 97 | rt.invalidateCopyPointerLocked(true) | ||
| 98 | } | 98 | } | ||
| 99 | + | pointerEpoch := rt.copyPointerEpoch | |||
| 99 | rt.copyMu.Unlock() | 100 | rt.copyMu.Unlock() | ||
| 101 | + | if d.beforeCopyModeRevalidate != nil { | |||
| 102 | + | d.beforeCopyModeRevalidate() | |||
| 103 | + | } | |||
| 100 | valid := sess.activeTab() == tb | 104 | valid := sess.activeTab() == tb | ||
| 101 | if valid { | 105 | if valid { | ||
| 102 | tb.mu.Lock() | 106 | tb.mu.Lock() | ||
| @@ -108,6 +112,10 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 108 | if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document { | 112 | if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document { | ||
| 109 | return false | 113 | return false | ||
| 110 | } | 114 | } | ||
| 115 | + | if pointerEpoch != rt.copyPointerEpoch { | |||
| 116 | + | rt.discardCopyCandidateLocked(mode) | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 111 | if !valid { | 119 | if !valid { | ||
| 112 | rt.clearCopyModeLocked() | 120 | rt.clearCopyModeLocked() | ||
| 113 | return false | 121 | return false | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +111 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -127,6 +128,116 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 127 | 128 | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 128 | 129 | } | |
| 129 | 130 | ||
| 131 | + | func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) { | |
| 132 | + | for _, tc := range []struct { | |
| 133 | + | name string | |
| 134 | + | invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) | |
| 135 | + | }{ | |
| 136 | + | { | |
| 137 | + | name: "release during publication", | |
| 138 | + | invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) { | |
| 139 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |
| 140 | + | }, | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "pane close during publication", | |
| 144 | + | invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) { | |
| 145 | + | require.NoError(t, d.closePane(sess, tb, p.id, ac, false)) | |
| 146 | + | }, | |
| 147 | + | }, | |
| 148 | + | } { | |
| 149 | + | t.Run(tc.name, func(t *testing.T) { | |
| 150 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 151 | + | defer func() { | |
| 152 | + | for _, release := range releases { | |
| 153 | + | release() | |
| 154 | + | } | |
| 155 | + | }() | |
| 156 | + | tb := sess.activeTab() | |
| 157 | + | p := tb.focusedPane() | |
| 158 | + | ||
| 159 | + | // A real press is required: the motion publication must inherit this | |
| 160 | + | // pointer epoch, rather than publishing a fresh interaction. | |
| 161 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 162 | + | ac.overlays.copyMu.Lock() | |
| 163 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 164 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |
| 165 | + | ac.overlays.copyMu.Unlock() | |
| 166 | + | ||
| 167 | + | reached := make(chan struct{}) | |
| 168 | + | resume := make(chan struct{}) | |
| 169 | + | d.beforeCopyModeRevalidate = func() { | |
| 170 | + | close(reached) | |
| 171 | + | <-resume | |
| 172 | + | } | |
| 173 | + | done := make(chan struct{}) | |
| 174 | + | go func() { | |
| 175 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 176 | + | close(done) | |
| 177 | + | }() | |
| 178 | + | <-reached | |
| 179 | + | ||
| 180 | + | tc.invalidate(t, d, sess, ac, tb, p) | |
| 181 | + | close(resume) | |
| 182 | + | <-done | |
| 183 | + | ||
| 184 | + | ac.overlays.copyMu.Lock() | |
| 185 | + | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | |
| 186 | + | require.Nil(t, ac.overlays.copyCandidate) | |
| 187 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 188 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 189 | + | ac.overlays.copyMu.Unlock() | |
| 190 | + | }) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) { | |
| 195 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 196 | + | defer func() { | |
| 197 | + | for _, release := range releases { | |
| 198 | + | release() | |
| 199 | + | } | |
| 200 | + | }() | |
| 201 | + | tb := sess.activeTab() | |
| 202 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 203 | + | installTestFloating(tb, floating, true) | |
| 204 | + | tb.mu.Lock() | |
| 205 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 206 | + | tb.mu.Unlock() | |
| 207 | + | require.True(t, visible) | |
| 208 | + | press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2) | |
| 209 | + | motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3) | |
| 210 | + | ||
| 211 | + | d.handleInput(sess, ac, press) | |
| 212 | + | ac.overlays.copyMu.Lock() | |
| 213 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 214 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating} | |
| 215 | + | ac.overlays.copyMu.Unlock() | |
| 216 | + | ||
| 217 | + | reached := make(chan struct{}) | |
| 218 | + | resume := make(chan struct{}) | |
| 219 | + | d.beforeCopyModeRevalidate = func() { | |
| 220 | + | close(reached) | |
| 221 | + | <-resume | |
| 222 | + | } | |
| 223 | + | done := make(chan struct{}) | |
| 224 | + | go func() { | |
| 225 | + | d.handleInput(sess, ac, motion) | |
| 226 | + | close(done) | |
| 227 | + | }() | |
| 228 | + | <-reached | |
| 229 | + | d.teardownFloating(tb, ac) | |
| 230 | + | close(resume) | |
| 231 | + | <-done | |
| 232 | + | ||
| 233 | + | ac.overlays.copyMu.Lock() | |
| 234 | + | require.Nil(t, ac.overlays.copyMode) | |
| 235 | + | require.Nil(t, ac.overlays.copyCandidate) | |
| 236 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 237 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 238 | + | ac.overlays.copyMu.Unlock() | |
| 239 | + | } | |
| 240 | + | ||
| 130 | 241 | func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) { | |
| 131 | 242 | for _, tc := range []struct { | |
| 132 | 243 | name string | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -127,6 +128,116 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 127 | require.Nil(t, copyTargetPane(ac.overlays)) | 128 | require.Nil(t, copyTargetPane(ac.overlays)) | ||
| 128 | } | 129 | } | ||
| 129 | 130 | ||||
| 131 | + | func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) { | |||
| 132 | + | for _, tc := range []struct { | |||
| 133 | + | name string | |||
| 134 | + | invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) | |||
| 135 | + | }{ | |||
| 136 | + | { | |||
| 137 | + | name: "release during publication", | |||
| 138 | + | invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) { | |||
| 139 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |||
| 140 | + | }, | |||
| 141 | + | }, | |||
| 142 | + | { | |||
| 143 | + | name: "pane close during publication", | |||
| 144 | + | invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) { | |||
| 145 | + | require.NoError(t, d.closePane(sess, tb, p.id, ac, false)) | |||
| 146 | + | }, | |||
| 147 | + | }, | |||
| 148 | + | } { | |||
| 149 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 150 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |||
| 151 | + | defer func() { | |||
| 152 | + | for _, release := range releases { | |||
| 153 | + | release() | |||
| 154 | + | } | |||
| 155 | + | }() | |||
| 156 | + | tb := sess.activeTab() | |||
| 157 | + | p := tb.focusedPane() | |||
| 158 | + | ||||
| 159 | + | // A real press is required: the motion publication must inherit this | |||
| 160 | + | // pointer epoch, rather than publishing a fresh interaction. | |||
| 161 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |||
| 162 | + | ac.overlays.copyMu.Lock() | |||
| 163 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 164 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |||
| 165 | + | ac.overlays.copyMu.Unlock() | |||
| 166 | + | ||||
| 167 | + | reached := make(chan struct{}) | |||
| 168 | + | resume := make(chan struct{}) | |||
| 169 | + | d.beforeCopyModeRevalidate = func() { | |||
| 170 | + | close(reached) | |||
| 171 | + | <-resume | |||
| 172 | + | } | |||
| 173 | + | done := make(chan struct{}) | |||
| 174 | + | go func() { | |||
| 175 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 176 | + | close(done) | |||
| 177 | + | }() | |||
| 178 | + | <-reached | |||
| 179 | + | ||||
| 180 | + | tc.invalidate(t, d, sess, ac, tb, p) | |||
| 181 | + | close(resume) | |||
| 182 | + | <-done | |||
| 183 | + | ||||
| 184 | + | ac.overlays.copyMu.Lock() | |||
| 185 | + | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | |||
| 186 | + | require.Nil(t, ac.overlays.copyCandidate) | |||
| 187 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 188 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 189 | + | ac.overlays.copyMu.Unlock() | |||
| 190 | + | }) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) { | |||
| 195 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |||
| 196 | + | defer func() { | |||
| 197 | + | for _, release := range releases { | |||
| 198 | + | release() | |||
| 199 | + | } | |||
| 200 | + | }() | |||
| 201 | + | tb := sess.activeTab() | |||
| 202 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 203 | + | installTestFloating(tb, floating, true) | |||
| 204 | + | tb.mu.Lock() | |||
| 205 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 206 | + | tb.mu.Unlock() | |||
| 207 | + | require.True(t, visible) | |||
| 208 | + | press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2) | |||
| 209 | + | motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3) | |||
| 210 | + | ||||
| 211 | + | d.handleInput(sess, ac, press) | |||
| 212 | + | ac.overlays.copyMu.Lock() | |||
| 213 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 214 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating} | |||
| 215 | + | ac.overlays.copyMu.Unlock() | |||
| 216 | + | ||||
| 217 | + | reached := make(chan struct{}) | |||
| 218 | + | resume := make(chan struct{}) | |||
| 219 | + | d.beforeCopyModeRevalidate = func() { | |||
| 220 | + | close(reached) | |||
| 221 | + | <-resume | |||
| 222 | + | } | |||
| 223 | + | done := make(chan struct{}) | |||
| 224 | + | go func() { | |||
| 225 | + | d.handleInput(sess, ac, motion) | |||
| 226 | + | close(done) | |||
| 227 | + | }() | |||
| 228 | + | <-reached | |||
| 229 | + | d.teardownFloating(tb, ac) | |||
| 230 | + | close(resume) | |||
| 231 | + | <-done | |||
| 232 | + | ||||
| 233 | + | ac.overlays.copyMu.Lock() | |||
| 234 | + | require.Nil(t, ac.overlays.copyMode) | |||
| 235 | + | require.Nil(t, ac.overlays.copyCandidate) | |||
| 236 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 237 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 238 | + | ac.overlays.copyMu.Unlock() | |||
| 239 | + | } | |||
| 240 | + | ||||
| 130 | func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) { | 241 | func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) { | ||
| 131 | for _, tc := range []struct { | 242 | for _, tc := range []struct { | ||
| 132 | name string | 243 | name string | ||
M internal/usecase/daemon/daemon.go +24 -21
| @@ -91,27 +91,30 @@ type Daemon struct { | |||
| 91 | 91 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | |
| 92 | 92 | // interval between JRS validation and its committed hand-off. | |
| 93 | 93 | beforeRecentSessionHandoff func() | |
| 94 | - | ptys ports.PTYFactory | |
| 95 | - | clock ports.Clock | |
| 96 | - | log *slog.Logger | |
| 97 | - | runtimeObserver ports.RuntimeObserver | |
| 98 | - | baseEnv []string | |
| 99 | - | shell string | |
| 100 | - | shellArgs []string | |
| 101 | - | shellOverride bool | |
| 102 | - | persist *persist.Persister | |
| 103 | - | persistEnabled bool | |
| 104 | - | snaps ports.SnapshotStore | |
| 105 | - | snapsEnabled bool | |
| 106 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 107 | - | snapshotJobs chan *snapshotCapture | |
| 108 | - | snapshotWorkerMu sync.Mutex | |
| 109 | - | snapshotWorkerID uint64 | |
| 110 | - | snapshotWorkerCtx context.Context | |
| 111 | - | snapshotWorkerCancel context.CancelFunc | |
| 112 | - | snapshotWorkerDone chan struct{} | |
| 113 | - | snapshotWorkerFlush chan struct{} | |
| 114 | - | snapshotWorkerFinalWake chan struct{} | |
| 94 | + | // beforeCopyModeRevalidate is a deterministic test seam between staging a | |
| 95 | + | // copy-mode candidate and revalidating its pane membership. | |
| 96 | + | beforeCopyModeRevalidate func() | |
| 97 | + | ptys ports.PTYFactory | |
| 98 | + | clock ports.Clock | |
| 99 | + | log *slog.Logger | |
| 100 | + | runtimeObserver ports.RuntimeObserver | |
| 101 | + | baseEnv []string | |
| 102 | + | shell string | |
| 103 | + | shellArgs []string | |
| 104 | + | shellOverride bool | |
| 105 | + | persist *persist.Persister | |
| 106 | + | persistEnabled bool | |
| 107 | + | snaps ports.SnapshotStore | |
| 108 | + | snapsEnabled bool | |
| 109 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 110 | + | snapshotJobs chan *snapshotCapture | |
| 111 | + | snapshotWorkerMu sync.Mutex | |
| 112 | + | snapshotWorkerID uint64 | |
| 113 | + | snapshotWorkerCtx context.Context | |
| 114 | + | snapshotWorkerCancel context.CancelFunc | |
| 115 | + | snapshotWorkerDone chan struct{} | |
| 116 | + | snapshotWorkerFlush chan struct{} | |
| 117 | + | snapshotWorkerFinalWake chan struct{} | |
| 115 | 118 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |
| 116 | 119 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |
| 117 | 120 | // sessions, each with only its newest terminal state while the worker blocks. | |
| @@ -91,27 +91,30 @@ type Daemon struct { | |||||
| 91 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | 91 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | ||
| 92 | // interval between JRS validation and its committed hand-off. | 92 | // interval between JRS validation and its committed hand-off. | ||
| 93 | beforeRecentSessionHandoff func() | 93 | beforeRecentSessionHandoff func() | ||
| 94 | - | ptys ports.PTYFactory | 94 | + | // beforeCopyModeRevalidate is a deterministic test seam between staging a |
| 95 | - | clock ports.Clock | 95 | + | // copy-mode candidate and revalidating its pane membership. |
| 96 | - | log *slog.Logger | 96 | + | beforeCopyModeRevalidate func() |
| 97 | - | runtimeObserver ports.RuntimeObserver | 97 | + | ptys ports.PTYFactory |
| 98 | - | baseEnv []string | 98 | + | clock ports.Clock |
| 99 | - | shell string | 99 | + | log *slog.Logger |
| 100 | - | shellArgs []string | 100 | + | runtimeObserver ports.RuntimeObserver |
| 101 | - | shellOverride bool | 101 | + | baseEnv []string |
| 102 | - | persist *persist.Persister | 102 | + | shell string |
| 103 | - | persistEnabled bool | 103 | + | shellArgs []string |
| 104 | - | snaps ports.SnapshotStore | 104 | + | shellOverride bool |
| 105 | - | snapsEnabled bool | 105 | + | persist *persist.Persister |
| 106 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | 106 | + | persistEnabled bool |
| 107 | - | snapshotJobs chan *snapshotCapture | 107 | + | snaps ports.SnapshotStore |
| 108 | - | snapshotWorkerMu sync.Mutex | 108 | + | snapsEnabled bool |
| 109 | - | snapshotWorkerID uint64 | 109 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) |
| 110 | - | snapshotWorkerCtx context.Context | 110 | + | snapshotJobs chan *snapshotCapture |
| 111 | - | snapshotWorkerCancel context.CancelFunc | 111 | + | snapshotWorkerMu sync.Mutex |
| 112 | - | snapshotWorkerDone chan struct{} | 112 | + | snapshotWorkerID uint64 |
| 113 | - | snapshotWorkerFlush chan struct{} | 113 | + | snapshotWorkerCtx context.Context |
| 114 | - | snapshotWorkerFinalWake chan struct{} | 114 | + | snapshotWorkerCancel context.CancelFunc |
| 115 | + | snapshotWorkerDone chan struct{} | |||
| 116 | + | snapshotWorkerFlush chan struct{} | |||
| 117 | + | snapshotWorkerFinalWake chan struct{} | |||
| 115 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | 118 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | ||
| 116 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | 119 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | ||
| 117 | // sessions, each with only its newest terminal state while the worker blocks. | 120 | // sessions, each with only its newest terminal state while the worker blocks. | ||
M internal/usecase/daemon/input.go +24 -5
| @@ -41,10 +41,12 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 41 | 41 | } | |
| 42 | 42 | sess := ac.currentSession() | |
| 43 | 43 | if sess == nil { | |
| 44 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 44 | 45 | return | |
| 45 | 46 | } | |
| 46 | 47 | tb := sess.activeTab() | |
| 47 | 48 | if tb == nil { | |
| 49 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 48 | 50 | return | |
| 49 | 51 | } | |
| 50 | 52 | ||
| @@ -69,6 +71,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 69 | 71 | if floatingVisible { | |
| 70 | 72 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| 71 | 73 | tb.mu.Unlock() | |
| 74 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 72 | 75 | return | |
| 73 | 76 | } | |
| 74 | 77 | tb.mu.Unlock() | |
| @@ -85,6 +88,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 85 | 88 | if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) { | |
| 86 | 89 | if !isMouseFocusPress(ev) { | |
| 87 | 90 | tb.mu.Unlock() | |
| 91 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 88 | 92 | return | |
| 89 | 93 | } | |
| 90 | 94 | oldFocus := focusedID | |
| @@ -111,6 +115,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 111 | 115 | hoveredFocused = pl.ID == oldFocus | |
| 112 | 116 | tb.mu.Unlock() | |
| 113 | 117 | if p == nil { | |
| 118 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 114 | 119 | return | |
| 115 | 120 | } | |
| 116 | 121 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | |
| @@ -125,17 +130,31 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 125 | 130 | } else { | |
| 126 | 131 | if multi { | |
| 127 | 132 | tb.mu.Unlock() | |
| 133 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 128 | 134 | return | |
| 129 | 135 | } | |
| 130 | 136 | p = tb.focusedPane() | |
| 131 | 137 | tb.mu.Unlock() | |
| 132 | 138 | if p == nil { | |
| 139 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 133 | 140 | return | |
| 134 | 141 | } | |
| 135 | 142 | } | |
| 136 | 143 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 137 | 144 | } | |
| 138 | 145 | ||
| 146 | + | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |
| 147 | + | // event that cannot be routed to terminal content. It never runs while tab or | |
| 148 | + | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |
| 149 | + | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |
| 150 | + | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | rt.copyMu.Lock() | |
| 154 | + | rt.invalidateCopyPointerLocked(true) | |
| 155 | + | rt.copyMu.Unlock() | |
| 156 | + | } | |
| 157 | + | ||
| 139 | 158 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| 140 | 159 | // press time. Geometry is resolved after copyMu is released to preserve the | |
| 141 | 160 | // daemon's copyMu -> tab/pane lock prohibition. | |
| @@ -153,10 +172,10 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 153 | 172 | // later floating/layout change must not turn an in-flight drag into a hit | |
| 154 | 173 | // on a different pane. | |
| 155 | 174 | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | |
| 156 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true) | |
| 175 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, ev.Type == mouse.Motion) | |
| 157 | 176 | if ev.Type == mouse.Release && !ok { | |
| 158 | 177 | rt.copyMu.Lock() | |
| 159 | - | rt.invalidateCopyPointerLocked(false) | |
| 178 | + | rt.invalidateCopyPointerLocked(true) | |
| 160 | 179 | rt.copyMu.Unlock() | |
| 161 | 180 | return | |
| 162 | 181 | } | |
| @@ -181,7 +200,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 181 | 200 | if !ok { | |
| 182 | 201 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 183 | 202 | rt.copyMu.Lock() | |
| 184 | - | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | |
| 203 | + | rt.invalidateCopyPointerLocked(true) | |
| 185 | 204 | rt.copyMu.Unlock() | |
| 186 | 205 | } | |
| 187 | 206 | return | |
| @@ -206,7 +225,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 206 | 225 | } | |
| 207 | 226 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 208 | 227 | rt.copyMu.Lock() | |
| 209 | - | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | |
| 228 | + | rt.invalidateCopyPointerLocked(true) | |
| 210 | 229 | rt.copyMu.Unlock() | |
| 211 | 230 | } | |
| 212 | 231 | return | |
| @@ -303,7 +322,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 303 | 322 | d.invalidateRender(sess, ac, true, "input.go") | |
| 304 | 323 | case mouse.Release: | |
| 305 | 324 | rt.copyMu.Lock() | |
| 306 | - | rt.invalidateCopyPointerLocked(false) | |
| 325 | + | rt.invalidateCopyPointerLocked(true) | |
| 307 | 326 | rt.copyMu.Unlock() | |
| 308 | 327 | } | |
| 309 | 328 | case mouse.WheelUp: | |
| @@ -41,10 +41,12 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 41 | } | 41 | } | ||
| 42 | sess := ac.currentSession() | 42 | sess := ac.currentSession() | ||
| 43 | if sess == nil { | 43 | if sess == nil { | ||
| 44 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 44 | return | 45 | return | ||
| 45 | } | 46 | } | ||
| 46 | tb := sess.activeTab() | 47 | tb := sess.activeTab() | ||
| 47 | if tb == nil { | 48 | if tb == nil { | ||
| 49 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 48 | return | 50 | return | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| @@ -69,6 +71,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 69 | if floatingVisible { | 71 | if floatingVisible { | ||
| 70 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | 72 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | ||
| 71 | tb.mu.Unlock() | 73 | tb.mu.Unlock() | ||
| 74 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 72 | return | 75 | return | ||
| 73 | } | 76 | } | ||
| 74 | tb.mu.Unlock() | 77 | tb.mu.Unlock() | ||
| @@ -85,6 +88,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 85 | if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) { | 88 | if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) { | ||
| 86 | if !isMouseFocusPress(ev) { | 89 | if !isMouseFocusPress(ev) { | ||
| 87 | tb.mu.Unlock() | 90 | tb.mu.Unlock() | ||
| 91 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 88 | return | 92 | return | ||
| 89 | } | 93 | } | ||
| 90 | oldFocus := focusedID | 94 | oldFocus := focusedID | ||
| @@ -111,6 +115,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 111 | hoveredFocused = pl.ID == oldFocus | 115 | hoveredFocused = pl.ID == oldFocus | ||
| 112 | tb.mu.Unlock() | 116 | tb.mu.Unlock() | ||
| 113 | if p == nil { | 117 | if p == nil { | ||
| 118 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 114 | return | 119 | return | ||
| 115 | } | 120 | } | ||
| 116 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | 121 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | ||
| @@ -125,17 +130,31 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 125 | } else { | 130 | } else { | ||
| 126 | if multi { | 131 | if multi { | ||
| 127 | tb.mu.Unlock() | 132 | tb.mu.Unlock() | ||
| 133 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 128 | return | 134 | return | ||
| 129 | } | 135 | } | ||
| 130 | p = tb.focusedPane() | 136 | p = tb.focusedPane() | ||
| 131 | tb.mu.Unlock() | 137 | tb.mu.Unlock() | ||
| 132 | if p == nil { | 138 | if p == nil { | ||
| 139 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 133 | return | 140 | return | ||
| 134 | } | 141 | } | ||
| 135 | } | 142 | } | ||
| 136 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | 143 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | ||
| 137 | } | 144 | } | ||
| 138 | 145 | ||||
| 146 | + | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |||
| 147 | + | // event that cannot be routed to terminal content. It never runs while tab or | |||
| 148 | + | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |||
| 149 | + | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||
| 150 | + | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | rt.copyMu.Lock() | |||
| 154 | + | rt.invalidateCopyPointerLocked(true) | |||
| 155 | + | rt.copyMu.Unlock() | |||
| 156 | + | } | |||
| 157 | + | ||||
| 139 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | 158 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | ||
| 140 | // press time. Geometry is resolved after copyMu is released to preserve the | 159 | // press time. Geometry is resolved after copyMu is released to preserve the | ||
| 141 | // daemon's copyMu -> tab/pane lock prohibition. | 160 | // daemon's copyMu -> tab/pane lock prohibition. | ||
| @@ -153,10 +172,10 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 153 | // later floating/layout change must not turn an in-flight drag into a hit | 172 | // later floating/layout change must not turn an in-flight drag into a hit | ||
| 154 | // on a different pane. | 173 | // on a different pane. | ||
| 155 | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | 174 | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | ||
| 156 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true) | 175 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, ev.Type == mouse.Motion) |
| 157 | if ev.Type == mouse.Release && !ok { | 176 | if ev.Type == mouse.Release && !ok { | ||
| 158 | rt.copyMu.Lock() | 177 | rt.copyMu.Lock() | ||
| 159 | - | rt.invalidateCopyPointerLocked(false) | 178 | + | rt.invalidateCopyPointerLocked(true) |
| 160 | rt.copyMu.Unlock() | 179 | rt.copyMu.Unlock() | ||
| 161 | return | 180 | return | ||
| 162 | } | 181 | } | ||
| @@ -181,7 +200,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 181 | if !ok { | 200 | if !ok { | ||
| 182 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | 201 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | ||
| 183 | rt.copyMu.Lock() | 202 | rt.copyMu.Lock() | ||
| 184 | - | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | 203 | + | rt.invalidateCopyPointerLocked(true) |
| 185 | rt.copyMu.Unlock() | 204 | rt.copyMu.Unlock() | ||
| 186 | } | 205 | } | ||
| 187 | return | 206 | return | ||
| @@ -206,7 +225,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 206 | } | 225 | } | ||
| 207 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | 226 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | ||
| 208 | rt.copyMu.Lock() | 227 | rt.copyMu.Lock() | ||
| 209 | - | rt.invalidateCopyPointerLocked(ev.Type == mouse.Press) | 228 | + | rt.invalidateCopyPointerLocked(true) |
| 210 | rt.copyMu.Unlock() | 229 | rt.copyMu.Unlock() | ||
| 211 | } | 230 | } | ||
| 212 | return | 231 | return | ||
| @@ -303,7 +322,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 303 | d.invalidateRender(sess, ac, true, "input.go") | 322 | d.invalidateRender(sess, ac, true, "input.go") | ||
| 304 | case mouse.Release: | 323 | case mouse.Release: | ||
| 305 | rt.copyMu.Lock() | 324 | rt.copyMu.Lock() | ||
| 306 | - | rt.invalidateCopyPointerLocked(false) | 325 | + | rt.invalidateCopyPointerLocked(true) |
| 307 | rt.copyMu.Unlock() | 326 | rt.copyMu.Unlock() | ||
| 308 | } | 327 | } | ||
| 309 | case mouse.WheelUp: | 328 | case mouse.WheelUp: | ||
M internal/usecase/daemon/input_test.go +136 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 19 | 19 | "github.com/bnema/vev/internal/usecase/keys" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 21 | 22 | "github.com/bnema/vev/internal/usecase/picker" | |
| 22 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 23 | 24 | ) | |
| @@ -961,6 +962,23 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 961 | 962 | require.Equal(t, 2, hi) | |
| 962 | 963 | } | |
| 963 | 964 | ||
| 965 | + | func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |
| 966 | + | p, release := newBlockingPTY(t) | |
| 967 | + | defer release() | |
| 968 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 969 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 970 | + | d.enterCopyMode(sess, ac) | |
| 971 | + | mustOutputData(t, sends) | |
| 972 | + | ||
| 973 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m")) | |
| 974 | + | d.handleInput(sess, ac, []byte("y")) | |
| 975 | + | ||
| 976 | + | for range 2 { // press repaint, then copy-mode exit repaint | |
| 977 | + | require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy") | |
| 978 | + | } | |
| 979 | + | require.False(t, ac.overlays.copyActive()) | |
| 980 | + | } | |
| 981 | + | ||
| 964 | 982 | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |
| 965 | 983 | p, _ := newBlockingPTY(t) | |
| 966 | 984 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -1057,6 +1075,30 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 1057 | 1075 | // a Press on the status row left the previous drag state in place and a | |
| 1058 | 1076 | // following Motion on a content row silently extended the old selection | |
| 1059 | 1077 | // instead of being a no-op. | |
| 1078 | + | func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) { | |
| 1079 | + | p, release := newBlockingPTY(t) | |
| 1080 | + | defer release() | |
| 1081 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1082 | + | d.enterCopyMode(sess, ac) | |
| 1083 | + | mustOutputData(t, sends) | |
| 1084 | + | ||
| 1085 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1086 | + | ac.overlays.copyMu.Lock() | |
| 1087 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1088 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |
| 1089 | + | ac.overlays.copyMu.Unlock() | |
| 1090 | + | ||
| 1091 | + | // Wire row 25 is below the focused pane content, so the release is not a | |
| 1092 | + | // valid endpoint and must not leave a drag that later motion can revive. | |
| 1093 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M")) | |
| 1094 | + | ac.overlays.copyMu.Lock() | |
| 1095 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1096 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 1097 | + | selection := ac.overlays.copyMode.Selection() | |
| 1098 | + | require.False(t, selection.Enabled) | |
| 1099 | + | ac.overlays.copyMu.Unlock() | |
| 1100 | + | } | |
| 1101 | + | ||
| 1060 | 1102 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |
| 1061 | 1103 | p, _ := newBlockingPTY(t) | |
| 1062 | 1104 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -1180,6 +1222,100 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing | |||
| 1180 | 1222 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 1181 | 1223 | } | |
| 1182 | 1224 | ||
| 1225 | + | func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |
| 1226 | + | for _, tc := range []struct { | |
| 1227 | + | name string | |
| 1228 | + | setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte | |
| 1229 | + | reject []byte | |
| 1230 | + | }{ | |
| 1231 | + | { | |
| 1232 | + | name: "split divider", | |
| 1233 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |
| 1234 | + | tb := sess.activeTab() | |
| 1235 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1236 | + | tb.mu.Lock() | |
| 1237 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1238 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1239 | + | tb.panes["pane-2"] = p2 | |
| 1240 | + | tb.mu.Unlock() | |
| 1241 | + | return []byte("\x1b[<0;21;2m") | |
| 1242 | + | }, | |
| 1243 | + | }, | |
| 1244 | + | { | |
| 1245 | + | name: "split title bar", | |
| 1246 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |
| 1247 | + | tb := sess.activeTab() | |
| 1248 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1249 | + | tb.mu.Lock() | |
| 1250 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1251 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1252 | + | tb.panes["pane-2"] = p2 | |
| 1253 | + | tb.mu.Unlock() | |
| 1254 | + | return []byte("\x1b[<0;1;1m") | |
| 1255 | + | }, | |
| 1256 | + | }, | |
| 1257 | + | { | |
| 1258 | + | name: "floating exterior", | |
| 1259 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte { | |
| 1260 | + | tb := sess.activeTab() | |
| 1261 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 1262 | + | installTestFloating(tb, floating, true) | |
| 1263 | + | tb.mu.Lock() | |
| 1264 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 1265 | + | tb.mu.Unlock() | |
| 1266 | + | require.True(t, visible) | |
| 1267 | + | *press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2) | |
| 1268 | + | return []byte("\x1b[<0;1;1m") | |
| 1269 | + | }, | |
| 1270 | + | }, | |
| 1271 | + | } { | |
| 1272 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1273 | + | p, release := newBlockingPTY(t) | |
| 1274 | + | defer release() | |
| 1275 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1276 | + | press := []byte("\x1b[<0;1;2M") | |
| 1277 | + | reject := tc.setup(t, d, sess, ac, &press) | |
| 1278 | + | ||
| 1279 | + | // A content press is fresh state. The rejected release must clear it | |
| 1280 | + | // before a following motion has an opportunity to publish copy mode. | |
| 1281 | + | d.handleInput(sess, ac, press) | |
| 1282 | + | ac.overlays.copyMu.Lock() | |
| 1283 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1284 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |
| 1285 | + | ac.overlays.copyMu.Unlock() | |
| 1286 | + | ||
| 1287 | + | d.handleInput(sess, ac, reject) | |
| 1288 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 1289 | + | ||
| 1290 | + | ac.overlays.copyMu.Lock() | |
| 1291 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1292 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 1293 | + | require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish") | |
| 1294 | + | ac.overlays.copyMu.Unlock() | |
| 1295 | + | }) | |
| 1296 | + | } | |
| 1297 | + | } | |
| 1298 | + | ||
| 1299 | + | func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) { | |
| 1300 | + | p, release := newBlockingPTY(t) | |
| 1301 | + | defer release() | |
| 1302 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1303 | + | ||
| 1304 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1305 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press | |
| 1306 | + | ac.overlays.copyMu.Lock() | |
| 1307 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1308 | + | ac.overlays.copyMu.Unlock() | |
| 1309 | + | ||
| 1310 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1311 | + | ac.setSession(nil) | |
| 1312 | + | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |
| 1313 | + | ac.overlays.copyMu.Lock() | |
| 1314 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1315 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 1316 | + | ac.overlays.copyMu.Unlock() | |
| 1317 | + | } | |
| 1318 | + | ||
| 1183 | 1319 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | |
| 1184 | 1320 | cases := []struct { | |
| 1185 | 1321 | name string | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 19 | "github.com/bnema/vev/internal/usecase/keys" | 19 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 20 | "github.com/bnema/vev/internal/usecase/layout" | 20 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 21 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 21 | "github.com/bnema/vev/internal/usecase/picker" | 22 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 22 | "github.com/bnema/vev/pkg/vt" | 23 | "github.com/bnema/vev/pkg/vt" | ||
| 23 | ) | 24 | ) | ||
| @@ -961,6 +962,23 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 961 | require.Equal(t, 2, hi) | 962 | require.Equal(t, 2, hi) | ||
| 962 | } | 963 | } | ||
| 963 | 964 | ||||
| 965 | + | func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |||
| 966 | + | p, release := newBlockingPTY(t) | |||
| 967 | + | defer release() | |||
| 968 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 969 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |||
| 970 | + | d.enterCopyMode(sess, ac) | |||
| 971 | + | mustOutputData(t, sends) | |||
| 972 | + | ||||
| 973 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m")) | |||
| 974 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 975 | + | ||||
| 976 | + | for range 2 { // press repaint, then copy-mode exit repaint | |||
| 977 | + | require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy") | |||
| 978 | + | } | |||
| 979 | + | require.False(t, ac.overlays.copyActive()) | |||
| 980 | + | } | |||
| 981 | + | ||||
| 964 | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | 982 | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | ||
| 965 | p, _ := newBlockingPTY(t) | 983 | p, _ := newBlockingPTY(t) | ||
| 966 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 984 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -1057,6 +1075,30 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||||
| 1057 | // a Press on the status row left the previous drag state in place and a | 1075 | // a Press on the status row left the previous drag state in place and a | ||
| 1058 | // following Motion on a content row silently extended the old selection | 1076 | // following Motion on a content row silently extended the old selection | ||
| 1059 | // instead of being a no-op. | 1077 | // instead of being a no-op. | ||
| 1078 | + | func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) { | |||
| 1079 | + | p, release := newBlockingPTY(t) | |||
| 1080 | + | defer release() | |||
| 1081 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1082 | + | d.enterCopyMode(sess, ac) | |||
| 1083 | + | mustOutputData(t, sends) | |||
| 1084 | + | ||||
| 1085 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |||
| 1086 | + | ac.overlays.copyMu.Lock() | |||
| 1087 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 1088 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |||
| 1089 | + | ac.overlays.copyMu.Unlock() | |||
| 1090 | + | ||||
| 1091 | + | // Wire row 25 is below the focused pane content, so the release is not a | |||
| 1092 | + | // valid endpoint and must not leave a drag that later motion can revive. | |||
| 1093 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M")) | |||
| 1094 | + | ac.overlays.copyMu.Lock() | |||
| 1095 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1096 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 1097 | + | selection := ac.overlays.copyMode.Selection() | |||
| 1098 | + | require.False(t, selection.Enabled) | |||
| 1099 | + | ac.overlays.copyMu.Unlock() | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1060 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | 1102 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | ||
| 1061 | p, _ := newBlockingPTY(t) | 1103 | p, _ := newBlockingPTY(t) | ||
| 1062 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1104 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -1180,6 +1222,100 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing | |||||
| 1180 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | 1222 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | ||
| 1181 | } | 1223 | } | ||
| 1182 | 1224 | ||||
| 1225 | + | func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1226 | + | for _, tc := range []struct { | |||
| 1227 | + | name string | |||
| 1228 | + | setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte | |||
| 1229 | + | reject []byte | |||
| 1230 | + | }{ | |||
| 1231 | + | { | |||
| 1232 | + | name: "split divider", | |||
| 1233 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |||
| 1234 | + | tb := sess.activeTab() | |||
| 1235 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 1236 | + | tb.mu.Lock() | |||
| 1237 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |||
| 1238 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 1239 | + | tb.panes["pane-2"] = p2 | |||
| 1240 | + | tb.mu.Unlock() | |||
| 1241 | + | return []byte("\x1b[<0;21;2m") | |||
| 1242 | + | }, | |||
| 1243 | + | }, | |||
| 1244 | + | { | |||
| 1245 | + | name: "split title bar", | |||
| 1246 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |||
| 1247 | + | tb := sess.activeTab() | |||
| 1248 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 1249 | + | tb.mu.Lock() | |||
| 1250 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |||
| 1251 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 1252 | + | tb.panes["pane-2"] = p2 | |||
| 1253 | + | tb.mu.Unlock() | |||
| 1254 | + | return []byte("\x1b[<0;1;1m") | |||
| 1255 | + | }, | |||
| 1256 | + | }, | |||
| 1257 | + | { | |||
| 1258 | + | name: "floating exterior", | |||
| 1259 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte { | |||
| 1260 | + | tb := sess.activeTab() | |||
| 1261 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 1262 | + | installTestFloating(tb, floating, true) | |||
| 1263 | + | tb.mu.Lock() | |||
| 1264 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 1265 | + | tb.mu.Unlock() | |||
| 1266 | + | require.True(t, visible) | |||
| 1267 | + | *press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2) | |||
| 1268 | + | return []byte("\x1b[<0;1;1m") | |||
| 1269 | + | }, | |||
| 1270 | + | }, | |||
| 1271 | + | } { | |||
| 1272 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1273 | + | p, release := newBlockingPTY(t) | |||
| 1274 | + | defer release() | |||
| 1275 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1276 | + | press := []byte("\x1b[<0;1;2M") | |||
| 1277 | + | reject := tc.setup(t, d, sess, ac, &press) | |||
| 1278 | + | ||||
| 1279 | + | // A content press is fresh state. The rejected release must clear it | |||
| 1280 | + | // before a following motion has an opportunity to publish copy mode. | |||
| 1281 | + | d.handleInput(sess, ac, press) | |||
| 1282 | + | ac.overlays.copyMu.Lock() | |||
| 1283 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 1284 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |||
| 1285 | + | ac.overlays.copyMu.Unlock() | |||
| 1286 | + | ||||
| 1287 | + | d.handleInput(sess, ac, reject) | |||
| 1288 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 1289 | + | ||||
| 1290 | + | ac.overlays.copyMu.Lock() | |||
| 1291 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1292 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 1293 | + | require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish") | |||
| 1294 | + | ac.overlays.copyMu.Unlock() | |||
| 1295 | + | }) | |||
| 1296 | + | } | |||
| 1297 | + | } | |||
| 1298 | + | ||||
| 1299 | + | func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) { | |||
| 1300 | + | p, release := newBlockingPTY(t) | |||
| 1301 | + | defer release() | |||
| 1302 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1303 | + | ||||
| 1304 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |||
| 1305 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press | |||
| 1306 | + | ac.overlays.copyMu.Lock() | |||
| 1307 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1308 | + | ac.overlays.copyMu.Unlock() | |||
| 1309 | + | ||||
| 1310 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |||
| 1311 | + | ac.setSession(nil) | |||
| 1312 | + | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |||
| 1313 | + | ac.overlays.copyMu.Lock() | |||
| 1314 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1315 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 1316 | + | ac.overlays.copyMu.Unlock() | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1183 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | 1319 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | ||
| 1184 | cases := []struct { | 1320 | cases := []struct { | ||
| 1185 | name string | 1321 | name string | ||
M internal/usecase/daemon/overlay_runtime.go +11 -0
| @@ -146,6 +146,17 @@ func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |||
| 146 | 146 | rt.copyClick = copyClickCandidate{} | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | |
| 150 | + | if rt.copyCandidate != candidate { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | rt.copyCandidate = nil | |
| 154 | + | rt.copyDocument = nil | |
| 155 | + | rt.copyPane = nil | |
| 156 | + | rt.copySearch = nil | |
| 157 | + | rt.copySearchPending = nil | |
| 158 | + | } | |
| 159 | + | ||
| 149 | 160 | func (rt *overlayRuntime) clearCopyModeLocked() { | |
| 150 | 161 | rt.copyMode = nil | |
| 151 | 162 | rt.copyCandidate = nil | |
| @@ -146,6 +146,17 @@ func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |||||
| 146 | rt.copyClick = copyClickCandidate{} | 146 | rt.copyClick = copyClickCandidate{} | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | + | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | |||
| 150 | + | if rt.copyCandidate != candidate { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | rt.copyCandidate = nil | |||
| 154 | + | rt.copyDocument = nil | |||
| 155 | + | rt.copyPane = nil | |||
| 156 | + | rt.copySearch = nil | |||
| 157 | + | rt.copySearchPending = nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 149 | func (rt *overlayRuntime) clearCopyModeLocked() { | 160 | func (rt *overlayRuntime) clearCopyModeLocked() { | ||
| 150 | rt.copyMode = nil | 161 | rt.copyMode = nil | ||
| 151 | rt.copyCandidate = nil | 162 | rt.copyCandidate = nil | ||