feat(copy): add clocked word selection
- commit
- 3026b441fdf37b1e20a931e74a524d97363010e3
- author
- brice · · 2026-07-15 09:13 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- d9ee20f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +359 -21
M internal/usecase/daemon/copymode.go +32 -3
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "strconv" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| @@ -16,6 +17,17 @@ import ( | |||
| 16 | 17 | ||
| 17 | 18 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | |
| 18 | 19 | ||
| 20 | + | const copyDoubleClickInterval = 500 * time.Millisecond | |
| 21 | + | ||
| 22 | + | func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col } | |
| 23 | + | ||
| 24 | + | func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool { | |
| 25 | + | candidate := rt.copyClick | |
| 26 | + | return candidate.valid && !candidate.dragged && candidate.pane == pane && | |
| 27 | + | sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) && | |
| 28 | + | now.Sub(candidate.at) <= copyDoubleClickInterval | |
| 29 | + | } | |
| 30 | + | ||
| 19 | 31 | func copyTargetPane(rt *overlayRuntime) *pane { | |
| 20 | 32 | if rt == nil { | |
| 21 | 33 | return nil | |
| @@ -94,7 +106,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 94 | 106 | if activate != nil { | |
| 95 | 107 | rt.clearCopyPointerForTransferLocked() | |
| 96 | 108 | } else { | |
| 97 | - | rt.invalidateCopyPointerLocked() | |
| 109 | + | rt.invalidateCopyPointerLocked(true) | |
| 98 | 110 | } | |
| 99 | 111 | pointerEpoch := rt.copyPointerEpoch | |
| 100 | 112 | rt.copyMu.Unlock() | |
| @@ -150,7 +162,17 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||
| 150 | 162 | pointer := rt.copyPointer | |
| 151 | 163 | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | |
| 152 | 164 | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos}) | |
| 165 | + | pointer = rt.copyPointer | |
| 153 | 166 | } | |
| 167 | + | now := d.clock.Now() | |
| 168 | + | if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) { | |
| 169 | + | pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos) | |
| 170 | + | changed = pointer.wordDrag || changed | |
| 171 | + | rt.copyClick = copyClickCandidate{} | |
| 172 | + | } else { | |
| 173 | + | rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now} | |
| 174 | + | } | |
| 175 | + | rt.copyPointer = pointer | |
| 154 | 176 | case mouse.Motion: | |
| 155 | 177 | pointer := rt.copyPointer | |
| 156 | 178 | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | |
| @@ -158,11 +180,18 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||
| 158 | 180 | rt.copyMode.StartCharacterSelection(pointer.press) | |
| 159 | 181 | pointer.dragging = true | |
| 160 | 182 | } | |
| 161 | - | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |
| 183 | + | if rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| 184 | + | rt.copyClick.dragged = true | |
| 185 | + | } | |
| 186 | + | if pointer.wordDrag { | |
| 187 | + | changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed | |
| 188 | + | } else { | |
| 189 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |
| 190 | + | } | |
| 162 | 191 | rt.copyPointer = pointer | |
| 163 | 192 | } | |
| 164 | 193 | case mouse.Release: | |
| 165 | - | rt.invalidateCopyPointerLocked() | |
| 194 | + | rt.invalidateCopyPointerLocked(false) | |
| 166 | 195 | } | |
| 167 | 196 | rt.copyMu.Unlock() | |
| 168 | 197 | if changed { | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | + | "time" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| @@ -16,6 +17,17 @@ import ( | |||||
| 16 | 17 | ||||
| 17 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | 18 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | ||
| 18 | 19 | ||||
| 20 | + | const copyDoubleClickInterval = 500 * time.Millisecond | |||
| 21 | + | ||||
| 22 | + | func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col } | |||
| 23 | + | ||||
| 24 | + | func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool { | |||
| 25 | + | candidate := rt.copyClick | |||
| 26 | + | return candidate.valid && !candidate.dragged && candidate.pane == pane && | |||
| 27 | + | sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) && | |||
| 28 | + | now.Sub(candidate.at) <= copyDoubleClickInterval | |||
| 29 | + | } | |||
| 30 | + | ||||
| 19 | func copyTargetPane(rt *overlayRuntime) *pane { | 31 | func copyTargetPane(rt *overlayRuntime) *pane { | ||
| 20 | if rt == nil { | 32 | if rt == nil { | ||
| 21 | return nil | 33 | return nil | ||
| @@ -94,7 +106,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 94 | if activate != nil { | 106 | if activate != nil { | ||
| 95 | rt.clearCopyPointerForTransferLocked() | 107 | rt.clearCopyPointerForTransferLocked() | ||
| 96 | } else { | 108 | } else { | ||
| 97 | - | rt.invalidateCopyPointerLocked() | 109 | + | rt.invalidateCopyPointerLocked(true) |
| 98 | } | 110 | } | ||
| 99 | pointerEpoch := rt.copyPointerEpoch | 111 | pointerEpoch := rt.copyPointerEpoch | ||
| 100 | rt.copyMu.Unlock() | 112 | rt.copyMu.Unlock() | ||
| @@ -150,7 +162,17 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||||
| 150 | pointer := rt.copyPointer | 162 | pointer := rt.copyPointer | ||
| 151 | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | 163 | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | ||
| 152 | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos}) | 164 | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos}) | ||
| 165 | + | pointer = rt.copyPointer | |||
| 153 | } | 166 | } | ||
| 167 | + | now := d.clock.Now() | |||
| 168 | + | if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) { | |||
| 169 | + | pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos) | |||
| 170 | + | changed = pointer.wordDrag || changed | |||
| 171 | + | rt.copyClick = copyClickCandidate{} | |||
| 172 | + | } else { | |||
| 173 | + | rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now} | |||
| 174 | + | } | |||
| 175 | + | rt.copyPointer = pointer | |||
| 154 | case mouse.Motion: | 176 | case mouse.Motion: | ||
| 155 | pointer := rt.copyPointer | 177 | pointer := rt.copyPointer | ||
| 156 | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | 178 | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | ||
| @@ -158,11 +180,18 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||||
| 158 | rt.copyMode.StartCharacterSelection(pointer.press) | 180 | rt.copyMode.StartCharacterSelection(pointer.press) | ||
| 159 | pointer.dragging = true | 181 | pointer.dragging = true | ||
| 160 | } | 182 | } | ||
| 161 | - | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | 183 | + | if rt.copyClick.valid && rt.copyClick.pane == pointer.pane { |
| 184 | + | rt.copyClick.dragged = true | |||
| 185 | + | } | |||
| 186 | + | if pointer.wordDrag { | |||
| 187 | + | changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed | |||
| 188 | + | } else { | |||
| 189 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |||
| 190 | + | } | |||
| 162 | rt.copyPointer = pointer | 191 | rt.copyPointer = pointer | ||
| 163 | } | 192 | } | ||
| 164 | case mouse.Release: | 193 | case mouse.Release: | ||
| 165 | - | rt.invalidateCopyPointerLocked() | 194 | + | rt.invalidateCopyPointerLocked(false) |
| 166 | } | 195 | } | ||
| 167 | rt.copyMu.Unlock() | 196 | rt.copyMu.Unlock() | ||
| 168 | if changed { | 197 | if changed { | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +21 -0
| @@ -20,10 +20,17 @@ func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) { | |||
| 20 | 20 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 21 | 21 | d.enterCopyMode(sess, ac) | |
| 22 | 22 | require.True(t, ac.overlays.copyActive()) | |
| 23 | + | ac.overlays.copyMu.Lock() | |
| 24 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 25 | + | ac.overlays.copyMu.Unlock() | |
| 23 | 26 | ||
| 24 | 27 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |
| 25 | 28 | ||
| 26 | 29 | require.False(t, ac.overlays.copyActive()) | |
| 30 | + | ac.overlays.copyMu.Lock() | |
| 31 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 32 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 33 | + | ac.overlays.copyMu.Unlock() | |
| 27 | 34 | } | |
| 28 | 35 | ||
| 29 | 36 | func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |
| @@ -35,11 +42,18 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |||
| 35 | 42 | }() | |
| 36 | 43 | d.enterCopyMode(sess, ac) | |
| 37 | 44 | require.True(t, ac.overlays.copyActive()) | |
| 45 | + | ac.overlays.copyMu.Lock() | |
| 46 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 47 | + | ac.overlays.copyMu.Unlock() | |
| 38 | 48 | ||
| 39 | 49 | require.True(t, sess.switchTab(1)) | |
| 40 | 50 | d.activateTab(sess, sess.activeTab()) | |
| 41 | 51 | ||
| 42 | 52 | require.False(t, ac.overlays.copyActive()) | |
| 53 | + | ac.overlays.copyMu.Lock() | |
| 54 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 55 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 56 | + | ac.overlays.copyMu.Unlock() | |
| 43 | 57 | } | |
| 44 | 58 | ||
| 45 | 59 | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |
| @@ -61,11 +75,16 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 61 | 75 | ac.overlays.copyPane = closing | |
| 62 | 76 | ac.overlays.copyDocument = document | |
| 63 | 77 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 78 | + | seedCopyInteractionLocked(ac.overlays, closing, document) | |
| 64 | 79 | ac.overlays.copyMu.Unlock() | |
| 65 | 80 | ||
| 66 | 81 | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false)) | |
| 67 | 82 | require.False(t, ac.overlays.copyActive()) | |
| 68 | 83 | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 84 | + | ac.overlays.copyMu.Lock() | |
| 85 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 86 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 87 | + | ac.overlays.copyMu.Unlock() | |
| 69 | 88 | } | |
| 70 | 89 | ||
| 71 | 90 | func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) { | |
| @@ -184,6 +203,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||
| 184 | 203 | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | |
| 185 | 204 | require.Nil(t, ac.overlays.copyCandidate) | |
| 186 | 205 | require.False(t, ac.overlays.copyPointer.valid) | |
| 206 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 187 | 207 | ac.overlays.copyMu.Unlock() | |
| 188 | 208 | }) | |
| 189 | 209 | } | |
| @@ -231,6 +251,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||
| 231 | 251 | require.Nil(t, ac.overlays.copyMode) | |
| 232 | 252 | require.Nil(t, ac.overlays.copyCandidate) | |
| 233 | 253 | require.False(t, ac.overlays.copyPointer.valid) | |
| 254 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 234 | 255 | ac.overlays.copyMu.Unlock() | |
| 235 | 256 | } | |
| 236 | 257 | ||
| @@ -20,10 +20,17 @@ func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) { | |||||
| 20 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | 20 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | ||
| 21 | d.enterCopyMode(sess, ac) | 21 | d.enterCopyMode(sess, ac) | ||
| 22 | require.True(t, ac.overlays.copyActive()) | 22 | require.True(t, ac.overlays.copyActive()) | ||
| 23 | + | ac.overlays.copyMu.Lock() | |||
| 24 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |||
| 25 | + | ac.overlays.copyMu.Unlock() | |||
| 23 | 26 | ||||
| 24 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | 27 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | ||
| 25 | 28 | ||||
| 26 | require.False(t, ac.overlays.copyActive()) | 29 | require.False(t, ac.overlays.copyActive()) | ||
| 30 | + | ac.overlays.copyMu.Lock() | |||
| 31 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 32 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 33 | + | ac.overlays.copyMu.Unlock() | |||
| 27 | } | 34 | } | ||
| 28 | 35 | ||||
| 29 | func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | 36 | func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | ||
| @@ -35,11 +42,18 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |||||
| 35 | }() | 42 | }() | ||
| 36 | d.enterCopyMode(sess, ac) | 43 | d.enterCopyMode(sess, ac) | ||
| 37 | require.True(t, ac.overlays.copyActive()) | 44 | require.True(t, ac.overlays.copyActive()) | ||
| 45 | + | ac.overlays.copyMu.Lock() | |||
| 46 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |||
| 47 | + | ac.overlays.copyMu.Unlock() | |||
| 38 | 48 | ||||
| 39 | require.True(t, sess.switchTab(1)) | 49 | require.True(t, sess.switchTab(1)) | ||
| 40 | d.activateTab(sess, sess.activeTab()) | 50 | d.activateTab(sess, sess.activeTab()) | ||
| 41 | 51 | ||||
| 42 | require.False(t, ac.overlays.copyActive()) | 52 | require.False(t, ac.overlays.copyActive()) | ||
| 53 | + | ac.overlays.copyMu.Lock() | |||
| 54 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 55 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 56 | + | ac.overlays.copyMu.Unlock() | |||
| 43 | } | 57 | } | ||
| 44 | 58 | ||||
| 45 | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | 59 | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | ||
| @@ -61,11 +75,16 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 61 | ac.overlays.copyPane = closing | 75 | ac.overlays.copyPane = closing | ||
| 62 | ac.overlays.copyDocument = document | 76 | ac.overlays.copyDocument = document | ||
| 63 | ac.overlays.copyMode = scopy.NewMode(document) | 77 | ac.overlays.copyMode = scopy.NewMode(document) | ||
| 78 | + | seedCopyInteractionLocked(ac.overlays, closing, document) | |||
| 64 | ac.overlays.copyMu.Unlock() | 79 | ac.overlays.copyMu.Unlock() | ||
| 65 | 80 | ||||
| 66 | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false)) | 81 | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false)) | ||
| 67 | require.False(t, ac.overlays.copyActive()) | 82 | require.False(t, ac.overlays.copyActive()) | ||
| 68 | require.Nil(t, copyTargetPane(ac.overlays)) | 83 | require.Nil(t, copyTargetPane(ac.overlays)) | ||
| 84 | + | ac.overlays.copyMu.Lock() | |||
| 85 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 86 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 87 | + | ac.overlays.copyMu.Unlock() | |||
| 69 | } | 88 | } | ||
| 70 | 89 | ||||
| 71 | func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) { | 90 | func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) { | ||
| @@ -184,6 +203,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||||
| 184 | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | 203 | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | ||
| 185 | require.Nil(t, ac.overlays.copyCandidate) | 204 | require.Nil(t, ac.overlays.copyCandidate) | ||
| 186 | require.False(t, ac.overlays.copyPointer.valid) | 205 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 206 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 187 | ac.overlays.copyMu.Unlock() | 207 | ac.overlays.copyMu.Unlock() | ||
| 188 | }) | 208 | }) | ||
| 189 | } | 209 | } | ||
| @@ -231,6 +251,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||||
| 231 | require.Nil(t, ac.overlays.copyMode) | 251 | require.Nil(t, ac.overlays.copyMode) | ||
| 232 | require.Nil(t, ac.overlays.copyCandidate) | 252 | require.Nil(t, ac.overlays.copyCandidate) | ||
| 233 | require.False(t, ac.overlays.copyPointer.valid) | 253 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 254 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 234 | ac.overlays.copyMu.Unlock() | 255 | ac.overlays.copyMu.Unlock() | ||
| 235 | } | 256 | } | ||
| 236 | 257 | ||||
A internal/usecase/daemon/copymode_mouse_test.go +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type manualMouseClock struct{ now time.Time } | |
| 16 | + | ||
| 17 | + | func (c *manualMouseClock) Now() time.Time { return c.now } | |
| 18 | + | func (c *manualMouseClock) Advance(d time.Duration) { c.now = c.now.Add(d) } | |
| 19 | + | func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |
| 20 | + | ||
| 21 | + | func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) { | |
| 22 | + | t.Helper() | |
| 23 | + | p, release := newBlockingPTY(t) | |
| 24 | + | t.Cleanup(release) | |
| 25 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 26 | + | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 27 | + | d.clock = clock | |
| 28 | + | pane := sess.activeTab().focusedPane() | |
| 29 | + | for i, row := range rows { | |
| 30 | + | copy(pane.screen.Frame.Row(i), testRow(row)) | |
| 31 | + | } | |
| 32 | + | d.enterCopyMode(sess, ac) | |
| 33 | + | return d, sess, ac, clock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) { | |
| 37 | + | d.handleInput(sess, ac, []byte(raw)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestCopyDoubleClick(t *testing.T) { | |
| 41 | + | t.Run("same normalized position within interval selects word", func(t *testing.T) { | |
| 42 | + | d, sess, ac, clock := mouseCopyHarness(t, "a界 beta") | |
| 43 | + | pane := sess.activeTab().focusedPane() | |
| 44 | + | d.exitCopyMode(ac) | |
| 45 | + | pane.mu.Lock() | |
| 46 | + | row := pane.screen.Frame.Row(0) | |
| 47 | + | row[2] = renderer.Cell{Continuation: true} | |
| 48 | + | pane.mu.Unlock() | |
| 49 | + | d.enterCopyMode(sess, ac) | |
| 50 | + | ||
| 51 | + | // SGR column 3 is the continuation cell of the wide glyph at column 2. | |
| 52 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m") | |
| 53 | + | clock.Advance(copyDoubleClickInterval) | |
| 54 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M") | |
| 55 | + | ||
| 56 | + | ac.overlays.copyMu.Lock() | |
| 57 | + | selection := ac.overlays.copyMode.Selection() | |
| 58 | + | pointer := ac.overlays.copyPointer | |
| 59 | + | ac.overlays.copyMu.Unlock() | |
| 60 | + | require.True(t, selection.Enabled) | |
| 61 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 62 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press) | |
| 63 | + | }) | |
| 64 | + | ||
| 65 | + | for _, tc := range []struct { | |
| 66 | + | name string | |
| 67 | + | advance time.Duration | |
| 68 | + | second string | |
| 69 | + | }{ | |
| 70 | + | {name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"}, | |
| 71 | + | {name: "different position", second: "\x1b[<0;2;2M"}, | |
| 72 | + | } { | |
| 73 | + | t.Run(tc.name+" stays passive", func(t *testing.T) { | |
| 74 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha") | |
| 75 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 76 | + | clock.Advance(tc.advance) | |
| 77 | + | copyMouseInput(d, sess, ac, tc.second) | |
| 78 | + | ac.overlays.copyMu.Lock() | |
| 79 | + | selection := ac.overlays.copyMode.Selection() | |
| 80 | + | ac.overlays.copyMu.Unlock() | |
| 81 | + | require.False(t, selection.Enabled) | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | t.Run("different pane is not a double click", func(t *testing.T) { | |
| 86 | + | d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)}) | |
| 87 | + | rt := newOverlayRuntime(&attachedClient{}) | |
| 88 | + | p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 89 | + | p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 90 | + | rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()} | |
| 91 | + | require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now())) | |
| 92 | + | }) | |
| 93 | + | ||
| 94 | + | for _, text := range []string{"alpha beta", "alpha/beta"} { | |
| 95 | + | t.Run("separator is passive "+text, func(t *testing.T) { | |
| 96 | + | d, sess, ac, clock := mouseCopyHarness(t, text) | |
| 97 | + | if text == "alpha/beta" { | |
| 98 | + | cfg := domain.Defaults() | |
| 99 | + | cfg.Copy.WordSeparators = "/" | |
| 100 | + | d.ApplyConfig(cfg) | |
| 101 | + | d.exitCopyMode(ac) | |
| 102 | + | d.enterCopyMode(sess, ac) | |
| 103 | + | } | |
| 104 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m") | |
| 105 | + | clock.Advance(time.Millisecond) | |
| 106 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M") | |
| 107 | + | ac.overlays.copyMu.Lock() | |
| 108 | + | selection := ac.overlays.copyMode.Selection() | |
| 109 | + | ac.overlays.copyMu.Unlock() | |
| 110 | + | require.False(t, selection.Enabled) | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | t.Run("normal screen double click publishes word drag", func(t *testing.T) { | |
| 115 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta") | |
| 116 | + | d.exitCopyMode(ac) | |
| 117 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 118 | + | clock.Advance(time.Millisecond) | |
| 119 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M") | |
| 120 | + | ac.overlays.copyMu.Lock() | |
| 121 | + | selection := ac.overlays.copyMode.Selection() | |
| 122 | + | text := ac.overlays.copyMode.SelectedText() | |
| 123 | + | pointer := ac.overlays.copyPointer | |
| 124 | + | ac.overlays.copyMu.Unlock() | |
| 125 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 126 | + | require.True(t, pointer.wordDrag) | |
| 127 | + | require.Equal(t, "alpha beta", text) | |
| 128 | + | }) | |
| 129 | + | ||
| 130 | + | t.Run("first click drag invalidates candidate", func(t *testing.T) { | |
| 131 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha") | |
| 132 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m") | |
| 133 | + | clock.Advance(time.Millisecond) | |
| 134 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |
| 135 | + | ac.overlays.copyMu.Lock() | |
| 136 | + | selection := ac.overlays.copyMode.Selection() | |
| 137 | + | ac.overlays.copyMu.Unlock() | |
| 138 | + | require.NotEqual(t, scopy.Word, selection.Granularity) | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestCopyWordDragAndOSC52(t *testing.T) { | |
| 143 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta") | |
| 144 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 145 | + | clock.Advance(time.Millisecond) | |
| 146 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m") | |
| 147 | + | ||
| 148 | + | ac.overlays.copyMu.Lock() | |
| 149 | + | mode := ac.overlays.copyMode | |
| 150 | + | selection := mode.Selection() | |
| 151 | + | text := mode.SelectedText() | |
| 152 | + | ac.overlays.copyMu.Unlock() | |
| 153 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 154 | + | require.Equal(t, "alpha beta\ngamma", text) | |
| 155 | + | require.Equal(t, "\x1b]52;c;YWxwaGEgYmV0YQpnYW1tYQ=\a", string(scopy.OSC52(text)[0])) | |
| 156 | + | ||
| 157 | + | copyMouseInput(d, sess, ac, "y") | |
| 158 | + | require.False(t, ac.overlays.copyActive()) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) { | |
| 162 | + | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc}) | |
| 163 | + | rt.copyClick = copyClickCandidate{valid: true, pane: p} | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestCopyPointerResetClearsClick(t *testing.T) { | |
| 167 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 168 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators) | |
| 169 | + | for _, tc := range []struct { | |
| 170 | + | name string | |
| 171 | + | reset func(*overlayRuntime) | |
| 172 | + | }{ | |
| 173 | + | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }}, | |
| 174 | + | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }}, | |
| 175 | + | {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }}, | |
| 176 | + | } { | |
| 177 | + | t.Run(tc.name, func(t *testing.T) { | |
| 178 | + | rt := newOverlayRuntime(&attachedClient{}) | |
| 179 | + | rt.copyMu.Lock() | |
| 180 | + | rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc) | |
| 181 | + | seedCopyInteractionLocked(rt, p, doc) | |
| 182 | + | tc.reset(rt) | |
| 183 | + | require.False(t, rt.copyPointer.valid) | |
| 184 | + | require.False(t, rt.copyClick.valid) | |
| 185 | + | rt.copyMu.Unlock() | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestCopyWordSelectionYanksExactOSC52(t *testing.T) { | |
| 191 | + | p, release := newBlockingPTY(t) | |
| 192 | + | defer release() | |
| 193 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 194 | + | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 195 | + | d.clock = clock | |
| 196 | + | copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | |
| 197 | + | d.enterCopyMode(sess, ac) | |
| 198 | + | mustOutputData(t, sends) | |
| 199 | + | ||
| 200 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 201 | + | clock.Advance(time.Millisecond) | |
| 202 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |
| 203 | + | mustOutputData(t, sends) | |
| 204 | + | copyMouseInput(d, sess, ac, "y") | |
| 205 | + | // The selection repaint can have been queued before the synchronous OSC 52 | |
| 206 | + | // output; consume it, then assert the wire payload itself byte-for-byte. | |
| 207 | + | mustOutputData(t, sends) | |
| 208 | + | require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends)) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |
| 212 | + | for _, tc := range []struct { | |
| 213 | + | name string | |
| 214 | + | run func(*Daemon, *session, *attachedClient) | |
| 215 | + | }{ | |
| 216 | + | {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }}, | |
| 217 | + | {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }}, | |
| 218 | + | {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }}, | |
| 219 | + | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 220 | + | p := sess.activeTab().focusedPane() | |
| 221 | + | p.mu.Lock() | |
| 222 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 223 | + | p.mu.Unlock() | |
| 224 | + | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 225 | + | }}, | |
| 226 | + | } { | |
| 227 | + | t.Run(tc.name, func(t *testing.T) { | |
| 228 | + | d, sess, ac, _ := mouseCopyHarness(t, "word") | |
| 229 | + | ac.overlays.copyMu.Lock() | |
| 230 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 231 | + | ac.overlays.copyMu.Unlock() | |
| 232 | + | tc.run(d, sess, ac) | |
| 233 | + | ac.overlays.copyMu.Lock() | |
| 234 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 235 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 236 | + | ac.overlays.copyMu.Unlock() | |
| 237 | + | }) | |
| 238 | + | } | |
| 239 | + | } | |
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type manualMouseClock struct{ now time.Time } | |||
| 16 | + | ||||
| 17 | + | func (c *manualMouseClock) Now() time.Time { return c.now } | |||
| 18 | + | func (c *manualMouseClock) Advance(d time.Duration) { c.now = c.now.Add(d) } | |||
| 19 | + | func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |||
| 20 | + | ||||
| 21 | + | func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | p, release := newBlockingPTY(t) | |||
| 24 | + | t.Cleanup(release) | |||
| 25 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 26 | + | clock := &manualMouseClock{now: time.Unix(1, 0)} | |||
| 27 | + | d.clock = clock | |||
| 28 | + | pane := sess.activeTab().focusedPane() | |||
| 29 | + | for i, row := range rows { | |||
| 30 | + | copy(pane.screen.Frame.Row(i), testRow(row)) | |||
| 31 | + | } | |||
| 32 | + | d.enterCopyMode(sess, ac) | |||
| 33 | + | return d, sess, ac, clock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) { | |||
| 37 | + | d.handleInput(sess, ac, []byte(raw)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestCopyDoubleClick(t *testing.T) { | |||
| 41 | + | t.Run("same normalized position within interval selects word", func(t *testing.T) { | |||
| 42 | + | d, sess, ac, clock := mouseCopyHarness(t, "a界 beta") | |||
| 43 | + | pane := sess.activeTab().focusedPane() | |||
| 44 | + | d.exitCopyMode(ac) | |||
| 45 | + | pane.mu.Lock() | |||
| 46 | + | row := pane.screen.Frame.Row(0) | |||
| 47 | + | row[2] = renderer.Cell{Continuation: true} | |||
| 48 | + | pane.mu.Unlock() | |||
| 49 | + | d.enterCopyMode(sess, ac) | |||
| 50 | + | ||||
| 51 | + | // SGR column 3 is the continuation cell of the wide glyph at column 2. | |||
| 52 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m") | |||
| 53 | + | clock.Advance(copyDoubleClickInterval) | |||
| 54 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M") | |||
| 55 | + | ||||
| 56 | + | ac.overlays.copyMu.Lock() | |||
| 57 | + | selection := ac.overlays.copyMode.Selection() | |||
| 58 | + | pointer := ac.overlays.copyPointer | |||
| 59 | + | ac.overlays.copyMu.Unlock() | |||
| 60 | + | require.True(t, selection.Enabled) | |||
| 61 | + | require.Equal(t, scopy.Word, selection.Granularity) | |||
| 62 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press) | |||
| 63 | + | }) | |||
| 64 | + | ||||
| 65 | + | for _, tc := range []struct { | |||
| 66 | + | name string | |||
| 67 | + | advance time.Duration | |||
| 68 | + | second string | |||
| 69 | + | }{ | |||
| 70 | + | {name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"}, | |||
| 71 | + | {name: "different position", second: "\x1b[<0;2;2M"}, | |||
| 72 | + | } { | |||
| 73 | + | t.Run(tc.name+" stays passive", func(t *testing.T) { | |||
| 74 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha") | |||
| 75 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |||
| 76 | + | clock.Advance(tc.advance) | |||
| 77 | + | copyMouseInput(d, sess, ac, tc.second) | |||
| 78 | + | ac.overlays.copyMu.Lock() | |||
| 79 | + | selection := ac.overlays.copyMode.Selection() | |||
| 80 | + | ac.overlays.copyMu.Unlock() | |||
| 81 | + | require.False(t, selection.Enabled) | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | t.Run("different pane is not a double click", func(t *testing.T) { | |||
| 86 | + | d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)}) | |||
| 87 | + | rt := newOverlayRuntime(&attachedClient{}) | |||
| 88 | + | p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1}) | |||
| 89 | + | p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1}) | |||
| 90 | + | rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()} | |||
| 91 | + | require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now())) | |||
| 92 | + | }) | |||
| 93 | + | ||||
| 94 | + | for _, text := range []string{"alpha beta", "alpha/beta"} { | |||
| 95 | + | t.Run("separator is passive "+text, func(t *testing.T) { | |||
| 96 | + | d, sess, ac, clock := mouseCopyHarness(t, text) | |||
| 97 | + | if text == "alpha/beta" { | |||
| 98 | + | cfg := domain.Defaults() | |||
| 99 | + | cfg.Copy.WordSeparators = "/" | |||
| 100 | + | d.ApplyConfig(cfg) | |||
| 101 | + | d.exitCopyMode(ac) | |||
| 102 | + | d.enterCopyMode(sess, ac) | |||
| 103 | + | } | |||
| 104 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m") | |||
| 105 | + | clock.Advance(time.Millisecond) | |||
| 106 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M") | |||
| 107 | + | ac.overlays.copyMu.Lock() | |||
| 108 | + | selection := ac.overlays.copyMode.Selection() | |||
| 109 | + | ac.overlays.copyMu.Unlock() | |||
| 110 | + | require.False(t, selection.Enabled) | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | t.Run("normal screen double click publishes word drag", func(t *testing.T) { | |||
| 115 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta") | |||
| 116 | + | d.exitCopyMode(ac) | |||
| 117 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |||
| 118 | + | clock.Advance(time.Millisecond) | |||
| 119 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M") | |||
| 120 | + | ac.overlays.copyMu.Lock() | |||
| 121 | + | selection := ac.overlays.copyMode.Selection() | |||
| 122 | + | text := ac.overlays.copyMode.SelectedText() | |||
| 123 | + | pointer := ac.overlays.copyPointer | |||
| 124 | + | ac.overlays.copyMu.Unlock() | |||
| 125 | + | require.Equal(t, scopy.Word, selection.Granularity) | |||
| 126 | + | require.True(t, pointer.wordDrag) | |||
| 127 | + | require.Equal(t, "alpha beta", text) | |||
| 128 | + | }) | |||
| 129 | + | ||||
| 130 | + | t.Run("first click drag invalidates candidate", func(t *testing.T) { | |||
| 131 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha") | |||
| 132 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m") | |||
| 133 | + | clock.Advance(time.Millisecond) | |||
| 134 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |||
| 135 | + | ac.overlays.copyMu.Lock() | |||
| 136 | + | selection := ac.overlays.copyMode.Selection() | |||
| 137 | + | ac.overlays.copyMu.Unlock() | |||
| 138 | + | require.NotEqual(t, scopy.Word, selection.Granularity) | |||
| 139 | + | }) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestCopyWordDragAndOSC52(t *testing.T) { | |||
| 143 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta") | |||
| 144 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |||
| 145 | + | clock.Advance(time.Millisecond) | |||
| 146 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m") | |||
| 147 | + | ||||
| 148 | + | ac.overlays.copyMu.Lock() | |||
| 149 | + | mode := ac.overlays.copyMode | |||
| 150 | + | selection := mode.Selection() | |||
| 151 | + | text := mode.SelectedText() | |||
| 152 | + | ac.overlays.copyMu.Unlock() | |||
| 153 | + | require.Equal(t, scopy.Word, selection.Granularity) | |||
| 154 | + | require.Equal(t, "alpha beta\ngamma", text) | |||
| 155 | + | require.Equal(t, "\x1b]52;c;YWxwaGEgYmV0YQpnYW1tYQ=\a", string(scopy.OSC52(text)[0])) | |||
| 156 | + | ||||
| 157 | + | copyMouseInput(d, sess, ac, "y") | |||
| 158 | + | require.False(t, ac.overlays.copyActive()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) { | |||
| 162 | + | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc}) | |||
| 163 | + | rt.copyClick = copyClickCandidate{valid: true, pane: p} | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestCopyPointerResetClearsClick(t *testing.T) { | |||
| 167 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |||
| 168 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators) | |||
| 169 | + | for _, tc := range []struct { | |||
| 170 | + | name string | |||
| 171 | + | reset func(*overlayRuntime) | |||
| 172 | + | }{ | |||
| 173 | + | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }}, | |||
| 174 | + | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }}, | |||
| 175 | + | {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }}, | |||
| 176 | + | } { | |||
| 177 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 178 | + | rt := newOverlayRuntime(&attachedClient{}) | |||
| 179 | + | rt.copyMu.Lock() | |||
| 180 | + | rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc) | |||
| 181 | + | seedCopyInteractionLocked(rt, p, doc) | |||
| 182 | + | tc.reset(rt) | |||
| 183 | + | require.False(t, rt.copyPointer.valid) | |||
| 184 | + | require.False(t, rt.copyClick.valid) | |||
| 185 | + | rt.copyMu.Unlock() | |||
| 186 | + | }) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestCopyWordSelectionYanksExactOSC52(t *testing.T) { | |||
| 191 | + | p, release := newBlockingPTY(t) | |||
| 192 | + | defer release() | |||
| 193 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 194 | + | clock := &manualMouseClock{now: time.Unix(1, 0)} | |||
| 195 | + | d.clock = clock | |||
| 196 | + | copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | |||
| 197 | + | d.enterCopyMode(sess, ac) | |||
| 198 | + | mustOutputData(t, sends) | |||
| 199 | + | ||||
| 200 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |||
| 201 | + | clock.Advance(time.Millisecond) | |||
| 202 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |||
| 203 | + | mustOutputData(t, sends) | |||
| 204 | + | copyMouseInput(d, sess, ac, "y") | |||
| 205 | + | // The selection repaint can have been queued before the synchronous OSC 52 | |||
| 206 | + | // output; consume it, then assert the wire payload itself byte-for-byte. | |||
| 207 | + | mustOutputData(t, sends) | |||
| 208 | + | require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends)) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||
| 212 | + | for _, tc := range []struct { | |||
| 213 | + | name string | |||
| 214 | + | run func(*Daemon, *session, *attachedClient) | |||
| 215 | + | }{ | |||
| 216 | + | {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }}, | |||
| 217 | + | {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }}, | |||
| 218 | + | {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }}, | |||
| 219 | + | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |||
| 220 | + | p := sess.activeTab().focusedPane() | |||
| 221 | + | p.mu.Lock() | |||
| 222 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |||
| 223 | + | p.mu.Unlock() | |||
| 224 | + | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |||
| 225 | + | }}, | |||
| 226 | + | } { | |||
| 227 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 228 | + | d, sess, ac, _ := mouseCopyHarness(t, "word") | |||
| 229 | + | ac.overlays.copyMu.Lock() | |||
| 230 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |||
| 231 | + | ac.overlays.copyMu.Unlock() | |||
| 232 | + | tc.run(d, sess, ac) | |||
| 233 | + | ac.overlays.copyMu.Lock() | |||
| 234 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 235 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 236 | + | ac.overlays.copyMu.Unlock() | |||
| 237 | + | }) | |||
| 238 | + | } | |||
| 239 | + | } | |||
M internal/usecase/daemon/input.go +47 -13
| @@ -166,14 +166,14 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||
| 166 | 166 | return | |
| 167 | 167 | } | |
| 168 | 168 | rt.copyMu.Lock() | |
| 169 | - | rt.invalidateCopyPointerLocked() | |
| 169 | + | rt.invalidateCopyPointerLocked(true) | |
| 170 | 170 | rt.copyMu.Unlock() | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | // handleFreshCopyPress is the only normal-screen press path that creates a | |
| 174 | 174 | // selection candidate. Its geometry comes from the same frame-absolute hit | |
| 175 | 175 | // test used by active copy mode, never from pane-local synthetic rectangles. | |
| 176 | - | func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |
| 176 | + | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |
| 177 | 177 | if routed == nil { | |
| 178 | 178 | return false | |
| 179 | 179 | } | |
| @@ -182,7 +182,7 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r | |||
| 182 | 182 | tb.mu.Unlock() | |
| 183 | 183 | if !ok || geometry.pane != routed { | |
| 184 | 184 | ac.overlays.copyMu.Lock() | |
| 185 | - | ac.overlays.invalidateCopyPointerLocked() | |
| 185 | + | ac.overlays.invalidateCopyPointerLocked(true) | |
| 186 | 186 | ac.overlays.copyMu.Unlock() | |
| 187 | 187 | return true | |
| 188 | 188 | } | |
| @@ -198,15 +198,42 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r | |||
| 198 | 198 | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 199 | 199 | if !ok { | |
| 200 | 200 | ac.overlays.copyMu.Lock() | |
| 201 | - | ac.overlays.invalidateCopyPointerLocked() | |
| 201 | + | ac.overlays.invalidateCopyPointerLocked(true) | |
| 202 | 202 | ac.overlays.copyMu.Unlock() | |
| 203 | 203 | return true | |
| 204 | 204 | } | |
| 205 | - | ac.overlays.copyMu.Lock() | |
| 206 | - | if ac.overlays.copyMode == nil && ac.overlays.copyCandidate == nil { | |
| 207 | - | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |
| 205 | + | rt := ac.overlays | |
| 206 | + | rt.copyMu.Lock() | |
| 207 | + | if rt.copyMode != nil || rt.copyCandidate != nil { | |
| 208 | + | rt.copyMu.Unlock() | |
| 209 | + | return true | |
| 210 | + | } | |
| 211 | + | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |
| 212 | + | pointer := rt.copyPointer | |
| 213 | + | now := d.clock.Now() | |
| 214 | + | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |
| 215 | + | if doubleClick { | |
| 216 | + | rt.copyClick = copyClickCandidate{} | |
| 217 | + | } else { | |
| 218 | + | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |
| 219 | + | } | |
| 220 | + | rt.copyMu.Unlock() | |
| 221 | + | if !doubleClick { | |
| 222 | + | return true | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | selectedWord := false | |
| 226 | + | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |
| 227 | + | selectedWord = mode.SelectWordAt(mapped.pos) | |
| 228 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 229 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | pointer.wordDrag = selectedWord | |
| 233 | + | runtime.copyPointer = pointer | |
| 234 | + | }) { | |
| 235 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 208 | 236 | } | |
| 209 | - | ac.overlays.copyMu.Unlock() | |
| 210 | 237 | return true | |
| 211 | 238 | } | |
| 212 | 239 | ||
| @@ -223,7 +250,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||
| 223 | 250 | if active && ev.Type == mouse.Release { | |
| 224 | 251 | rt.copyMu.Lock() | |
| 225 | 252 | if rt.copyPointerEpoch == epoch { | |
| 226 | - | rt.invalidateCopyPointerLocked() | |
| 253 | + | rt.invalidateCopyPointerLocked(false) | |
| 227 | 254 | } | |
| 228 | 255 | rt.copyMu.Unlock() | |
| 229 | 256 | return true | |
| @@ -234,7 +261,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||
| 234 | 261 | if ev.Type == mouse.Release { | |
| 235 | 262 | rt.copyMu.Lock() | |
| 236 | 263 | if rt.copyPointerEpoch == epoch { | |
| 237 | - | rt.invalidateCopyPointerLocked() | |
| 264 | + | rt.invalidateCopyPointerLocked(false) | |
| 238 | 265 | } | |
| 239 | 266 | rt.copyMu.Unlock() | |
| 240 | 267 | return true | |
| @@ -248,16 +275,23 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||
| 248 | 275 | if mouseMode != 0 || altScreen { | |
| 249 | 276 | rt.copyMu.Lock() | |
| 250 | 277 | if rt.copyPointerEpoch == epoch { | |
| 251 | - | rt.invalidateCopyPointerLocked() | |
| 278 | + | rt.invalidateCopyPointerLocked(true) | |
| 252 | 279 | } | |
| 253 | 280 | rt.copyMu.Unlock() | |
| 254 | 281 | return false // preserve child forwarding when it enabled mouse reporting. | |
| 255 | 282 | } | |
| 256 | 283 | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |
| 284 | + | if ev.Type == mouse.Motion { | |
| 285 | + | rt.copyMu.Lock() | |
| 286 | + | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| 287 | + | rt.copyClick.dragged = true | |
| 288 | + | } | |
| 289 | + | rt.copyMu.Unlock() | |
| 290 | + | } | |
| 257 | 291 | if ev.Type == mouse.Release { | |
| 258 | 292 | rt.copyMu.Lock() | |
| 259 | 293 | if rt.copyPointerEpoch == epoch { | |
| 260 | - | rt.invalidateCopyPointerLocked() | |
| 294 | + | rt.invalidateCopyPointerLocked(false) | |
| 261 | 295 | } | |
| 262 | 296 | rt.copyMu.Unlock() | |
| 263 | 297 | return true | |
| @@ -321,7 +355,7 @@ func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSn | |||
| 321 | 355 | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | |
| 322 | 356 | return | |
| 323 | 357 | } | |
| 324 | - | rt.invalidateCopyPointerLocked() | |
| 358 | + | rt.invalidateCopyPointerLocked(true) | |
| 325 | 359 | } | |
| 326 | 360 | ||
| 327 | 361 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| @@ -166,14 +166,14 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||||
| 166 | return | 166 | return | ||
| 167 | } | 167 | } | ||
| 168 | rt.copyMu.Lock() | 168 | rt.copyMu.Lock() | ||
| 169 | - | rt.invalidateCopyPointerLocked() | 169 | + | rt.invalidateCopyPointerLocked(true) |
| 170 | rt.copyMu.Unlock() | 170 | rt.copyMu.Unlock() | ||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | // handleFreshCopyPress is the only normal-screen press path that creates a | 173 | // handleFreshCopyPress is the only normal-screen press path that creates a | ||
| 174 | // selection candidate. Its geometry comes from the same frame-absolute hit | 174 | // selection candidate. Its geometry comes from the same frame-absolute hit | ||
| 175 | // test used by active copy mode, never from pane-local synthetic rectangles. | 175 | // test used by active copy mode, never from pane-local synthetic rectangles. | ||
| 176 | - | func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | 176 | + | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { |
| 177 | if routed == nil { | 177 | if routed == nil { | ||
| 178 | return false | 178 | return false | ||
| 179 | } | 179 | } | ||
| @@ -182,7 +182,7 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r | |||||
| 182 | tb.mu.Unlock() | 182 | tb.mu.Unlock() | ||
| 183 | if !ok || geometry.pane != routed { | 183 | if !ok || geometry.pane != routed { | ||
| 184 | ac.overlays.copyMu.Lock() | 184 | ac.overlays.copyMu.Lock() | ||
| 185 | - | ac.overlays.invalidateCopyPointerLocked() | 185 | + | ac.overlays.invalidateCopyPointerLocked(true) |
| 186 | ac.overlays.copyMu.Unlock() | 186 | ac.overlays.copyMu.Unlock() | ||
| 187 | return true | 187 | return true | ||
| 188 | } | 188 | } | ||
| @@ -198,15 +198,42 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r | |||||
| 198 | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | 198 | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | ||
| 199 | if !ok { | 199 | if !ok { | ||
| 200 | ac.overlays.copyMu.Lock() | 200 | ac.overlays.copyMu.Lock() | ||
| 201 | - | ac.overlays.invalidateCopyPointerLocked() | 201 | + | ac.overlays.invalidateCopyPointerLocked(true) |
| 202 | ac.overlays.copyMu.Unlock() | 202 | ac.overlays.copyMu.Unlock() | ||
| 203 | return true | 203 | return true | ||
| 204 | } | 204 | } | ||
| 205 | - | ac.overlays.copyMu.Lock() | 205 | + | rt := ac.overlays |
| 206 | - | if ac.overlays.copyMode == nil && ac.overlays.copyCandidate == nil { | 206 | + | rt.copyMu.Lock() |
| 207 | - | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | 207 | + | if rt.copyMode != nil || rt.copyCandidate != nil { |
| 208 | + | rt.copyMu.Unlock() | |||
| 209 | + | return true | |||
| 210 | + | } | |||
| 211 | + | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |||
| 212 | + | pointer := rt.copyPointer | |||
| 213 | + | now := d.clock.Now() | |||
| 214 | + | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |||
| 215 | + | if doubleClick { | |||
| 216 | + | rt.copyClick = copyClickCandidate{} | |||
| 217 | + | } else { | |||
| 218 | + | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |||
| 219 | + | } | |||
| 220 | + | rt.copyMu.Unlock() | |||
| 221 | + | if !doubleClick { | |||
| 222 | + | return true | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | selectedWord := false | |||
| 226 | + | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |||
| 227 | + | selectedWord = mode.SelectWordAt(mapped.pos) | |||
| 228 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 229 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | pointer.wordDrag = selectedWord | |||
| 233 | + | runtime.copyPointer = pointer | |||
| 234 | + | }) { | |||
| 235 | + | d.invalidateRender(sess, ac, true, "input.go") | |||
| 208 | } | 236 | } | ||
| 209 | - | ac.overlays.copyMu.Unlock() | |||
| 210 | return true | 237 | return true | ||
| 211 | } | 238 | } | ||
| 212 | 239 | ||||
| @@ -223,7 +250,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||||
| 223 | if active && ev.Type == mouse.Release { | 250 | if active && ev.Type == mouse.Release { | ||
| 224 | rt.copyMu.Lock() | 251 | rt.copyMu.Lock() | ||
| 225 | if rt.copyPointerEpoch == epoch { | 252 | if rt.copyPointerEpoch == epoch { | ||
| 226 | - | rt.invalidateCopyPointerLocked() | 253 | + | rt.invalidateCopyPointerLocked(false) |
| 227 | } | 254 | } | ||
| 228 | rt.copyMu.Unlock() | 255 | rt.copyMu.Unlock() | ||
| 229 | return true | 256 | return true | ||
| @@ -234,7 +261,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||||
| 234 | if ev.Type == mouse.Release { | 261 | if ev.Type == mouse.Release { | ||
| 235 | rt.copyMu.Lock() | 262 | rt.copyMu.Lock() | ||
| 236 | if rt.copyPointerEpoch == epoch { | 263 | if rt.copyPointerEpoch == epoch { | ||
| 237 | - | rt.invalidateCopyPointerLocked() | 264 | + | rt.invalidateCopyPointerLocked(false) |
| 238 | } | 265 | } | ||
| 239 | rt.copyMu.Unlock() | 266 | rt.copyMu.Unlock() | ||
| 240 | return true | 267 | return true | ||
| @@ -248,16 +275,23 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||||
| 248 | if mouseMode != 0 || altScreen { | 275 | if mouseMode != 0 || altScreen { | ||
| 249 | rt.copyMu.Lock() | 276 | rt.copyMu.Lock() | ||
| 250 | if rt.copyPointerEpoch == epoch { | 277 | if rt.copyPointerEpoch == epoch { | ||
| 251 | - | rt.invalidateCopyPointerLocked() | 278 | + | rt.invalidateCopyPointerLocked(true) |
| 252 | } | 279 | } | ||
| 253 | rt.copyMu.Unlock() | 280 | rt.copyMu.Unlock() | ||
| 254 | return false // preserve child forwarding when it enabled mouse reporting. | 281 | return false // preserve child forwarding when it enabled mouse reporting. | ||
| 255 | } | 282 | } | ||
| 256 | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | 283 | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | ||
| 284 | + | if ev.Type == mouse.Motion { | |||
| 285 | + | rt.copyMu.Lock() | |||
| 286 | + | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |||
| 287 | + | rt.copyClick.dragged = true | |||
| 288 | + | } | |||
| 289 | + | rt.copyMu.Unlock() | |||
| 290 | + | } | |||
| 257 | if ev.Type == mouse.Release { | 291 | if ev.Type == mouse.Release { | ||
| 258 | rt.copyMu.Lock() | 292 | rt.copyMu.Lock() | ||
| 259 | if rt.copyPointerEpoch == epoch { | 293 | if rt.copyPointerEpoch == epoch { | ||
| 260 | - | rt.invalidateCopyPointerLocked() | 294 | + | rt.invalidateCopyPointerLocked(false) |
| 261 | } | 295 | } | ||
| 262 | rt.copyMu.Unlock() | 296 | rt.copyMu.Unlock() | ||
| 263 | return true | 297 | return true | ||
| @@ -321,7 +355,7 @@ func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSn | |||||
| 321 | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | 355 | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | ||
| 322 | return | 356 | return | ||
| 323 | } | 357 | } | ||
| 324 | - | rt.invalidateCopyPointerLocked() | 358 | + | rt.invalidateCopyPointerLocked(true) |
| 325 | } | 359 | } | ||
| 326 | 360 | ||||
| 327 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | 361 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | ||
M internal/usecase/daemon/overlay_runtime.go +19 -4
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "sync" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -44,6 +45,7 @@ type overlayRuntime struct { | |||
| 44 | 45 | copySearchPending []byte | |
| 45 | 46 | copyFeedback string | |
| 46 | 47 | copyPointer copyPointerState | |
| 48 | + | copyClick copyClickCandidate | |
| 47 | 49 | copyPointerEpoch uint64 | |
| 48 | 50 | } | |
| 49 | 51 | ||
| @@ -55,6 +57,15 @@ type copyPointerState struct { | |||
| 55 | 57 | geometry copyMouseGeometry | |
| 56 | 58 | press scopy.Pos | |
| 57 | 59 | dragging bool | |
| 60 | + | wordDrag bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type copyClickCandidate struct { | |
| 64 | + | valid bool | |
| 65 | + | pane *pane | |
| 66 | + | pos scopy.Pos | |
| 67 | + | at time.Time | |
| 68 | + | dragged bool | |
| 58 | 69 | } | |
| 59 | 70 | ||
| 60 | 71 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | |
| @@ -120,15 +131,19 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||
| 120 | 131 | rt.copyPointer = pointer | |
| 121 | 132 | } | |
| 122 | 133 | ||
| 123 | - | func (rt *overlayRuntime) invalidateCopyPointerLocked() { | |
| 134 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | |
| 124 | 135 | rt.copyPointerEpoch++ | |
| 125 | 136 | rt.copyPointer = copyPointerState{} | |
| 137 | + | if clearClick { | |
| 138 | + | rt.copyClick = copyClickCandidate{} | |
| 139 | + | } | |
| 126 | 140 | } | |
| 127 | 141 | ||
| 128 | 142 | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | |
| 129 | 143 | // teardown occurring while publication revalidates can invalidate the transfer. | |
| 130 | 144 | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |
| 131 | 145 | rt.copyPointer = copyPointerState{} | |
| 146 | + | rt.copyClick = copyClickCandidate{} | |
| 132 | 147 | } | |
| 133 | 148 | ||
| 134 | 149 | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | |
| @@ -149,7 +164,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() { | |||
| 149 | 164 | rt.copyPane = nil | |
| 150 | 165 | rt.copySearch = nil | |
| 151 | 166 | rt.copySearchPending = nil | |
| 152 | - | rt.invalidateCopyPointerLocked() | |
| 167 | + | rt.invalidateCopyPointerLocked(true) | |
| 153 | 168 | } | |
| 154 | 169 | ||
| 155 | 170 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |
| @@ -159,11 +174,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 159 | 174 | rt.copyMu.Lock() | |
| 160 | 175 | defer rt.copyMu.Unlock() | |
| 161 | 176 | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | |
| 162 | - | prePublication := rt.copyPointer.pane == p | |
| 177 | + | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p | |
| 163 | 178 | if active { | |
| 164 | 179 | rt.clearCopyModeLocked() | |
| 165 | 180 | } else if prePublication { | |
| 166 | - | rt.invalidateCopyPointerLocked() | |
| 181 | + | rt.invalidateCopyPointerLocked(true) | |
| 167 | 182 | } | |
| 168 | 183 | return active || prePublication | |
| 169 | 184 | } | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "sync" | 4 | "sync" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | scopy "github.com/bnema/vev/internal/usecase/copy" | 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 7 | "github.com/bnema/vev/internal/usecase/palette" | 8 | "github.com/bnema/vev/internal/usecase/palette" | ||
| @@ -44,6 +45,7 @@ type overlayRuntime struct { | |||||
| 44 | copySearchPending []byte | 45 | copySearchPending []byte | ||
| 45 | copyFeedback string | 46 | copyFeedback string | ||
| 46 | copyPointer copyPointerState | 47 | copyPointer copyPointerState | ||
| 48 | + | copyClick copyClickCandidate | |||
| 47 | copyPointerEpoch uint64 | 49 | copyPointerEpoch uint64 | ||
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| @@ -55,6 +57,15 @@ type copyPointerState struct { | |||||
| 55 | geometry copyMouseGeometry | 57 | geometry copyMouseGeometry | ||
| 56 | press scopy.Pos | 58 | press scopy.Pos | ||
| 57 | dragging bool | 59 | dragging bool | ||
| 60 | + | wordDrag bool | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type copyClickCandidate struct { | |||
| 64 | + | valid bool | |||
| 65 | + | pane *pane | |||
| 66 | + | pos scopy.Pos | |||
| 67 | + | at time.Time | |||
| 68 | + | dragged bool | |||
| 58 | } | 69 | } | ||
| 59 | 70 | ||||
| 60 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | 71 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | ||
| @@ -120,15 +131,19 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||||
| 120 | rt.copyPointer = pointer | 131 | rt.copyPointer = pointer | ||
| 121 | } | 132 | } | ||
| 122 | 133 | ||||
| 123 | - | func (rt *overlayRuntime) invalidateCopyPointerLocked() { | 134 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { |
| 124 | rt.copyPointerEpoch++ | 135 | rt.copyPointerEpoch++ | ||
| 125 | rt.copyPointer = copyPointerState{} | 136 | rt.copyPointer = copyPointerState{} | ||
| 137 | + | if clearClick { | |||
| 138 | + | rt.copyClick = copyClickCandidate{} | |||
| 139 | + | } | |||
| 126 | } | 140 | } | ||
| 127 | 141 | ||||
| 128 | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | 142 | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | ||
| 129 | // teardown occurring while publication revalidates can invalidate the transfer. | 143 | // teardown occurring while publication revalidates can invalidate the transfer. | ||
| 130 | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | 144 | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | ||
| 131 | rt.copyPointer = copyPointerState{} | 145 | rt.copyPointer = copyPointerState{} | ||
| 146 | + | rt.copyClick = copyClickCandidate{} | |||
| 132 | } | 147 | } | ||
| 133 | 148 | ||||
| 134 | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | 149 | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | ||
| @@ -149,7 +164,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() { | |||||
| 149 | rt.copyPane = nil | 164 | rt.copyPane = nil | ||
| 150 | rt.copySearch = nil | 165 | rt.copySearch = nil | ||
| 151 | rt.copySearchPending = nil | 166 | rt.copySearchPending = nil | ||
| 152 | - | rt.invalidateCopyPointerLocked() | 167 | + | rt.invalidateCopyPointerLocked(true) |
| 153 | } | 168 | } | ||
| 154 | 169 | ||||
| 155 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | 170 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | ||
| @@ -159,11 +174,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||||
| 159 | rt.copyMu.Lock() | 174 | rt.copyMu.Lock() | ||
| 160 | defer rt.copyMu.Unlock() | 175 | defer rt.copyMu.Unlock() | ||
| 161 | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | 176 | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | ||
| 162 | - | prePublication := rt.copyPointer.pane == p | 177 | + | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p |
| 163 | if active { | 178 | if active { | ||
| 164 | rt.clearCopyModeLocked() | 179 | rt.clearCopyModeLocked() | ||
| 165 | } else if prePublication { | 180 | } else if prePublication { | ||
| 166 | - | rt.invalidateCopyPointerLocked() | 181 | + | rt.invalidateCopyPointerLocked(true) |
| 167 | } | 182 | } | ||
| 168 | return active || prePublication | 183 | return active || prePublication | ||
| 169 | } | 184 | } | ||
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -181,7 +181,7 @@ func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) | |||
| 181 | 181 | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}}) | |
| 182 | 182 | staleEpoch := ac.overlays.copyPointer.epoch | |
| 183 | 183 | ac.overlays.clearCopyPointerForTransferLocked() | |
| 184 | - | ac.overlays.invalidateCopyPointerLocked() // release/close while publish revalidates | |
| 184 | + | ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates | |
| 185 | 185 | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | |
| 186 | 186 | require.False(t, ac.overlays.copyPointer.valid) | |
| 187 | 187 | ac.overlays.copyCandidate = scopy.NewMode(document) | |
| @@ -181,7 +181,7 @@ func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) | |||||
| 181 | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}}) | 181 | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}}) | ||
| 182 | staleEpoch := ac.overlays.copyPointer.epoch | 182 | staleEpoch := ac.overlays.copyPointer.epoch | ||
| 183 | ac.overlays.clearCopyPointerForTransferLocked() | 183 | ac.overlays.clearCopyPointerForTransferLocked() | ||
| 184 | - | ac.overlays.invalidateCopyPointerLocked() // release/close while publish revalidates | 184 | + | ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates |
| 185 | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | 185 | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | ||
| 186 | require.False(t, ac.overlays.copyPointer.valid) | 186 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 187 | ac.overlays.copyCandidate = scopy.NewMode(document) | 187 | ac.overlays.copyCandidate = scopy.NewMode(document) | ||