detached b30c769

fix(daemon): centralize copy mouse coordinates

commit
b30c7694f476925458a566eb5046d3f56a25df96
author
brice · · 2026-07-15 07:49 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
71f5218
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +455 -161
A internal/usecase/daemon/copy_mouse_map.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/mouse"
7+)
8+
9+// clientTopBarRows is deliberately kept at the client/frame boundary. Layout
10+// coordinates are content-relative and must cross this boundary exactly once.
11+const clientTopBarRows = 1
12+
13+type copyMouseGeometry struct {
14+ pane *pane
15+ content domain.Rect // absolute client-frame coordinates
16+}
17+
18+type mappedCopyMouse struct {
19+ pane *pane
20+ pos scopy.Pos
21+}
22+
23+func copyContentToClientRect(content domain.Rect) domain.Rect {
24+ content.Y += clientTopBarRows
25+ return content
26+}
27+
28+func mapCopyMouse(ev mouse.Event, geometry copyMouseGeometry, viewportTop int, document *scopy.Document, clamp bool) (mappedCopyMouse, bool) {
29+ if document == nil || geometry.pane == nil || geometry.content.Width <= 0 || geometry.content.Height <= 0 {
30+ return mappedCopyMouse{}, false
31+ }
32+ col, row := ev.Col, ev.Row
33+ if clamp {
34+ col = clampInt(col, geometry.content.X, geometry.content.X+geometry.content.Width-1)
35+ row = clampInt(row, geometry.content.Y, geometry.content.Y+geometry.content.Height-1)
36+ } else if !pointInRect(col, row, geometry.content) {
37+ return mappedCopyMouse{}, false
38+ }
39+ pos, ok := document.Normalize(scopy.Pos{Row: viewportTop + row - geometry.content.Y, Col: col - geometry.content.X})
40+ if !ok {
41+ return mappedCopyMouse{}, false
42+ }
43+ return mappedCopyMouse{pane: geometry.pane, pos: pos}, true
44+}
45+
46+// hitTestCopyMouseGeometryLocked resolves a new press. Floating geometry is
47+// exclusive while visible; normal title bars and dividers are intentionally
48+// not copy content. Caller holds tb.mu.
49+func hitTestCopyMouseGeometryLocked(tb *tab, cfg domain.FloatingConfig, col, row int) (copyMouseGeometry, bool) {
50+ if tb == nil || row < clientTopBarRows {
51+ return copyMouseGeometry{}, false
52+ }
53+ if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible {
54+ content := copyContentToClientRect(geometry.Inner)
55+ if !pointInRect(col, row, content) {
56+ return copyMouseGeometry{}, false
57+ }
58+ return copyMouseGeometry{pane: p, content: content}, true
59+ }
60+ pl, ok := hitTestPlacementLocked(tb, col, row-clientTopBarRows)
61+ if !ok || pl.Collapsed || !pointInRect(col, row-clientTopBarRows, pl.Content) {
62+ return copyMouseGeometry{}, false
63+ }
64+ p := tb.panes[pl.ID]
65+ if p == nil {
66+ return copyMouseGeometry{}, false
67+ }
68+ return copyMouseGeometry{pane: p, content: copyContentToClientRect(pl.Content)}, true
69+}
70+
71+// copyMouseGeometryForPaneLocked resolves geometry for an existing drag. It
72+// never hit-tests the new pointer, preventing cross-pane document switches.
73+func copyMouseGeometryForPaneLocked(tb *tab, cfg domain.FloatingConfig, target *pane) (copyMouseGeometry, bool) {
74+ if tb == nil || target == nil {
75+ return copyMouseGeometry{}, false
76+ }
77+ if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible {
78+ if p != target {
79+ return copyMouseGeometry{}, false
80+ }
81+ return copyMouseGeometry{pane: p, content: copyContentToClientRect(geometry.Inner)}, true
82+ }
83+ if tb.panes[target.id] != target {
84+ return copyMouseGeometry{}, false
85+ }
86+ placements, ok := solvedPlacementsLocked(tb)
87+ if !ok {
88+ return copyMouseGeometry{}, false
89+ }
90+ for _, pl := range placements {
91+ if pl.ID == target.id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
92+ return copyMouseGeometry{pane: target, content: copyContentToClientRect(pl.Content)}, true
93+ }
94+ }
95+ return copyMouseGeometry{}, false
96+}
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/mouse"
7+)
8+
9+// clientTopBarRows is deliberately kept at the client/frame boundary. Layout
10+// coordinates are content-relative and must cross this boundary exactly once.
11+const clientTopBarRows = 1
12+
13+type copyMouseGeometry struct {
14+ pane *pane
15+ content domain.Rect // absolute client-frame coordinates
16+}
17+
18+type mappedCopyMouse struct {
19+ pane *pane
20+ pos scopy.Pos
21+}
22+
23+func copyContentToClientRect(content domain.Rect) domain.Rect {
24+ content.Y += clientTopBarRows
25+ return content
26+}
27+
28+func mapCopyMouse(ev mouse.Event, geometry copyMouseGeometry, viewportTop int, document *scopy.Document, clamp bool) (mappedCopyMouse, bool) {
29+ if document == nil || geometry.pane == nil || geometry.content.Width <= 0 || geometry.content.Height <= 0 {
30+ return mappedCopyMouse{}, false
31+ }
32+ col, row := ev.Col, ev.Row
33+ if clamp {
34+ col = clampInt(col, geometry.content.X, geometry.content.X+geometry.content.Width-1)
35+ row = clampInt(row, geometry.content.Y, geometry.content.Y+geometry.content.Height-1)
36+ } else if !pointInRect(col, row, geometry.content) {
37+ return mappedCopyMouse{}, false
38+ }
39+ pos, ok := document.Normalize(scopy.Pos{Row: viewportTop + row - geometry.content.Y, Col: col - geometry.content.X})
40+ if !ok {
41+ return mappedCopyMouse{}, false
42+ }
43+ return mappedCopyMouse{pane: geometry.pane, pos: pos}, true
44+}
45+
46+// hitTestCopyMouseGeometryLocked resolves a new press. Floating geometry is
47+// exclusive while visible; normal title bars and dividers are intentionally
48+// not copy content. Caller holds tb.mu.
49+func hitTestCopyMouseGeometryLocked(tb *tab, cfg domain.FloatingConfig, col, row int) (copyMouseGeometry, bool) {
50+ if tb == nil || row < clientTopBarRows {
51+ return copyMouseGeometry{}, false
52+ }
53+ if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible {
54+ content := copyContentToClientRect(geometry.Inner)
55+ if !pointInRect(col, row, content) {
56+ return copyMouseGeometry{}, false
57+ }
58+ return copyMouseGeometry{pane: p, content: content}, true
59+ }
60+ pl, ok := hitTestPlacementLocked(tb, col, row-clientTopBarRows)
61+ if !ok || pl.Collapsed || !pointInRect(col, row-clientTopBarRows, pl.Content) {
62+ return copyMouseGeometry{}, false
63+ }
64+ p := tb.panes[pl.ID]
65+ if p == nil {
66+ return copyMouseGeometry{}, false
67+ }
68+ return copyMouseGeometry{pane: p, content: copyContentToClientRect(pl.Content)}, true
69+}
70+
71+// copyMouseGeometryForPaneLocked resolves geometry for an existing drag. It
72+// never hit-tests the new pointer, preventing cross-pane document switches.
73+func copyMouseGeometryForPaneLocked(tb *tab, cfg domain.FloatingConfig, target *pane) (copyMouseGeometry, bool) {
74+ if tb == nil || target == nil {
75+ return copyMouseGeometry{}, false
76+ }
77+ if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible {
78+ if p != target {
79+ return copyMouseGeometry{}, false
80+ }
81+ return copyMouseGeometry{pane: p, content: copyContentToClientRect(geometry.Inner)}, true
82+ }
83+ if tb.panes[target.id] != target {
84+ return copyMouseGeometry{}, false
85+ }
86+ placements, ok := solvedPlacementsLocked(tb)
87+ if !ok {
88+ return copyMouseGeometry{}, false
89+ }
90+ for _, pl := range placements {
91+ if pl.ID == target.id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
92+ return copyMouseGeometry{pane: target, content: copyContentToClientRect(pl.Content)}, true
93+ }
94+ }
95+ return copyMouseGeometry{}, false
96+}
A internal/usecase/daemon/copy_mouse_map_test.go +84 -0
@@ -0,0 +1,84 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+ "github.com/bnema/vev/internal/usecase/mouse"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func TestCopyMouseMapTopBarAndContent(t *testing.T) {
15+ p := newPane("pane", nil, domain.Size{Cols: 5, Rows: 2})
16+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("bravo")}, 5, 2), domain.DefaultWordSeparators)
17+ geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: 1, Width: 5, Height: 2}}
18+
19+ _, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 0}, geometry, 0, doc, false)
20+ require.False(t, ok)
21+ mapped, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, doc, false)
22+ require.True(t, ok)
23+ require.Same(t, p, mapped.pane)
24+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, mapped.pos)
25+ mapped, ok = mapCopyMouse(mouse.Event{Col: 4, Row: 2}, geometry, 0, doc, false)
26+ require.True(t, ok)
27+ require.Equal(t, scopy.Pos{Row: 1, Col: 4}, mapped.pos)
28+ _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 3}, geometry, 0, doc, false)
29+ require.False(t, ok, "bottom status is not content")
30+}
31+
32+func TestCopyMouseMapClampsAndNormalizesWideCells(t *testing.T) {
33+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
34+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}}, 4, 1), domain.DefaultWordSeparators)
35+ geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 3, Y: 1, Width: 4, Height: 1}}
36+
37+ mapped, ok := mapCopyMouse(mouse.Event{Col: 5, Row: 1}, geometry, 0, doc, false)
38+ require.True(t, ok)
39+ require.Equal(t, scopy.Pos{Row: 0, Col: 1}, mapped.pos)
40+ mapped, ok = mapCopyMouse(mouse.Event{Col: 99, Row: 99}, geometry, 0, doc, true)
41+ require.True(t, ok)
42+ require.Equal(t, scopy.Pos{Row: 0, Col: 3}, mapped.pos)
43+}
44+
45+func TestCopyMouseMapSplitAndFloatingRects(t *testing.T) {
46+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 2})
47+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("abcd"), testRow("efgh")}, 4, 2), domain.DefaultWordSeparators)
48+ for _, tc := range []struct {
49+ name string
50+ geometry domain.Rect
51+ ev mouse.Event
52+ want scopy.Pos
53+ ok bool
54+ }{
55+ {"split content", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 23, Row: 3}, scopy.Pos{Row: 1, Col: 2}, true},
56+ {"split title", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 21, Row: 1}, scopy.Pos{}, false},
57+ {"floating inner", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 11, Row: 5}, scopy.Pos{Row: 0, Col: 1}, true},
58+ {"floating border", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 9, Row: 5}, scopy.Pos{}, false},
59+ } {
60+ t.Run(tc.name, func(t *testing.T) {
61+ mapped, ok := mapCopyMouse(tc.ev, copyMouseGeometry{pane: p, content: tc.geometry}, 0, doc, false)
62+ require.Equal(t, tc.ok, ok)
63+ if ok {
64+ require.Equal(t, tc.want, mapped.pos)
65+ }
66+ })
67+ }
68+}
69+
70+func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) {
71+ p, release := newBlockingPTY(t)
72+ defer release()
73+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
74+ tb := sess.activeTab()
75+
76+ tb.mu.Lock()
77+ geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1)
78+ tb.mu.Unlock()
79+ require.True(t, ok)
80+ require.Equal(t, 1, geometry.content.Y)
81+ _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0,
82+ scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("a")}, 80, 1), domain.DefaultWordSeparators), false)
83+ require.True(t, ok)
84+}
@@ -0,0 +1,84 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+ "github.com/bnema/vev/internal/usecase/mouse"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func TestCopyMouseMapTopBarAndContent(t *testing.T) {
15+ p := newPane("pane", nil, domain.Size{Cols: 5, Rows: 2})
16+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("bravo")}, 5, 2), domain.DefaultWordSeparators)
17+ geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: 1, Width: 5, Height: 2}}
18+
19+ _, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 0}, geometry, 0, doc, false)
20+ require.False(t, ok)
21+ mapped, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, doc, false)
22+ require.True(t, ok)
23+ require.Same(t, p, mapped.pane)
24+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, mapped.pos)
25+ mapped, ok = mapCopyMouse(mouse.Event{Col: 4, Row: 2}, geometry, 0, doc, false)
26+ require.True(t, ok)
27+ require.Equal(t, scopy.Pos{Row: 1, Col: 4}, mapped.pos)
28+ _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 3}, geometry, 0, doc, false)
29+ require.False(t, ok, "bottom status is not content")
30+}
31+
32+func TestCopyMouseMapClampsAndNormalizesWideCells(t *testing.T) {
33+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
34+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}}, 4, 1), domain.DefaultWordSeparators)
35+ geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 3, Y: 1, Width: 4, Height: 1}}
36+
37+ mapped, ok := mapCopyMouse(mouse.Event{Col: 5, Row: 1}, geometry, 0, doc, false)
38+ require.True(t, ok)
39+ require.Equal(t, scopy.Pos{Row: 0, Col: 1}, mapped.pos)
40+ mapped, ok = mapCopyMouse(mouse.Event{Col: 99, Row: 99}, geometry, 0, doc, true)
41+ require.True(t, ok)
42+ require.Equal(t, scopy.Pos{Row: 0, Col: 3}, mapped.pos)
43+}
44+
45+func TestCopyMouseMapSplitAndFloatingRects(t *testing.T) {
46+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 2})
47+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("abcd"), testRow("efgh")}, 4, 2), domain.DefaultWordSeparators)
48+ for _, tc := range []struct {
49+ name string
50+ geometry domain.Rect
51+ ev mouse.Event
52+ want scopy.Pos
53+ ok bool
54+ }{
55+ {"split content", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 23, Row: 3}, scopy.Pos{Row: 1, Col: 2}, true},
56+ {"split title", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 21, Row: 1}, scopy.Pos{}, false},
57+ {"floating inner", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 11, Row: 5}, scopy.Pos{Row: 0, Col: 1}, true},
58+ {"floating border", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 9, Row: 5}, scopy.Pos{}, false},
59+ } {
60+ t.Run(tc.name, func(t *testing.T) {
61+ mapped, ok := mapCopyMouse(tc.ev, copyMouseGeometry{pane: p, content: tc.geometry}, 0, doc, false)
62+ require.Equal(t, tc.ok, ok)
63+ if ok {
64+ require.Equal(t, tc.want, mapped.pos)
65+ }
66+ })
67+ }
68+}
69+
70+func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) {
71+ p, release := newBlockingPTY(t)
72+ defer release()
73+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
74+ tb := sess.activeTab()
75+
76+ tb.mu.Lock()
77+ geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1)
78+ tb.mu.Unlock()
79+ require.True(t, ok)
80+ require.Equal(t, 1, geometry.content.Y)
81+ _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0,
82+ scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("a")}, 80, 1), domain.DefaultWordSeparators), false)
83+ require.True(t, ok)
84+}
M internal/usecase/daemon/copymode.go +23 -28
@@ -91,9 +91,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
9191 rt.copyPane = p
9292 rt.copySearch = nil
9393 rt.copySearchPending = nil
94- rt.copyPressRowValid = false
95- rt.copyDragging = false
96- rt.normalMousePressValid = false
94+ if activate != nil {
95+ rt.clearCopyPointerForTransferLocked()
96+ } else {
97+ rt.invalidateCopyPointerLocked(true)
98+ }
9799 rt.copyMu.Unlock()
98100 valid := sess.activeTab() == tb
99101 if valid {
@@ -118,48 +120,41 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
118120 return true
119121 }
120122
121-// copyMouse remains row-compatible until centralized pointer mapping supplies columns.
122-func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
123+// copyMouse receives already mapped client-frame coordinates. The caller owns
124+// geometry resolution; this function only mutates immutable-document state.
125+func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse) {
123126 rt := ac.overlays
124127 if ev.Button != mouse.Left {
125128 return
126129 }
127130 rt.copyMu.Lock()
128- if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
129- rt.copyMu.Unlock()
130- return
131- }
132- document := rt.copyDocument
133- if ev.Row >= document.Height() {
134- if ev.Type == mouse.Press {
135- rt.copyPressRowValid = false
136- rt.copyDragging = false
131+ if rt.copyMode == nil || rt.copyPane != mapped.pane || rt.copyDocument == nil || rt.copyDocument != rt.copyMode.Document() {
132+ if ev.Type == mouse.Release {
133+ rt.invalidateCopyPointerLocked(false)
137134 }
138135 rt.copyMu.Unlock()
139136 return
140137 }
141- pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0}
142138 changed := false
143139 switch ev.Type {
144140 case mouse.Press:
145- if rt.copyMode.SetPosition(pos) {
146- changed = true
141+ changed = rt.copyMode.SetPosition(mapped.pos)
142+ pointer := rt.copyPointer
143+ if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {
144+ rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, press: mapped.pos})
147145 }
148- rt.copyPressRow = rt.copyMode.Cursor().Row
149- rt.copyPressRowValid = true
150- rt.copyDragging = false
151146 case mouse.Motion:
152- if rt.copyPressRowValid {
153- if !rt.copyDragging {
154- rt.copyMode.SetPosition(scopy.Pos{Row: rt.copyPressRow, Col: 0})
155- rt.copyMode.ToggleLineSelection()
156- rt.copyDragging = true
147+ pointer := rt.copyPointer
148+ if pointer.valid && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
149+ if !pointer.dragging {
150+ rt.copyMode.StartCharacterSelection(pointer.press)
151+ pointer.dragging = true
157152 }
158- changed = rt.copyMode.MoveRows(pos.Row-rt.copyMode.Cursor().Row) || changed
153+ changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
154+ rt.copyPointer = pointer
159155 }
160156 case mouse.Release:
161- rt.copyPressRowValid = false
162- rt.copyDragging = false
157+ rt.invalidateCopyPointerLocked(false)
163158 }
164159 rt.copyMu.Unlock()
165160 if changed {
@@ -91,9 +91,11 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
91 rt.copyPane = p91 rt.copyPane = p
92 rt.copySearch = nil92 rt.copySearch = nil
93 rt.copySearchPending = nil93 rt.copySearchPending = nil
94- rt.copyPressRowValid = false94+ if activate != nil {
95- rt.copyDragging = false95+ rt.clearCopyPointerForTransferLocked()
96- rt.normalMousePressValid = false96+ } else {
97+ rt.invalidateCopyPointerLocked(true)
98+ }
97 rt.copyMu.Unlock()99 rt.copyMu.Unlock()
98 valid := sess.activeTab() == tb100 valid := sess.activeTab() == tb
99 if valid {101 if valid {
@@ -118,48 +120,41 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
118 return true120 return true
119 }121 }
120 122
121-// copyMouse remains row-compatible until centralized pointer mapping supplies columns.123+// copyMouse receives already mapped client-frame coordinates. The caller owns
122-func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {124+// geometry resolution; this function only mutates immutable-document state.
125+func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse) {
123 rt := ac.overlays126 rt := ac.overlays
124 if ev.Button != mouse.Left {127 if ev.Button != mouse.Left {
125 return128 return
126 }129 }
127 rt.copyMu.Lock()130 rt.copyMu.Lock()
128- if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {131+ if rt.copyMode == nil || rt.copyPane != mapped.pane || rt.copyDocument == nil || rt.copyDocument != rt.copyMode.Document() {
129- rt.copyMu.Unlock()132+ if ev.Type == mouse.Release {
130- return133+ rt.invalidateCopyPointerLocked(false)
131- }
132- document := rt.copyDocument
133- if ev.Row >= document.Height() {
134- if ev.Type == mouse.Press {
135- rt.copyPressRowValid = false
136- rt.copyDragging = false
137 }134 }
138 rt.copyMu.Unlock()135 rt.copyMu.Unlock()
139 return136 return
140 }137 }
141- pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0}
142 changed := false138 changed := false
143 switch ev.Type {139 switch ev.Type {
144 case mouse.Press:140 case mouse.Press:
145- if rt.copyMode.SetPosition(pos) {141+ changed = rt.copyMode.SetPosition(mapped.pos)
146- changed = true142+ pointer := rt.copyPointer
143+ if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {
144+ rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, press: mapped.pos})
147 }145 }
148- rt.copyPressRow = rt.copyMode.Cursor().Row
149- rt.copyPressRowValid = true
150- rt.copyDragging = false
151 case mouse.Motion:146 case mouse.Motion:
152- if rt.copyPressRowValid {147+ pointer := rt.copyPointer
153- if !rt.copyDragging {148+ if pointer.valid && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
154- rt.copyMode.SetPosition(scopy.Pos{Row: rt.copyPressRow, Col: 0})149+ if !pointer.dragging {
155- rt.copyMode.ToggleLineSelection()150+ rt.copyMode.StartCharacterSelection(pointer.press)
156- rt.copyDragging = true151+ pointer.dragging = true
157 }152 }
158- changed = rt.copyMode.MoveRows(pos.Row-rt.copyMode.Cursor().Row) || changed153+ changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
154+ rt.copyPointer = pointer
159 }155 }
160 case mouse.Release:156 case mouse.Release:
161- rt.copyPressRowValid = false157+ rt.invalidateCopyPointerLocked(false)
162- rt.copyDragging = false
163 }158 }
164 rt.copyMu.Unlock()159 rt.copyMu.Unlock()
165 if changed {160 if changed {
M internal/usecase/daemon/copymode_test.go +2 -2
@@ -864,7 +864,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
864864 copy(pane.screen.Frame.Row(0), testRow("alpha"))
865865 copy(pane.screen.Frame.Row(1), testRow("bravo"))
866866
867- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
867+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
868868 mustOutputData(t, sends)
869869 require.NotNil(t, ac.overlays.copyMode)
870870 ac.overlays.copyMu.Lock()
@@ -876,6 +876,6 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
876876 out := awaitFrame(t, sends, ports.MsgOutput)
877877 msg, err := ports.UnmarshalOutput(out.Payload)
878878 require.NoError(t, err)
879- want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07"
879+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
880880 require.Equal(t, want, string(msg.Data))
881881 }
@@ -864,7 +864,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
864 copy(pane.screen.Frame.Row(0), testRow("alpha"))864 copy(pane.screen.Frame.Row(0), testRow("alpha"))
865 copy(pane.screen.Frame.Row(1), testRow("bravo"))865 copy(pane.screen.Frame.Row(1), testRow("bravo"))
866 866
867- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))867+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
868 mustOutputData(t, sends)868 mustOutputData(t, sends)
869 require.NotNil(t, ac.overlays.copyMode)869 require.NotNil(t, ac.overlays.copyMode)
870 ac.overlays.copyMu.Lock()870 ac.overlays.copyMu.Lock()
@@ -876,6 +876,6 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
876 out := awaitFrame(t, sends, ports.MsgOutput)876 out := awaitFrame(t, sends, ports.MsgOutput)
877 msg, err := ports.UnmarshalOutput(out.Payload)877 msg, err := ports.UnmarshalOutput(out.Payload)
878 require.NoError(t, err)878 require.NoError(t, err)
879- want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07"879+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
880 require.Equal(t, want, string(msg.Data))880 require.Equal(t, want, string(msg.Data))
881 }881 }
M internal/usecase/daemon/input.go +125 -94
@@ -5,6 +5,7 @@ import (
55 "bytes"
66 "strconv"
77
8+ "github.com/bnema/vev/internal/domain"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
910 "github.com/bnema/vev/internal/usecase/keys"
1011 "github.com/bnema/vev/internal/usecase/layout"
@@ -51,70 +52,13 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
5152 if rt.copySearchActive() {
5253 return
5354 }
54- copyPane := copyTargetPane(rt)
55- tb.mu.Lock()
56- floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
57- floatingCopy := floatingVisible && copyPane == floatingPane
58- tb.mu.Unlock()
59- if floatingCopy {
60- contentRow := ev.Row - 1
61- if !pointInRect(ev.Col, contentRow, geometry.Inner) {
62- return
63- }
64- // Match the multi-pane translation: hand copyMouse a zero-based
65- // popup row so a click selects exactly the row under the pointer.
66- ev.Row = contentRow
67- ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
68- switch ev.Button {
69- case mouse.Left:
70- d.copyMouse(sess, ac, ev)
71- case mouse.WheelUp:
72- d.copyWheel(sess, ac, -3)
73- case mouse.WheelDown:
74- d.copyWheel(sess, ac, 3)
75- }
76- return
77- }
78- if ev.Button == mouse.Left {
79- contentRow := ev.Row - 1
80- tb.mu.Lock()
81- p := tb.focusedPane()
82- var pl layout.Placement
83- var ok bool
84- multi := len(tb.panes) > 1
85- if p != nil {
86- pl, ok = focusedPlacementLocked(tb)
87- }
88- if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
89- if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
90- oldFocus := tb.tree.Focus
91- focusPlacementLocked(tb, target.ID)
92- d.applyLayoutLocked(tb)
93- tb.mu.Unlock()
94- d.exitCopyMode(ac)
95- if target.ID != oldFocus {
96- d.refreshPaneTitleOnFocus(sess, target.ID)
97- }
98- d.invalidateRender(sess, ac, true, "input.go")
99- return
100- }
101- }
102- tb.mu.Unlock()
103- if ok && multi {
104- clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
105- clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
106- ev.Col = clampedCol
107- ev.Row = clampedRow
108- ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
109- }
110- }
11155 switch ev.Button {
112- case mouse.Left:
113- d.copyMouse(sess, ac, ev)
11456 case mouse.WheelUp:
11557 d.copyWheel(sess, ac, -3)
11658 case mouse.WheelDown:
11759 d.copyWheel(sess, ac, 3)
60+ case mouse.Left:
61+ d.handleActiveCopyMouse(sess, ac, tb, ev)
11862 }
11963 return
12064 }
@@ -192,6 +136,92 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
192136 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
193137 }
194138
139+// handleActiveCopyMouse keeps every drag tied to the pane/document captured at
140+// press time. Geometry is resolved after copyMu is released to preserve the
141+// daemon's copyMu -> tab/pane lock prohibition.
142+func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
143+ rt := ac.overlays
144+ rt.copyMu.Lock()
145+ mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument
146+ pointer := rt.copyPointer
147+ rt.copyMu.Unlock()
148+ if mode == nil || target == nil || document == nil {
149+ return
150+ }
151+ cfg := d.currentFloatingConfig()
152+ // Once pressed, use the committed rectangle captured by that press. A
153+ // later floating/layout change must not turn an in-flight drag into a hit
154+ // on a different pane.
155+ if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document {
156+ mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true)
157+ if ev.Type == mouse.Release && !ok {
158+ rt.copyMu.Lock()
159+ rt.invalidateCopyPointerLocked(false)
160+ rt.copyMu.Unlock()
161+ return
162+ }
163+ if ok {
164+ d.copyMouse(sess, ac, ev, mapped)
165+ }
166+ return
167+ }
168+ tb.mu.Lock()
169+ geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target)
170+ if !ok && ev.Type == mouse.Press {
171+ // Preserve the established press-on-another-pane behavior: copy mode
172+ // exits rather than retargeting its immutable document.
173+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target {
174+ if tb.tree != nil {
175+ focusPlacementLocked(tb, hit.pane.id)
176+ d.applyLayoutLocked(tb)
177+ }
178+ }
179+ }
180+ tb.mu.Unlock()
181+ if !ok {
182+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
183+ rt.copyMu.Lock()
184+ rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)
185+ rt.copyMu.Unlock()
186+ }
187+ return
188+ }
189+ clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
190+ mapped, mappedOK := mapCopyMouse(ev, geometry, mode.ViewportTop, document, clamp)
191+ if !mappedOK {
192+ if ev.Type == mouse.Press {
193+ otherPane := false
194+ tb.mu.Lock()
195+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target {
196+ otherPane = true
197+ if tb.tree != nil {
198+ focusPlacementLocked(tb, hit.pane.id)
199+ d.applyLayoutLocked(tb)
200+ }
201+ }
202+ tb.mu.Unlock()
203+ if otherPane {
204+ d.exitCopyMode(ac)
205+ }
206+ }
207+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
208+ rt.copyMu.Lock()
209+ rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)
210+ rt.copyMu.Unlock()
211+ }
212+ return
213+ }
214+ if ev.Type == mouse.Press {
215+ // Keep the resolved frame geometry with the pointer installed by copyMouse.
216+ rt.copyMu.Lock()
217+ if rt.copyMode == mode && rt.copyPane == target && rt.copyDocument == document {
218+ rt.beginCopyPointerLocked(copyPointerState{pane: target, document: document, geometry: geometry, press: mapped.pos})
219+ }
220+ rt.copyMu.Unlock()
221+ }
222+ d.copyMouse(sess, ac, ev, mapped)
223+}
224+
195225 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
196226 if p == nil {
197227 return
@@ -199,12 +229,9 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
199229 rt := ac.overlays
200230 p.mu.Lock()
201231 childRows := p.screen.Frame.Height
232+ childCols := p.screen.Frame.Width
202233 mouseMode, mouseSGR := p.screen.MouseMode()
203234 altScreen := p.screen.AltScreenActive()
204- scrollbackRows := 0
205- if p.history != nil {
206- scrollbackRows = p.history.Len()
207- }
208235 p.mu.Unlock()
209236
210237 if mouseMode != 0 {
@@ -221,58 +248,62 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
221248
222249 switch ev.Button {
223250 case mouse.Left:
251+ // At this point ev is pane-local in X and retains its client-frame Y,
252+ // so this synthetic geometry crosses the top-bar boundary once.
253+ geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: clientTopBarRows, Width: childCols, Height: childRows}}
224254 switch ev.Type {
225255 case mouse.Press:
226- if altScreen || ev.Row >= childRows {
256+ if altScreen {
227257 rt.copyMu.Lock()
228- rt.normalMousePressValid = false
258+ rt.invalidateCopyPointerLocked(true)
259+ rt.copyMu.Unlock()
260+ return
261+ }
262+ p.mu.Lock()
263+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
264+ p.mu.Unlock()
265+ document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators)
266+ mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)
267+ if !ok {
268+ rt.copyMu.Lock()
269+ rt.invalidateCopyPointerLocked(true)
229270 rt.copyMu.Unlock()
230271 return
231272 }
232273 rt.copyMu.Lock()
233- rt.normalMousePressRow = ev.Row
234- rt.normalMousePressTop = scrollbackRows
235- rt.normalMousePressValid = true
274+ rt.beginCopyPointerLocked(copyPointerState{pane: p, document: document, geometry: geometry, press: mapped.pos})
236275 rt.copyMu.Unlock()
237276 case mouse.Motion:
238- if altScreen || ev.Row >= childRows {
277+ if altScreen {
239278 return
240279 }
241280 rt.copyMu.Lock()
242- pressValid := rt.normalMousePressValid
243- pressRow := rt.normalMousePressRow
244- pressTop := rt.normalMousePressTop
281+ pointer := rt.copyPointer
245282 rt.copyMu.Unlock()
246- if !pressValid {
283+ if !pointer.valid || pointer.pane != p {
247284 return
248285 }
249-
250- // The immutable snapshot is the only live-pane access needed for drag entry.
251- p.mu.Lock()
252- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
253- p.mu.Unlock()
254- cfg := d.currentCopyConfig()
255- document := scopy.NewDocument(snapshot, cfg.WordSeparators)
256- tb := sess.activeTab()
257- if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
258- mode.SetPosition(scopy.Pos{Row: pressTop + pressRow, Col: 0})
259- mode.ToggleLineSelection()
260- mode.MoveRows(document.Len() - document.Height() + ev.Row - mode.Cursor().Row)
261- }, nil) {
286+ mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, true)
287+ if !ok {
262288 return
263289 }
264- rt.copyMu.Lock()
265- // Row-compatible temporary pointer state; phase 4 supplies exact columns.
266- if rt.copyPane == p && rt.copyDocument == document && rt.copyMode != nil {
267- rt.copyPressRow = pressTop + pressRow
268- rt.copyPressRowValid = true
269- rt.copyDragging = true
290+ tb := sess.activeTab()
291+ if !d.publishCopyMode(sess, ac, tb, p, pointer.document, func(mode *scopy.Mode) {
292+ mode.StartCharacterSelection(pointer.press)
293+ mode.ExtendCharacterSelection(mapped.pos)
294+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
295+ if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
296+ return
297+ }
298+ pointer.dragging = true
299+ runtime.copyPointer = pointer
300+ }) {
301+ return
270302 }
271- rt.copyMu.Unlock()
272303 d.invalidateRender(sess, ac, true, "input.go")
273304 case mouse.Release:
274305 rt.copyMu.Lock()
275- rt.normalMousePressValid = false
306+ rt.invalidateCopyPointerLocked(false)
276307 rt.copyMu.Unlock()
277308 }
278309 case mouse.WheelUp:
@@ -5,6 +5,7 @@ import (
5 "bytes"5 "bytes"
6 "strconv"6 "strconv"
7 7
8+ "github.com/bnema/vev/internal/domain"
8 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
@@ -51,70 +52,13 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
51 if rt.copySearchActive() {52 if rt.copySearchActive() {
52 return53 return
53 }54 }
54- copyPane := copyTargetPane(rt)
55- tb.mu.Lock()
56- floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
57- floatingCopy := floatingVisible && copyPane == floatingPane
58- tb.mu.Unlock()
59- if floatingCopy {
60- contentRow := ev.Row - 1
61- if !pointInRect(ev.Col, contentRow, geometry.Inner) {
62- return
63- }
64- // Match the multi-pane translation: hand copyMouse a zero-based
65- // popup row so a click selects exactly the row under the pointer.
66- ev.Row = contentRow
67- ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
68- switch ev.Button {
69- case mouse.Left:
70- d.copyMouse(sess, ac, ev)
71- case mouse.WheelUp:
72- d.copyWheel(sess, ac, -3)
73- case mouse.WheelDown:
74- d.copyWheel(sess, ac, 3)
75- }
76- return
77- }
78- if ev.Button == mouse.Left {
79- contentRow := ev.Row - 1
80- tb.mu.Lock()
81- p := tb.focusedPane()
82- var pl layout.Placement
83- var ok bool
84- multi := len(tb.panes) > 1
85- if p != nil {
86- pl, ok = focusedPlacementLocked(tb)
87- }
88- if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
89- if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
90- oldFocus := tb.tree.Focus
91- focusPlacementLocked(tb, target.ID)
92- d.applyLayoutLocked(tb)
93- tb.mu.Unlock()
94- d.exitCopyMode(ac)
95- if target.ID != oldFocus {
96- d.refreshPaneTitleOnFocus(sess, target.ID)
97- }
98- d.invalidateRender(sess, ac, true, "input.go")
99- return
100- }
101- }
102- tb.mu.Unlock()
103- if ok && multi {
104- clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
105- clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
106- ev.Col = clampedCol
107- ev.Row = clampedRow
108- ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
109- }
110- }
111 switch ev.Button {55 switch ev.Button {
112- case mouse.Left:
113- d.copyMouse(sess, ac, ev)
114 case mouse.WheelUp:56 case mouse.WheelUp:
115 d.copyWheel(sess, ac, -3)57 d.copyWheel(sess, ac, -3)
116 case mouse.WheelDown:58 case mouse.WheelDown:
117 d.copyWheel(sess, ac, 3)59 d.copyWheel(sess, ac, 3)
60+ case mouse.Left:
61+ d.handleActiveCopyMouse(sess, ac, tb, ev)
118 }62 }
119 return63 return
120 }64 }
@@ -192,6 +136,92 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
192 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)136 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
193 }137 }
194 138
139+// handleActiveCopyMouse keeps every drag tied to the pane/document captured at
140+// press time. Geometry is resolved after copyMu is released to preserve the
141+// daemon's copyMu -> tab/pane lock prohibition.
142+func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) {
143+ rt := ac.overlays
144+ rt.copyMu.Lock()
145+ mode, target, document := rt.copyMode, rt.copyPane, rt.copyDocument
146+ pointer := rt.copyPointer
147+ rt.copyMu.Unlock()
148+ if mode == nil || target == nil || document == nil {
149+ return
150+ }
151+ cfg := d.currentFloatingConfig()
152+ // Once pressed, use the committed rectangle captured by that press. A
153+ // later floating/layout change must not turn an in-flight drag into a hit
154+ // on a different pane.
155+ if ev.Type != mouse.Press && pointer.valid && pointer.pane == target && pointer.document == document {
156+ mapped, ok := mapCopyMouse(ev, pointer.geometry, mode.ViewportTop, document, true)
157+ if ev.Type == mouse.Release && !ok {
158+ rt.copyMu.Lock()
159+ rt.invalidateCopyPointerLocked(false)
160+ rt.copyMu.Unlock()
161+ return
162+ }
163+ if ok {
164+ d.copyMouse(sess, ac, ev, mapped)
165+ }
166+ return
167+ }
168+ tb.mu.Lock()
169+ geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, target)
170+ if !ok && ev.Type == mouse.Press {
171+ // Preserve the established press-on-another-pane behavior: copy mode
172+ // exits rather than retargeting its immutable document.
173+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target {
174+ if tb.tree != nil {
175+ focusPlacementLocked(tb, hit.pane.id)
176+ d.applyLayoutLocked(tb)
177+ }
178+ }
179+ }
180+ tb.mu.Unlock()
181+ if !ok {
182+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
183+ rt.copyMu.Lock()
184+ rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)
185+ rt.copyMu.Unlock()
186+ }
187+ return
188+ }
189+ clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release
190+ mapped, mappedOK := mapCopyMouse(ev, geometry, mode.ViewportTop, document, clamp)
191+ if !mappedOK {
192+ if ev.Type == mouse.Press {
193+ otherPane := false
194+ tb.mu.Lock()
195+ if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != target {
196+ otherPane = true
197+ if tb.tree != nil {
198+ focusPlacementLocked(tb, hit.pane.id)
199+ d.applyLayoutLocked(tb)
200+ }
201+ }
202+ tb.mu.Unlock()
203+ if otherPane {
204+ d.exitCopyMode(ac)
205+ }
206+ }
207+ if ev.Type == mouse.Release || ev.Type == mouse.Press {
208+ rt.copyMu.Lock()
209+ rt.invalidateCopyPointerLocked(ev.Type == mouse.Press)
210+ rt.copyMu.Unlock()
211+ }
212+ return
213+ }
214+ if ev.Type == mouse.Press {
215+ // Keep the resolved frame geometry with the pointer installed by copyMouse.
216+ rt.copyMu.Lock()
217+ if rt.copyMode == mode && rt.copyPane == target && rt.copyDocument == document {
218+ rt.beginCopyPointerLocked(copyPointerState{pane: target, document: document, geometry: geometry, press: mapped.pos})
219+ }
220+ rt.copyMu.Unlock()
221+ }
222+ d.copyMouse(sess, ac, ev, mapped)
223+}
224+
195 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {225 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
196 if p == nil {226 if p == nil {
197 return227 return
@@ -199,12 +229,9 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
199 rt := ac.overlays229 rt := ac.overlays
200 p.mu.Lock()230 p.mu.Lock()
201 childRows := p.screen.Frame.Height231 childRows := p.screen.Frame.Height
232+ childCols := p.screen.Frame.Width
202 mouseMode, mouseSGR := p.screen.MouseMode()233 mouseMode, mouseSGR := p.screen.MouseMode()
203 altScreen := p.screen.AltScreenActive()234 altScreen := p.screen.AltScreenActive()
204- scrollbackRows := 0
205- if p.history != nil {
206- scrollbackRows = p.history.Len()
207- }
208 p.mu.Unlock()235 p.mu.Unlock()
209 236
210 if mouseMode != 0 {237 if mouseMode != 0 {
@@ -221,58 +248,62 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
221 248
222 switch ev.Button {249 switch ev.Button {
223 case mouse.Left:250 case mouse.Left:
251+ // At this point ev is pane-local in X and retains its client-frame Y,
252+ // so this synthetic geometry crosses the top-bar boundary once.
253+ geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: clientTopBarRows, Width: childCols, Height: childRows}}
224 switch ev.Type {254 switch ev.Type {
225 case mouse.Press:255 case mouse.Press:
226- if altScreen || ev.Row >= childRows {256+ if altScreen {
227 rt.copyMu.Lock()257 rt.copyMu.Lock()
228- rt.normalMousePressValid = false258+ rt.invalidateCopyPointerLocked(true)
259+ rt.copyMu.Unlock()
260+ return
261+ }
262+ p.mu.Lock()
263+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
264+ p.mu.Unlock()
265+ document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators)
266+ mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)
267+ if !ok {
268+ rt.copyMu.Lock()
269+ rt.invalidateCopyPointerLocked(true)
229 rt.copyMu.Unlock()270 rt.copyMu.Unlock()
230 return271 return
231 }272 }
232 rt.copyMu.Lock()273 rt.copyMu.Lock()
233- rt.normalMousePressRow = ev.Row274+ rt.beginCopyPointerLocked(copyPointerState{pane: p, document: document, geometry: geometry, press: mapped.pos})
234- rt.normalMousePressTop = scrollbackRows
235- rt.normalMousePressValid = true
236 rt.copyMu.Unlock()275 rt.copyMu.Unlock()
237 case mouse.Motion:276 case mouse.Motion:
238- if altScreen || ev.Row >= childRows {277+ if altScreen {
239 return278 return
240 }279 }
241 rt.copyMu.Lock()280 rt.copyMu.Lock()
242- pressValid := rt.normalMousePressValid281+ pointer := rt.copyPointer
243- pressRow := rt.normalMousePressRow
244- pressTop := rt.normalMousePressTop
245 rt.copyMu.Unlock()282 rt.copyMu.Unlock()
246- if !pressValid {283+ if !pointer.valid || pointer.pane != p {
247 return284 return
248 }285 }
249-286+ mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, true)
250- // The immutable snapshot is the only live-pane access needed for drag entry.287+ if !ok {
251- p.mu.Lock()
252- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
253- p.mu.Unlock()
254- cfg := d.currentCopyConfig()
255- document := scopy.NewDocument(snapshot, cfg.WordSeparators)
256- tb := sess.activeTab()
257- if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
258- mode.SetPosition(scopy.Pos{Row: pressTop + pressRow, Col: 0})
259- mode.ToggleLineSelection()
260- mode.MoveRows(document.Len() - document.Height() + ev.Row - mode.Cursor().Row)
261- }, nil) {
262 return288 return
263 }289 }
264- rt.copyMu.Lock()290+ tb := sess.activeTab()
265- // Row-compatible temporary pointer state; phase 4 supplies exact columns.291+ if !d.publishCopyMode(sess, ac, tb, p, pointer.document, func(mode *scopy.Mode) {
266- if rt.copyPane == p && rt.copyDocument == document && rt.copyMode != nil {292+ mode.StartCharacterSelection(pointer.press)
267- rt.copyPressRow = pressTop + pressRow293+ mode.ExtendCharacterSelection(mapped.pos)
268- rt.copyPressRowValid = true294+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
269- rt.copyDragging = true295+ if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
296+ return
297+ }
298+ pointer.dragging = true
299+ runtime.copyPointer = pointer
300+ }) {
301+ return
270 }302 }
271- rt.copyMu.Unlock()
272 d.invalidateRender(sess, ac, true, "input.go")303 d.invalidateRender(sess, ac, true, "input.go")
273 case mouse.Release:304 case mouse.Release:
274 rt.copyMu.Lock()305 rt.copyMu.Lock()
275- rt.normalMousePressValid = false306+ rt.invalidateCopyPointerLocked(false)
276 rt.copyMu.Unlock()307 rt.copyMu.Unlock()
277 }308 }
278 case mouse.WheelUp:309 case mouse.WheelUp:
M internal/usecase/daemon/input_test.go +39 -14
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
1819 "github.com/bnema/vev/internal/usecase/keys"
1920 "github.com/bnema/vev/internal/usecase/layout"
2021 "github.com/bnema/vev/internal/usecase/picker"
@@ -945,9 +946,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
945946 }
946947
947948 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
948- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
949+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
949950 require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
950- d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
951+ d.handleInput(sess, ac, []byte("\x1b[<32;1;4M"))
951952 mustOutputData(t, sends)
952953 require.NotNil(t, ac.overlays.copyMode)
953954 selection := ac.overlays.copyMode.Selection()
@@ -960,6 +961,30 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
960961 require.Equal(t, 2, hi)
961962 }
962963
964+func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
965+ p, _ := newBlockingPTY(t)
966+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
967+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
968+ d.enterCopyMode(sess, ac)
969+ mustOutputData(t, sends)
970+
971+ // SGR is one based: Cy2 is the first body row. Dragging from column 3
972+ // back to column 1 is the same stream as the forward endpoints 1..3.
973+ d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M"))
974+ mustOutputData(t, sends)
975+ d.handleInput(sess, ac, []byte("\x1b[<0;2;2m"))
976+ d.handleInput(sess, ac, []byte("y"))
977+ var msg ports.Output
978+ require.Eventually(t, func() bool {
979+ frame := awaitFrame(t, sends, ports.MsgOutput)
980+ var err error
981+ msg, err = ports.UnmarshalOutput(frame.Payload)
982+ return err == nil && string(msg.Data) == string(scopy.OSC52("lph")[0])
983+ }, 2*time.Second, 5*time.Millisecond)
984+ require.Equal(t, scopy.OSC52("lph")[0], msg.Data)
985+ require.Nil(t, ac.overlays.copyPointer.pane)
986+}
987+
963988 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
964989 p, _ := newBlockingPTY(t)
965990 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -969,7 +994,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
969994
970995 d.enterCopyMode(sess, ac)
971996 mustOutputData(t, sends)
972- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
997+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
973998 mustOutputData(t, sends)
974999 require.NotNil(t, ac.overlays.copyMode)
9751000 selection := ac.overlays.copyMode.Selection()
@@ -989,7 +1014,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
9891014 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
9901015 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
9911016 require.NoError(t, err)
992- require.Equal(t, "alpha\nbravo", string(decoded))
1017+ require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded))
9931018 require.Nil(t, ac.overlays.copyMode)
9941019
9951020 exitPaint := string(mustOutputData(t, sends))
@@ -1009,16 +1034,16 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
10091034 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
10101035
10111036 // Press on a content row establishes a (soon to be stale) anchor.
1012- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
1037+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
10131038 require.Nil(t, ac.overlays.copyMode)
10141039
10151040 // Release lands on the status row: must still clear the press state,
10161041 // regardless of the row it landed on.
1017- d.handleInput(sess, ac, []byte("\x1b[<0;1;24m"))
1042+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25m"))
10181043
10191044 // A brand new button hold starts with a Press on the status row: must
10201045 // clear (not silently preserve) any prior press state.
1021- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
1046+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
10221047
10231048 // Motion onto a content row must not resurrect a stale anchor.
10241049 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
@@ -1044,7 +1069,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10441069 mustOutputData(t, sends)
10451070
10461071 // Full drag: press row0, motion to row1 -> selection [0,1].
1047- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
1072+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
10481073 mustOutputData(t, sends)
10491074 require.NotNil(t, ac.overlays.copyMode)
10501075 selection := ac.overlays.copyMode.Selection()
@@ -1057,8 +1082,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10571082 require.Equal(t, 1, hi)
10581083
10591084 // Release, then a new button hold starting with Press on the status row.
1060- d.handleInput(sess, ac, []byte("\x1b[<0;1;2m"))
1061- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
1085+ d.handleInput(sess, ac, []byte("\x1b[<0;1;3m"))
1086+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
10621087
10631088 // Motion onto a content row must be a no-op: the status-row press must
10641089 // have cleared copyPressRowValid/copyDragging.
@@ -1087,8 +1112,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
10871112 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
10881113
10891114 // Press on a content row while scrollback is empty: the anchor is
1090- // content-stable at pressTop(0)+pressRow(0) = row 0.
1091- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
1115+ // content-stable at the first body row = document row 0.
1116+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
10921117
10931118 // Simulate 5 lines evicted into scrollback between the Press and the
10941119 // first Motion (e.g. the child kept producing output).
@@ -1110,7 +1135,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
11101135 lo, hi = hi, lo
11111136 }
11121137 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
1113- require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
1138+ require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable")
11141139 }
11151140
11161141 func TestMouseSplitReportPreservesOrder(t *testing.T) {
@@ -1219,7 +1244,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
12191244 d.enterCopyMode(sess, ac)
12201245 mustOutputData(t, sends)
12211246
1222- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
1247+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M"))
12231248 mustOutputData(t, sends)
12241249
12251250 selection := ac.overlays.copyMode.Selection()
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/keys"19 "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/layout"20 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/picker"21 "github.com/bnema/vev/internal/usecase/picker"
@@ -945,9 +946,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
945 }946 }
946 947
947 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))948 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
948- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))949+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
949 require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")950 require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
950- d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))951+ d.handleInput(sess, ac, []byte("\x1b[<32;1;4M"))
951 mustOutputData(t, sends)952 mustOutputData(t, sends)
952 require.NotNil(t, ac.overlays.copyMode)953 require.NotNil(t, ac.overlays.copyMode)
953 selection := ac.overlays.copyMode.Selection()954 selection := ac.overlays.copyMode.Selection()
@@ -960,6 +961,30 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
960 require.Equal(t, 2, hi)961 require.Equal(t, 2, hi)
961 }962 }
962 963
964+func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
965+ p, _ := newBlockingPTY(t)
966+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
967+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
968+ d.enterCopyMode(sess, ac)
969+ mustOutputData(t, sends)
970+
971+ // SGR is one based: Cy2 is the first body row. Dragging from column 3
972+ // back to column 1 is the same stream as the forward endpoints 1..3.
973+ d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M"))
974+ mustOutputData(t, sends)
975+ d.handleInput(sess, ac, []byte("\x1b[<0;2;2m"))
976+ d.handleInput(sess, ac, []byte("y"))
977+ var msg ports.Output
978+ require.Eventually(t, func() bool {
979+ frame := awaitFrame(t, sends, ports.MsgOutput)
980+ var err error
981+ msg, err = ports.UnmarshalOutput(frame.Payload)
982+ return err == nil && string(msg.Data) == string(scopy.OSC52("lph")[0])
983+ }, 2*time.Second, 5*time.Millisecond)
984+ require.Equal(t, scopy.OSC52("lph")[0], msg.Data)
985+ require.Nil(t, ac.overlays.copyPointer.pane)
986+}
987+
963 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {988 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
964 p, _ := newBlockingPTY(t)989 p, _ := newBlockingPTY(t)
965 d, sess, ac, sends := newManualSessionWithPTYs(t, p)990 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -969,7 +994,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
969 994
970 d.enterCopyMode(sess, ac)995 d.enterCopyMode(sess, ac)
971 mustOutputData(t, sends)996 mustOutputData(t, sends)
972- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))997+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
973 mustOutputData(t, sends)998 mustOutputData(t, sends)
974 require.NotNil(t, ac.overlays.copyMode)999 require.NotNil(t, ac.overlays.copyMode)
975 selection := ac.overlays.copyMode.Selection()1000 selection := ac.overlays.copyMode.Selection()
@@ -989,7 +1014,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
989 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)1014 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
990 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))1015 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
991 require.NoError(t, err)1016 require.NoError(t, err)
992- require.Equal(t, "alpha\nbravo", string(decoded))1017+ require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded))
993 require.Nil(t, ac.overlays.copyMode)1018 require.Nil(t, ac.overlays.copyMode)
994 1019
995 exitPaint := string(mustOutputData(t, sends))1020 exitPaint := string(mustOutputData(t, sends))
@@ -1009,16 +1034,16 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
1009 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")1034 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1010 1035
1011 // Press on a content row establishes a (soon to be stale) anchor.1036 // Press on a content row establishes a (soon to be stale) anchor.
1012- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))1037+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1013 require.Nil(t, ac.overlays.copyMode)1038 require.Nil(t, ac.overlays.copyMode)
1014 1039
1015 // Release lands on the status row: must still clear the press state,1040 // Release lands on the status row: must still clear the press state,
1016 // regardless of the row it landed on.1041 // regardless of the row it landed on.
1017- d.handleInput(sess, ac, []byte("\x1b[<0;1;24m"))1042+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25m"))
1018 1043
1019 // A brand new button hold starts with a Press on the status row: must1044 // A brand new button hold starts with a Press on the status row: must
1020 // clear (not silently preserve) any prior press state.1045 // clear (not silently preserve) any prior press state.
1021- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))1046+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
1022 1047
1023 // Motion onto a content row must not resurrect a stale anchor.1048 // Motion onto a content row must not resurrect a stale anchor.
1024 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))1049 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
@@ -1044,7 +1069,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1044 mustOutputData(t, sends)1069 mustOutputData(t, sends)
1045 1070
1046 // Full drag: press row0, motion to row1 -> selection [0,1].1071 // Full drag: press row0, motion to row1 -> selection [0,1].
1047- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))1072+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
1048 mustOutputData(t, sends)1073 mustOutputData(t, sends)
1049 require.NotNil(t, ac.overlays.copyMode)1074 require.NotNil(t, ac.overlays.copyMode)
1050 selection := ac.overlays.copyMode.Selection()1075 selection := ac.overlays.copyMode.Selection()
@@ -1057,8 +1082,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1057 require.Equal(t, 1, hi)1082 require.Equal(t, 1, hi)
1058 1083
1059 // Release, then a new button hold starting with Press on the status row.1084 // Release, then a new button hold starting with Press on the status row.
1060- d.handleInput(sess, ac, []byte("\x1b[<0;1;2m"))1085+ d.handleInput(sess, ac, []byte("\x1b[<0;1;3m"))
1061- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))1086+ d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
1062 1087
1063 // Motion onto a content row must be a no-op: the status-row press must1088 // Motion onto a content row must be a no-op: the status-row press must
1064 // have cleared copyPressRowValid/copyDragging.1089 // have cleared copyPressRowValid/copyDragging.
@@ -1087,8 +1112,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
1087 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")1112 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1088 1113
1089 // Press on a content row while scrollback is empty: the anchor is1114 // Press on a content row while scrollback is empty: the anchor is
1090- // content-stable at pressTop(0)+pressRow(0) = row 0.1115+ // content-stable at the first body row = document row 0.
1091- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))1116+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1092 1117
1093 // Simulate 5 lines evicted into scrollback between the Press and the1118 // Simulate 5 lines evicted into scrollback between the Press and the
1094 // first Motion (e.g. the child kept producing output).1119 // first Motion (e.g. the child kept producing output).
@@ -1110,7 +1135,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
1110 lo, hi = hi, lo1135 lo, hi = hi, lo
1111 }1136 }
1112 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")1137 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
1113- require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")1138+ require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable")
1114 }1139 }
1115 1140
1116 func TestMouseSplitReportPreservesOrder(t *testing.T) {1141 func TestMouseSplitReportPreservesOrder(t *testing.T) {
@@ -1219,7 +1244,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1219 d.enterCopyMode(sess, ac)1244 d.enterCopyMode(sess, ac)
1220 mustOutputData(t, sends)1245 mustOutputData(t, sends)
1221 1246
1222- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))1247+ d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M"))
1223 mustOutputData(t, sends)1248 mustOutputData(t, sends)
1224 1249
1225 selection := ac.overlays.copyMode.Selection()1250 selection := ac.overlays.copyMode.Selection()
M internal/usecase/daemon/mouse_panes.go +0 -3
@@ -144,8 +144,5 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
144144 }
145145 rt.copyMu.Lock()
146146 rt.clearCopyModeLocked()
147- rt.copyPressRowValid = false
148- rt.copyDragging = false
149- rt.normalMousePressValid = false
150147 rt.copyMu.Unlock()
151148 }
@@ -144,8 +144,5 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
144 }144 }
145 rt.copyMu.Lock()145 rt.copyMu.Lock()
146 rt.clearCopyModeLocked()146 rt.clearCopyModeLocked()
147- rt.copyPressRowValid = false
148- rt.copyDragging = false
149- rt.normalMousePressValid = false
150 rt.copyMu.Unlock()147 rt.copyMu.Unlock()
151 }148 }
M internal/usecase/daemon/overlay_runtime.go +63 -20
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "sync"
5+ "time"
56
67 scopy "github.com/bnema/vev/internal/usecase/copy"
78 "github.com/bnema/vev/internal/usecase/palette"
@@ -33,22 +34,38 @@ type overlayRuntime struct {
3334 promptSubmit func(string) error
3435 promptPending []byte
3536
36- copyMu sync.Mutex
37- copyMode *scopy.Mode
38- copyCandidate *scopy.Mode
39- copyDocument *scopy.Document
40- copyPane *pane
41- copyPending []byte
42- copyESC pendingByteTimer
43- copySearch *visualsearch.Model
44- copySearchPending []byte
45- copyFeedback string
46- copyPressRow int
47- copyPressRowValid bool
48- copyDragging bool
49- normalMousePressRow int
50- normalMousePressTop int
51- normalMousePressValid bool
37+ copyMu sync.Mutex
38+ copyMode *scopy.Mode
39+ copyCandidate *scopy.Mode
40+ copyDocument *scopy.Document
41+ copyPane *pane
42+ copyPending []byte
43+ copyESC pendingByteTimer
44+ copySearch *visualsearch.Model
45+ copySearchPending []byte
46+ copyFeedback string
47+ copyPointer copyPointerState
48+ copyClick copyClickCandidate
49+ copyPointerEpoch uint64
50+}
51+
52+type copyPointerState struct {
53+ valid bool
54+ epoch uint64
55+ pane *pane
56+ document *scopy.Document
57+ geometry copyMouseGeometry
58+ press scopy.Pos
59+ dragging bool
60+ wordDrag bool
61+}
62+
63+type copyClickCandidate struct {
64+ valid bool
65+ pane *pane
66+ pos scopy.Pos
67+ at time.Time
68+ dragged bool
5269 }
5370
5471 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
@@ -107,6 +124,28 @@ func (rt *overlayRuntime) copySearchActive() bool {
107124 return rt.copySearch != nil
108125 }
109126
127+func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
128+ rt.copyPointerEpoch++
129+ pointer.epoch = rt.copyPointerEpoch
130+ pointer.valid = true
131+ rt.copyPointer = pointer
132+}
133+
134+func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
135+ rt.copyPointerEpoch++
136+ rt.copyPointer = copyPointerState{}
137+ if clearClick {
138+ rt.copyClick = copyClickCandidate{}
139+ }
140+}
141+
142+// clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
143+// teardown occurring while publication revalidates can invalidate the transfer.
144+func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
145+ rt.copyPointer = copyPointerState{}
146+ rt.copyClick = copyClickCandidate{}
147+}
148+
110149 func (rt *overlayRuntime) clearCopyModeLocked() {
111150 rt.copyMode = nil
112151 rt.copyCandidate = nil
@@ -114,6 +153,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() {
114153 rt.copyPane = nil
115154 rt.copySearch = nil
116155 rt.copySearchPending = nil
156+ rt.invalidateCopyPointerLocked(true)
117157 }
118158
119159 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
@@ -122,11 +162,14 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
122162 }
123163 rt.copyMu.Lock()
124164 defer rt.copyMu.Unlock()
125- if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) {
126- return false
165+ active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
166+ prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
167+ if active {
168+ rt.clearCopyModeLocked()
169+ } else if prePublication {
170+ rt.invalidateCopyPointerLocked(true)
127171 }
128- rt.clearCopyModeLocked()
129- return true
172+ return active || prePublication
130173 }
131174
132175 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "sync"4 "sync"
5+ "time"
5 6
6 scopy "github.com/bnema/vev/internal/usecase/copy"7 scopy "github.com/bnema/vev/internal/usecase/copy"
7 "github.com/bnema/vev/internal/usecase/palette"8 "github.com/bnema/vev/internal/usecase/palette"
@@ -33,22 +34,38 @@ type overlayRuntime struct {
33 promptSubmit func(string) error34 promptSubmit func(string) error
34 promptPending []byte35 promptPending []byte
35 36
36- copyMu sync.Mutex37+ copyMu sync.Mutex
37- copyMode *scopy.Mode38+ copyMode *scopy.Mode
38- copyCandidate *scopy.Mode39+ copyCandidate *scopy.Mode
39- copyDocument *scopy.Document40+ copyDocument *scopy.Document
40- copyPane *pane41+ copyPane *pane
41- copyPending []byte42+ copyPending []byte
42- copyESC pendingByteTimer43+ copyESC pendingByteTimer
43- copySearch *visualsearch.Model44+ copySearch *visualsearch.Model
44- copySearchPending []byte45+ copySearchPending []byte
45- copyFeedback string46+ copyFeedback string
46- copyPressRow int47+ copyPointer copyPointerState
47- copyPressRowValid bool48+ copyClick copyClickCandidate
48- copyDragging bool49+ copyPointerEpoch uint64
49- normalMousePressRow int50+}
50- normalMousePressTop int51+
51- normalMousePressValid bool52+type copyPointerState struct {
53+ valid bool
54+ epoch uint64
55+ pane *pane
56+ document *scopy.Document
57+ geometry copyMouseGeometry
58+ press scopy.Pos
59+ dragging bool
60+ wordDrag bool
61+}
62+
63+type copyClickCandidate struct {
64+ valid bool
65+ pane *pane
66+ pos scopy.Pos
67+ at time.Time
68+ dragged bool
52 }69 }
53 70
54 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {71 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
@@ -107,6 +124,28 @@ func (rt *overlayRuntime) copySearchActive() bool {
107 return rt.copySearch != nil124 return rt.copySearch != nil
108 }125 }
109 126
127+func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
128+ rt.copyPointerEpoch++
129+ pointer.epoch = rt.copyPointerEpoch
130+ pointer.valid = true
131+ rt.copyPointer = pointer
132+}
133+
134+func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
135+ rt.copyPointerEpoch++
136+ rt.copyPointer = copyPointerState{}
137+ if clearClick {
138+ rt.copyClick = copyClickCandidate{}
139+ }
140+}
141+
142+// clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
143+// teardown occurring while publication revalidates can invalidate the transfer.
144+func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
145+ rt.copyPointer = copyPointerState{}
146+ rt.copyClick = copyClickCandidate{}
147+}
148+
110 func (rt *overlayRuntime) clearCopyModeLocked() {149 func (rt *overlayRuntime) clearCopyModeLocked() {
111 rt.copyMode = nil150 rt.copyMode = nil
112 rt.copyCandidate = nil151 rt.copyCandidate = nil
@@ -114,6 +153,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() {
114 rt.copyPane = nil153 rt.copyPane = nil
115 rt.copySearch = nil154 rt.copySearch = nil
116 rt.copySearchPending = nil155 rt.copySearchPending = nil
156+ rt.invalidateCopyPointerLocked(true)
117 }157 }
118 158
119 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {159 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
@@ -122,11 +162,14 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
122 }162 }
123 rt.copyMu.Lock()163 rt.copyMu.Lock()
124 defer rt.copyMu.Unlock()164 defer rt.copyMu.Unlock()
125- if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) {165+ active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
126- return false166+ prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
167+ if active {
168+ rt.clearCopyModeLocked()
169+ } else if prePublication {
170+ rt.invalidateCopyPointerLocked(true)
127 }171 }
128- rt.clearCopyModeLocked()172+ return active || prePublication
129- return true
130 }173 }
131 174
132 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {175 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
M internal/usecase/daemon/overlay_runtime_test.go +23 -0
@@ -171,6 +171,29 @@ func TestOverlayRuntimeSnapshotCarriesCopyModeDocumentAndClearReleasesIt(t *test
171171 ac.overlays.copyMu.Unlock()
172172 }
173173
174+func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) {
175+ ac := &attachedClient{}
176+ ac.initOverlays()
177+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
178+ document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 1), domain.DefaultWordSeparators)
179+
180+ ac.overlays.copyMu.Lock()
181+ ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}})
182+ staleEpoch := ac.overlays.copyPointer.epoch
183+ ac.overlays.clearCopyPointerForTransferLocked()
184+ ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates
185+ require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch)
186+ require.False(t, ac.overlays.copyPointer.valid)
187+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p}
188+ ac.overlays.copyMu.Unlock()
189+
190+ require.True(t, ac.overlays.clearCopyModeForPane(p))
191+ ac.overlays.copyMu.Lock()
192+ require.False(t, ac.overlays.copyPointer.valid)
193+ require.False(t, ac.overlays.copyClick.valid)
194+ ac.overlays.copyMu.Unlock()
195+}
196+
174197 func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
175198 ac := &attachedClient{}
176199 ac.initOverlays()
@@ -171,6 +171,29 @@ func TestOverlayRuntimeSnapshotCarriesCopyModeDocumentAndClearReleasesIt(t *test
171 ac.overlays.copyMu.Unlock()171 ac.overlays.copyMu.Unlock()
172 }172 }
173 173
174+func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) {
175+ ac := &attachedClient{}
176+ ac.initOverlays()
177+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
178+ document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 1), domain.DefaultWordSeparators)
179+
180+ ac.overlays.copyMu.Lock()
181+ ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}})
182+ staleEpoch := ac.overlays.copyPointer.epoch
183+ ac.overlays.clearCopyPointerForTransferLocked()
184+ ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates
185+ require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch)
186+ require.False(t, ac.overlays.copyPointer.valid)
187+ ac.overlays.copyClick = copyClickCandidate{valid: true, pane: p}
188+ ac.overlays.copyMu.Unlock()
189+
190+ require.True(t, ac.overlays.clearCopyModeForPane(p))
191+ ac.overlays.copyMu.Lock()
192+ require.False(t, ac.overlays.copyPointer.valid)
193+ require.False(t, ac.overlays.copyClick.valid)
194+ ac.overlays.copyMu.Unlock()
195+}
196+
174 func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {197 func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
175 ac := &attachedClient{}198 ac := &attachedClient{}
176 ac.initOverlays()199 ac.initOverlays()

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