detached 3026b44

feat(copy): add clocked word selection

commit
3026b441fdf37b1e20a931e74a524d97363010e3
author
brice · · 2026-07-15 09:13 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
d9ee20f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +359 -21
M internal/usecase/daemon/copymode.go +32 -3
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "strconv"
6+ "time"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
@@ -16,6 +17,17 @@ import (
1617
1718 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
1819
20+const copyDoubleClickInterval = 500 * time.Millisecond
21+
22+func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }
23+
24+func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {
25+ candidate := rt.copyClick
26+ return candidate.valid && !candidate.dragged && candidate.pane == pane &&
27+ sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) &&
28+ now.Sub(candidate.at) <= copyDoubleClickInterval
29+}
30+
1931 func copyTargetPane(rt *overlayRuntime) *pane {
2032 if rt == nil {
2133 return nil
@@ -94,7 +106,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
94106 if activate != nil {
95107 rt.clearCopyPointerForTransferLocked()
96108 } else {
97- rt.invalidateCopyPointerLocked()
109+ rt.invalidateCopyPointerLocked(true)
98110 }
99111 pointerEpoch := rt.copyPointerEpoch
100112 rt.copyMu.Unlock()
@@ -150,7 +162,17 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
150162 pointer := rt.copyPointer
151163 if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {
152164 rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos})
165+ pointer = rt.copyPointer
153166 }
167+ now := d.clock.Now()
168+ if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) {
169+ pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos)
170+ changed = pointer.wordDrag || changed
171+ rt.copyClick = copyClickCandidate{}
172+ } else {
173+ rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now}
174+ }
175+ rt.copyPointer = pointer
154176 case mouse.Motion:
155177 pointer := rt.copyPointer
156178 if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
@@ -158,11 +180,18 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
158180 rt.copyMode.StartCharacterSelection(pointer.press)
159181 pointer.dragging = true
160182 }
161- changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
183+ if rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
184+ rt.copyClick.dragged = true
185+ }
186+ if pointer.wordDrag {
187+ changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
188+ } else {
189+ changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
190+ }
162191 rt.copyPointer = pointer
163192 }
164193 case mouse.Release:
165- rt.invalidateCopyPointerLocked()
194+ rt.invalidateCopyPointerLocked(false)
166195 }
167196 rt.copyMu.Unlock()
168197 if changed {
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "strconv"5 "strconv"
6+ "time"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
@@ -16,6 +17,17 @@ import (
16 17
17 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}18 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
18 19
20+const copyDoubleClickInterval = 500 * time.Millisecond
21+
22+func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }
23+
24+func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {
25+ candidate := rt.copyClick
26+ return candidate.valid && !candidate.dragged && candidate.pane == pane &&
27+ sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) &&
28+ now.Sub(candidate.at) <= copyDoubleClickInterval
29+}
30+
19 func copyTargetPane(rt *overlayRuntime) *pane {31 func copyTargetPane(rt *overlayRuntime) *pane {
20 if rt == nil {32 if rt == nil {
21 return nil33 return nil
@@ -94,7 +106,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
94 if activate != nil {106 if activate != nil {
95 rt.clearCopyPointerForTransferLocked()107 rt.clearCopyPointerForTransferLocked()
96 } else {108 } else {
97- rt.invalidateCopyPointerLocked()109+ rt.invalidateCopyPointerLocked(true)
98 }110 }
99 pointerEpoch := rt.copyPointerEpoch111 pointerEpoch := rt.copyPointerEpoch
100 rt.copyMu.Unlock()112 rt.copyMu.Unlock()
@@ -150,7 +162,17 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
150 pointer := rt.copyPointer162 pointer := rt.copyPointer
151 if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {163 if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {
152 rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos})164 rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos})
165+ pointer = rt.copyPointer
153 }166 }
167+ now := d.clock.Now()
168+ if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) {
169+ pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos)
170+ changed = pointer.wordDrag || changed
171+ rt.copyClick = copyClickCandidate{}
172+ } else {
173+ rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now}
174+ }
175+ rt.copyPointer = pointer
154 case mouse.Motion:176 case mouse.Motion:
155 pointer := rt.copyPointer177 pointer := rt.copyPointer
156 if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {178 if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
@@ -158,11 +180,18 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma
158 rt.copyMode.StartCharacterSelection(pointer.press)180 rt.copyMode.StartCharacterSelection(pointer.press)
159 pointer.dragging = true181 pointer.dragging = true
160 }182 }
161- changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed183+ if rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
184+ rt.copyClick.dragged = true
185+ }
186+ if pointer.wordDrag {
187+ changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
188+ } else {
189+ changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
190+ }
162 rt.copyPointer = pointer191 rt.copyPointer = pointer
163 }192 }
164 case mouse.Release:193 case mouse.Release:
165- rt.invalidateCopyPointerLocked()194+ rt.invalidateCopyPointerLocked(false)
166 }195 }
167 rt.copyMu.Unlock()196 rt.copyMu.Unlock()
168 if changed {197 if changed {
M internal/usecase/daemon/copymode_lifecycle_test.go +21 -0
@@ -20,10 +20,17 @@ func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
2020 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
2121 d.enterCopyMode(sess, ac)
2222 require.True(t, ac.overlays.copyActive())
23+ ac.overlays.copyMu.Lock()
24+ seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
25+ ac.overlays.copyMu.Unlock()
2326
2427 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
2528
2629 require.False(t, ac.overlays.copyActive())
30+ ac.overlays.copyMu.Lock()
31+ require.False(t, ac.overlays.copyPointer.valid)
32+ require.False(t, ac.overlays.copyClick.valid)
33+ ac.overlays.copyMu.Unlock()
2734 }
2835
2936 func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
@@ -35,11 +42,18 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
3542 }()
3643 d.enterCopyMode(sess, ac)
3744 require.True(t, ac.overlays.copyActive())
45+ ac.overlays.copyMu.Lock()
46+ seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
47+ ac.overlays.copyMu.Unlock()
3848
3949 require.True(t, sess.switchTab(1))
4050 d.activateTab(sess, sess.activeTab())
4151
4252 require.False(t, ac.overlays.copyActive())
53+ ac.overlays.copyMu.Lock()
54+ require.False(t, ac.overlays.copyPointer.valid)
55+ require.False(t, ac.overlays.copyClick.valid)
56+ ac.overlays.copyMu.Unlock()
4357 }
4458
4559 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
@@ -61,11 +75,16 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
6175 ac.overlays.copyPane = closing
6276 ac.overlays.copyDocument = document
6377 ac.overlays.copyMode = scopy.NewMode(document)
78+ seedCopyInteractionLocked(ac.overlays, closing, document)
6479 ac.overlays.copyMu.Unlock()
6580
6681 require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
6782 require.False(t, ac.overlays.copyActive())
6883 require.Nil(t, copyTargetPane(ac.overlays))
84+ ac.overlays.copyMu.Lock()
85+ require.False(t, ac.overlays.copyPointer.valid)
86+ require.False(t, ac.overlays.copyClick.valid)
87+ ac.overlays.copyMu.Unlock()
6988 }
7089
7190 func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
@@ -184,6 +203,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.
184203 require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
185204 require.Nil(t, ac.overlays.copyCandidate)
186205 require.False(t, ac.overlays.copyPointer.valid)
206+ require.False(t, ac.overlays.copyClick.valid)
187207 ac.overlays.copyMu.Unlock()
188208 })
189209 }
@@ -231,6 +251,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test
231251 require.Nil(t, ac.overlays.copyMode)
232252 require.Nil(t, ac.overlays.copyCandidate)
233253 require.False(t, ac.overlays.copyPointer.valid)
254+ require.False(t, ac.overlays.copyClick.valid)
234255 ac.overlays.copyMu.Unlock()
235256 }
236257
@@ -20,10 +20,17 @@ func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
20 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)20 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
21 d.enterCopyMode(sess, ac)21 d.enterCopyMode(sess, ac)
22 require.True(t, ac.overlays.copyActive())22 require.True(t, ac.overlays.copyActive())
23+ ac.overlays.copyMu.Lock()
24+ seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
25+ ac.overlays.copyMu.Unlock()
23 26
24 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})27 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
25 28
26 require.False(t, ac.overlays.copyActive())29 require.False(t, ac.overlays.copyActive())
30+ ac.overlays.copyMu.Lock()
31+ require.False(t, ac.overlays.copyPointer.valid)
32+ require.False(t, ac.overlays.copyClick.valid)
33+ ac.overlays.copyMu.Unlock()
27 }34 }
28 35
29 func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {36 func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
@@ -35,11 +42,18 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
35 }()42 }()
36 d.enterCopyMode(sess, ac)43 d.enterCopyMode(sess, ac)
37 require.True(t, ac.overlays.copyActive())44 require.True(t, ac.overlays.copyActive())
45+ ac.overlays.copyMu.Lock()
46+ seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
47+ ac.overlays.copyMu.Unlock()
38 48
39 require.True(t, sess.switchTab(1))49 require.True(t, sess.switchTab(1))
40 d.activateTab(sess, sess.activeTab())50 d.activateTab(sess, sess.activeTab())
41 51
42 require.False(t, ac.overlays.copyActive())52 require.False(t, ac.overlays.copyActive())
53+ ac.overlays.copyMu.Lock()
54+ require.False(t, ac.overlays.copyPointer.valid)
55+ require.False(t, ac.overlays.copyClick.valid)
56+ ac.overlays.copyMu.Unlock()
43 }57 }
44 58
45 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {59 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
@@ -61,11 +75,16 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
61 ac.overlays.copyPane = closing75 ac.overlays.copyPane = closing
62 ac.overlays.copyDocument = document76 ac.overlays.copyDocument = document
63 ac.overlays.copyMode = scopy.NewMode(document)77 ac.overlays.copyMode = scopy.NewMode(document)
78+ seedCopyInteractionLocked(ac.overlays, closing, document)
64 ac.overlays.copyMu.Unlock()79 ac.overlays.copyMu.Unlock()
65 80
66 require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))81 require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
67 require.False(t, ac.overlays.copyActive())82 require.False(t, ac.overlays.copyActive())
68 require.Nil(t, copyTargetPane(ac.overlays))83 require.Nil(t, copyTargetPane(ac.overlays))
84+ ac.overlays.copyMu.Lock()
85+ require.False(t, ac.overlays.copyPointer.valid)
86+ require.False(t, ac.overlays.copyClick.valid)
87+ ac.overlays.copyMu.Unlock()
69 }88 }
70 89
71 func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {90 func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
@@ -184,6 +203,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.
184 require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")203 require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
185 require.Nil(t, ac.overlays.copyCandidate)204 require.Nil(t, ac.overlays.copyCandidate)
186 require.False(t, ac.overlays.copyPointer.valid)205 require.False(t, ac.overlays.copyPointer.valid)
206+ require.False(t, ac.overlays.copyClick.valid)
187 ac.overlays.copyMu.Unlock()207 ac.overlays.copyMu.Unlock()
188 })208 })
189 }209 }
@@ -231,6 +251,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test
231 require.Nil(t, ac.overlays.copyMode)251 require.Nil(t, ac.overlays.copyMode)
232 require.Nil(t, ac.overlays.copyCandidate)252 require.Nil(t, ac.overlays.copyCandidate)
233 require.False(t, ac.overlays.copyPointer.valid)253 require.False(t, ac.overlays.copyPointer.valid)
254+ require.False(t, ac.overlays.copyClick.valid)
234 ac.overlays.copyMu.Unlock()255 ac.overlays.copyMu.Unlock()
235 }256 }
236 257
A internal/usecase/daemon/copymode_mouse_test.go +239 -0
@@ -0,0 +1,239 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ scopy "github.com/bnema/vev/internal/usecase/copy"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+type manualMouseClock struct{ now time.Time }
16+
17+func (c *manualMouseClock) Now() time.Time { return c.now }
18+func (c *manualMouseClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
19+func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
20+
21+func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) {
22+ t.Helper()
23+ p, release := newBlockingPTY(t)
24+ t.Cleanup(release)
25+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
26+ clock := &manualMouseClock{now: time.Unix(1, 0)}
27+ d.clock = clock
28+ pane := sess.activeTab().focusedPane()
29+ for i, row := range rows {
30+ copy(pane.screen.Frame.Row(i), testRow(row))
31+ }
32+ d.enterCopyMode(sess, ac)
33+ return d, sess, ac, clock
34+}
35+
36+func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) {
37+ d.handleInput(sess, ac, []byte(raw))
38+}
39+
40+func TestCopyDoubleClick(t *testing.T) {
41+ t.Run("same normalized position within interval selects word", func(t *testing.T) {
42+ d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")
43+ pane := sess.activeTab().focusedPane()
44+ d.exitCopyMode(ac)
45+ pane.mu.Lock()
46+ row := pane.screen.Frame.Row(0)
47+ row[2] = renderer.Cell{Continuation: true}
48+ pane.mu.Unlock()
49+ d.enterCopyMode(sess, ac)
50+
51+ // SGR column 3 is the continuation cell of the wide glyph at column 2.
52+ copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m")
53+ clock.Advance(copyDoubleClickInterval)
54+ copyMouseInput(d, sess, ac, "\x1b[<0;3;2M")
55+
56+ ac.overlays.copyMu.Lock()
57+ selection := ac.overlays.copyMode.Selection()
58+ pointer := ac.overlays.copyPointer
59+ ac.overlays.copyMu.Unlock()
60+ require.True(t, selection.Enabled)
61+ require.Equal(t, scopy.Word, selection.Granularity)
62+ require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press)
63+ })
64+
65+ for _, tc := range []struct {
66+ name string
67+ advance time.Duration
68+ second string
69+ }{
70+ {name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"},
71+ {name: "different position", second: "\x1b[<0;2;2M"},
72+ } {
73+ t.Run(tc.name+" stays passive", func(t *testing.T) {
74+ d, sess, ac, clock := mouseCopyHarness(t, "alpha")
75+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
76+ clock.Advance(tc.advance)
77+ copyMouseInput(d, sess, ac, tc.second)
78+ ac.overlays.copyMu.Lock()
79+ selection := ac.overlays.copyMode.Selection()
80+ ac.overlays.copyMu.Unlock()
81+ require.False(t, selection.Enabled)
82+ })
83+ }
84+
85+ t.Run("different pane is not a double click", func(t *testing.T) {
86+ d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)})
87+ rt := newOverlayRuntime(&attachedClient{})
88+ p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1})
89+ p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1})
90+ rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()}
91+ require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now()))
92+ })
93+
94+ for _, text := range []string{"alpha beta", "alpha/beta"} {
95+ t.Run("separator is passive "+text, func(t *testing.T) {
96+ d, sess, ac, clock := mouseCopyHarness(t, text)
97+ if text == "alpha/beta" {
98+ cfg := domain.Defaults()
99+ cfg.Copy.WordSeparators = "/"
100+ d.ApplyConfig(cfg)
101+ d.exitCopyMode(ac)
102+ d.enterCopyMode(sess, ac)
103+ }
104+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
105+ clock.Advance(time.Millisecond)
106+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
107+ ac.overlays.copyMu.Lock()
108+ selection := ac.overlays.copyMode.Selection()
109+ ac.overlays.copyMu.Unlock()
110+ require.False(t, selection.Enabled)
111+ })
112+ }
113+
114+ t.Run("normal screen double click publishes word drag", func(t *testing.T) {
115+ d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
116+ d.exitCopyMode(ac)
117+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
118+ clock.Advance(time.Millisecond)
119+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M")
120+ ac.overlays.copyMu.Lock()
121+ selection := ac.overlays.copyMode.Selection()
122+ text := ac.overlays.copyMode.SelectedText()
123+ pointer := ac.overlays.copyPointer
124+ ac.overlays.copyMu.Unlock()
125+ require.Equal(t, scopy.Word, selection.Granularity)
126+ require.True(t, pointer.wordDrag)
127+ require.Equal(t, "alpha beta", text)
128+ })
129+
130+ t.Run("first click drag invalidates candidate", func(t *testing.T) {
131+ d, sess, ac, clock := mouseCopyHarness(t, "alpha")
132+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m")
133+ clock.Advance(time.Millisecond)
134+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
135+ ac.overlays.copyMu.Lock()
136+ selection := ac.overlays.copyMode.Selection()
137+ ac.overlays.copyMu.Unlock()
138+ require.NotEqual(t, scopy.Word, selection.Granularity)
139+ })
140+}
141+
142+func TestCopyWordDragAndOSC52(t *testing.T) {
143+ d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
144+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
145+ clock.Advance(time.Millisecond)
146+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m")
147+
148+ ac.overlays.copyMu.Lock()
149+ mode := ac.overlays.copyMode
150+ selection := mode.Selection()
151+ text := mode.SelectedText()
152+ ac.overlays.copyMu.Unlock()
153+ require.Equal(t, scopy.Word, selection.Granularity)
154+ require.Equal(t, "alpha beta\ngamma", text)
155+ require.Equal(t, "\x1b]52;c;YWxwaGEgYmV0YQpnYW1tYQ=\a", string(scopy.OSC52(text)[0]))
156+
157+ copyMouseInput(d, sess, ac, "y")
158+ require.False(t, ac.overlays.copyActive())
159+}
160+
161+func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) {
162+ rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc})
163+ rt.copyClick = copyClickCandidate{valid: true, pane: p}
164+}
165+
166+func TestCopyPointerResetClearsClick(t *testing.T) {
167+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
168+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators)
169+ for _, tc := range []struct {
170+ name string
171+ reset func(*overlayRuntime)
172+ }{
173+ {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }},
174+ {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }},
175+ {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }},
176+ } {
177+ t.Run(tc.name, func(t *testing.T) {
178+ rt := newOverlayRuntime(&attachedClient{})
179+ rt.copyMu.Lock()
180+ rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc)
181+ seedCopyInteractionLocked(rt, p, doc)
182+ tc.reset(rt)
183+ require.False(t, rt.copyPointer.valid)
184+ require.False(t, rt.copyClick.valid)
185+ rt.copyMu.Unlock()
186+ })
187+ }
188+}
189+
190+func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
191+ p, release := newBlockingPTY(t)
192+ defer release()
193+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
194+ clock := &manualMouseClock{now: time.Unix(1, 0)}
195+ d.clock = clock
196+ copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta"))
197+ d.enterCopyMode(sess, ac)
198+ mustOutputData(t, sends)
199+
200+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
201+ clock.Advance(time.Millisecond)
202+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
203+ mustOutputData(t, sends)
204+ copyMouseInput(d, sess, ac, "y")
205+ // The selection repaint can have been queued before the synchronous OSC 52
206+ // output; consume it, then assert the wire payload itself byte-for-byte.
207+ mustOutputData(t, sends)
208+ require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends))
209+}
210+
211+func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
212+ for _, tc := range []struct {
213+ name string
214+ run func(*Daemon, *session, *attachedClient)
215+ }{
216+ {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }},
217+ {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
218+ {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
219+ {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
220+ p := sess.activeTab().focusedPane()
221+ p.mu.Lock()
222+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
223+ p.mu.Unlock()
224+ require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
225+ }},
226+ } {
227+ t.Run(tc.name, func(t *testing.T) {
228+ d, sess, ac, _ := mouseCopyHarness(t, "word")
229+ ac.overlays.copyMu.Lock()
230+ seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
231+ ac.overlays.copyMu.Unlock()
232+ tc.run(d, sess, ac)
233+ ac.overlays.copyMu.Lock()
234+ require.False(t, ac.overlays.copyPointer.valid)
235+ require.False(t, ac.overlays.copyClick.valid)
236+ ac.overlays.copyMu.Unlock()
237+ })
238+ }
239+}
@@ -0,0 +1,239 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ scopy "github.com/bnema/vev/internal/usecase/copy"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+type manualMouseClock struct{ now time.Time }
16+
17+func (c *manualMouseClock) Now() time.Time { return c.now }
18+func (c *manualMouseClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
19+func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
20+
21+func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) {
22+ t.Helper()
23+ p, release := newBlockingPTY(t)
24+ t.Cleanup(release)
25+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
26+ clock := &manualMouseClock{now: time.Unix(1, 0)}
27+ d.clock = clock
28+ pane := sess.activeTab().focusedPane()
29+ for i, row := range rows {
30+ copy(pane.screen.Frame.Row(i), testRow(row))
31+ }
32+ d.enterCopyMode(sess, ac)
33+ return d, sess, ac, clock
34+}
35+
36+func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) {
37+ d.handleInput(sess, ac, []byte(raw))
38+}
39+
40+func TestCopyDoubleClick(t *testing.T) {
41+ t.Run("same normalized position within interval selects word", func(t *testing.T) {
42+ d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")
43+ pane := sess.activeTab().focusedPane()
44+ d.exitCopyMode(ac)
45+ pane.mu.Lock()
46+ row := pane.screen.Frame.Row(0)
47+ row[2] = renderer.Cell{Continuation: true}
48+ pane.mu.Unlock()
49+ d.enterCopyMode(sess, ac)
50+
51+ // SGR column 3 is the continuation cell of the wide glyph at column 2.
52+ copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m")
53+ clock.Advance(copyDoubleClickInterval)
54+ copyMouseInput(d, sess, ac, "\x1b[<0;3;2M")
55+
56+ ac.overlays.copyMu.Lock()
57+ selection := ac.overlays.copyMode.Selection()
58+ pointer := ac.overlays.copyPointer
59+ ac.overlays.copyMu.Unlock()
60+ require.True(t, selection.Enabled)
61+ require.Equal(t, scopy.Word, selection.Granularity)
62+ require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press)
63+ })
64+
65+ for _, tc := range []struct {
66+ name string
67+ advance time.Duration
68+ second string
69+ }{
70+ {name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"},
71+ {name: "different position", second: "\x1b[<0;2;2M"},
72+ } {
73+ t.Run(tc.name+" stays passive", func(t *testing.T) {
74+ d, sess, ac, clock := mouseCopyHarness(t, "alpha")
75+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
76+ clock.Advance(tc.advance)
77+ copyMouseInput(d, sess, ac, tc.second)
78+ ac.overlays.copyMu.Lock()
79+ selection := ac.overlays.copyMode.Selection()
80+ ac.overlays.copyMu.Unlock()
81+ require.False(t, selection.Enabled)
82+ })
83+ }
84+
85+ t.Run("different pane is not a double click", func(t *testing.T) {
86+ d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)})
87+ rt := newOverlayRuntime(&attachedClient{})
88+ p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1})
89+ p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1})
90+ rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()}
91+ require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now()))
92+ })
93+
94+ for _, text := range []string{"alpha beta", "alpha/beta"} {
95+ t.Run("separator is passive "+text, func(t *testing.T) {
96+ d, sess, ac, clock := mouseCopyHarness(t, text)
97+ if text == "alpha/beta" {
98+ cfg := domain.Defaults()
99+ cfg.Copy.WordSeparators = "/"
100+ d.ApplyConfig(cfg)
101+ d.exitCopyMode(ac)
102+ d.enterCopyMode(sess, ac)
103+ }
104+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
105+ clock.Advance(time.Millisecond)
106+ copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
107+ ac.overlays.copyMu.Lock()
108+ selection := ac.overlays.copyMode.Selection()
109+ ac.overlays.copyMu.Unlock()
110+ require.False(t, selection.Enabled)
111+ })
112+ }
113+
114+ t.Run("normal screen double click publishes word drag", func(t *testing.T) {
115+ d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
116+ d.exitCopyMode(ac)
117+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
118+ clock.Advance(time.Millisecond)
119+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M")
120+ ac.overlays.copyMu.Lock()
121+ selection := ac.overlays.copyMode.Selection()
122+ text := ac.overlays.copyMode.SelectedText()
123+ pointer := ac.overlays.copyPointer
124+ ac.overlays.copyMu.Unlock()
125+ require.Equal(t, scopy.Word, selection.Granularity)
126+ require.True(t, pointer.wordDrag)
127+ require.Equal(t, "alpha beta", text)
128+ })
129+
130+ t.Run("first click drag invalidates candidate", func(t *testing.T) {
131+ d, sess, ac, clock := mouseCopyHarness(t, "alpha")
132+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m")
133+ clock.Advance(time.Millisecond)
134+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
135+ ac.overlays.copyMu.Lock()
136+ selection := ac.overlays.copyMode.Selection()
137+ ac.overlays.copyMu.Unlock()
138+ require.NotEqual(t, scopy.Word, selection.Granularity)
139+ })
140+}
141+
142+func TestCopyWordDragAndOSC52(t *testing.T) {
143+ d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
144+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
145+ clock.Advance(time.Millisecond)
146+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m")
147+
148+ ac.overlays.copyMu.Lock()
149+ mode := ac.overlays.copyMode
150+ selection := mode.Selection()
151+ text := mode.SelectedText()
152+ ac.overlays.copyMu.Unlock()
153+ require.Equal(t, scopy.Word, selection.Granularity)
154+ require.Equal(t, "alpha beta\ngamma", text)
155+ require.Equal(t, "\x1b]52;c;YWxwaGEgYmV0YQpnYW1tYQ=\a", string(scopy.OSC52(text)[0]))
156+
157+ copyMouseInput(d, sess, ac, "y")
158+ require.False(t, ac.overlays.copyActive())
159+}
160+
161+func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) {
162+ rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc})
163+ rt.copyClick = copyClickCandidate{valid: true, pane: p}
164+}
165+
166+func TestCopyPointerResetClearsClick(t *testing.T) {
167+ p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
168+ doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators)
169+ for _, tc := range []struct {
170+ name string
171+ reset func(*overlayRuntime)
172+ }{
173+ {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }},
174+ {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }},
175+ {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }},
176+ } {
177+ t.Run(tc.name, func(t *testing.T) {
178+ rt := newOverlayRuntime(&attachedClient{})
179+ rt.copyMu.Lock()
180+ rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc)
181+ seedCopyInteractionLocked(rt, p, doc)
182+ tc.reset(rt)
183+ require.False(t, rt.copyPointer.valid)
184+ require.False(t, rt.copyClick.valid)
185+ rt.copyMu.Unlock()
186+ })
187+ }
188+}
189+
190+func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
191+ p, release := newBlockingPTY(t)
192+ defer release()
193+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
194+ clock := &manualMouseClock{now: time.Unix(1, 0)}
195+ d.clock = clock
196+ copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta"))
197+ d.enterCopyMode(sess, ac)
198+ mustOutputData(t, sends)
199+
200+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
201+ clock.Advance(time.Millisecond)
202+ copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
203+ mustOutputData(t, sends)
204+ copyMouseInput(d, sess, ac, "y")
205+ // The selection repaint can have been queued before the synchronous OSC 52
206+ // output; consume it, then assert the wire payload itself byte-for-byte.
207+ mustOutputData(t, sends)
208+ require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends))
209+}
210+
211+func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
212+ for _, tc := range []struct {
213+ name string
214+ run func(*Daemon, *session, *attachedClient)
215+ }{
216+ {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }},
217+ {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
218+ {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
219+ {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
220+ p := sess.activeTab().focusedPane()
221+ p.mu.Lock()
222+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
223+ p.mu.Unlock()
224+ require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
225+ }},
226+ } {
227+ t.Run(tc.name, func(t *testing.T) {
228+ d, sess, ac, _ := mouseCopyHarness(t, "word")
229+ ac.overlays.copyMu.Lock()
230+ seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
231+ ac.overlays.copyMu.Unlock()
232+ tc.run(d, sess, ac)
233+ ac.overlays.copyMu.Lock()
234+ require.False(t, ac.overlays.copyPointer.valid)
235+ require.False(t, ac.overlays.copyClick.valid)
236+ ac.overlays.copyMu.Unlock()
237+ })
238+ }
239+}
M internal/usecase/daemon/input.go +47 -13
@@ -166,14 +166,14 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
166166 return
167167 }
168168 rt.copyMu.Lock()
169- rt.invalidateCopyPointerLocked()
169+ rt.invalidateCopyPointerLocked(true)
170170 rt.copyMu.Unlock()
171171 }
172172
173173 // handleFreshCopyPress is the only normal-screen press path that creates a
174174 // selection candidate. Its geometry comes from the same frame-absolute hit
175175 // test used by active copy mode, never from pane-local synthetic rectangles.
176-func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool {
176+func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool {
177177 if routed == nil {
178178 return false
179179 }
@@ -182,7 +182,7 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r
182182 tb.mu.Unlock()
183183 if !ok || geometry.pane != routed {
184184 ac.overlays.copyMu.Lock()
185- ac.overlays.invalidateCopyPointerLocked()
185+ ac.overlays.invalidateCopyPointerLocked(true)
186186 ac.overlays.copyMu.Unlock()
187187 return true
188188 }
@@ -198,15 +198,42 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r
198198 mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)
199199 if !ok {
200200 ac.overlays.copyMu.Lock()
201- ac.overlays.invalidateCopyPointerLocked()
201+ ac.overlays.invalidateCopyPointerLocked(true)
202202 ac.overlays.copyMu.Unlock()
203203 return true
204204 }
205- ac.overlays.copyMu.Lock()
206- if ac.overlays.copyMode == nil && ac.overlays.copyCandidate == nil {
207- ac.overlays.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
205+ rt := ac.overlays
206+ rt.copyMu.Lock()
207+ if rt.copyMode != nil || rt.copyCandidate != nil {
208+ rt.copyMu.Unlock()
209+ return true
210+ }
211+ rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
212+ pointer := rt.copyPointer
213+ now := d.clock.Now()
214+ doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now)
215+ if doubleClick {
216+ rt.copyClick = copyClickCandidate{}
217+ } else {
218+ rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now}
219+ }
220+ rt.copyMu.Unlock()
221+ if !doubleClick {
222+ return true
223+ }
224+
225+ selectedWord := false
226+ if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) {
227+ selectedWord = mode.SelectWordAt(mapped.pos)
228+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
229+ if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
230+ return
231+ }
232+ pointer.wordDrag = selectedWord
233+ runtime.copyPointer = pointer
234+ }) {
235+ d.invalidateRender(sess, ac, true, "input.go")
208236 }
209- ac.overlays.copyMu.Unlock()
210237 return true
211238 }
212239
@@ -223,7 +250,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
223250 if active && ev.Type == mouse.Release {
224251 rt.copyMu.Lock()
225252 if rt.copyPointerEpoch == epoch {
226- rt.invalidateCopyPointerLocked()
253+ rt.invalidateCopyPointerLocked(false)
227254 }
228255 rt.copyMu.Unlock()
229256 return true
@@ -234,7 +261,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
234261 if ev.Type == mouse.Release {
235262 rt.copyMu.Lock()
236263 if rt.copyPointerEpoch == epoch {
237- rt.invalidateCopyPointerLocked()
264+ rt.invalidateCopyPointerLocked(false)
238265 }
239266 rt.copyMu.Unlock()
240267 return true
@@ -248,16 +275,23 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
248275 if mouseMode != 0 || altScreen {
249276 rt.copyMu.Lock()
250277 if rt.copyPointerEpoch == epoch {
251- rt.invalidateCopyPointerLocked()
278+ rt.invalidateCopyPointerLocked(true)
252279 }
253280 rt.copyMu.Unlock()
254281 return false // preserve child forwarding when it enabled mouse reporting.
255282 }
256283 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+ }
257291 if ev.Type == mouse.Release {
258292 rt.copyMu.Lock()
259293 if rt.copyPointerEpoch == epoch {
260- rt.invalidateCopyPointerLocked()
294+ rt.invalidateCopyPointerLocked(false)
261295 }
262296 rt.copyMu.Unlock()
263297 return true
@@ -321,7 +355,7 @@ func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSn
321355 rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
322356 return
323357 }
324- rt.invalidateCopyPointerLocked()
358+ rt.invalidateCopyPointerLocked(true)
325359 }
326360
327361 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
@@ -166,14 +166,14 @@ func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) {
166 return166 return
167 }167 }
168 rt.copyMu.Lock()168 rt.copyMu.Lock()
169- rt.invalidateCopyPointerLocked()169+ rt.invalidateCopyPointerLocked(true)
170 rt.copyMu.Unlock()170 rt.copyMu.Unlock()
171 }171 }
172 172
173 // handleFreshCopyPress is the only normal-screen press path that creates a173 // handleFreshCopyPress is the only normal-screen press path that creates a
174 // selection candidate. Its geometry comes from the same frame-absolute hit174 // selection candidate. Its geometry comes from the same frame-absolute hit
175 // test used by active copy mode, never from pane-local synthetic rectangles.175 // test used by active copy mode, never from pane-local synthetic rectangles.
176-func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool {176+func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool {
177 if routed == nil {177 if routed == nil {
178 return false178 return false
179 }179 }
@@ -182,7 +182,7 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r
182 tb.mu.Unlock()182 tb.mu.Unlock()
183 if !ok || geometry.pane != routed {183 if !ok || geometry.pane != routed {
184 ac.overlays.copyMu.Lock()184 ac.overlays.copyMu.Lock()
185- ac.overlays.invalidateCopyPointerLocked()185+ ac.overlays.invalidateCopyPointerLocked(true)
186 ac.overlays.copyMu.Unlock()186 ac.overlays.copyMu.Unlock()
187 return true187 return true
188 }188 }
@@ -198,15 +198,42 @@ func (d *Daemon) handleFreshCopyPress(_ *session, ac *attachedClient, tb *tab, r
198 mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)198 mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false)
199 if !ok {199 if !ok {
200 ac.overlays.copyMu.Lock()200 ac.overlays.copyMu.Lock()
201- ac.overlays.invalidateCopyPointerLocked()201+ ac.overlays.invalidateCopyPointerLocked(true)
202 ac.overlays.copyMu.Unlock()202 ac.overlays.copyMu.Unlock()
203 return true203 return true
204 }204 }
205- ac.overlays.copyMu.Lock()205+ rt := ac.overlays
206- if ac.overlays.copyMode == nil && ac.overlays.copyCandidate == nil {206+ rt.copyMu.Lock()
207- ac.overlays.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})207+ if rt.copyMode != nil || rt.copyCandidate != nil {
208+ rt.copyMu.Unlock()
209+ return true
210+ }
211+ rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos})
212+ pointer := rt.copyPointer
213+ now := d.clock.Now()
214+ doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now)
215+ if doubleClick {
216+ rt.copyClick = copyClickCandidate{}
217+ } else {
218+ rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now}
219+ }
220+ rt.copyMu.Unlock()
221+ if !doubleClick {
222+ return true
223+ }
224+
225+ selectedWord := false
226+ if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) {
227+ selectedWord = mode.SelectWordAt(mapped.pos)
228+ }, func(runtime *overlayRuntime, mode *scopy.Mode) {
229+ if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document {
230+ return
231+ }
232+ pointer.wordDrag = selectedWord
233+ runtime.copyPointer = pointer
234+ }) {
235+ d.invalidateRender(sess, ac, true, "input.go")
208 }236 }
209- ac.overlays.copyMu.Unlock()
210 return true237 return true
211 }238 }
212 239
@@ -223,7 +250,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
223 if active && ev.Type == mouse.Release {250 if active && ev.Type == mouse.Release {
224 rt.copyMu.Lock()251 rt.copyMu.Lock()
225 if rt.copyPointerEpoch == epoch {252 if rt.copyPointerEpoch == epoch {
226- rt.invalidateCopyPointerLocked()253+ rt.invalidateCopyPointerLocked(false)
227 }254 }
228 rt.copyMu.Unlock()255 rt.copyMu.Unlock()
229 return true256 return true
@@ -234,7 +261,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
234 if ev.Type == mouse.Release {261 if ev.Type == mouse.Release {
235 rt.copyMu.Lock()262 rt.copyMu.Lock()
236 if rt.copyPointerEpoch == epoch {263 if rt.copyPointerEpoch == epoch {
237- rt.invalidateCopyPointerLocked()264+ rt.invalidateCopyPointerLocked(false)
238 }265 }
239 rt.copyMu.Unlock()266 rt.copyMu.Unlock()
240 return true267 return true
@@ -248,16 +275,23 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
248 if mouseMode != 0 || altScreen {275 if mouseMode != 0 || altScreen {
249 rt.copyMu.Lock()276 rt.copyMu.Lock()
250 if rt.copyPointerEpoch == epoch {277 if rt.copyPointerEpoch == epoch {
251- rt.invalidateCopyPointerLocked()278+ rt.invalidateCopyPointerLocked(true)
252 }279 }
253 rt.copyMu.Unlock()280 rt.copyMu.Unlock()
254 return false // preserve child forwarding when it enabled mouse reporting.281 return false // preserve child forwarding when it enabled mouse reporting.
255 }282 }
256 mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion)283 mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion)
284+ if ev.Type == mouse.Motion {
285+ rt.copyMu.Lock()
286+ if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
287+ rt.copyClick.dragged = true
288+ }
289+ rt.copyMu.Unlock()
290+ }
257 if ev.Type == mouse.Release {291 if ev.Type == mouse.Release {
258 rt.copyMu.Lock()292 rt.copyMu.Lock()
259 if rt.copyPointerEpoch == epoch {293 if rt.copyPointerEpoch == epoch {
260- rt.invalidateCopyPointerLocked()294+ rt.invalidateCopyPointerLocked(false)
261 }295 }
262 rt.copyMu.Unlock()296 rt.copyMu.Unlock()
263 return true297 return true
@@ -321,7 +355,7 @@ func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSn
321 rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {355 rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document {
322 return356 return
323 }357 }
324- rt.invalidateCopyPointerLocked()358+ rt.invalidateCopyPointerLocked(true)
325 }359 }
326 360
327 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at361 // handleActiveCopyMouse keeps every drag tied to the pane/document captured at
M internal/usecase/daemon/overlay_runtime.go +19 -4
@@ -2,6 +2,7 @@ package daemon
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"
@@ -44,6 +45,7 @@ type overlayRuntime struct {
4445 copySearchPending []byte
4546 copyFeedback string
4647 copyPointer copyPointerState
48+ copyClick copyClickCandidate
4749 copyPointerEpoch uint64
4850 }
4951
@@ -55,6 +57,15 @@ type copyPointerState struct {
5557 geometry copyMouseGeometry
5658 press scopy.Pos
5759 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
5869 }
5970
6071 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
@@ -120,15 +131,19 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
120131 rt.copyPointer = pointer
121132 }
122133
123-func (rt *overlayRuntime) invalidateCopyPointerLocked() {
134+func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
124135 rt.copyPointerEpoch++
125136 rt.copyPointer = copyPointerState{}
137+ if clearClick {
138+ rt.copyClick = copyClickCandidate{}
139+ }
126140 }
127141
128142 // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
129143 // teardown occurring while publication revalidates can invalidate the transfer.
130144 func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
131145 rt.copyPointer = copyPointerState{}
146+ rt.copyClick = copyClickCandidate{}
132147 }
133148
134149 func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
@@ -149,7 +164,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() {
149164 rt.copyPane = nil
150165 rt.copySearch = nil
151166 rt.copySearchPending = nil
152- rt.invalidateCopyPointerLocked()
167+ rt.invalidateCopyPointerLocked(true)
153168 }
154169
155170 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
@@ -159,11 +174,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
159174 rt.copyMu.Lock()
160175 defer rt.copyMu.Unlock()
161176 active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
162- prePublication := rt.copyPointer.pane == p
177+ prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
163178 if active {
164179 rt.clearCopyModeLocked()
165180 } else if prePublication {
166- rt.invalidateCopyPointerLocked()
181+ rt.invalidateCopyPointerLocked(true)
167182 }
168183 return active || prePublication
169184 }
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "sync"4 "sync"
5+ "time"
5 6
6 scopy "github.com/bnema/vev/internal/usecase/copy"7 scopy "github.com/bnema/vev/internal/usecase/copy"
7 "github.com/bnema/vev/internal/usecase/palette"8 "github.com/bnema/vev/internal/usecase/palette"
@@ -44,6 +45,7 @@ type overlayRuntime struct {
44 copySearchPending []byte45 copySearchPending []byte
45 copyFeedback string46 copyFeedback string
46 copyPointer copyPointerState47 copyPointer copyPointerState
48+ copyClick copyClickCandidate
47 copyPointerEpoch uint6449 copyPointerEpoch uint64
48 }50 }
49 51
@@ -55,6 +57,15 @@ type copyPointerState struct {
55 geometry copyMouseGeometry57 geometry copyMouseGeometry
56 press scopy.Pos58 press scopy.Pos
57 dragging bool59 dragging bool
60+ wordDrag bool
61+}
62+
63+type copyClickCandidate struct {
64+ valid bool
65+ pane *pane
66+ pos scopy.Pos
67+ at time.Time
68+ dragged bool
58 }69 }
59 70
60 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {71 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
@@ -120,15 +131,19 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
120 rt.copyPointer = pointer131 rt.copyPointer = pointer
121 }132 }
122 133
123-func (rt *overlayRuntime) invalidateCopyPointerLocked() {134+func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
124 rt.copyPointerEpoch++135 rt.copyPointerEpoch++
125 rt.copyPointer = copyPointerState{}136 rt.copyPointer = copyPointerState{}
137+ if clearClick {
138+ rt.copyClick = copyClickCandidate{}
139+ }
126 }140 }
127 141
128 // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or142 // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
129 // teardown occurring while publication revalidates can invalidate the transfer.143 // teardown occurring while publication revalidates can invalidate the transfer.
130 func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {144 func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
131 rt.copyPointer = copyPointerState{}145 rt.copyPointer = copyPointerState{}
146+ rt.copyClick = copyClickCandidate{}
132 }147 }
133 148
134 func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {149 func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
@@ -149,7 +164,7 @@ func (rt *overlayRuntime) clearCopyModeLocked() {
149 rt.copyPane = nil164 rt.copyPane = nil
150 rt.copySearch = nil165 rt.copySearch = nil
151 rt.copySearchPending = nil166 rt.copySearchPending = nil
152- rt.invalidateCopyPointerLocked()167+ rt.invalidateCopyPointerLocked(true)
153 }168 }
154 169
155 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {170 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
@@ -159,11 +174,11 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
159 rt.copyMu.Lock()174 rt.copyMu.Lock()
160 defer rt.copyMu.Unlock()175 defer rt.copyMu.Unlock()
161 active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)176 active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
162- prePublication := rt.copyPointer.pane == p177+ prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
163 if active {178 if active {
164 rt.clearCopyModeLocked()179 rt.clearCopyModeLocked()
165 } else if prePublication {180 } else if prePublication {
166- rt.invalidateCopyPointerLocked()181+ rt.invalidateCopyPointerLocked(true)
167 }182 }
168 return active || prePublication183 return active || prePublication
169 }184 }
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -181,7 +181,7 @@ func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T)
181181 ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}})
182182 staleEpoch := ac.overlays.copyPointer.epoch
183183 ac.overlays.clearCopyPointerForTransferLocked()
184- ac.overlays.invalidateCopyPointerLocked() // release/close while publish revalidates
184+ ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates
185185 require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch)
186186 require.False(t, ac.overlays.copyPointer.valid)
187187 ac.overlays.copyCandidate = scopy.NewMode(document)
@@ -181,7 +181,7 @@ func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T)
181 ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}})181 ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}})
182 staleEpoch := ac.overlays.copyPointer.epoch182 staleEpoch := ac.overlays.copyPointer.epoch
183 ac.overlays.clearCopyPointerForTransferLocked()183 ac.overlays.clearCopyPointerForTransferLocked()
184- ac.overlays.invalidateCopyPointerLocked() // release/close while publish revalidates184+ ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates
185 require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch)185 require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch)
186 require.False(t, ac.overlays.copyPointer.valid)186 require.False(t, ac.overlays.copyPointer.valid)
187 ac.overlays.copyCandidate = scopy.NewMode(document)187 ac.overlays.copyCandidate = scopy.NewMode(document)

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