1package daemon
2
3import (
4 "strconv"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 renderer "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 scopy "github.com/bnema/vev/internal/usecase/copy"
14)
15
16type manualMouseClock struct{ now time.Time }
17
18func (c *manualMouseClock) Now() time.Time { return c.now }
19func (c *manualMouseClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
20func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
21
22func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) {
23 t.Helper()
24 p, release := newBlockingPTY(t)
25 t.Cleanup(release)
26 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
27 clock := &manualMouseClock{now: time.Unix(1, 0)}
28 d.clock = clock
29 pane := testAttachmentTab(sess).focusedPane()
30 for i, row := range rows {
31 writeTestRow(pane.screen, i, row)
32 }
33 d.enterCopyMode(sess, ac)
34 return d, sess, ac, clock
35}
36
37func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) {
38 d.handleInput(sess, ac, []byte(raw))
39}
40
41func TestCopyDoubleClick(t *testing.T) {
42 t.Run("same normalized position within interval selects word", func(t *testing.T) {
43 d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")
44 pane := testAttachmentTab(sess).focusedPane()
45 d.exitCopyMode(ac)
46 pane.mu.Lock()
47 require.True(t, pane.screen.Cell(2, 0).Continuation)
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
142func TestCopyWordDragKeepsWordAnchorOnFirstMotion(t *testing.T) {
143 for _, tc := range []struct {
144 name string
145 pressCol, motionCol int
146 wantAnchor, wantActive scopy.Pos
147 }{
148 {
149 name: "forward from inside first word",
150 pressCol: 3,
151 motionCol: 8,
152 wantAnchor: scopy.Pos{Row: 0},
153 wantActive: scopy.Pos{Row: 0, Col: 9},
154 },
155 {
156 name: "reverse from inside second word",
157 pressCol: 8,
158 motionCol: 3,
159 wantAnchor: scopy.Pos{Row: 0, Col: 6},
160 wantActive: scopy.Pos{Row: 0, Col: 4},
161 },
162 } {
163 t.Run(tc.name, func(t *testing.T) {
164 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
165 copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2m")
166 clock.Advance(time.Millisecond)
167 copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<32;"+strconv.Itoa(tc.motionCol)+";2M")
168
169 ac.overlays.copyMu.Lock()
170 selection := ac.overlays.copyMode.Selection()
171 text := ac.overlays.copyMode.SelectedText()
172 pointer := ac.overlays.copyPointer
173 ac.overlays.copyMu.Unlock()
174 require.Equal(t, scopy.Word, selection.Granularity)
175 require.Equal(t, tc.wantAnchor, selection.Anchor)
176 require.Equal(t, tc.wantActive, selection.Active)
177 require.Equal(t, "alpha beta", text)
178 require.True(t, pointer.dragging)
179 require.True(t, pointer.wordDrag)
180 })
181 }
182}
183
184func TestCopyCharacterDragStartsAtPress(t *testing.T) {
185 d, sess, ac, _ := mouseCopyHarness(t, "alpha beta")
186 copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<32;8;2M")
187
188 ac.overlays.copyMu.Lock()
189 selection := ac.overlays.copyMode.Selection()
190 pointer := ac.overlays.copyPointer
191 ac.overlays.copyMu.Unlock()
192 require.Equal(t, scopy.Character, selection.Granularity)
193 require.Equal(t, scopy.Pos{Row: 0, Col: 2}, selection.Anchor)
194 require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active)
195 require.True(t, pointer.dragging)
196 require.False(t, pointer.wordDrag)
197}
198
199func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) {
200 d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
201
202 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
203 ac.overlays.copyMu.Lock()
204 require.True(t, ac.overlays.copyPointer.valid)
205 epoch := ac.overlays.copyPointerEpoch
206 ac.overlays.copyMu.Unlock()
207
208 copyMouseInput(d, sess, ac, "/")
209 require.True(t, ac.overlays.copySearchActive())
210 copyMouseInput(d, sess, ac, "\x1b[<0;1;2m")
211
212 ac.overlays.copyMu.Lock()
213 require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input")
214 require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch")
215 ac.overlays.copyMu.Unlock()
216
217 copyMouseInput(d, sess, ac, "\x03")
218 require.False(t, ac.overlays.copySearchActive())
219 copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M")
220
221 ac.overlays.copyMu.Lock()
222 selection := ac.overlays.copyMode.Selection()
223 ac.overlays.copyMu.Unlock()
224 require.True(t, selection.Enabled)
225 require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor)
226 require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active)
227}
228
229func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) {
230 for _, tc := range []struct {
231 name string
232 text string
233 setup func(*Daemon)
234 }{
235 {name: "whitespace", text: "alpha beta"},
236 {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) {
237 cfg := domain.Defaults()
238 cfg.Copy.WordSeparators = "/"
239 d.ApplyConfig(cfg)
240 }},
241 } {
242 t.Run(tc.name, func(t *testing.T) {
243 d, sess, ac, clock := mouseCopyHarness(t, tc.text)
244 d.exitCopyMode(ac)
245 if tc.setup != nil {
246 tc.setup(d)
247 }
248
249 copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
250 clock.Advance(time.Millisecond)
251 copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
252
253 ac.overlays.copyMu.Lock()
254 mode := ac.overlays.copyMode
255 selection := mode.Selection()
256 cursor := mode.Cursor()
257 ac.overlays.copyMu.Unlock()
258 require.False(t, selection.Enabled)
259 require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor)
260 })
261 }
262}
263
264func TestCopyWordDragAndOSC52(t *testing.T) {
265 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
266 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
267 clock.Advance(time.Millisecond)
268 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m")
269
270 ac.overlays.copyMu.Lock()
271 mode := ac.overlays.copyMode
272 selection := mode.Selection()
273 text := mode.SelectedText()
274 ac.overlays.copyMu.Unlock()
275 want := "alpha beta\ngamma"
276 require.Equal(t, scopy.Word, selection.Granularity)
277 require.Equal(t, want, text)
278 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
279
280 copyMouseInput(d, sess, ac, "y")
281 require.False(t, ac.overlays.copyActive())
282}
283
284func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) {
285 rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc})
286 rt.copyClick = copyClickCandidate{valid: true, pane: p}
287}
288
289func TestCopyPointerResetClearsClick(t *testing.T) {
290 p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
291 doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators)
292 for _, tc := range []struct {
293 name string
294 reset func(*overlayRuntime)
295 holdsCopyMu bool
296 }{
297 {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }, holdsCopyMu: true},
298 {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }, holdsCopyMu: true},
299 {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }},
300 } {
301 t.Run(tc.name, func(t *testing.T) {
302 rt := newOverlayRuntime(&attachedClient{})
303 rt.copyMu.Lock()
304 rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc)
305 seedCopyInteractionLocked(rt, p, doc)
306 if !tc.holdsCopyMu {
307 rt.copyMu.Unlock()
308 tc.reset(rt)
309 rt.copyMu.Lock()
310 } else {
311 tc.reset(rt)
312 }
313 require.False(t, rt.copyPointer.valid)
314 require.False(t, rt.copyClick.valid)
315 rt.copyMu.Unlock()
316 })
317 }
318}
319
320func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
321 p, release := newBlockingPTY(t)
322 defer release()
323 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
324 clock := &manualMouseClock{now: time.Unix(1, 0)}
325 d.clock = clock
326 writeTestRow(testAttachmentTab(sess).focusedPane().screen, 0, "alpha beta")
327 d.enterCopyMode(sess, ac)
328 mustOutputData(t, sends)
329
330 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
331 clock.Advance(time.Millisecond)
332 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
333 mustOutputData(t, sends)
334 copyMouseInput(d, sess, ac, "y")
335 // The selection repaint can have been queued before the synchronous OSC 52
336 // output; consume it, then assert the wire payload itself byte-for-byte.
337 mustOutputData(t, sends)
338 require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends))
339}
340
341func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
342 for _, tc := range []struct {
343 name string
344 run func(*Daemon, *session, *attachedClient)
345 }{
346 {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }},
347 {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
348 {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
349 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
350 p := testAttachmentTab(sess).focusedPane()
351 p.mu.Lock()
352 doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
353 p.mu.Unlock()
354 require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))
355 }},
356 } {
357 t.Run(tc.name, func(t *testing.T) {
358 d, sess, ac, _ := mouseCopyHarness(t, "word")
359 ac.overlays.copyMu.Lock()
360 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
361 ac.overlays.copyMu.Unlock()
362 tc.run(d, sess, ac)
363 ac.overlays.copyMu.Lock()
364 require.False(t, ac.overlays.copyPointer.valid)
365 require.False(t, ac.overlays.copyClick.valid)
366 ac.overlays.copyMu.Unlock()
367 })
368 }
369}