detached b26b9f7

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 *
9494 if activate != nil {
9595 rt.clearCopyPointerForTransferLocked()
9696 } else {
97- rt.invalidateCopyPointerLocked(true)
97+ rt.invalidateCopyPointerLocked()
9898 }
9999 pointerEpoch := rt.copyPointerEpoch
100100 rt.copyMu.Unlock()
@@ -128,18 +128,18 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
128128 return true
129129 }
130130
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) {
134135 rt := ac.overlays
135136 if ev.Button != mouse.Left {
136137 return
137138 }
138139 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 {
143143 rt.copyMu.Unlock()
144144 return
145145 }
@@ -149,11 +149,11 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
149149 changed = rt.copyMode.SetPosition(mapped.pos)
150150 pointer := rt.copyPointer
151151 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})
153153 }
154154 case mouse.Motion:
155155 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 {
157157 if !pointer.dragging {
158158 rt.copyMode.StartCharacterSelection(pointer.press)
159159 pointer.dragging = true
@@ -162,7 +162,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
162162 rt.copyPointer = pointer
163163 }
164164 case mouse.Release:
165- rt.invalidateCopyPointerLocked(false)
165+ rt.invalidateCopyPointerLocked()
166166 }
167167 rt.copyMu.Unlock()
168168 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.copyPointerEpoch99 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 true128 return true
129 }129 }
130 130
131-// copyMouse receives already mapped client-frame coordinates. The caller owns131+// 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.overlays135 rt := ac.overlays
135 if ev.Button != mouse.Left {136 if ev.Button != mouse.Left {
136 return137 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 return144 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.copyPointer150 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.copyPointer155 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 = true159 pointer.dragging = true
@@ -162,7 +162,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
162 rt.copyPointer = pointer162 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.
161161 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
162162 ac.overlays.copyMu.Lock()
163163 require.True(t, ac.overlays.copyPointer.valid)
164- ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p}
165164 ac.overlays.copyMu.Unlock()
166165
167166 reached := make(chan struct{})
@@ -185,7 +184,6 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.
185184 require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
186185 require.Nil(t, ac.overlays.copyCandidate)
187186 require.False(t, ac.overlays.copyPointer.valid)
188- require.False(t, ac.overlays.copyClick.valid)
189187 ac.overlays.copyMu.Unlock()
190188 })
191189 }
@@ -211,7 +209,6 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test
211209 d.handleInput(sess, ac, press)
212210 ac.overlays.copyMu.Lock()
213211 require.True(t, ac.overlays.copyPointer.valid)
214- ac.overlays.copyClick = copyClickCandidate{valid: true, pane: floating}
215212 ac.overlays.copyMu.Unlock()
216213
217214 reached := make(chan struct{})
@@ -234,7 +231,6 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test
234231 require.Nil(t, ac.overlays.copyMode)
235232 require.Nil(t, ac.overlays.copyCandidate)
236233 require.False(t, ac.overlays.copyPointer.valid)
237- require.False(t, ac.overlays.copyClick.valid)
238234 ac.overlays.copyMu.Unlock()
239235 }
240236
@@ -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) {
813813
814814 d.enterCopyMode(sess, ac)
815815 awaitFrame(t, sends, ports.MsgOutput)
816+ ac.overlays.copyMu.Lock()
816817 viewportTop := ac.overlays.copyMode.ViewportTop
818+ ac.overlays.copyMu.Unlock()
817819 inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner
818820
819821 // 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.ViewportTop817 viewportTop := ac.overlays.copyMode.ViewportTop
818+ ac.overlays.copyMu.Unlock()
817 inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner819 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 + content821 // 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 {
9494 // beforeCopyModeRevalidate is a deterministic test seam between staging a
9595 // copy-mode candidate and revalidating its pane membership.
9696 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{}
118121 // snapshotFinalJobs coalesces terminal captures by session when the bounded
119122 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
120123 // 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 a94 // 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.PTYFactory97+ // beforeCopyMouseMap is a deterministic seam after an immutable copy-input
98- clock ports.Clock98+ // snapshot is captured and before its mapped position is applied.
99- log *slog.Logger99+ beforeCopyMouseMap func()
100- runtimeObserver ports.RuntimeObserver100+ ptys ports.PTYFactory
101- baseEnv []string101+ clock ports.Clock
102- shell string102+ log *slog.Logger
103- shellArgs []string103+ runtimeObserver ports.RuntimeObserver
104- shellOverride bool104+ baseEnv []string
105- persist *persist.Persister105+ shell string
106- persistEnabled bool106+ shellArgs []string
107- snaps ports.SnapshotStore107+ shellOverride bool
108- snapsEnabled bool108+ persist *persist.Persister
109- snapshotMarshal func(snapcodec.Session) ([]byte, error)109+ persistEnabled bool
110- snapshotJobs chan *snapshotCapture110+ snaps ports.SnapshotStore
111- snapshotWorkerMu sync.Mutex111+ snapsEnabled bool
112- snapshotWorkerID uint64112+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
113- snapshotWorkerCtx context.Context113+ snapshotJobs chan *snapshotCapture
114- snapshotWorkerCancel context.CancelFunc114+ 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 bounded121 // snapshotFinalJobs coalesces terminal captures by session when the bounded
119 // regular queue is full. It retains at most snapshotFinalQueueCapacity named122 // 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 (
55 "bytes"
66 "strconv"
77
8- "github.com/bnema/vev/internal/domain"
98 scopy "github.com/bnema/vev/internal/usecase/copy"
109 "github.com/bnema/vev/internal/usecase/keys"
1110 "github.com/bnema/vev/internal/usecase/layout"
@@ -34,6 +33,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3433 }
3534
3635 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
36+ frameEvent := ev
3737 ac.initOverlays()
3838 rt := ac.overlays
3939 if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
@@ -64,6 +64,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
6464 }
6565 return
6666 }
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+ }
6772
6873 tb.mu.Lock()
6974 contentRow := ev.Row - 1
@@ -75,6 +80,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7580 return
7681 }
7782 tb.mu.Unlock()
83+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) {
84+ return
85+ }
7886 ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
7987 d.handleTerminalMouse(sess, ac, floating, ev, true, true)
8088 return
@@ -144,6 +152,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
144152 return
145153 }
146154 }
155+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) {
156+ return
157+ }
147158 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
148159 }
149160
@@ -155,67 +166,209 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
155166 return
156167 }
157168 rt.copyMu.Lock()
158- rt.invalidateCopyPointerLocked(true)
169+ rt.invalidateCopyPointerLocked()
159170 rt.copyMu.Unlock()
160171 }
161172
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+
162314 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
163315 // press time. Geometry is resolved after copyMu is released to preserve the
164316 // daemon's copyMu -> tab/pane lock prohibition.
165317 func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
166318 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 {
172321 return
173322 }
174323 cfg := d.currentFloatingConfig()
175324 // Once pressed, use the committed rectangle captured by that press. A
176325 // later floating/layout change must not turn an in-flight drag into a hit
177326 // 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)
180332 if ev.Type == mouse.Release && !ok {
181333 rt.copyMu.Lock()
182- rt.invalidateCopyPointerLocked(true)
334+ rt.invalidateCopyPointerLocked()
183335 rt.copyMu.Unlock()
184336 return
185337 }
186338 if ok {
187- d.copyMouse(sess, ac, ev, mapped)
339+ d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry)
188340 }
189341 return
190342 }
191343 tb.mu.Lock()
192- geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target)
344+ geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
193345 if !ok && ev.Type == mouse.Press {
194346 // Preserve the established press-on-another-pane behavior: copy mode
195347 // 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)
201351 }
202352 }
203353 tb.mu.Unlock()
204354 if !ok {
205355 if ev.Type == mouse.Release || ev.Type == mouse.Press {
206356 rt.copyMu.Lock()
207- rt.invalidateCopyPointerLocked(true)
357+ rt.invalidateCopyPointerLocked()
208358 rt.copyMu.Unlock()
209359 }
210360 return
211361 }
212362 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)
214367 if !mappedOK {
215368 if ev.Type == mouse.Press {
216369 otherPane := false
217370 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 {
219372 otherPane = true
220373 if tb.tree != nil {
221374 focusPlacementLocked(tb, hit.pane.id)
@@ -229,30 +382,20 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
229382 }
230383 if ev.Type == mouse.Release || ev.Type == mouse.Press {
231384 rt.copyMu.Lock()
232- rt.invalidateCopyPointerLocked(true)
385+ rt.invalidateCopyPointerLocked()
233386 rt.copyMu.Unlock()
234387 }
235388 return
236389 }
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)
246391 }
247392
248393 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
249394 if p == nil {
250395 return
251396 }
252- rt := ac.overlays
253397 p.mu.Lock()
254398 childRows := p.screen.Frame.Height
255- childCols := p.screen.Frame.Width
256399 mouseMode, mouseSGR := p.screen.MouseMode()
257400 altScreen := p.screen.AltScreenActive()
258401 p.mu.Unlock()
@@ -270,65 +413,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
270413 }
271414
272415 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- }
332416 case mouse.WheelUp:
333417 if altScreen {
334418 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.overlays38 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 return65 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 - 174 contentRow := ev.Row - 1
@@ -75,6 +80,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
75 return80 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 return88 return
@@ -144,6 +152,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
144 return152 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 return166 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 at314 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
163 // press time. Geometry is resolved after copyMu is released to preserve the315 // 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.overlays318 rt := ac.overlays
167- rt.copyMu.Lock()319+ snapshot, active := snapshotCopyMouseInput(rt)
168- mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument320+ if !active {
169- pointer := rt.copyPointer
170- rt.copyMu.Unlock()
171- if mode == nil || target == nil || document == nil {
172 return321 return
173 }322 }
174 cfg := d.currentFloatingConfig()323 cfg := d.currentFloatingConfig()
175 // Once pressed, use the committed rectangle captured by that press. A324 // 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 hit325 // 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 return336 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 return341 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 mode346 // 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 return360 return
211 }361 }
212 clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release362 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 := false369 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 = true372 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 return388 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 return395 return
251 }396 }
252- rt := ac.overlays
253 p.mu.Lock()397 p.mu.Lock()
254 childRows := p.screen.Frame.Height398 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)
10851085 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
10861086 ac.overlays.copyMu.Lock()
10871087 require.True(t, ac.overlays.copyPointer.valid)
1088- ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
10891088 ac.overlays.copyMu.Unlock()
10901089
10911090 // Wire row 25 is below the focused pane content, so the release is not a
@@ -1093,7 +1092,6 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T)
10931092 d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M"))
10941093 ac.overlays.copyMu.Lock()
10951094 require.False(t, ac.overlays.copyPointer.valid)
1096- require.False(t, ac.overlays.copyClick.valid)
10971095 selection := ac.overlays.copyMode.Selection()
10981096 require.False(t, selection.Enabled)
10991097 ac.overlays.copyMu.Unlock()
@@ -1281,7 +1279,6 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
12811279 d.handleInput(sess, ac, press)
12821280 ac.overlays.copyMu.Lock()
12831281 require.True(t, ac.overlays.copyPointer.valid)
1284- ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
12851282 ac.overlays.copyMu.Unlock()
12861283
12871284 d.handleInput(sess, ac, reject)
@@ -1289,7 +1286,6 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
12891286
12901287 ac.overlays.copyMu.Lock()
12911288 require.False(t, ac.overlays.copyPointer.valid)
1292- require.False(t, ac.overlays.copyClick.valid)
12931289 require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish")
12941290 ac.overlays.copyMu.Unlock()
12951291 })
@@ -1312,7 +1308,6 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T)
13121308 d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
13131309 ac.overlays.copyMu.Lock()
13141310 require.False(t, ac.overlays.copyPointer.valid)
1315- require.False(t, ac.overlays.copyClick.valid)
13161311 ac.overlays.copyMu.Unlock()
13171312 }
13181313
@@ -1340,14 +1335,12 @@ func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing
13401335 d.handleInput(sess, ac, []byte("\x1b[<0;22;3M"))
13411336 ac.overlays.copyMu.Lock()
13421337 require.True(t, ac.overlays.copyPointer.valid)
1343- ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane}
13441338 epoch := ac.overlays.copyPointerEpoch
13451339 ac.overlays.copyMu.Unlock()
13461340
13471341 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
13481342 ac.overlays.copyMu.Lock()
13491343 require.False(t, ac.overlays.copyPointer.valid)
1350- require.False(t, ac.overlays.copyClick.valid)
13511344 require.Greater(t, ac.overlays.copyPointerEpoch, epoch)
13521345 ac.overlays.copyMu.Unlock()
13531346 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) {
14791472 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
14801473 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
14811474 }
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 a1090 // 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.copyPointerEpoch1338 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
22
33 import (
44 "sync"
5- "time"
65
76 scopy "github.com/bnema/vev/internal/usecase/copy"
87 "github.com/bnema/vev/internal/usecase/palette"
@@ -45,7 +44,6 @@ type overlayRuntime struct {
4544 copySearchPending []byte
4645 copyFeedback string
4746 copyPointer copyPointerState
48- copyClick copyClickCandidate
4947 copyPointerEpoch uint64
5048 }
5149
@@ -57,15 +55,6 @@ type copyPointerState struct {
5755 geometry copyMouseGeometry
5856 press scopy.Pos
5957 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
6958 }
7059
7160 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
@@ -131,19 +120,15 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
131120 rt.copyPointer = pointer
132121 }
133122
134-func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
123+func (rt *overlayRuntime) invalidateCopyPointerLocked() {
135124 rt.copyPointerEpoch++
136125 rt.copyPointer = copyPointerState{}
137- if clearClick {
138- rt.copyClick = copyClickCandidate{}
139- }
140126 }
141127
142128 // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
143129 // teardown occurring while publication revalidates can invalidate the transfer.
144130 func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
145131 rt.copyPointer = copyPointerState{}
146- rt.copyClick = copyClickCandidate{}
147132 }
148133
149134 func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
@@ -164,7 +149,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() {
164149 rt.copyPane = nil
165150 rt.copySearch = nil
166151 rt.copySearchPending = nil
167- rt.invalidateCopyPointerLocked(true)
152+ rt.invalidateCopyPointerLocked()
168153 }
169154
170155 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
@@ -174,11 +159,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
174159 rt.copyMu.Lock()
175160 defer rt.copyMu.Unlock()
176161 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
178163 if active {
179164 rt.clearCopyModeLocked()
180165 } else if prePublication {
181- rt.invalidateCopyPointerLocked(true)
166+ rt.invalidateCopyPointerLocked()
182167 }
183168 return active || prePublication
184169 }
@@ -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 []byte44 copySearchPending []byte
46 copyFeedback string45 copyFeedback string
47 copyPointer copyPointerState46 copyPointer copyPointerState
48- copyClick copyClickCandidate
49 copyPointerEpoch uint6447 copyPointerEpoch uint64
50 }48 }
51 49
@@ -57,15 +55,6 @@ type copyPointerState struct {
57 geometry copyMouseGeometry55 geometry copyMouseGeometry
58 press scopy.Pos56 press scopy.Pos
59 dragging bool57 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 = pointer120 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 or128 // 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 = nil149 rt.copyPane = nil
165 rt.copySearch = nil150 rt.copySearch = nil
166 rt.copySearchPending = nil151 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 == p162+ 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 || prePublication168 return active || prePublication
184 }169 }
M internal/usecase/daemon/overlay_runtime_test.go +4 -3
@@ -181,16 +181,17 @@ func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T)
181181 ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}})
182182 staleEpoch := ac.overlays.copyPointer.epoch
183183 ac.overlays.clearCopyPointerForTransferLocked()
184- ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates
184+ ac.overlays.invalidateCopyPointerLocked() // release/close while publish revalidates
185185 require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch)
186186 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
188190 ac.overlays.copyMu.Unlock()
189191
190192 require.True(t, ac.overlays.clearCopyModeForPane(p))
191193 ac.overlays.copyMu.Lock()
192194 require.False(t, ac.overlays.copyPointer.valid)
193- require.False(t, ac.overlays.copyClick.valid)
194195 ac.overlays.copyMu.Unlock()
195196 }
196197
@@ -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.epoch182 staleEpoch := ac.overlays.copyPointer.epoch
183 ac.overlays.clearCopyPointerForTransferLocked()183 ac.overlays.clearCopyPointerForTransferLocked()
184- ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates184+ 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close