detached aaa3319

fix(copy): preserve pointer lifecycle across search

commit
aaa33191c9a30d29aede81e2d6ed30ee949ef06c
author
brice · · 2026-07-15 09:42 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
5173b95
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +371 -283
A internal/usecase/daemon/copy_mouse_input.go +305 -0
@@ -0,0 +1,305 @@
1+// Package daemon holds vev's server-side session multiplexer use case.
2+package daemon
3+
4+import (
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/mouse"
7+)
8+
9+// handleCopyMouse owns copy-mode mouse routing. A release is lifecycle input
10+// even while visual search owns all other copy-mode mouse input.
11+func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) bool {
12+ rt := ac.overlays
13+ if !rt.copyActive() {
14+ return false
15+ }
16+ if ev.Button == mouse.Left && ev.Type == mouse.Release {
17+ rt.copyMu.Lock()
18+ rt.invalidateCopyPointerLocked(false)
19+ rt.copyMu.Unlock()
20+ return true
21+ }
22+ if rt.copySearchActive() {
23+ return true
24+ }
25+ switch ev.Button {
26+ case mouse.WheelUp:
27+ d.copyWheel(sess, ac, -3)
28+ case mouse.WheelDown:
29+ d.copyWheel(sess, ac, 3)
30+ case mouse.Left:
31+ d.handleActiveCopyMouse(sess, ac, tb, ev)
32+ }
33+ return true
34+}
35+
36+// invalidateRejectedLeftPointer clears a stale drag before returning from an
37+// event that cannot be routed to terminal content. It never runs while tab or
38+// pane locks are held, preserving the copyMu -> tab/pane lock prohibition.
39+func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
40+ if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) {
41+ return
42+ }
43+ rt.copyMu.Lock()
44+ rt.invalidateCopyPointerLocked(true)
45+ rt.copyMu.Unlock()
46+}
47+
48+// handleFreshCopyPress is the only normal-screen press path that creates a
49+// selection candidate. Its geometry comes from the same frame-absolute hit
50+// test used by active copy mode, never from pane-local synthetic rectangles.
51+func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool {
52+ if routed == nil {
53+ return false
54+ }
55+ tb.mu.Lock()
56+ geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)
57+ tb.mu.Unlock()
58+ if !ok || geometry.pane != routed {
59+ ac.overlays.copyMu.Lock()
60+ ac.overlays.invalidateCopyPointerLocked(true)
61+ ac.overlays.copyMu.Unlock()
62+ return true
63+ }
64+ geometry.pane.mu.Lock()
65+ mouseMode, _ := geometry.pane.screen.MouseMode()
66+ altScreen := geometry.pane.screen.AltScreenActive()
67+ snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame)
68+ geometry.pane.mu.Unlock()
69+ if mouseMode != 0 || altScreen {
70+ return false // child forwarding retains its existing raw-byte path.
71+ }
72+ document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators)
73+ mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)
74+ if !ok {
75+ ac.overlays.copyMu.Lock()
76+ ac.overlays.invalidateCopyPointerLocked(true)
77+ ac.overlays.copyMu.Unlock()
78+ return true
79+ }
80+ rt := ac.overlays
81+ rt.copyMu.Lock()
82+ if rt.copyMode != nil || rt.copyCandidate != nil {
83+ rt.copyMu.Unlock()
84+ return true
85+ }
86+ rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
87+ pointer := rt.copyPointer
88+ now := d.clock.Now()
89+ doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now)
90+ if doubleClick {
91+ rt.copyClick = copyClickCandidate{}
92+ } else {
93+ rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now}
94+ }
95+ rt.copyMu.Unlock()
96+ if !doubleClick {
97+ return true
98+ }
99+
100+ selectedWord := false
101+ if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) {
102+ mode.SetPosition(mapped.pos)
103+ selectedWord = mode.SelectWordAt(mapped.pos)
104+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
105+ if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
106+ return
107+ }
108+ pointer.wordDrag = selectedWord
109+ runtime.copyPointer = pointer
110+ }) {
111+ d.invalidateRender(sess, ac, true, "input.go")
112+ }
113+ return true
114+}
115+
116+// handleFreshCopyPointer maps a pre-copy drag against its press-owned document
117+// and geometry. It is intentionally independent of the currently hovered pane.
118+func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool {
119+ rt := ac.overlays
120+ rt.copyMu.Lock()
121+ pointer := rt.copyPointer
122+ epoch := rt.copyPointerEpoch
123+ active := rt.copyMode != nil || rt.copyCandidate != nil
124+ rt.copyMu.Unlock()
125+ if !pointer.valid {
126+ if active && ev.Type == mouse.Release {
127+ rt.copyMu.Lock()
128+ if rt.copyPointerEpoch == epoch {
129+ rt.invalidateCopyPointerLocked(false)
130+ }
131+ rt.copyMu.Unlock()
132+ return true
133+ }
134+ return false
135+ }
136+ if active {
137+ if ev.Type == mouse.Release {
138+ rt.copyMu.Lock()
139+ if rt.copyPointerEpoch == epoch {
140+ rt.invalidateCopyPointerLocked(false)
141+ }
142+ rt.copyMu.Unlock()
143+ return true
144+ }
145+ return false
146+ }
147+ pointer.pane.mu.Lock()
148+ mouseMode, _ := pointer.pane.screen.MouseMode()
149+ altScreen := pointer.pane.screen.AltScreenActive()
150+ pointer.pane.mu.Unlock()
151+ if mouseMode != 0 || altScreen {
152+ rt.copyMu.Lock()
153+ if rt.copyPointerEpoch == epoch {
154+ rt.invalidateCopyPointerLocked(true)
155+ }
156+ rt.copyMu.Unlock()
157+ return false // preserve child forwarding when it enabled mouse reporting.
158+ }
159+ mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion)
160+ if ev.Type == mouse.Motion {
161+ rt.copyMu.Lock()
162+ if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
163+ rt.copyClick.dragged = true
164+ }
165+ rt.copyMu.Unlock()
166+ }
167+ if ev.Type == mouse.Release {
168+ rt.copyMu.Lock()
169+ if rt.copyPointerEpoch == epoch {
170+ rt.invalidateCopyPointerLocked(false)
171+ }
172+ rt.copyMu.Unlock()
173+ return true
174+ }
175+ if !ok {
176+ return true
177+ }
178+ if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) {
179+ mode.StartCharacterSelection(pointer.press)
180+ mode.ExtendCharacterSelection(mapped.pos)
181+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
182+ if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
183+ return
184+ }
185+ pointer.dragging = true
186+ runtime.copyPointer = pointer
187+ }) {
188+ return true
189+ }
190+ d.invalidateRender(sess, ac, true, "input.go")
191+ return true
192+}
193+
194+type copyMouseInputSnapshot struct {
195+ mode *scopy.Mode
196+ pane *pane
197+ document *scopy.Document
198+ viewportTop int
199+ pointer copyPointerState
200+ epoch uint64
201+}
202+
203+// snapshotCopyMouseInput captures every mutable copy-mode value needed for a
204+// mapping while copyMu is held. Mapping must never inspect Mode afterwards.
205+func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) {
206+ if rt == nil {
207+ return copyMouseInputSnapshot{}, false
208+ }
209+ rt.copyMu.Lock()
210+ defer rt.copyMu.Unlock()
211+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
212+ return copyMouseInputSnapshot{}, false
213+ }
214+ return copyMouseInputSnapshot{
215+ mode: rt.copyMode,
216+ pane: rt.copyPane,
217+ document: rt.copyDocument,
218+ viewportTop: rt.copyMode.ViewportTop,
219+ pointer: rt.copyPointer,
220+ epoch: rt.copyPointerEpoch,
221+ }, true
222+}
223+
224+// invalidateSnapshotCopyPointer clears only the pointer captured with the
225+// input snapshot. Mapping runs outside copyMu, so a newer press can otherwise
226+// be erased by an older rejected event.
227+func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) {
228+ rt.copyMu.Lock()
229+ defer rt.copyMu.Unlock()
230+ if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode ||
231+ rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
232+ return
233+ }
234+ rt.invalidateCopyPointerLocked(true)
235+}
236+
237+// handleActiveCopyMouse keeps every drag tied to the pane/document captured at
238+// press time. Geometry is resolved after copyMu is released to preserve the
239+// daemon's copyMu -> tab/pane lock prohibition.
240+func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
241+ rt := ac.overlays
242+ snapshot, active := snapshotCopyMouseInput(rt)
243+ if !active {
244+ return
245+ }
246+ if d.beforeCopyMouseMap != nil {
247+ d.beforeCopyMouseMap()
248+ }
249+ cfg := d.currentFloatingConfig()
250+ // Once pressed, use the committed rectangle captured by that press. A
251+ // later floating/layout change must not turn an in-flight drag into a hit
252+ // on a different pane.
253+ if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
254+ mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
255+ if ev.Type == mouse.Release && !ok {
256+ invalidateSnapshotCopyPointer(rt, snapshot)
257+ return
258+ }
259+ if ok {
260+ d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry)
261+ }
262+ return
263+ }
264+ tb.mu.Lock()
265+ geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
266+ if !ok && ev.Type == mouse.Press {
267+ // Preserve the established press-on-another-pane behavior: copy mode
268+ // exits rather than retargeting its immutable document.
269+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil {
270+ focusPlacementLocked(tb, hit.pane.id)
271+ d.applyLayoutLocked(tb)
272+ }
273+ }
274+ tb.mu.Unlock()
275+ if !ok {
276+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
277+ invalidateSnapshotCopyPointer(rt, snapshot)
278+ }
279+ return
280+ }
281+ clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
282+ mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
283+ if !mappedOK {
284+ if ev.Type == mouse.Press {
285+ otherPane := false
286+ tb.mu.Lock()
287+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
288+ otherPane = true
289+ if tb.tree != nil {
290+ focusPlacementLocked(tb, hit.pane.id)
291+ d.applyLayoutLocked(tb)
292+ }
293+ }
294+ tb.mu.Unlock()
295+ if otherPane {
296+ d.exitCopyMode(ac)
297+ }
298+ }
299+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
300+ invalidateSnapshotCopyPointer(rt, snapshot)
301+ }
302+ return
303+ }
304+ d.copyMouse(sess, ac, ev, mapped, snapshot, geometry)
305+}
@@ -0,0 +1,305 @@
1+// Package daemon holds vev's server-side session multiplexer use case.
2+package daemon
3+
4+import (
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/mouse"
7+)
8+
9+// handleCopyMouse owns copy-mode mouse routing. A release is lifecycle input
10+// even while visual search owns all other copy-mode mouse input.
11+func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) bool {
12+ rt := ac.overlays
13+ if !rt.copyActive() {
14+ return false
15+ }
16+ if ev.Button == mouse.Left && ev.Type == mouse.Release {
17+ rt.copyMu.Lock()
18+ rt.invalidateCopyPointerLocked(false)
19+ rt.copyMu.Unlock()
20+ return true
21+ }
22+ if rt.copySearchActive() {
23+ return true
24+ }
25+ switch ev.Button {
26+ case mouse.WheelUp:
27+ d.copyWheel(sess, ac, -3)
28+ case mouse.WheelDown:
29+ d.copyWheel(sess, ac, 3)
30+ case mouse.Left:
31+ d.handleActiveCopyMouse(sess, ac, tb, ev)
32+ }
33+ return true
34+}
35+
36+// invalidateRejectedLeftPointer clears a stale drag before returning from an
37+// event that cannot be routed to terminal content. It never runs while tab or
38+// pane locks are held, preserving the copyMu -> tab/pane lock prohibition.
39+func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
40+ if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) {
41+ return
42+ }
43+ rt.copyMu.Lock()
44+ rt.invalidateCopyPointerLocked(true)
45+ rt.copyMu.Unlock()
46+}
47+
48+// handleFreshCopyPress is the only normal-screen press path that creates a
49+// selection candidate. Its geometry comes from the same frame-absolute hit
50+// test used by active copy mode, never from pane-local synthetic rectangles.
51+func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool {
52+ if routed == nil {
53+ return false
54+ }
55+ tb.mu.Lock()
56+ geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)
57+ tb.mu.Unlock()
58+ if !ok || geometry.pane != routed {
59+ ac.overlays.copyMu.Lock()
60+ ac.overlays.invalidateCopyPointerLocked(true)
61+ ac.overlays.copyMu.Unlock()
62+ return true
63+ }
64+ geometry.pane.mu.Lock()
65+ mouseMode, _ := geometry.pane.screen.MouseMode()
66+ altScreen := geometry.pane.screen.AltScreenActive()
67+ snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame)
68+ geometry.pane.mu.Unlock()
69+ if mouseMode != 0 || altScreen {
70+ return false // child forwarding retains its existing raw-byte path.
71+ }
72+ document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators)
73+ mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)
74+ if !ok {
75+ ac.overlays.copyMu.Lock()
76+ ac.overlays.invalidateCopyPointerLocked(true)
77+ ac.overlays.copyMu.Unlock()
78+ return true
79+ }
80+ rt := ac.overlays
81+ rt.copyMu.Lock()
82+ if rt.copyMode != nil || rt.copyCandidate != nil {
83+ rt.copyMu.Unlock()
84+ return true
85+ }
86+ rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
87+ pointer := rt.copyPointer
88+ now := d.clock.Now()
89+ doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now)
90+ if doubleClick {
91+ rt.copyClick = copyClickCandidate{}
92+ } else {
93+ rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now}
94+ }
95+ rt.copyMu.Unlock()
96+ if !doubleClick {
97+ return true
98+ }
99+
100+ selectedWord := false
101+ if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) {
102+ mode.SetPosition(mapped.pos)
103+ selectedWord = mode.SelectWordAt(mapped.pos)
104+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
105+ if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
106+ return
107+ }
108+ pointer.wordDrag = selectedWord
109+ runtime.copyPointer = pointer
110+ }) {
111+ d.invalidateRender(sess, ac, true, "input.go")
112+ }
113+ return true
114+}
115+
116+// handleFreshCopyPointer maps a pre-copy drag against its press-owned document
117+// and geometry. It is intentionally independent of the currently hovered pane.
118+func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool {
119+ rt := ac.overlays
120+ rt.copyMu.Lock()
121+ pointer := rt.copyPointer
122+ epoch := rt.copyPointerEpoch
123+ active := rt.copyMode != nil || rt.copyCandidate != nil
124+ rt.copyMu.Unlock()
125+ if !pointer.valid {
126+ if active && ev.Type == mouse.Release {
127+ rt.copyMu.Lock()
128+ if rt.copyPointerEpoch == epoch {
129+ rt.invalidateCopyPointerLocked(false)
130+ }
131+ rt.copyMu.Unlock()
132+ return true
133+ }
134+ return false
135+ }
136+ if active {
137+ if ev.Type == mouse.Release {
138+ rt.copyMu.Lock()
139+ if rt.copyPointerEpoch == epoch {
140+ rt.invalidateCopyPointerLocked(false)
141+ }
142+ rt.copyMu.Unlock()
143+ return true
144+ }
145+ return false
146+ }
147+ pointer.pane.mu.Lock()
148+ mouseMode, _ := pointer.pane.screen.MouseMode()
149+ altScreen := pointer.pane.screen.AltScreenActive()
150+ pointer.pane.mu.Unlock()
151+ if mouseMode != 0 || altScreen {
152+ rt.copyMu.Lock()
153+ if rt.copyPointerEpoch == epoch {
154+ rt.invalidateCopyPointerLocked(true)
155+ }
156+ rt.copyMu.Unlock()
157+ return false // preserve child forwarding when it enabled mouse reporting.
158+ }
159+ mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion)
160+ if ev.Type == mouse.Motion {
161+ rt.copyMu.Lock()
162+ if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
163+ rt.copyClick.dragged = true
164+ }
165+ rt.copyMu.Unlock()
166+ }
167+ if ev.Type == mouse.Release {
168+ rt.copyMu.Lock()
169+ if rt.copyPointerEpoch == epoch {
170+ rt.invalidateCopyPointerLocked(false)
171+ }
172+ rt.copyMu.Unlock()
173+ return true
174+ }
175+ if !ok {
176+ return true
177+ }
178+ if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) {
179+ mode.StartCharacterSelection(pointer.press)
180+ mode.ExtendCharacterSelection(mapped.pos)
181+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
182+ if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
183+ return
184+ }
185+ pointer.dragging = true
186+ runtime.copyPointer = pointer
187+ }) {
188+ return true
189+ }
190+ d.invalidateRender(sess, ac, true, "input.go")
191+ return true
192+}
193+
194+type copyMouseInputSnapshot struct {
195+ mode *scopy.Mode
196+ pane *pane
197+ document *scopy.Document
198+ viewportTop int
199+ pointer copyPointerState
200+ epoch uint64
201+}
202+
203+// snapshotCopyMouseInput captures every mutable copy-mode value needed for a
204+// mapping while copyMu is held. Mapping must never inspect Mode afterwards.
205+func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) {
206+ if rt == nil {
207+ return copyMouseInputSnapshot{}, false
208+ }
209+ rt.copyMu.Lock()
210+ defer rt.copyMu.Unlock()
211+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
212+ return copyMouseInputSnapshot{}, false
213+ }
214+ return copyMouseInputSnapshot{
215+ mode: rt.copyMode,
216+ pane: rt.copyPane,
217+ document: rt.copyDocument,
218+ viewportTop: rt.copyMode.ViewportTop,
219+ pointer: rt.copyPointer,
220+ epoch: rt.copyPointerEpoch,
221+ }, true
222+}
223+
224+// invalidateSnapshotCopyPointer clears only the pointer captured with the
225+// input snapshot. Mapping runs outside copyMu, so a newer press can otherwise
226+// be erased by an older rejected event.
227+func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) {
228+ rt.copyMu.Lock()
229+ defer rt.copyMu.Unlock()
230+ if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode ||
231+ rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
232+ return
233+ }
234+ rt.invalidateCopyPointerLocked(true)
235+}
236+
237+// handleActiveCopyMouse keeps every drag tied to the pane/document captured at
238+// press time. Geometry is resolved after copyMu is released to preserve the
239+// daemon's copyMu -> tab/pane lock prohibition.
240+func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
241+ rt := ac.overlays
242+ snapshot, active := snapshotCopyMouseInput(rt)
243+ if !active {
244+ return
245+ }
246+ if d.beforeCopyMouseMap != nil {
247+ d.beforeCopyMouseMap()
248+ }
249+ cfg := d.currentFloatingConfig()
250+ // Once pressed, use the committed rectangle captured by that press. A
251+ // later floating/layout change must not turn an in-flight drag into a hit
252+ // on a different pane.
253+ if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
254+ mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
255+ if ev.Type == mouse.Release && !ok {
256+ invalidateSnapshotCopyPointer(rt, snapshot)
257+ return
258+ }
259+ if ok {
260+ d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry)
261+ }
262+ return
263+ }
264+ tb.mu.Lock()
265+ geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
266+ if !ok && ev.Type == mouse.Press {
267+ // Preserve the established press-on-another-pane behavior: copy mode
268+ // exits rather than retargeting its immutable document.
269+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil {
270+ focusPlacementLocked(tb, hit.pane.id)
271+ d.applyLayoutLocked(tb)
272+ }
273+ }
274+ tb.mu.Unlock()
275+ if !ok {
276+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
277+ invalidateSnapshotCopyPointer(rt, snapshot)
278+ }
279+ return
280+ }
281+ clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
282+ mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
283+ if !mappedOK {
284+ if ev.Type == mouse.Press {
285+ otherPane := false
286+ tb.mu.Lock()
287+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
288+ otherPane = true
289+ if tb.tree != nil {
290+ focusPlacementLocked(tb, hit.pane.id)
291+ d.applyLayoutLocked(tb)
292+ }
293+ }
294+ tb.mu.Unlock()
295+ if otherPane {
296+ d.exitCopyMode(ac)
297+ }
298+ }
299+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
300+ invalidateSnapshotCopyPointer(rt, snapshot)
301+ }
302+ return
303+ }
304+ d.copyMouse(sess, ac, ev, mapped, snapshot, geometry)
305+}
M internal/usecase/daemon/copymode_mouse_test.go +65 -0
@@ -140,6 +140,71 @@ func TestCopyDoubleClick(t *testing.T) {
140140 })
141141 }
142142
143+func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) {
144+ d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
145+
146+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
147+ ac.overlays.copyMu.Lock()
148+ require.True(t, ac.overlays.copyPointer.valid)
149+ epoch := ac.overlays.copyPointerEpoch
150+ ac.overlays.copyMu.Unlock()
151+
152+ copyMouseInput(d, sess, ac, "/")
153+ require.True(t, ac.overlays.copySearchActive())
154+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2m")
155+
156+ ac.overlays.copyMu.Lock()
157+ require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input")
158+ require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch")
159+ ac.overlays.copyMu.Unlock()
160+
161+ copyMouseInput(d, sess, ac, "\x03")
162+ require.False(t, ac.overlays.copySearchActive())
163+ copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M")
164+
165+ ac.overlays.copyMu.Lock()
166+ selection := ac.overlays.copyMode.Selection()
167+ ac.overlays.copyMu.Unlock()
168+ require.True(t, selection.Enabled)
169+ require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor)
170+ require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active)
171+}
172+
173+func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) {
174+ for _, tc := range []struct {
175+ name string
176+ text string
177+ setup func(*Daemon)
178+ }{
179+ {name: "whitespace", text: "alpha beta"},
180+ {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) {
181+ cfg := domain.Defaults()
182+ cfg.Copy.WordSeparators = "/"
183+ d.ApplyConfig(cfg)
184+ }},
185+ } {
186+ t.Run(tc.name, func(t *testing.T) {
187+ d, sess, ac, clock := mouseCopyHarness(t, tc.text)
188+ d.exitCopyMode(ac)
189+ if tc.setup != nil {
190+ tc.setup(d)
191+ }
192+
193+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
194+ clock.Advance(time.Millisecond)
195+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
196+
197+ ac.overlays.copyMu.Lock()
198+ mode := ac.overlays.copyMode
199+ selection := mode.Selection()
200+ cursor := mode.Cursor()
201+ ac.overlays.copyMu.Unlock()
202+ require.False(t, selection.Enabled)
203+ require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor)
204+ })
205+ }
206+}
207+
143208 func TestCopyWordDragAndOSC52(t *testing.T) {
144209 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
145210 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
@@ -140,6 +140,71 @@ func TestCopyDoubleClick(t *testing.T) {
140 })140 })
141 }141 }
142 142
143+func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) {
144+ d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
145+
146+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
147+ ac.overlays.copyMu.Lock()
148+ require.True(t, ac.overlays.copyPointer.valid)
149+ epoch := ac.overlays.copyPointerEpoch
150+ ac.overlays.copyMu.Unlock()
151+
152+ copyMouseInput(d, sess, ac, "/")
153+ require.True(t, ac.overlays.copySearchActive())
154+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2m")
155+
156+ ac.overlays.copyMu.Lock()
157+ require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input")
158+ require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch")
159+ ac.overlays.copyMu.Unlock()
160+
161+ copyMouseInput(d, sess, ac, "\x03")
162+ require.False(t, ac.overlays.copySearchActive())
163+ copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M")
164+
165+ ac.overlays.copyMu.Lock()
166+ selection := ac.overlays.copyMode.Selection()
167+ ac.overlays.copyMu.Unlock()
168+ require.True(t, selection.Enabled)
169+ require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor)
170+ require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active)
171+}
172+
173+func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) {
174+ for _, tc := range []struct {
175+ name string
176+ text string
177+ setup func(*Daemon)
178+ }{
179+ {name: "whitespace", text: "alpha beta"},
180+ {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) {
181+ cfg := domain.Defaults()
182+ cfg.Copy.WordSeparators = "/"
183+ d.ApplyConfig(cfg)
184+ }},
185+ } {
186+ t.Run(tc.name, func(t *testing.T) {
187+ d, sess, ac, clock := mouseCopyHarness(t, tc.text)
188+ d.exitCopyMode(ac)
189+ if tc.setup != nil {
190+ tc.setup(d)
191+ }
192+
193+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
194+ clock.Advance(time.Millisecond)
195+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
196+
197+ ac.overlays.copyMu.Lock()
198+ mode := ac.overlays.copyMode
199+ selection := mode.Selection()
200+ cursor := mode.Cursor()
201+ ac.overlays.copyMu.Unlock()
202+ require.False(t, selection.Enabled)
203+ require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor)
204+ })
205+ }
206+}
207+
143 func TestCopyWordDragAndOSC52(t *testing.T) {208 func TestCopyWordDragAndOSC52(t *testing.T) {
144 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")209 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
145 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")210 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
M internal/usecase/daemon/input.go +1 -283
@@ -5,7 +5,6 @@ import (
55 "bytes"
66 "strconv"
77
8- scopy "github.com/bnema/vev/internal/usecase/copy"
98 "github.com/bnema/vev/internal/usecase/keys"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/internal/usecase/mouse"
@@ -50,18 +49,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
5049 return
5150 }
5251
53- if rt.copyActive() {
54- if rt.copySearchActive() {
55- return
56- }
57- switch ev.Button {
58- case mouse.WheelUp:
59- d.copyWheel(sess, ac, -3)
60- case mouse.WheelDown:
61- d.copyWheel(sess, ac, 3)
62- case mouse.Left:
63- d.handleActiveCopyMouse(sess, ac, tb, ev)
64- }
52+ if d.handleCopyMouse(sess, ac, tb, ev) {
6553 return
6654 }
6755 // A fresh drag is pinned to its press geometry. Handle later events before
@@ -158,276 +146,6 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
158146 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
159147 }
160148
161-// invalidateRejectedLeftPointer clears a stale drag before returning from an
162-// event that cannot be routed to terminal content. It never runs while tab or
163-// pane locks are held, preserving the copyMu -> tab/pane lock prohibition.
164-func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
165- if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) {
166- return
167- }
168- rt.copyMu.Lock()
169- rt.invalidateCopyPointerLocked(true)
170- rt.copyMu.Unlock()
171-}
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(sess *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(true)
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(true)
202- ac.overlays.copyMu.Unlock()
203- return true
204- }
205- rt := ac.overlays
206- rt.copyMu.Lock()
207- if rt.copyMode != nil || rt.copyCandidate != nil {
208- rt.copyMu.Unlock()
209- return true
210- }
211- rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
212- pointer := rt.copyPointer
213- now := d.clock.Now()
214- doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now)
215- if doubleClick {
216- rt.copyClick = copyClickCandidate{}
217- } else {
218- rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now}
219- }
220- rt.copyMu.Unlock()
221- if !doubleClick {
222- return true
223- }
224-
225- selectedWord := false
226- if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) {
227- selectedWord = mode.SelectWordAt(mapped.pos)
228- }, func(runtime *overlayRuntime, mode *scopy.Mode) {
229- if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
230- return
231- }
232- pointer.wordDrag = selectedWord
233- runtime.copyPointer = pointer
234- }) {
235- d.invalidateRender(sess, ac, true, "input.go")
236- }
237- return true
238-}
239-
240-// handleFreshCopyPointer maps a pre-copy drag against its press-owned document
241-// and geometry. It is intentionally independent of the currently hovered pane.
242-func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool {
243- rt := ac.overlays
244- rt.copyMu.Lock()
245- pointer := rt.copyPointer
246- epoch := rt.copyPointerEpoch
247- active := rt.copyMode != nil || rt.copyCandidate != nil
248- rt.copyMu.Unlock()
249- if !pointer.valid {
250- if active && ev.Type == mouse.Release {
251- rt.copyMu.Lock()
252- if rt.copyPointerEpoch == epoch {
253- rt.invalidateCopyPointerLocked(false)
254- }
255- rt.copyMu.Unlock()
256- return true
257- }
258- return false
259- }
260- if active {
261- if ev.Type == mouse.Release {
262- rt.copyMu.Lock()
263- if rt.copyPointerEpoch == epoch {
264- rt.invalidateCopyPointerLocked(false)
265- }
266- rt.copyMu.Unlock()
267- return true
268- }
269- return false
270- }
271- pointer.pane.mu.Lock()
272- mouseMode, _ := pointer.pane.screen.MouseMode()
273- altScreen := pointer.pane.screen.AltScreenActive()
274- pointer.pane.mu.Unlock()
275- if mouseMode != 0 || altScreen {
276- rt.copyMu.Lock()
277- if rt.copyPointerEpoch == epoch {
278- rt.invalidateCopyPointerLocked(true)
279- }
280- rt.copyMu.Unlock()
281- return false // preserve child forwarding when it enabled mouse reporting.
282- }
283- mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion)
284- if ev.Type == mouse.Motion {
285- rt.copyMu.Lock()
286- if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
287- rt.copyClick.dragged = true
288- }
289- rt.copyMu.Unlock()
290- }
291- if ev.Type == mouse.Release {
292- rt.copyMu.Lock()
293- if rt.copyPointerEpoch == epoch {
294- rt.invalidateCopyPointerLocked(false)
295- }
296- rt.copyMu.Unlock()
297- return true
298- }
299- if !ok {
300- return true
301- }
302- if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) {
303- mode.StartCharacterSelection(pointer.press)
304- mode.ExtendCharacterSelection(mapped.pos)
305- }, func(runtime *overlayRuntime, mode *scopy.Mode) {
306- if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
307- return
308- }
309- pointer.dragging = true
310- runtime.copyPointer = pointer
311- }) {
312- return true
313- }
314- d.invalidateRender(sess, ac, true, "input.go")
315- return true
316-}
317-
318-type copyMouseInputSnapshot struct {
319- mode *scopy.Mode
320- pane *pane
321- document *scopy.Document
322- viewportTop int
323- pointer copyPointerState
324- epoch uint64
325-}
326-
327-// snapshotCopyMouseInput captures every mutable copy-mode value needed for a
328-// mapping while copyMu is held. Mapping must never inspect Mode afterwards.
329-func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) {
330- if rt == nil {
331- return copyMouseInputSnapshot{}, false
332- }
333- rt.copyMu.Lock()
334- defer rt.copyMu.Unlock()
335- if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
336- return copyMouseInputSnapshot{}, false
337- }
338- return copyMouseInputSnapshot{
339- mode: rt.copyMode,
340- pane: rt.copyPane,
341- document: rt.copyDocument,
342- viewportTop: rt.copyMode.ViewportTop,
343- pointer: rt.copyPointer,
344- epoch: rt.copyPointerEpoch,
345- }, true
346-}
347-
348-// invalidateSnapshotCopyPointer clears only the pointer captured with the
349-// input snapshot. Mapping runs outside copyMu, so a newer press can otherwise
350-// be erased by an older rejected event.
351-func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) {
352- rt.copyMu.Lock()
353- defer rt.copyMu.Unlock()
354- if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode ||
355- rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
356- return
357- }
358- rt.invalidateCopyPointerLocked(true)
359-}
360-
361-// handleActiveCopyMouse keeps every drag tied to the pane/document captured at
362-// press time. Geometry is resolved after copyMu is released to preserve the
363-// daemon's copyMu -> tab/pane lock prohibition.
364-func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
365- rt := ac.overlays
366- snapshot, active := snapshotCopyMouseInput(rt)
367- if !active {
368- return
369- }
370- if d.beforeCopyMouseMap != nil {
371- d.beforeCopyMouseMap()
372- }
373- cfg := d.currentFloatingConfig()
374- // Once pressed, use the committed rectangle captured by that press. A
375- // later floating/layout change must not turn an in-flight drag into a hit
376- // on a different pane.
377- if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
378- mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
379- if ev.Type == mouse.Release && !ok {
380- invalidateSnapshotCopyPointer(rt, snapshot)
381- return
382- }
383- if ok {
384- d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry)
385- }
386- return
387- }
388- tb.mu.Lock()
389- geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
390- if !ok && ev.Type == mouse.Press {
391- // Preserve the established press-on-another-pane behavior: copy mode
392- // exits rather than retargeting its immutable document.
393- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil {
394- focusPlacementLocked(tb, hit.pane.id)
395- d.applyLayoutLocked(tb)
396- }
397- }
398- tb.mu.Unlock()
399- if !ok {
400- if ev.Type == mouse.Release || ev.Type == mouse.Press {
401- invalidateSnapshotCopyPointer(rt, snapshot)
402- }
403- return
404- }
405- clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
406- mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
407- if !mappedOK {
408- if ev.Type == mouse.Press {
409- otherPane := false
410- tb.mu.Lock()
411- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
412- otherPane = true
413- if tb.tree != nil {
414- focusPlacementLocked(tb, hit.pane.id)
415- d.applyLayoutLocked(tb)
416- }
417- }
418- tb.mu.Unlock()
419- if otherPane {
420- d.exitCopyMode(ac)
421- }
422- }
423- if ev.Type == mouse.Release || ev.Type == mouse.Press {
424- invalidateSnapshotCopyPointer(rt, snapshot)
425- }
426- return
427- }
428- d.copyMouse(sess, ac, ev, mapped, snapshot, geometry)
429-}
430-
431149 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
432150 if p == nil {
433151 return
@@ -5,7 +5,6 @@ import (
5 "bytes"5 "bytes"
6 "strconv"6 "strconv"
7 7
8- scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/bnema/vev/internal/usecase/keys"8 "github.com/bnema/vev/internal/usecase/keys"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/mouse"10 "github.com/bnema/vev/internal/usecase/mouse"
@@ -50,18 +49,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
50 return49 return
51 }50 }
52 51
53- if rt.copyActive() {52+ if d.handleCopyMouse(sess, ac, tb, ev) {
54- if rt.copySearchActive() {
55- return
56- }
57- switch ev.Button {
58- case mouse.WheelUp:
59- d.copyWheel(sess, ac, -3)
60- case mouse.WheelDown:
61- d.copyWheel(sess, ac, 3)
62- case mouse.Left:
63- d.handleActiveCopyMouse(sess, ac, tb, ev)
64- }
65 return53 return
66 }54 }
67 // A fresh drag is pinned to its press geometry. Handle later events before55 // A fresh drag is pinned to its press geometry. Handle later events before
@@ -158,276 +146,6 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
158 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)146 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
159 }147 }
160 148
161-// invalidateRejectedLeftPointer clears a stale drag before returning from an
162-// event that cannot be routed to terminal content. It never runs while tab or
163-// pane locks are held, preserving the copyMu -> tab/pane lock prohibition.
164-func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
165- if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) {
166- return
167- }
168- rt.copyMu.Lock()
169- rt.invalidateCopyPointerLocked(true)
170- rt.copyMu.Unlock()
171-}
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(sess *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(true)
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(true)
202- ac.overlays.copyMu.Unlock()
203- return true
204- }
205- rt := ac.overlays
206- rt.copyMu.Lock()
207- if rt.copyMode != nil || rt.copyCandidate != nil {
208- rt.copyMu.Unlock()
209- return true
210- }
211- rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
212- pointer := rt.copyPointer
213- now := d.clock.Now()
214- doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now)
215- if doubleClick {
216- rt.copyClick = copyClickCandidate{}
217- } else {
218- rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now}
219- }
220- rt.copyMu.Unlock()
221- if !doubleClick {
222- return true
223- }
224-
225- selectedWord := false
226- if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) {
227- selectedWord = mode.SelectWordAt(mapped.pos)
228- }, func(runtime *overlayRuntime, mode *scopy.Mode) {
229- if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
230- return
231- }
232- pointer.wordDrag = selectedWord
233- runtime.copyPointer = pointer
234- }) {
235- d.invalidateRender(sess, ac, true, "input.go")
236- }
237- return true
238-}
239-
240-// handleFreshCopyPointer maps a pre-copy drag against its press-owned document
241-// and geometry. It is intentionally independent of the currently hovered pane.
242-func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool {
243- rt := ac.overlays
244- rt.copyMu.Lock()
245- pointer := rt.copyPointer
246- epoch := rt.copyPointerEpoch
247- active := rt.copyMode != nil || rt.copyCandidate != nil
248- rt.copyMu.Unlock()
249- if !pointer.valid {
250- if active && ev.Type == mouse.Release {
251- rt.copyMu.Lock()
252- if rt.copyPointerEpoch == epoch {
253- rt.invalidateCopyPointerLocked(false)
254- }
255- rt.copyMu.Unlock()
256- return true
257- }
258- return false
259- }
260- if active {
261- if ev.Type == mouse.Release {
262- rt.copyMu.Lock()
263- if rt.copyPointerEpoch == epoch {
264- rt.invalidateCopyPointerLocked(false)
265- }
266- rt.copyMu.Unlock()
267- return true
268- }
269- return false
270- }
271- pointer.pane.mu.Lock()
272- mouseMode, _ := pointer.pane.screen.MouseMode()
273- altScreen := pointer.pane.screen.AltScreenActive()
274- pointer.pane.mu.Unlock()
275- if mouseMode != 0 || altScreen {
276- rt.copyMu.Lock()
277- if rt.copyPointerEpoch == epoch {
278- rt.invalidateCopyPointerLocked(true)
279- }
280- rt.copyMu.Unlock()
281- return false // preserve child forwarding when it enabled mouse reporting.
282- }
283- mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion)
284- if ev.Type == mouse.Motion {
285- rt.copyMu.Lock()
286- if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
287- rt.copyClick.dragged = true
288- }
289- rt.copyMu.Unlock()
290- }
291- if ev.Type == mouse.Release {
292- rt.copyMu.Lock()
293- if rt.copyPointerEpoch == epoch {
294- rt.invalidateCopyPointerLocked(false)
295- }
296- rt.copyMu.Unlock()
297- return true
298- }
299- if !ok {
300- return true
301- }
302- if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) {
303- mode.StartCharacterSelection(pointer.press)
304- mode.ExtendCharacterSelection(mapped.pos)
305- }, func(runtime *overlayRuntime, mode *scopy.Mode) {
306- if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
307- return
308- }
309- pointer.dragging = true
310- runtime.copyPointer = pointer
311- }) {
312- return true
313- }
314- d.invalidateRender(sess, ac, true, "input.go")
315- return true
316-}
317-
318-type copyMouseInputSnapshot struct {
319- mode *scopy.Mode
320- pane *pane
321- document *scopy.Document
322- viewportTop int
323- pointer copyPointerState
324- epoch uint64
325-}
326-
327-// snapshotCopyMouseInput captures every mutable copy-mode value needed for a
328-// mapping while copyMu is held. Mapping must never inspect Mode afterwards.
329-func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) {
330- if rt == nil {
331- return copyMouseInputSnapshot{}, false
332- }
333- rt.copyMu.Lock()
334- defer rt.copyMu.Unlock()
335- if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
336- return copyMouseInputSnapshot{}, false
337- }
338- return copyMouseInputSnapshot{
339- mode: rt.copyMode,
340- pane: rt.copyPane,
341- document: rt.copyDocument,
342- viewportTop: rt.copyMode.ViewportTop,
343- pointer: rt.copyPointer,
344- epoch: rt.copyPointerEpoch,
345- }, true
346-}
347-
348-// invalidateSnapshotCopyPointer clears only the pointer captured with the
349-// input snapshot. Mapping runs outside copyMu, so a newer press can otherwise
350-// be erased by an older rejected event.
351-func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) {
352- rt.copyMu.Lock()
353- defer rt.copyMu.Unlock()
354- if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode ||
355- rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
356- return
357- }
358- rt.invalidateCopyPointerLocked(true)
359-}
360-
361-// handleActiveCopyMouse keeps every drag tied to the pane/document captured at
362-// press time. Geometry is resolved after copyMu is released to preserve the
363-// daemon's copyMu -> tab/pane lock prohibition.
364-func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
365- rt := ac.overlays
366- snapshot, active := snapshotCopyMouseInput(rt)
367- if !active {
368- return
369- }
370- if d.beforeCopyMouseMap != nil {
371- d.beforeCopyMouseMap()
372- }
373- cfg := d.currentFloatingConfig()
374- // Once pressed, use the committed rectangle captured by that press. A
375- // later floating/layout change must not turn an in-flight drag into a hit
376- // on a different pane.
377- if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document {
378- mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion)
379- if ev.Type == mouse.Release && !ok {
380- invalidateSnapshotCopyPointer(rt, snapshot)
381- return
382- }
383- if ok {
384- d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry)
385- }
386- return
387- }
388- tb.mu.Lock()
389- geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
390- if !ok && ev.Type == mouse.Press {
391- // Preserve the established press-on-another-pane behavior: copy mode
392- // exits rather than retargeting its immutable document.
393- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil {
394- focusPlacementLocked(tb, hit.pane.id)
395- d.applyLayoutLocked(tb)
396- }
397- }
398- tb.mu.Unlock()
399- if !ok {
400- if ev.Type == mouse.Release || ev.Type == mouse.Press {
401- invalidateSnapshotCopyPointer(rt, snapshot)
402- }
403- return
404- }
405- clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
406- mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp)
407- if !mappedOK {
408- if ev.Type == mouse.Press {
409- otherPane := false
410- tb.mu.Lock()
411- if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
412- otherPane = true
413- if tb.tree != nil {
414- focusPlacementLocked(tb, hit.pane.id)
415- d.applyLayoutLocked(tb)
416- }
417- }
418- tb.mu.Unlock()
419- if otherPane {
420- d.exitCopyMode(ac)
421- }
422- }
423- if ev.Type == mouse.Release || ev.Type == mouse.Press {
424- invalidateSnapshotCopyPointer(rt, snapshot)
425- }
426- return
427- }
428- d.copyMouse(sess, ac, ev, mapped, snapshot, geometry)
429-}
430-
431 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {149 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
432 if p == nil {150 if p == nil {
433 return151 return

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