refactor(copy): centralize safe pointer mapping
- commit
- b26b9f7785930873db3ce9355edc710a5b8ce104
- author
- brice · · 2026-07-15 08:38 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- d57a965
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +314 -156
M internal/usecase/daemon/copymode.go +11 -11
| @@ -94,7 +94,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 94 | 94 | if activate != nil { | |
| 95 | 95 | rt.clearCopyPointerForTransferLocked() | |
| 96 | 96 | } else { | |
| 97 | - | rt.invalidateCopyPointerLocked(true) | |
| 97 | + | rt.invalidateCopyPointerLocked() | |
| 98 | 98 | } | |
| 99 | 99 | pointerEpoch := rt.copyPointerEpoch | |
| 100 | 100 | rt.copyMu.Unlock() | |
| @@ -128,18 +128,18 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 128 | 128 | return true | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | - | // copyMouse receives already mapped client-frame coordinates. The caller owns | |
| 132 | - | // geometry resolution; this function only mutates immutable-document state. | |
| 133 | - | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse) { | |
| 131 | + | // copyMouse receives an already mapped client-frame position. It revalidates | |
| 132 | + | // the immutable snapshot under copyMu before changing selection state, so a | |
| 133 | + | // concurrent scroll or pointer lifecycle transition cannot apply stale rows. | |
| 134 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse, snapshot copyMouseInputSnapshot, geometry copyMouseGeometry) { | |
| 134 | 135 | rt := ac.overlays | |
| 135 | 136 | if ev.Button != mouse.Left { | |
| 136 | 137 | return | |
| 137 | 138 | } | |
| 138 | 139 | rt.copyMu.Lock() | |
| 139 | - | if rt.copyMode == nil || rt.copyPane != mapped.pane || rt.copyDocument == nil || rt.copyDocument != rt.copyMode.Document() { | |
| 140 | - | if ev.Type == mouse.Release { | |
| 141 | - | rt.invalidateCopyPointerLocked(false) | |
| 142 | - | } | |
| 140 | + | if rt.copyMode != snapshot.mode || rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document || | |
| 141 | + | rt.copyPointerEpoch != snapshot.epoch || rt.copyMode == nil || rt.copyDocument != rt.copyMode.Document() || | |
| 142 | + | rt.copyMode.ViewportTop != snapshot.viewportTop { | |
| 143 | 143 | rt.copyMu.Unlock() | |
| 144 | 144 | return | |
| 145 | 145 | } | |
| @@ -149,11 +149,11 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||
| 149 | 149 | changed = rt.copyMode.SetPosition(mapped.pos) | |
| 150 | 150 | pointer := rt.copyPointer | |
| 151 | 151 | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | |
| 152 | - | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, press: mapped.pos}) | |
| 152 | + | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos}) | |
| 153 | 153 | } | |
| 154 | 154 | case mouse.Motion: | |
| 155 | 155 | pointer := rt.copyPointer | |
| 156 | - | if pointer.valid && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | |
| 156 | + | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | |
| 157 | 157 | if !pointer.dragging { | |
| 158 | 158 | rt.copyMode.StartCharacterSelection(pointer.press) | |
| 159 | 159 | pointer.dragging = true | |
| @@ -162,7 +162,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||
| 162 | 162 | rt.copyPointer = pointer | |
| 163 | 163 | } | |
| 164 | 164 | case mouse.Release: | |
| 165 | - | rt.invalidateCopyPointerLocked(false) | |
| 165 | + | rt.invalidateCopyPointerLocked() | |
| 166 | 166 | } | |
| 167 | 167 | rt.copyMu.Unlock() | |
| 168 | 168 | if changed { | |
| @@ -94,7 +94,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 94 | if activate != nil { | 94 | if activate != nil { | ||
| 95 | rt.clearCopyPointerForTransferLocked() | 95 | rt.clearCopyPointerForTransferLocked() | ||
| 96 | } else { | 96 | } else { | ||
| 97 | - | rt.invalidateCopyPointerLocked(true) | 97 | + | rt.invalidateCopyPointerLocked() |
| 98 | } | 98 | } | ||
| 99 | pointerEpoch := rt.copyPointerEpoch | 99 | pointerEpoch := rt.copyPointerEpoch | ||
| 100 | rt.copyMu.Unlock() | 100 | rt.copyMu.Unlock() | ||
| @@ -128,18 +128,18 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 128 | return true | 128 | return true | ||
| 129 | } | 129 | } | ||
| 130 | 130 | ||||
| 131 | - | // copyMouse receives already mapped client-frame coordinates. The caller owns | 131 | + | // copyMouse receives an already mapped client-frame position. It revalidates |
| 132 | - | // geometry resolution; this function only mutates immutable-document state. | 132 | + | // the immutable snapshot under copyMu before changing selection state, so a |
| 133 | - | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse) { | 133 | + | // concurrent scroll or pointer lifecycle transition cannot apply stale rows. |
| 134 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse, snapshot copyMouseInputSnapshot, geometry copyMouseGeometry) { | |||
| 134 | rt := ac.overlays | 135 | rt := ac.overlays | ||
| 135 | if ev.Button != mouse.Left { | 136 | if ev.Button != mouse.Left { | ||
| 136 | return | 137 | return | ||
| 137 | } | 138 | } | ||
| 138 | rt.copyMu.Lock() | 139 | rt.copyMu.Lock() | ||
| 139 | - | if rt.copyMode == nil || rt.copyPane != mapped.pane || rt.copyDocument == nil || rt.copyDocument != rt.copyMode.Document() { | 140 | + | if rt.copyMode != snapshot.mode || rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document || |
| 140 | - | if ev.Type == mouse.Release { | 141 | + | rt.copyPointerEpoch != snapshot.epoch || rt.copyMode == nil || rt.copyDocument != rt.copyMode.Document() || |
| 141 | - | rt.invalidateCopyPointerLocked(false) | 142 | + | rt.copyMode.ViewportTop != snapshot.viewportTop { |
| 142 | - | } | |||
| 143 | rt.copyMu.Unlock() | 143 | rt.copyMu.Unlock() | ||
| 144 | return | 144 | return | ||
| 145 | } | 145 | } | ||
| @@ -149,11 +149,11 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||||
| 149 | changed = rt.copyMode.SetPosition(mapped.pos) | 149 | changed = rt.copyMode.SetPosition(mapped.pos) | ||
| 150 | pointer := rt.copyPointer | 150 | pointer := rt.copyPointer | ||
| 151 | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | 151 | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | ||
| 152 | - | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, press: mapped.pos}) | 152 | + | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos}) |
| 153 | } | 153 | } | ||
| 154 | case mouse.Motion: | 154 | case mouse.Motion: | ||
| 155 | pointer := rt.copyPointer | 155 | pointer := rt.copyPointer | ||
| 156 | - | if pointer.valid && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | 156 | + | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { |
| 157 | if !pointer.dragging { | 157 | if !pointer.dragging { | ||
| 158 | rt.copyMode.StartCharacterSelection(pointer.press) | 158 | rt.copyMode.StartCharacterSelection(pointer.press) | ||
| 159 | pointer.dragging = true | 159 | pointer.dragging = true | ||
| @@ -162,7 +162,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||||
| 162 | rt.copyPointer = pointer | 162 | rt.copyPointer = pointer | ||
| 163 | } | 163 | } | ||
| 164 | case mouse.Release: | 164 | case mouse.Release: | ||
| 165 | - | rt.invalidateCopyPointerLocked(false) | 165 | + | rt.invalidateCopyPointerLocked() |
| 166 | } | 166 | } | ||
| 167 | rt.copyMu.Unlock() | 167 | rt.copyMu.Unlock() | ||
| 168 | if changed { | 168 | if changed { | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +0 -4
| @@ -161,7 +161,6 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||
| 161 | 161 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 162 | 162 | ac.overlays.copyMu.Lock() | |
| 163 | 163 | require.True(t, ac.overlays.copyPointer.valid) | |
| 164 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |
| 165 | 164 | ac.overlays.copyMu.Unlock() | |
| 166 | 165 | ||
| 167 | 166 | reached := make(chan struct{}) | |
| @@ -185,7 +184,6 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||
| 185 | 184 | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | |
| 186 | 185 | require.Nil(t, ac.overlays.copyCandidate) | |
| 187 | 186 | require.False(t, ac.overlays.copyPointer.valid) | |
| 188 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 189 | 187 | ac.overlays.copyMu.Unlock() | |
| 190 | 188 | }) | |
| 191 | 189 | } | |
| @@ -211,7 +209,6 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||
| 211 | 209 | d.handleInput(sess, ac, press) | |
| 212 | 210 | ac.overlays.copyMu.Lock() | |
| 213 | 211 | require.True(t, ac.overlays.copyPointer.valid) | |
| 214 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating} | |
| 215 | 212 | ac.overlays.copyMu.Unlock() | |
| 216 | 213 | ||
| 217 | 214 | reached := make(chan struct{}) | |
| @@ -234,7 +231,6 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||
| 234 | 231 | require.Nil(t, ac.overlays.copyMode) | |
| 235 | 232 | require.Nil(t, ac.overlays.copyCandidate) | |
| 236 | 233 | require.False(t, ac.overlays.copyPointer.valid) | |
| 237 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 238 | 234 | ac.overlays.copyMu.Unlock() | |
| 239 | 235 | } | |
| 240 | 236 | ||
| @@ -161,7 +161,6 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||||
| 161 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | 161 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | ||
| 162 | ac.overlays.copyMu.Lock() | 162 | ac.overlays.copyMu.Lock() | ||
| 163 | require.True(t, ac.overlays.copyPointer.valid) | 163 | require.True(t, ac.overlays.copyPointer.valid) | ||
| 164 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |||
| 165 | ac.overlays.copyMu.Unlock() | 164 | ac.overlays.copyMu.Unlock() | ||
| 166 | 165 | ||||
| 167 | reached := make(chan struct{}) | 166 | reached := make(chan struct{}) | ||
| @@ -185,7 +184,6 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||||
| 185 | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | 184 | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | ||
| 186 | require.Nil(t, ac.overlays.copyCandidate) | 185 | require.Nil(t, ac.overlays.copyCandidate) | ||
| 187 | require.False(t, ac.overlays.copyPointer.valid) | 186 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 188 | - | require.False(t, ac.overlays.copyClick.valid) | |||
| 189 | ac.overlays.copyMu.Unlock() | 187 | ac.overlays.copyMu.Unlock() | ||
| 190 | }) | 188 | }) | ||
| 191 | } | 189 | } | ||
| @@ -211,7 +209,6 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||||
| 211 | d.handleInput(sess, ac, press) | 209 | d.handleInput(sess, ac, press) | ||
| 212 | ac.overlays.copyMu.Lock() | 210 | ac.overlays.copyMu.Lock() | ||
| 213 | require.True(t, ac.overlays.copyPointer.valid) | 211 | require.True(t, ac.overlays.copyPointer.valid) | ||
| 214 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating} | |||
| 215 | ac.overlays.copyMu.Unlock() | 212 | ac.overlays.copyMu.Unlock() | ||
| 216 | 213 | ||||
| 217 | reached := make(chan struct{}) | 214 | reached := make(chan struct{}) | ||
| @@ -234,7 +231,6 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||||
| 234 | require.Nil(t, ac.overlays.copyMode) | 231 | require.Nil(t, ac.overlays.copyMode) | ||
| 235 | require.Nil(t, ac.overlays.copyCandidate) | 232 | require.Nil(t, ac.overlays.copyCandidate) | ||
| 236 | require.False(t, ac.overlays.copyPointer.valid) | 233 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 237 | - | require.False(t, ac.overlays.copyClick.valid) | |||
| 238 | ac.overlays.copyMu.Unlock() | 234 | ac.overlays.copyMu.Unlock() | ||
| 239 | } | 235 | } | ||
| 240 | 236 | ||||
M internal/usecase/daemon/copymode_test.go +2 -0
| @@ -813,7 +813,9 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 813 | 813 | ||
| 814 | 814 | d.enterCopyMode(sess, ac) | |
| 815 | 815 | awaitFrame(t, sends, ports.MsgOutput) | |
| 816 | + | ac.overlays.copyMu.Lock() | |
| 816 | 817 | viewportTop := ac.overlays.copyMode.ViewportTop | |
| 818 | + | ac.overlays.copyMu.Unlock() | |
| 817 | 819 | inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner | |
| 818 | 820 | ||
| 819 | 821 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | |
| @@ -813,7 +813,9 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||||
| 813 | 813 | ||||
| 814 | d.enterCopyMode(sess, ac) | 814 | d.enterCopyMode(sess, ac) | ||
| 815 | awaitFrame(t, sends, ports.MsgOutput) | 815 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 816 | + | ac.overlays.copyMu.Lock() | |||
| 816 | viewportTop := ac.overlays.copyMode.ViewportTop | 817 | viewportTop := ac.overlays.copyMode.ViewportTop | ||
| 818 | + | ac.overlays.copyMu.Unlock() | |||
| 817 | inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner | 819 | inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner | ||
| 818 | 820 | ||||
| 819 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | 821 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | ||
M internal/usecase/daemon/daemon.go +24 -21
| @@ -94,27 +94,30 @@ type Daemon struct { | |||
| 94 | 94 | // beforeCopyModeRevalidate is a deterministic test seam between staging a | |
| 95 | 95 | // copy-mode candidate and revalidating its pane membership. | |
| 96 | 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{} | |
| 97 | + | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | |
| 98 | + | // snapshot is captured and before its mapped position is applied. | |
| 99 | + | beforeCopyMouseMap func() | |
| 100 | + | ptys ports.PTYFactory | |
| 101 | + | clock ports.Clock | |
| 102 | + | log *slog.Logger | |
| 103 | + | runtimeObserver ports.RuntimeObserver | |
| 104 | + | baseEnv []string | |
| 105 | + | shell string | |
| 106 | + | shellArgs []string | |
| 107 | + | shellOverride bool | |
| 108 | + | persist *persist.Persister | |
| 109 | + | persistEnabled bool | |
| 110 | + | snaps ports.SnapshotStore | |
| 111 | + | snapsEnabled bool | |
| 112 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 113 | + | snapshotJobs chan *snapshotCapture | |
| 114 | + | snapshotWorkerMu sync.Mutex | |
| 115 | + | snapshotWorkerID uint64 | |
| 116 | + | snapshotWorkerCtx context.Context | |
| 117 | + | snapshotWorkerCancel context.CancelFunc | |
| 118 | + | snapshotWorkerDone chan struct{} | |
| 119 | + | snapshotWorkerFlush chan struct{} | |
| 120 | + | snapshotWorkerFinalWake chan struct{} | |
| 118 | 121 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |
| 119 | 122 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |
| 120 | 123 | // sessions, each with only its newest terminal state while the worker blocks. | |
| @@ -94,27 +94,30 @@ type Daemon struct { | |||||
| 94 | // beforeCopyModeRevalidate is a deterministic test seam between staging a | 94 | // beforeCopyModeRevalidate is a deterministic test seam between staging a | ||
| 95 | // copy-mode candidate and revalidating its pane membership. | 95 | // copy-mode candidate and revalidating its pane membership. | ||
| 96 | beforeCopyModeRevalidate func() | 96 | beforeCopyModeRevalidate func() | ||
| 97 | - | ptys ports.PTYFactory | 97 | + | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input |
| 98 | - | clock ports.Clock | 98 | + | // snapshot is captured and before its mapped position is applied. |
| 99 | - | log *slog.Logger | 99 | + | beforeCopyMouseMap func() |
| 100 | - | runtimeObserver ports.RuntimeObserver | 100 | + | ptys ports.PTYFactory |
| 101 | - | baseEnv []string | 101 | + | clock ports.Clock |
| 102 | - | shell string | 102 | + | log *slog.Logger |
| 103 | - | shellArgs []string | 103 | + | runtimeObserver ports.RuntimeObserver |
| 104 | - | shellOverride bool | 104 | + | baseEnv []string |
| 105 | - | persist *persist.Persister | 105 | + | shell string |
| 106 | - | persistEnabled bool | 106 | + | shellArgs []string |
| 107 | - | snaps ports.SnapshotStore | 107 | + | shellOverride bool |
| 108 | - | snapsEnabled bool | 108 | + | persist *persist.Persister |
| 109 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | 109 | + | persistEnabled bool |
| 110 | - | snapshotJobs chan *snapshotCapture | 110 | + | snaps ports.SnapshotStore |
| 111 | - | snapshotWorkerMu sync.Mutex | 111 | + | snapsEnabled bool |
| 112 | - | snapshotWorkerID uint64 | 112 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) |
| 113 | - | snapshotWorkerCtx context.Context | 113 | + | snapshotJobs chan *snapshotCapture |
| 114 | - | snapshotWorkerCancel context.CancelFunc | 114 | + | snapshotWorkerMu sync.Mutex |
| 115 | - | snapshotWorkerDone chan struct{} | 115 | + | snapshotWorkerID uint64 |
| 116 | - | snapshotWorkerFlush chan struct{} | 116 | + | snapshotWorkerCtx context.Context |
| 117 | - | snapshotWorkerFinalWake chan struct{} | 117 | + | snapshotWorkerCancel context.CancelFunc |
| 118 | + | snapshotWorkerDone chan struct{} | |||
| 119 | + | snapshotWorkerFlush chan struct{} | |||
| 120 | + | snapshotWorkerFinalWake chan struct{} | |||
| 118 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | 121 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | ||
| 119 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | 122 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | ||
| 120 | // sessions, each with only its newest terminal state while the worker blocks. | 123 | // sessions, each with only its newest terminal state while the worker blocks. | ||
M internal/usecase/daemon/input.go +175 -91
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/keys" | |
| 11 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -34,6 +33,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 34 | 33 | } | |
| 35 | 34 | ||
| 36 | 35 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 36 | + | frameEvent := ev | |
| 37 | 37 | ac.initOverlays() | |
| 38 | 38 | rt := ac.overlays | |
| 39 | 39 | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | |
| @@ -64,6 +64,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 64 | 64 | } | |
| 65 | 65 | return | |
| 66 | 66 | } | |
| 67 | + | // A fresh drag is pinned to its press geometry. Handle later events before | |
| 68 | + | // normal terminal routing so crossing a split cannot retarget its document. | |
| 69 | + | if ev.Button == mouse.Left && ev.Type != mouse.Press && d.handleFreshCopyPointer(sess, ac, ev) { | |
| 70 | + | return | |
| 71 | + | } | |
| 67 | 72 | ||
| 68 | 73 | tb.mu.Lock() | |
| 69 | 74 | contentRow := ev.Row - 1 | |
| @@ -75,6 +80,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 75 | 80 | return | |
| 76 | 81 | } | |
| 77 | 82 | tb.mu.Unlock() | |
| 83 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) { | |
| 84 | + | return | |
| 85 | + | } | |
| 78 | 86 | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |
| 79 | 87 | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | |
| 80 | 88 | return | |
| @@ -144,6 +152,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 144 | 152 | return | |
| 145 | 153 | } | |
| 146 | 154 | } | |
| 155 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) { | |
| 156 | + | return | |
| 157 | + | } | |
| 147 | 158 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 148 | 159 | } | |
| 149 | 160 | ||
| @@ -155,67 +166,209 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||
| 155 | 166 | return | |
| 156 | 167 | } | |
| 157 | 168 | rt.copyMu.Lock() | |
| 158 | - | rt.invalidateCopyPointerLocked(true) | |
| 169 | + | rt.invalidateCopyPointerLocked() | |
| 159 | 170 | rt.copyMu.Unlock() | |
| 160 | 171 | } | |
| 161 | 172 | ||
| 173 | + | // handleFreshCopyPress is the only normal-screen press path that creates a | |
| 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. | |
| 176 | + | func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |
| 177 | + | if routed == nil { | |
| 178 | + | return false | |
| 179 | + | } | |
| 180 | + | tb.mu.Lock() | |
| 181 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 182 | + | tb.mu.Unlock() | |
| 183 | + | if !ok || geometry.pane != routed { | |
| 184 | + | ac.overlays.copyMu.Lock() | |
| 185 | + | ac.overlays.invalidateCopyPointerLocked() | |
| 186 | + | ac.overlays.copyMu.Unlock() | |
| 187 | + | return true | |
| 188 | + | } | |
| 189 | + | geometry.pane.mu.Lock() | |
| 190 | + | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 191 | + | altScreen := geometry.pane.screen.AltScreenActive() | |
| 192 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |
| 193 | + | geometry.pane.mu.Unlock() | |
| 194 | + | if mouseMode != 0 || altScreen { | |
| 195 | + | return false // child forwarding retains its existing raw-byte path. | |
| 196 | + | } | |
| 197 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |
| 198 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 199 | + | if !ok { | |
| 200 | + | ac.overlays.copyMu.Lock() | |
| 201 | + | ac.overlays.invalidateCopyPointerLocked() | |
| 202 | + | ac.overlays.copyMu.Unlock() | |
| 203 | + | return true | |
| 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}) | |
| 208 | + | } | |
| 209 | + | ac.overlays.copyMu.Unlock() | |
| 210 | + | return true | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |
| 214 | + | // and geometry. It is intentionally independent of the currently hovered pane. | |
| 215 | + | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |
| 216 | + | rt := ac.overlays | |
| 217 | + | rt.copyMu.Lock() | |
| 218 | + | pointer := rt.copyPointer | |
| 219 | + | epoch := rt.copyPointerEpoch | |
| 220 | + | active := rt.copyMode != nil || rt.copyCandidate != nil | |
| 221 | + | rt.copyMu.Unlock() | |
| 222 | + | if !pointer.valid { | |
| 223 | + | if active && ev.Type == mouse.Release { | |
| 224 | + | rt.copyMu.Lock() | |
| 225 | + | if rt.copyPointerEpoch == epoch { | |
| 226 | + | rt.invalidateCopyPointerLocked() | |
| 227 | + | } | |
| 228 | + | rt.copyMu.Unlock() | |
| 229 | + | return true | |
| 230 | + | } | |
| 231 | + | return false | |
| 232 | + | } | |
| 233 | + | if active { | |
| 234 | + | if ev.Type == mouse.Release { | |
| 235 | + | rt.copyMu.Lock() | |
| 236 | + | if rt.copyPointerEpoch == epoch { | |
| 237 | + | rt.invalidateCopyPointerLocked() | |
| 238 | + | } | |
| 239 | + | rt.copyMu.Unlock() | |
| 240 | + | return true | |
| 241 | + | } | |
| 242 | + | return false | |
| 243 | + | } | |
| 244 | + | pointer.pane.mu.Lock() | |
| 245 | + | mouseMode, _ := pointer.pane.screen.MouseMode() | |
| 246 | + | altScreen := pointer.pane.screen.AltScreenActive() | |
| 247 | + | pointer.pane.mu.Unlock() | |
| 248 | + | if mouseMode != 0 || altScreen { | |
| 249 | + | rt.copyMu.Lock() | |
| 250 | + | if rt.copyPointerEpoch == epoch { | |
| 251 | + | rt.invalidateCopyPointerLocked() | |
| 252 | + | } | |
| 253 | + | rt.copyMu.Unlock() | |
| 254 | + | return false // preserve child forwarding when it enabled mouse reporting. | |
| 255 | + | } | |
| 256 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |
| 257 | + | if ev.Type == mouse.Release { | |
| 258 | + | rt.copyMu.Lock() | |
| 259 | + | if rt.copyPointerEpoch == epoch { | |
| 260 | + | rt.invalidateCopyPointerLocked() | |
| 261 | + | } | |
| 262 | + | rt.copyMu.Unlock() | |
| 263 | + | return true | |
| 264 | + | } | |
| 265 | + | if !ok { | |
| 266 | + | return true | |
| 267 | + | } | |
| 268 | + | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 269 | + | mode.StartCharacterSelection(pointer.press) | |
| 270 | + | mode.ExtendCharacterSelection(mapped.pos) | |
| 271 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 272 | + | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | pointer.dragging = true | |
| 276 | + | runtime.copyPointer = pointer | |
| 277 | + | }) { | |
| 278 | + | return true | |
| 279 | + | } | |
| 280 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 281 | + | return true | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | type copyMouseInputSnapshot struct { | |
| 285 | + | mode *scopy.Mode | |
| 286 | + | pane *pane | |
| 287 | + | document *scopy.Document | |
| 288 | + | viewportTop int | |
| 289 | + | pointer copyPointerState | |
| 290 | + | epoch uint64 | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |
| 294 | + | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |
| 295 | + | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |
| 296 | + | if rt == nil { | |
| 297 | + | return copyMouseInputSnapshot{}, false | |
| 298 | + | } | |
| 299 | + | rt.copyMu.Lock() | |
| 300 | + | defer rt.copyMu.Unlock() | |
| 301 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 302 | + | return copyMouseInputSnapshot{}, false | |
| 303 | + | } | |
| 304 | + | return copyMouseInputSnapshot{ | |
| 305 | + | mode: rt.copyMode, | |
| 306 | + | pane: rt.copyPane, | |
| 307 | + | document: rt.copyDocument, | |
| 308 | + | viewportTop: rt.copyMode.ViewportTop, | |
| 309 | + | pointer: rt.copyPointer, | |
| 310 | + | epoch: rt.copyPointerEpoch, | |
| 311 | + | }, true | |
| 312 | + | } | |
| 313 | + | ||
| 162 | 314 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| 163 | 315 | // press time. Geometry is resolved after copyMu is released to preserve the | |
| 164 | 316 | // daemon's copyMu -> tab/pane lock prohibition. | |
| 165 | 317 | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |
| 166 | 318 | rt := ac.overlays | |
| 167 | - | rt.copyMu.Lock() | |
| 168 | - | mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument | |
| 169 | - | pointer := rt.copyPointer | |
| 170 | - | rt.copyMu.Unlock() | |
| 171 | - | if mode == nil || target == nil || document == nil { | |
| 319 | + | snapshot, active := snapshotCopyMouseInput(rt) | |
| 320 | + | if !active { | |
| 172 | 321 | return | |
| 173 | 322 | } | |
| 174 | 323 | cfg := d.currentFloatingConfig() | |
| 175 | 324 | // Once pressed, use the committed rectangle captured by that press. A | |
| 176 | 325 | // later floating/layout change must not turn an in-flight drag into a hit | |
| 177 | 326 | // on a different pane. | |
| 178 | - | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | |
| 179 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, ev.Type == mouse.Motion) | |
| 327 | + | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |
| 328 | + | if d.beforeCopyMouseMap != nil { | |
| 329 | + | d.beforeCopyMouseMap() | |
| 330 | + | } | |
| 331 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |
| 180 | 332 | if ev.Type == mouse.Release && !ok { | |
| 181 | 333 | rt.copyMu.Lock() | |
| 182 | - | rt.invalidateCopyPointerLocked(true) | |
| 334 | + | rt.invalidateCopyPointerLocked() | |
| 183 | 335 | rt.copyMu.Unlock() | |
| 184 | 336 | return | |
| 185 | 337 | } | |
| 186 | 338 | if ok { | |
| 187 | - | d.copyMouse(sess, ac, ev, mapped) | |
| 339 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) | |
| 188 | 340 | } | |
| 189 | 341 | return | |
| 190 | 342 | } | |
| 191 | 343 | tb.mu.Lock() | |
| 192 | - | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target) | |
| 344 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 193 | 345 | if !ok && ev.Type == mouse.Press { | |
| 194 | 346 | // Preserve the established press-on-another-pane behavior: copy mode | |
| 195 | 347 | // exits rather than retargeting its immutable document. | |
| 196 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | |
| 197 | - | if tb.tree != nil { | |
| 198 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 199 | - | d.applyLayoutLocked(tb) | |
| 200 | - | } | |
| 348 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | |
| 349 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 350 | + | d.applyLayoutLocked(tb) | |
| 201 | 351 | } | |
| 202 | 352 | } | |
| 203 | 353 | tb.mu.Unlock() | |
| 204 | 354 | if !ok { | |
| 205 | 355 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 206 | 356 | rt.copyMu.Lock() | |
| 207 | - | rt.invalidateCopyPointerLocked(true) | |
| 357 | + | rt.invalidateCopyPointerLocked() | |
| 208 | 358 | rt.copyMu.Unlock() | |
| 209 | 359 | } | |
| 210 | 360 | return | |
| 211 | 361 | } | |
| 212 | 362 | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |
| 213 | - | mapped, mappedOK := mapCopyMouse(ev, geometry, mode.ViewportTop, document, clamp) | |
| 363 | + | if d.beforeCopyMouseMap != nil { | |
| 364 | + | d.beforeCopyMouseMap() | |
| 365 | + | } | |
| 366 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |
| 214 | 367 | if !mappedOK { | |
| 215 | 368 | if ev.Type == mouse.Press { | |
| 216 | 369 | otherPane := false | |
| 217 | 370 | tb.mu.Lock() | |
| 218 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | |
| 371 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 219 | 372 | otherPane = true | |
| 220 | 373 | if tb.tree != nil { | |
| 221 | 374 | focusPlacementLocked(tb, hit.pane.id) | |
| @@ -229,30 +382,20 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 229 | 382 | } | |
| 230 | 383 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 231 | 384 | rt.copyMu.Lock() | |
| 232 | - | rt.invalidateCopyPointerLocked(true) | |
| 385 | + | rt.invalidateCopyPointerLocked() | |
| 233 | 386 | rt.copyMu.Unlock() | |
| 234 | 387 | } | |
| 235 | 388 | return | |
| 236 | 389 | } | |
| 237 | - | if ev.Type == mouse.Press { | |
| 238 | - | // Keep the resolved frame geometry with the pointer installed by copyMouse. | |
| 239 | - | rt.copyMu.Lock() | |
| 240 | - | if rt.copyMode == mode && rt.copyPane == target && rt.copyDocument == document { | |
| 241 | - | rt.beginCopyPointerLocked(copyPointerState{pane: target, document: document, geometry: geometry, press: mapped.pos}) | |
| 242 | - | } | |
| 243 | - | rt.copyMu.Unlock() | |
| 244 | - | } | |
| 245 | - | d.copyMouse(sess, ac, ev, mapped) | |
| 390 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) | |
| 246 | 391 | } | |
| 247 | 392 | ||
| 248 | 393 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |
| 249 | 394 | if p == nil { | |
| 250 | 395 | return | |
| 251 | 396 | } | |
| 252 | - | rt := ac.overlays | |
| 253 | 397 | p.mu.Lock() | |
| 254 | 398 | childRows := p.screen.Frame.Height | |
| 255 | - | childCols := p.screen.Frame.Width | |
| 256 | 399 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 257 | 400 | altScreen := p.screen.AltScreenActive() | |
| 258 | 401 | p.mu.Unlock() | |
| @@ -270,65 +413,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 270 | 413 | } | |
| 271 | 414 | ||
| 272 | 415 | switch ev.Button { | |
| 273 | - | case mouse.Left: | |
| 274 | - | // At this point ev is pane-local in X and retains its client-frame Y, | |
| 275 | - | // so this synthetic geometry crosses the top-bar boundary once. | |
| 276 | - | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: clientTopBarRows, Width: childCols, Height: childRows}} | |
| 277 | - | switch ev.Type { | |
| 278 | - | case mouse.Press: | |
| 279 | - | if altScreen { | |
| 280 | - | rt.copyMu.Lock() | |
| 281 | - | rt.invalidateCopyPointerLocked(true) | |
| 282 | - | rt.copyMu.Unlock() | |
| 283 | - | return | |
| 284 | - | } | |
| 285 | - | p.mu.Lock() | |
| 286 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 287 | - | p.mu.Unlock() | |
| 288 | - | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |
| 289 | - | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 290 | - | if !ok { | |
| 291 | - | rt.copyMu.Lock() | |
| 292 | - | rt.invalidateCopyPointerLocked(true) | |
| 293 | - | rt.copyMu.Unlock() | |
| 294 | - | return | |
| 295 | - | } | |
| 296 | - | rt.copyMu.Lock() | |
| 297 | - | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: document, geometry: geometry, press: mapped.pos}) | |
| 298 | - | rt.copyMu.Unlock() | |
| 299 | - | case mouse.Motion: | |
| 300 | - | if altScreen { | |
| 301 | - | return | |
| 302 | - | } | |
| 303 | - | rt.copyMu.Lock() | |
| 304 | - | pointer := rt.copyPointer | |
| 305 | - | rt.copyMu.Unlock() | |
| 306 | - | if !pointer.valid || pointer.pane != p { | |
| 307 | - | return | |
| 308 | - | } | |
| 309 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, true) | |
| 310 | - | if !ok { | |
| 311 | - | return | |
| 312 | - | } | |
| 313 | - | tb := sess.activeTab() | |
| 314 | - | if !d.publishCopyMode(sess, ac, tb, p, pointer.document, func(mode *scopy.Mode) { | |
| 315 | - | mode.StartCharacterSelection(pointer.press) | |
| 316 | - | mode.ExtendCharacterSelection(mapped.pos) | |
| 317 | - | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 318 | - | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 319 | - | return | |
| 320 | - | } | |
| 321 | - | pointer.dragging = true | |
| 322 | - | runtime.copyPointer = pointer | |
| 323 | - | }) { | |
| 324 | - | return | |
| 325 | - | } | |
| 326 | - | d.invalidateRender(sess, ac, true, "input.go") | |
| 327 | - | case mouse.Release: | |
| 328 | - | rt.copyMu.Lock() | |
| 329 | - | rt.invalidateCopyPointerLocked(true) | |
| 330 | - | rt.copyMu.Unlock() | |
| 331 | - | } | |
| 332 | 416 | case mouse.WheelUp: | |
| 333 | 417 | if altScreen { | |
| 334 | 418 | d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | 7 | ||||
| 8 | - | "github.com/bnema/vev/internal/domain" | |||
| 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 10 | "github.com/bnema/vev/internal/usecase/keys" | 9 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -34,6 +33,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||||
| 34 | } | 33 | } | ||
| 35 | 34 | ||||
| 36 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | 35 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | ||
| 36 | + | frameEvent := ev | |||
| 37 | ac.initOverlays() | 37 | ac.initOverlays() | ||
| 38 | rt := ac.overlays | 38 | rt := ac.overlays | ||
| 39 | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | 39 | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | ||
| @@ -64,6 +64,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 64 | } | 64 | } | ||
| 65 | return | 65 | return | ||
| 66 | } | 66 | } | ||
| 67 | + | // A fresh drag is pinned to its press geometry. Handle later events before | |||
| 68 | + | // normal terminal routing so crossing a split cannot retarget its document. | |||
| 69 | + | if ev.Button == mouse.Left && ev.Type != mouse.Press && d.handleFreshCopyPointer(sess, ac, ev) { | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 67 | 72 | ||||
| 68 | tb.mu.Lock() | 73 | tb.mu.Lock() | ||
| 69 | contentRow := ev.Row - 1 | 74 | contentRow := ev.Row - 1 | ||
| @@ -75,6 +80,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 75 | return | 80 | return | ||
| 76 | } | 81 | } | ||
| 77 | tb.mu.Unlock() | 82 | tb.mu.Unlock() | ||
| 83 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 78 | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | 86 | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | ||
| 79 | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | 87 | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | ||
| 80 | return | 88 | return | ||
| @@ -144,6 +152,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 144 | return | 152 | return | ||
| 145 | } | 153 | } | ||
| 146 | } | 154 | } | ||
| 155 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) { | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 147 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | 158 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | ||
| 148 | } | 159 | } | ||
| 149 | 160 | ||||
| @@ -155,67 +166,209 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||||
| 155 | return | 166 | return | ||
| 156 | } | 167 | } | ||
| 157 | rt.copyMu.Lock() | 168 | rt.copyMu.Lock() | ||
| 158 | - | rt.invalidateCopyPointerLocked(true) | 169 | + | rt.invalidateCopyPointerLocked() |
| 159 | rt.copyMu.Unlock() | 170 | rt.copyMu.Unlock() | ||
| 160 | } | 171 | } | ||
| 161 | 172 | ||||
| 173 | + | // handleFreshCopyPress is the only normal-screen press path that creates a | |||
| 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. | |||
| 176 | + | func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |||
| 177 | + | if routed == nil { | |||
| 178 | + | return false | |||
| 179 | + | } | |||
| 180 | + | tb.mu.Lock() | |||
| 181 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |||
| 182 | + | tb.mu.Unlock() | |||
| 183 | + | if !ok || geometry.pane != routed { | |||
| 184 | + | ac.overlays.copyMu.Lock() | |||
| 185 | + | ac.overlays.invalidateCopyPointerLocked() | |||
| 186 | + | ac.overlays.copyMu.Unlock() | |||
| 187 | + | return true | |||
| 188 | + | } | |||
| 189 | + | geometry.pane.mu.Lock() | |||
| 190 | + | mouseMode, _ := geometry.pane.screen.MouseMode() | |||
| 191 | + | altScreen := geometry.pane.screen.AltScreenActive() | |||
| 192 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |||
| 193 | + | geometry.pane.mu.Unlock() | |||
| 194 | + | if mouseMode != 0 || altScreen { | |||
| 195 | + | return false // child forwarding retains its existing raw-byte path. | |||
| 196 | + | } | |||
| 197 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |||
| 198 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |||
| 199 | + | if !ok { | |||
| 200 | + | ac.overlays.copyMu.Lock() | |||
| 201 | + | ac.overlays.invalidateCopyPointerLocked() | |||
| 202 | + | ac.overlays.copyMu.Unlock() | |||
| 203 | + | return true | |||
| 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}) | |||
| 208 | + | } | |||
| 209 | + | ac.overlays.copyMu.Unlock() | |||
| 210 | + | return true | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |||
| 214 | + | // and geometry. It is intentionally independent of the currently hovered pane. | |||
| 215 | + | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |||
| 216 | + | rt := ac.overlays | |||
| 217 | + | rt.copyMu.Lock() | |||
| 218 | + | pointer := rt.copyPointer | |||
| 219 | + | epoch := rt.copyPointerEpoch | |||
| 220 | + | active := rt.copyMode != nil || rt.copyCandidate != nil | |||
| 221 | + | rt.copyMu.Unlock() | |||
| 222 | + | if !pointer.valid { | |||
| 223 | + | if active && ev.Type == mouse.Release { | |||
| 224 | + | rt.copyMu.Lock() | |||
| 225 | + | if rt.copyPointerEpoch == epoch { | |||
| 226 | + | rt.invalidateCopyPointerLocked() | |||
| 227 | + | } | |||
| 228 | + | rt.copyMu.Unlock() | |||
| 229 | + | return true | |||
| 230 | + | } | |||
| 231 | + | return false | |||
| 232 | + | } | |||
| 233 | + | if active { | |||
| 234 | + | if ev.Type == mouse.Release { | |||
| 235 | + | rt.copyMu.Lock() | |||
| 236 | + | if rt.copyPointerEpoch == epoch { | |||
| 237 | + | rt.invalidateCopyPointerLocked() | |||
| 238 | + | } | |||
| 239 | + | rt.copyMu.Unlock() | |||
| 240 | + | return true | |||
| 241 | + | } | |||
| 242 | + | return false | |||
| 243 | + | } | |||
| 244 | + | pointer.pane.mu.Lock() | |||
| 245 | + | mouseMode, _ := pointer.pane.screen.MouseMode() | |||
| 246 | + | altScreen := pointer.pane.screen.AltScreenActive() | |||
| 247 | + | pointer.pane.mu.Unlock() | |||
| 248 | + | if mouseMode != 0 || altScreen { | |||
| 249 | + | rt.copyMu.Lock() | |||
| 250 | + | if rt.copyPointerEpoch == epoch { | |||
| 251 | + | rt.invalidateCopyPointerLocked() | |||
| 252 | + | } | |||
| 253 | + | rt.copyMu.Unlock() | |||
| 254 | + | return false // preserve child forwarding when it enabled mouse reporting. | |||
| 255 | + | } | |||
| 256 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |||
| 257 | + | if ev.Type == mouse.Release { | |||
| 258 | + | rt.copyMu.Lock() | |||
| 259 | + | if rt.copyPointerEpoch == epoch { | |||
| 260 | + | rt.invalidateCopyPointerLocked() | |||
| 261 | + | } | |||
| 262 | + | rt.copyMu.Unlock() | |||
| 263 | + | return true | |||
| 264 | + | } | |||
| 265 | + | if !ok { | |||
| 266 | + | return true | |||
| 267 | + | } | |||
| 268 | + | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |||
| 269 | + | mode.StartCharacterSelection(pointer.press) | |||
| 270 | + | mode.ExtendCharacterSelection(mapped.pos) | |||
| 271 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 272 | + | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | pointer.dragging = true | |||
| 276 | + | runtime.copyPointer = pointer | |||
| 277 | + | }) { | |||
| 278 | + | return true | |||
| 279 | + | } | |||
| 280 | + | d.invalidateRender(sess, ac, true, "input.go") | |||
| 281 | + | return true | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | type copyMouseInputSnapshot struct { | |||
| 285 | + | mode *scopy.Mode | |||
| 286 | + | pane *pane | |||
| 287 | + | document *scopy.Document | |||
| 288 | + | viewportTop int | |||
| 289 | + | pointer copyPointerState | |||
| 290 | + | epoch uint64 | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |||
| 294 | + | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |||
| 295 | + | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |||
| 296 | + | if rt == nil { | |||
| 297 | + | return copyMouseInputSnapshot{}, false | |||
| 298 | + | } | |||
| 299 | + | rt.copyMu.Lock() | |||
| 300 | + | defer rt.copyMu.Unlock() | |||
| 301 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |||
| 302 | + | return copyMouseInputSnapshot{}, false | |||
| 303 | + | } | |||
| 304 | + | return copyMouseInputSnapshot{ | |||
| 305 | + | mode: rt.copyMode, | |||
| 306 | + | pane: rt.copyPane, | |||
| 307 | + | document: rt.copyDocument, | |||
| 308 | + | viewportTop: rt.copyMode.ViewportTop, | |||
| 309 | + | pointer: rt.copyPointer, | |||
| 310 | + | epoch: rt.copyPointerEpoch, | |||
| 311 | + | }, true | |||
| 312 | + | } | |||
| 313 | + | ||||
| 162 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | 314 | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | ||
| 163 | // press time. Geometry is resolved after copyMu is released to preserve the | 315 | // press time. Geometry is resolved after copyMu is released to preserve the | ||
| 164 | // daemon's copyMu -> tab/pane lock prohibition. | 316 | // daemon's copyMu -> tab/pane lock prohibition. | ||
| 165 | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | 317 | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | ||
| 166 | rt := ac.overlays | 318 | rt := ac.overlays | ||
| 167 | - | rt.copyMu.Lock() | 319 | + | snapshot, active := snapshotCopyMouseInput(rt) |
| 168 | - | mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument | 320 | + | if !active { |
| 169 | - | pointer := rt.copyPointer | |||
| 170 | - | rt.copyMu.Unlock() | |||
| 171 | - | if mode == nil || target == nil || document == nil { | |||
| 172 | return | 321 | return | ||
| 173 | } | 322 | } | ||
| 174 | cfg := d.currentFloatingConfig() | 323 | cfg := d.currentFloatingConfig() | ||
| 175 | // Once pressed, use the committed rectangle captured by that press. A | 324 | // Once pressed, use the committed rectangle captured by that press. A | ||
| 176 | // later floating/layout change must not turn an in-flight drag into a hit | 325 | // later floating/layout change must not turn an in-flight drag into a hit | ||
| 177 | // on a different pane. | 326 | // on a different pane. | ||
| 178 | - | if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document { | 327 | + | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { |
| 179 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, ev.Type == mouse.Motion) | 328 | + | if d.beforeCopyMouseMap != nil { |
| 329 | + | d.beforeCopyMouseMap() | |||
| 330 | + | } | |||
| 331 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |||
| 180 | if ev.Type == mouse.Release && !ok { | 332 | if ev.Type == mouse.Release && !ok { | ||
| 181 | rt.copyMu.Lock() | 333 | rt.copyMu.Lock() | ||
| 182 | - | rt.invalidateCopyPointerLocked(true) | 334 | + | rt.invalidateCopyPointerLocked() |
| 183 | rt.copyMu.Unlock() | 335 | rt.copyMu.Unlock() | ||
| 184 | return | 336 | return | ||
| 185 | } | 337 | } | ||
| 186 | if ok { | 338 | if ok { | ||
| 187 | - | d.copyMouse(sess, ac, ev, mapped) | 339 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) |
| 188 | } | 340 | } | ||
| 189 | return | 341 | return | ||
| 190 | } | 342 | } | ||
| 191 | tb.mu.Lock() | 343 | tb.mu.Lock() | ||
| 192 | - | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target) | 344 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) |
| 193 | if !ok && ev.Type == mouse.Press { | 345 | if !ok && ev.Type == mouse.Press { | ||
| 194 | // Preserve the established press-on-another-pane behavior: copy mode | 346 | // Preserve the established press-on-another-pane behavior: copy mode | ||
| 195 | // exits rather than retargeting its immutable document. | 347 | // exits rather than retargeting its immutable document. | ||
| 196 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | 348 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { |
| 197 | - | if tb.tree != nil { | 349 | + | focusPlacementLocked(tb, hit.pane.id) |
| 198 | - | focusPlacementLocked(tb, hit.pane.id) | 350 | + | d.applyLayoutLocked(tb) |
| 199 | - | d.applyLayoutLocked(tb) | |||
| 200 | - | } | |||
| 201 | } | 351 | } | ||
| 202 | } | 352 | } | ||
| 203 | tb.mu.Unlock() | 353 | tb.mu.Unlock() | ||
| 204 | if !ok { | 354 | if !ok { | ||
| 205 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | 355 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | ||
| 206 | rt.copyMu.Lock() | 356 | rt.copyMu.Lock() | ||
| 207 | - | rt.invalidateCopyPointerLocked(true) | 357 | + | rt.invalidateCopyPointerLocked() |
| 208 | rt.copyMu.Unlock() | 358 | rt.copyMu.Unlock() | ||
| 209 | } | 359 | } | ||
| 210 | return | 360 | return | ||
| 211 | } | 361 | } | ||
| 212 | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | 362 | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | ||
| 213 | - | mapped, mappedOK := mapCopyMouse(ev, geometry, mode.ViewportTop, document, clamp) | 363 | + | if d.beforeCopyMouseMap != nil { |
| 364 | + | d.beforeCopyMouseMap() | |||
| 365 | + | } | |||
| 366 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |||
| 214 | if !mappedOK { | 367 | if !mappedOK { | ||
| 215 | if ev.Type == mouse.Press { | 368 | if ev.Type == mouse.Press { | ||
| 216 | otherPane := false | 369 | otherPane := false | ||
| 217 | tb.mu.Lock() | 370 | tb.mu.Lock() | ||
| 218 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target { | 371 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { |
| 219 | otherPane = true | 372 | otherPane = true | ||
| 220 | if tb.tree != nil { | 373 | if tb.tree != nil { | ||
| 221 | focusPlacementLocked(tb, hit.pane.id) | 374 | focusPlacementLocked(tb, hit.pane.id) | ||
| @@ -229,30 +382,20 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 229 | } | 382 | } | ||
| 230 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | 383 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | ||
| 231 | rt.copyMu.Lock() | 384 | rt.copyMu.Lock() | ||
| 232 | - | rt.invalidateCopyPointerLocked(true) | 385 | + | rt.invalidateCopyPointerLocked() |
| 233 | rt.copyMu.Unlock() | 386 | rt.copyMu.Unlock() | ||
| 234 | } | 387 | } | ||
| 235 | return | 388 | return | ||
| 236 | } | 389 | } | ||
| 237 | - | if ev.Type == mouse.Press { | 390 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) |
| 238 | - | // Keep the resolved frame geometry with the pointer installed by copyMouse. | |||
| 239 | - | rt.copyMu.Lock() | |||
| 240 | - | if rt.copyMode == mode && rt.copyPane == target && rt.copyDocument == document { | |||
| 241 | - | rt.beginCopyPointerLocked(copyPointerState{pane: target, document: document, geometry: geometry, press: mapped.pos}) | |||
| 242 | - | } | |||
| 243 | - | rt.copyMu.Unlock() | |||
| 244 | - | } | |||
| 245 | - | d.copyMouse(sess, ac, ev, mapped) | |||
| 246 | } | 391 | } | ||
| 247 | 392 | ||||
| 248 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | 393 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | ||
| 249 | if p == nil { | 394 | if p == nil { | ||
| 250 | return | 395 | return | ||
| 251 | } | 396 | } | ||
| 252 | - | rt := ac.overlays | |||
| 253 | p.mu.Lock() | 397 | p.mu.Lock() | ||
| 254 | childRows := p.screen.Frame.Height | 398 | childRows := p.screen.Frame.Height | ||
| 255 | - | childCols := p.screen.Frame.Width | |||
| 256 | mouseMode, mouseSGR := p.screen.MouseMode() | 399 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| 257 | altScreen := p.screen.AltScreenActive() | 400 | altScreen := p.screen.AltScreenActive() | ||
| 258 | p.mu.Unlock() | 401 | p.mu.Unlock() | ||
| @@ -270,65 +413,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 270 | } | 413 | } | ||
| 271 | 414 | ||||
| 272 | switch ev.Button { | 415 | switch ev.Button { | ||
| 273 | - | case mouse.Left: | |||
| 274 | - | // At this point ev is pane-local in X and retains its client-frame Y, | |||
| 275 | - | // so this synthetic geometry crosses the top-bar boundary once. | |||
| 276 | - | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: clientTopBarRows, Width: childCols, Height: childRows}} | |||
| 277 | - | switch ev.Type { | |||
| 278 | - | case mouse.Press: | |||
| 279 | - | if altScreen { | |||
| 280 | - | rt.copyMu.Lock() | |||
| 281 | - | rt.invalidateCopyPointerLocked(true) | |||
| 282 | - | rt.copyMu.Unlock() | |||
| 283 | - | return | |||
| 284 | - | } | |||
| 285 | - | p.mu.Lock() | |||
| 286 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |||
| 287 | - | p.mu.Unlock() | |||
| 288 | - | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |||
| 289 | - | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |||
| 290 | - | if !ok { | |||
| 291 | - | rt.copyMu.Lock() | |||
| 292 | - | rt.invalidateCopyPointerLocked(true) | |||
| 293 | - | rt.copyMu.Unlock() | |||
| 294 | - | return | |||
| 295 | - | } | |||
| 296 | - | rt.copyMu.Lock() | |||
| 297 | - | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: document, geometry: geometry, press: mapped.pos}) | |||
| 298 | - | rt.copyMu.Unlock() | |||
| 299 | - | case mouse.Motion: | |||
| 300 | - | if altScreen { | |||
| 301 | - | return | |||
| 302 | - | } | |||
| 303 | - | rt.copyMu.Lock() | |||
| 304 | - | pointer := rt.copyPointer | |||
| 305 | - | rt.copyMu.Unlock() | |||
| 306 | - | if !pointer.valid || pointer.pane != p { | |||
| 307 | - | return | |||
| 308 | - | } | |||
| 309 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, true) | |||
| 310 | - | if !ok { | |||
| 311 | - | return | |||
| 312 | - | } | |||
| 313 | - | tb := sess.activeTab() | |||
| 314 | - | if !d.publishCopyMode(sess, ac, tb, p, pointer.document, func(mode *scopy.Mode) { | |||
| 315 | - | mode.StartCharacterSelection(pointer.press) | |||
| 316 | - | mode.ExtendCharacterSelection(mapped.pos) | |||
| 317 | - | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 318 | - | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 319 | - | return | |||
| 320 | - | } | |||
| 321 | - | pointer.dragging = true | |||
| 322 | - | runtime.copyPointer = pointer | |||
| 323 | - | }) { | |||
| 324 | - | return | |||
| 325 | - | } | |||
| 326 | - | d.invalidateRender(sess, ac, true, "input.go") | |||
| 327 | - | case mouse.Release: | |||
| 328 | - | rt.copyMu.Lock() | |||
| 329 | - | rt.invalidateCopyPointerLocked(true) | |||
| 330 | - | rt.copyMu.Unlock() | |||
| 331 | - | } | |||
| 332 | case mouse.WheelUp: | 416 | case mouse.WheelUp: | ||
| 333 | if altScreen { | 417 | if altScreen { | ||
| 334 | d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) | 418 | d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) | ||
M internal/usecase/daemon/input_test.go +94 -7
| @@ -1085,7 +1085,6 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) | |||
| 1085 | 1085 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1086 | 1086 | ac.overlays.copyMu.Lock() | |
| 1087 | 1087 | require.True(t, ac.overlays.copyPointer.valid) | |
| 1088 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |
| 1089 | 1088 | ac.overlays.copyMu.Unlock() | |
| 1090 | 1089 | ||
| 1091 | 1090 | // Wire row 25 is below the focused pane content, so the release is not a | |
| @@ -1093,7 +1092,6 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) | |||
| 1093 | 1092 | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M")) | |
| 1094 | 1093 | ac.overlays.copyMu.Lock() | |
| 1095 | 1094 | require.False(t, ac.overlays.copyPointer.valid) | |
| 1096 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 1097 | 1095 | selection := ac.overlays.copyMode.Selection() | |
| 1098 | 1096 | require.False(t, selection.Enabled) | |
| 1099 | 1097 | ac.overlays.copyMu.Unlock() | |
| @@ -1281,7 +1279,6 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1281 | 1279 | d.handleInput(sess, ac, press) | |
| 1282 | 1280 | ac.overlays.copyMu.Lock() | |
| 1283 | 1281 | require.True(t, ac.overlays.copyPointer.valid) | |
| 1284 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |
| 1285 | 1282 | ac.overlays.copyMu.Unlock() | |
| 1286 | 1283 | ||
| 1287 | 1284 | d.handleInput(sess, ac, reject) | |
| @@ -1289,7 +1286,6 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1289 | 1286 | ||
| 1290 | 1287 | ac.overlays.copyMu.Lock() | |
| 1291 | 1288 | require.False(t, ac.overlays.copyPointer.valid) | |
| 1292 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 1293 | 1289 | require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish") | |
| 1294 | 1290 | ac.overlays.copyMu.Unlock() | |
| 1295 | 1291 | }) | |
| @@ -1312,7 +1308,6 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) | |||
| 1312 | 1308 | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |
| 1313 | 1309 | ac.overlays.copyMu.Lock() | |
| 1314 | 1310 | require.False(t, ac.overlays.copyPointer.valid) | |
| 1315 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 1316 | 1311 | ac.overlays.copyMu.Unlock() | |
| 1317 | 1312 | } | |
| 1318 | 1313 | ||
| @@ -1340,14 +1335,12 @@ func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing | |||
| 1340 | 1335 | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | |
| 1341 | 1336 | ac.overlays.copyMu.Lock() | |
| 1342 | 1337 | require.True(t, ac.overlays.copyPointer.valid) | |
| 1343 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |
| 1344 | 1338 | epoch := ac.overlays.copyPointerEpoch | |
| 1345 | 1339 | ac.overlays.copyMu.Unlock() | |
| 1346 | 1340 | ||
| 1347 | 1341 | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1348 | 1342 | ac.overlays.copyMu.Lock() | |
| 1349 | 1343 | require.False(t, ac.overlays.copyPointer.valid) | |
| 1350 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 1351 | 1344 | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | |
| 1352 | 1345 | ac.overlays.copyMu.Unlock() | |
| 1353 | 1346 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | |
| @@ -1479,3 +1472,97 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||
| 1479 | 1472 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 1480 | 1473 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |
| 1481 | 1474 | } | |
| 1475 | + | ||
| 1476 | + | func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) { | |
| 1477 | + | p, release := newBlockingPTY(t) | |
| 1478 | + | defer release() | |
| 1479 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1480 | + | pane := sess.activeTab().focusedPane() | |
| 1481 | + | for range 8 { | |
| 1482 | + | pane.history.Append(testRow("history")) | |
| 1483 | + | } | |
| 1484 | + | d.enterCopyMode(sess, ac) | |
| 1485 | + | mustOutputData(t, sends) | |
| 1486 | + | ||
| 1487 | + | ac.overlays.copyMu.Lock() | |
| 1488 | + | before := ac.overlays.copyMode.Cursor() | |
| 1489 | + | ac.overlays.copyMu.Unlock() | |
| 1490 | + | d.beforeCopyMouseMap = func() { | |
| 1491 | + | ac.overlays.copyMu.Lock() | |
| 1492 | + | ac.overlays.copyMode.ViewportTop-- | |
| 1493 | + | ac.overlays.copyMu.Unlock() | |
| 1494 | + | } | |
| 1495 | + | defer func() { d.beforeCopyMouseMap = nil }() | |
| 1496 | + | ||
| 1497 | + | // The seam changes ViewportTop after the map snapshot but before copyMouse | |
| 1498 | + | // revalidates it. The stale mapped row must not move the selection cursor. | |
| 1499 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1500 | + | ac.overlays.copyMu.Lock() | |
| 1501 | + | require.Equal(t, before, ac.overlays.copyMode.Cursor()) | |
| 1502 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1503 | + | ac.overlays.copyMu.Unlock() | |
| 1504 | + | } | |
| 1505 | + | ||
| 1506 | + | func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) { | |
| 1507 | + | for _, tc := range []struct { | |
| 1508 | + | name string | |
| 1509 | + | setup func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) | |
| 1510 | + | }{ | |
| 1511 | + | { | |
| 1512 | + | name: "mono", | |
| 1513 | + | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |
| 1514 | + | return sess.activeTab().focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1} | |
| 1515 | + | }, | |
| 1516 | + | }, | |
| 1517 | + | { | |
| 1518 | + | name: "split", | |
| 1519 | + | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |
| 1520 | + | tb := sess.activeTab() | |
| 1521 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1522 | + | tb.mu.Lock() | |
| 1523 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1524 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1525 | + | tb.panes["pane-2"] = p2 | |
| 1526 | + | tb.mu.Unlock() | |
| 1527 | + | return p2, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 22, Row: 3} | |
| 1528 | + | }, | |
| 1529 | + | }, | |
| 1530 | + | { | |
| 1531 | + | name: "floating", | |
| 1532 | + | setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) { | |
| 1533 | + | p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 1534 | + | installTestFloating(sess.activeTab(), p, true) | |
| 1535 | + | tb := sess.activeTab() | |
| 1536 | + | tb.mu.Lock() | |
| 1537 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 1538 | + | tb.mu.Unlock() | |
| 1539 | + | require.True(t, visible) | |
| 1540 | + | return p, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: geometry.Inner.X + 1, Row: geometry.Inner.Y + 2} | |
| 1541 | + | }, | |
| 1542 | + | }, | |
| 1543 | + | } { | |
| 1544 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1545 | + | p, release := newBlockingPTY(t) | |
| 1546 | + | defer release() | |
| 1547 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1548 | + | wantPane, ev := tc.setup(t, d, sess) | |
| 1549 | + | tb := sess.activeTab() | |
| 1550 | + | tb.mu.Lock() | |
| 1551 | + | wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 1552 | + | tb.mu.Unlock() | |
| 1553 | + | require.True(t, ok) | |
| 1554 | + | require.Same(t, wantPane, wantGeometry.pane) | |
| 1555 | + | ||
| 1556 | + | d.handleMouse(ac, ev) | |
| 1557 | + | ac.overlays.copyMu.Lock() | |
| 1558 | + | pointer := ac.overlays.copyPointer | |
| 1559 | + | ac.overlays.copyMu.Unlock() | |
| 1560 | + | require.True(t, pointer.valid) | |
| 1561 | + | require.Same(t, wantPane, pointer.pane) | |
| 1562 | + | require.Equal(t, wantGeometry, pointer.geometry) | |
| 1563 | + | mapped, ok := mapCopyMouse(ev, wantGeometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, false) | |
| 1564 | + | require.True(t, ok) | |
| 1565 | + | require.Equal(t, mapped.pos, pointer.press) | |
| 1566 | + | }) | |
| 1567 | + | } | |
| 1568 | + | } | |
| @@ -1085,7 +1085,6 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) | |||||
| 1085 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | 1085 | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | ||
| 1086 | ac.overlays.copyMu.Lock() | 1086 | ac.overlays.copyMu.Lock() | ||
| 1087 | require.True(t, ac.overlays.copyPointer.valid) | 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() | 1088 | ac.overlays.copyMu.Unlock() | ||
| 1090 | 1089 | ||||
| 1091 | // Wire row 25 is below the focused pane content, so the release is not a | 1090 | // Wire row 25 is below the focused pane content, so the release is not a | ||
| @@ -1093,7 +1092,6 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) | |||||
| 1093 | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M")) | 1092 | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M")) | ||
| 1094 | ac.overlays.copyMu.Lock() | 1093 | ac.overlays.copyMu.Lock() | ||
| 1095 | require.False(t, ac.overlays.copyPointer.valid) | 1094 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 1096 | - | require.False(t, ac.overlays.copyClick.valid) | |||
| 1097 | selection := ac.overlays.copyMode.Selection() | 1095 | selection := ac.overlays.copyMode.Selection() | ||
| 1098 | require.False(t, selection.Enabled) | 1096 | require.False(t, selection.Enabled) | ||
| 1099 | ac.overlays.copyMu.Unlock() | 1097 | ac.overlays.copyMu.Unlock() | ||
| @@ -1281,7 +1279,6 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||||
| 1281 | d.handleInput(sess, ac, press) | 1279 | d.handleInput(sess, ac, press) | ||
| 1282 | ac.overlays.copyMu.Lock() | 1280 | ac.overlays.copyMu.Lock() | ||
| 1283 | require.True(t, ac.overlays.copyPointer.valid) | 1281 | require.True(t, ac.overlays.copyPointer.valid) | ||
| 1284 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |||
| 1285 | ac.overlays.copyMu.Unlock() | 1282 | ac.overlays.copyMu.Unlock() | ||
| 1286 | 1283 | ||||
| 1287 | d.handleInput(sess, ac, reject) | 1284 | d.handleInput(sess, ac, reject) | ||
| @@ -1289,7 +1286,6 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||||
| 1289 | 1286 | ||||
| 1290 | ac.overlays.copyMu.Lock() | 1287 | ac.overlays.copyMu.Lock() | ||
| 1291 | require.False(t, ac.overlays.copyPointer.valid) | 1288 | 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") | 1289 | require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish") | ||
| 1294 | ac.overlays.copyMu.Unlock() | 1290 | ac.overlays.copyMu.Unlock() | ||
| 1295 | }) | 1291 | }) | ||
| @@ -1312,7 +1308,6 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) | |||||
| 1312 | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | 1308 | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | ||
| 1313 | ac.overlays.copyMu.Lock() | 1309 | ac.overlays.copyMu.Lock() | ||
| 1314 | require.False(t, ac.overlays.copyPointer.valid) | 1310 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 1315 | - | require.False(t, ac.overlays.copyClick.valid) | |||
| 1316 | ac.overlays.copyMu.Unlock() | 1311 | ac.overlays.copyMu.Unlock() | ||
| 1317 | } | 1312 | } | ||
| 1318 | 1313 | ||||
| @@ -1340,14 +1335,12 @@ func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing | |||||
| 1340 | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | 1335 | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | ||
| 1341 | ac.overlays.copyMu.Lock() | 1336 | ac.overlays.copyMu.Lock() | ||
| 1342 | require.True(t, ac.overlays.copyPointer.valid) | 1337 | require.True(t, ac.overlays.copyPointer.valid) | ||
| 1343 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |||
| 1344 | epoch := ac.overlays.copyPointerEpoch | 1338 | epoch := ac.overlays.copyPointerEpoch | ||
| 1345 | ac.overlays.copyMu.Unlock() | 1339 | ac.overlays.copyMu.Unlock() | ||
| 1346 | 1340 | ||||
| 1347 | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | 1341 | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | ||
| 1348 | ac.overlays.copyMu.Lock() | 1342 | ac.overlays.copyMu.Lock() | ||
| 1349 | require.False(t, ac.overlays.copyPointer.valid) | 1343 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 1350 | - | require.False(t, ac.overlays.copyClick.valid) | |||
| 1351 | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | 1344 | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | ||
| 1352 | ac.overlays.copyMu.Unlock() | 1345 | ac.overlays.copyMu.Unlock() | ||
| 1353 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | 1346 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | ||
| @@ -1479,3 +1472,97 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||||
| 1479 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | 1472 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | ||
| 1480 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | 1473 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | ||
| 1481 | } | 1474 | } | ||
| 1475 | + | ||||
| 1476 | + | func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) { | |||
| 1477 | + | p, release := newBlockingPTY(t) | |||
| 1478 | + | defer release() | |||
| 1479 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1480 | + | pane := sess.activeTab().focusedPane() | |||
| 1481 | + | for range 8 { | |||
| 1482 | + | pane.history.Append(testRow("history")) | |||
| 1483 | + | } | |||
| 1484 | + | d.enterCopyMode(sess, ac) | |||
| 1485 | + | mustOutputData(t, sends) | |||
| 1486 | + | ||||
| 1487 | + | ac.overlays.copyMu.Lock() | |||
| 1488 | + | before := ac.overlays.copyMode.Cursor() | |||
| 1489 | + | ac.overlays.copyMu.Unlock() | |||
| 1490 | + | d.beforeCopyMouseMap = func() { | |||
| 1491 | + | ac.overlays.copyMu.Lock() | |||
| 1492 | + | ac.overlays.copyMode.ViewportTop-- | |||
| 1493 | + | ac.overlays.copyMu.Unlock() | |||
| 1494 | + | } | |||
| 1495 | + | defer func() { d.beforeCopyMouseMap = nil }() | |||
| 1496 | + | ||||
| 1497 | + | // The seam changes ViewportTop after the map snapshot but before copyMouse | |||
| 1498 | + | // revalidates it. The stale mapped row must not move the selection cursor. | |||
| 1499 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |||
| 1500 | + | ac.overlays.copyMu.Lock() | |||
| 1501 | + | require.Equal(t, before, ac.overlays.copyMode.Cursor()) | |||
| 1502 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1503 | + | ac.overlays.copyMu.Unlock() | |||
| 1504 | + | } | |||
| 1505 | + | ||||
| 1506 | + | func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) { | |||
| 1507 | + | for _, tc := range []struct { | |||
| 1508 | + | name string | |||
| 1509 | + | setup func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) | |||
| 1510 | + | }{ | |||
| 1511 | + | { | |||
| 1512 | + | name: "mono", | |||
| 1513 | + | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |||
| 1514 | + | return sess.activeTab().focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1} | |||
| 1515 | + | }, | |||
| 1516 | + | }, | |||
| 1517 | + | { | |||
| 1518 | + | name: "split", | |||
| 1519 | + | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |||
| 1520 | + | tb := sess.activeTab() | |||
| 1521 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 1522 | + | tb.mu.Lock() | |||
| 1523 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |||
| 1524 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 1525 | + | tb.panes["pane-2"] = p2 | |||
| 1526 | + | tb.mu.Unlock() | |||
| 1527 | + | return p2, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 22, Row: 3} | |||
| 1528 | + | }, | |||
| 1529 | + | }, | |||
| 1530 | + | { | |||
| 1531 | + | name: "floating", | |||
| 1532 | + | setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) { | |||
| 1533 | + | p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 1534 | + | installTestFloating(sess.activeTab(), p, true) | |||
| 1535 | + | tb := sess.activeTab() | |||
| 1536 | + | tb.mu.Lock() | |||
| 1537 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 1538 | + | tb.mu.Unlock() | |||
| 1539 | + | require.True(t, visible) | |||
| 1540 | + | return p, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: geometry.Inner.X + 1, Row: geometry.Inner.Y + 2} | |||
| 1541 | + | }, | |||
| 1542 | + | }, | |||
| 1543 | + | } { | |||
| 1544 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1545 | + | p, release := newBlockingPTY(t) | |||
| 1546 | + | defer release() | |||
| 1547 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1548 | + | wantPane, ev := tc.setup(t, d, sess) | |||
| 1549 | + | tb := sess.activeTab() | |||
| 1550 | + | tb.mu.Lock() | |||
| 1551 | + | wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |||
| 1552 | + | tb.mu.Unlock() | |||
| 1553 | + | require.True(t, ok) | |||
| 1554 | + | require.Same(t, wantPane, wantGeometry.pane) | |||
| 1555 | + | ||||
| 1556 | + | d.handleMouse(ac, ev) | |||
| 1557 | + | ac.overlays.copyMu.Lock() | |||
| 1558 | + | pointer := ac.overlays.copyPointer | |||
| 1559 | + | ac.overlays.copyMu.Unlock() | |||
| 1560 | + | require.True(t, pointer.valid) | |||
| 1561 | + | require.Same(t, wantPane, pointer.pane) | |||
| 1562 | + | require.Equal(t, wantGeometry, pointer.geometry) | |||
| 1563 | + | mapped, ok := mapCopyMouse(ev, wantGeometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, false) | |||
| 1564 | + | require.True(t, ok) | |||
| 1565 | + | require.Equal(t, mapped.pos, pointer.press) | |||
| 1566 | + | }) | |||
| 1567 | + | } | |||
| 1568 | + | } | |||
M internal/usecase/daemon/overlay_runtime.go +4 -19
| @@ -2,7 +2,6 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "sync" | |
| 5 | - | "time" | |
| 6 | 5 | ||
| 7 | 6 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 8 | 7 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -45,7 +44,6 @@ type overlayRuntime struct { | |||
| 45 | 44 | copySearchPending []byte | |
| 46 | 45 | copyFeedback string | |
| 47 | 46 | copyPointer copyPointerState | |
| 48 | - | copyClick copyClickCandidate | |
| 49 | 47 | copyPointerEpoch uint64 | |
| 50 | 48 | } | |
| 51 | 49 | ||
| @@ -57,15 +55,6 @@ type copyPointerState struct { | |||
| 57 | 55 | geometry copyMouseGeometry | |
| 58 | 56 | press scopy.Pos | |
| 59 | 57 | 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 | |
| 69 | 58 | } | |
| 70 | 59 | ||
| 71 | 60 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | |
| @@ -131,19 +120,15 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||
| 131 | 120 | rt.copyPointer = pointer | |
| 132 | 121 | } | |
| 133 | 122 | ||
| 134 | - | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | |
| 123 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked() { | |
| 135 | 124 | rt.copyPointerEpoch++ | |
| 136 | 125 | rt.copyPointer = copyPointerState{} | |
| 137 | - | if clearClick { | |
| 138 | - | rt.copyClick = copyClickCandidate{} | |
| 139 | - | } | |
| 140 | 126 | } | |
| 141 | 127 | ||
| 142 | 128 | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | |
| 143 | 129 | // teardown occurring while publication revalidates can invalidate the transfer. | |
| 144 | 130 | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |
| 145 | 131 | rt.copyPointer = copyPointerState{} | |
| 146 | - | rt.copyClick = copyClickCandidate{} | |
| 147 | 132 | } | |
| 148 | 133 | ||
| 149 | 134 | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | |
| @@ -164,7 +149,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() { | |||
| 164 | 149 | rt.copyPane = nil | |
| 165 | 150 | rt.copySearch = nil | |
| 166 | 151 | rt.copySearchPending = nil | |
| 167 | - | rt.invalidateCopyPointerLocked(true) | |
| 152 | + | rt.invalidateCopyPointerLocked() | |
| 168 | 153 | } | |
| 169 | 154 | ||
| 170 | 155 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |
| @@ -174,11 +159,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 174 | 159 | rt.copyMu.Lock() | |
| 175 | 160 | defer rt.copyMu.Unlock() | |
| 176 | 161 | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | |
| 177 | - | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p | |
| 162 | + | prePublication := rt.copyPointer.pane == p | |
| 178 | 163 | if active { | |
| 179 | 164 | rt.clearCopyModeLocked() | |
| 180 | 165 | } else if prePublication { | |
| 181 | - | rt.invalidateCopyPointerLocked(true) | |
| 166 | + | rt.invalidateCopyPointerLocked() | |
| 182 | 167 | } | |
| 183 | 168 | return active || prePublication | |
| 184 | 169 | } | |
| @@ -2,7 +2,6 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "sync" | 4 | "sync" | ||
| 5 | - | "time" | |||
| 6 | 5 | ||||
| 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | 6 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 8 | "github.com/bnema/vev/internal/usecase/palette" | 7 | "github.com/bnema/vev/internal/usecase/palette" | ||
| @@ -45,7 +44,6 @@ type overlayRuntime struct { | |||||
| 45 | copySearchPending []byte | 44 | copySearchPending []byte | ||
| 46 | copyFeedback string | 45 | copyFeedback string | ||
| 47 | copyPointer copyPointerState | 46 | copyPointer copyPointerState | ||
| 48 | - | copyClick copyClickCandidate | |||
| 49 | copyPointerEpoch uint64 | 47 | copyPointerEpoch uint64 | ||
| 50 | } | 48 | } | ||
| 51 | 49 | ||||
| @@ -57,15 +55,6 @@ type copyPointerState struct { | |||||
| 57 | geometry copyMouseGeometry | 55 | geometry copyMouseGeometry | ||
| 58 | press scopy.Pos | 56 | press scopy.Pos | ||
| 59 | dragging bool | 57 | 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 | |||
| 69 | } | 58 | } | ||
| 70 | 59 | ||||
| 71 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | 60 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | ||
| @@ -131,19 +120,15 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||||
| 131 | rt.copyPointer = pointer | 120 | rt.copyPointer = pointer | ||
| 132 | } | 121 | } | ||
| 133 | 122 | ||||
| 134 | - | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | 123 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked() { |
| 135 | rt.copyPointerEpoch++ | 124 | rt.copyPointerEpoch++ | ||
| 136 | rt.copyPointer = copyPointerState{} | 125 | rt.copyPointer = copyPointerState{} | ||
| 137 | - | if clearClick { | |||
| 138 | - | rt.copyClick = copyClickCandidate{} | |||
| 139 | - | } | |||
| 140 | } | 126 | } | ||
| 141 | 127 | ||||
| 142 | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | 128 | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | ||
| 143 | // teardown occurring while publication revalidates can invalidate the transfer. | 129 | // teardown occurring while publication revalidates can invalidate the transfer. | ||
| 144 | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | 130 | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | ||
| 145 | rt.copyPointer = copyPointerState{} | 131 | rt.copyPointer = copyPointerState{} | ||
| 146 | - | rt.copyClick = copyClickCandidate{} | |||
| 147 | } | 132 | } | ||
| 148 | 133 | ||||
| 149 | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | 134 | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | ||
| @@ -164,7 +149,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() { | |||||
| 164 | rt.copyPane = nil | 149 | rt.copyPane = nil | ||
| 165 | rt.copySearch = nil | 150 | rt.copySearch = nil | ||
| 166 | rt.copySearchPending = nil | 151 | rt.copySearchPending = nil | ||
| 167 | - | rt.invalidateCopyPointerLocked(true) | 152 | + | rt.invalidateCopyPointerLocked() |
| 168 | } | 153 | } | ||
| 169 | 154 | ||||
| 170 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | 155 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | ||
| @@ -174,11 +159,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||||
| 174 | rt.copyMu.Lock() | 159 | rt.copyMu.Lock() | ||
| 175 | defer rt.copyMu.Unlock() | 160 | defer rt.copyMu.Unlock() | ||
| 176 | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | 161 | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | ||
| 177 | - | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p | 162 | + | prePublication := rt.copyPointer.pane == p |
| 178 | if active { | 163 | if active { | ||
| 179 | rt.clearCopyModeLocked() | 164 | rt.clearCopyModeLocked() | ||
| 180 | } else if prePublication { | 165 | } else if prePublication { | ||
| 181 | - | rt.invalidateCopyPointerLocked(true) | 166 | + | rt.invalidateCopyPointerLocked() |
| 182 | } | 167 | } | ||
| 183 | return active || prePublication | 168 | return active || prePublication | ||
| 184 | } | 169 | } | ||
M internal/usecase/daemon/overlay_runtime_test.go +4 -3
| @@ -181,16 +181,17 @@ 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(true) // release/close while publish revalidates | |
| 184 | + | ac.overlays.invalidateCopyPointerLocked() // release/close while publish revalidates | |
| 185 | 185 | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | |
| 186 | 186 | require.False(t, ac.overlays.copyPointer.valid) | |
| 187 | - | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p} | |
| 187 | + | ac.overlays.copyCandidate = scopy.NewMode(document) | |
| 188 | + | ac.overlays.copyDocument = document | |
| 189 | + | ac.overlays.copyPane = p | |
| 188 | 190 | ac.overlays.copyMu.Unlock() | |
| 189 | 191 | ||
| 190 | 192 | require.True(t, ac.overlays.clearCopyModeForPane(p)) | |
| 191 | 193 | ac.overlays.copyMu.Lock() | |
| 192 | 194 | require.False(t, ac.overlays.copyPointer.valid) | |
| 193 | - | require.False(t, ac.overlays.copyClick.valid) | |
| 194 | 195 | ac.overlays.copyMu.Unlock() | |
| 195 | 196 | } | |
| 196 | 197 | ||
| @@ -181,16 +181,17 @@ 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(true) // release/close while publish revalidates | 184 | + | ac.overlays.invalidateCopyPointerLocked() // 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.copyClick = copyClickCandidate{valid: true, pane: p} | 187 | + | ac.overlays.copyCandidate = scopy.NewMode(document) |
| 188 | + | ac.overlays.copyDocument = document | |||
| 189 | + | ac.overlays.copyPane = p | |||
| 188 | ac.overlays.copyMu.Unlock() | 190 | ac.overlays.copyMu.Unlock() | ||
| 189 | 191 | ||||
| 190 | require.True(t, ac.overlays.clearCopyModeForPane(p)) | 192 | require.True(t, ac.overlays.clearCopyModeForPane(p)) | ||
| 191 | ac.overlays.copyMu.Lock() | 193 | ac.overlays.copyMu.Lock() | ||
| 192 | require.False(t, ac.overlays.copyPointer.valid) | 194 | require.False(t, ac.overlays.copyPointer.valid) | ||
| 193 | - | require.False(t, ac.overlays.copyClick.valid) | |||
| 194 | ac.overlays.copyMu.Unlock() | 195 | ac.overlays.copyMu.Unlock() | ||
| 195 | } | 196 | } | ||
| 196 | 197 | ||||