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 copy(pane.screen.Frame.Row(i), testRow(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 row := pane.screen.Frame.Row(0)
48 row[2] = renderer.Cell{Continuation: true}
49 pane.mu.Unlock()
50 d.enterCopyMode(sess, ac)
51
52 // SGR column 3 is the continuation cell of the wide glyph at column 2.
53 copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m")
54 clock.Advance(copyDoubleClickInterval)
55 copyMouseInput(d, sess, ac, "\x1b[<0;3;2M")
56
57 ac.overlays.copyMu.Lock()
58 selection := ac.overlays.copyMode.Selection()
59 pointer := ac.overlays.copyPointer
60 ac.overlays.copyMu.Unlock()
61 require.True(t, selection.Enabled)
62 require.Equal(t, scopy.Word, selection.Granularity)
63 require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press)
64 })
65
66 for _, tc := range []struct {
67 name string
68 advance time.Duration
69 second string
70 }{
71 {name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"},
72 {name: "different position", second: "\x1b[<0;2;2M"},
73 } {
74 t.Run(tc.name+" stays passive", func(t *testing.T) {
75 d, sess, ac, clock := mouseCopyHarness(t, "alpha")
76 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
77 clock.Advance(tc.advance)
78 copyMouseInput(d, sess, ac, tc.second)
79 ac.overlays.copyMu.Lock()
80 selection := ac.overlays.copyMode.Selection()
81 ac.overlays.copyMu.Unlock()
82 require.False(t, selection.Enabled)
83 })
84 }
85
86 t.Run("different pane is not a double click", func(t *testing.T) {
87 d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)})
88 rt := newOverlayRuntime(&attachedClient{})
89 p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1})
90 p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1})
91 rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()}
92 require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now()))
93 })
94
95 for _, text := range []string{"alpha beta", "alpha/beta"} {
96 t.Run("separator is passive "+text, func(t *testing.T) {
97 d, sess, ac, clock := mouseCopyHarness(t, text)
98 if text == "alpha/beta" {
99 cfg := domain.Defaults()
100 cfg.Copy.WordSeparators = "/"
101 d.ApplyConfig(cfg)
102 d.exitCopyMode(ac)
103 d.enterCopyMode(sess, ac)
104 }
105 copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
106 clock.Advance(time.Millisecond)
107 copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
108 ac.overlays.copyMu.Lock()
109 selection := ac.overlays.copyMode.Selection()
110 ac.overlays.copyMu.Unlock()
111 require.False(t, selection.Enabled)
112 })
113 }
114
115 t.Run("normal screen double click publishes word drag", func(t *testing.T) {
116 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
117 d.exitCopyMode(ac)
118 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
119 clock.Advance(time.Millisecond)
120 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M")
121 ac.overlays.copyMu.Lock()
122 selection := ac.overlays.copyMode.Selection()
123 text := ac.overlays.copyMode.SelectedText()
124 pointer := ac.overlays.copyPointer
125 ac.overlays.copyMu.Unlock()
126 require.Equal(t, scopy.Word, selection.Granularity)
127 require.True(t, pointer.wordDrag)
128 require.Equal(t, "alpha beta", text)
129 })
130
131 t.Run("first click drag invalidates candidate", func(t *testing.T) {
132 d, sess, ac, clock := mouseCopyHarness(t, "alpha")
133 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m")
134 clock.Advance(time.Millisecond)
135 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
136 ac.overlays.copyMu.Lock()
137 selection := ac.overlays.copyMode.Selection()
138 ac.overlays.copyMu.Unlock()
139 require.NotEqual(t, scopy.Word, selection.Granularity)
140 })
141}
142
143func TestCopyWordDragKeepsWordAnchorOnFirstMotion(t *testing.T) {
144 for _, tc := range []struct {
145 name string
146 pressCol, motionCol int
147 wantAnchor, wantActive scopy.Pos
148 }{
149 {
150 name: "forward from inside first word",
151 pressCol: 3,
152 motionCol: 8,
153 wantAnchor: scopy.Pos{Row: 0},
154 wantActive: scopy.Pos{Row: 0, Col: 9},
155 },
156 {
157 name: "reverse from inside second word",
158 pressCol: 8,
159 motionCol: 3,
160 wantAnchor: scopy.Pos{Row: 0, Col: 6},
161 wantActive: scopy.Pos{Row: 0, Col: 4},
162 },
163 } {
164 t.Run(tc.name, func(t *testing.T) {
165 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
166 copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2m")
167 clock.Advance(time.Millisecond)
168 copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<32;"+strconv.Itoa(tc.motionCol)+";2M")
169
170 ac.overlays.copyMu.Lock()
171 selection := ac.overlays.copyMode.Selection()
172 text := ac.overlays.copyMode.SelectedText()
173 pointer := ac.overlays.copyPointer
174 ac.overlays.copyMu.Unlock()
175 require.Equal(t, scopy.Word, selection.Granularity)
176 require.Equal(t, tc.wantAnchor, selection.Anchor)
177 require.Equal(t, tc.wantActive, selection.Active)
178 require.Equal(t, "alpha beta", text)
179 require.True(t, pointer.dragging)
180 require.True(t, pointer.wordDrag)
181 })
182 }
183}
184
185func TestCopyCharacterDragStartsAtPress(t *testing.T) {
186 d, sess, ac, _ := mouseCopyHarness(t, "alpha beta")
187 copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<32;8;2M")
188
189 ac.overlays.copyMu.Lock()
190 selection := ac.overlays.copyMode.Selection()
191 pointer := ac.overlays.copyPointer
192 ac.overlays.copyMu.Unlock()
193 require.Equal(t, scopy.Character, selection.Granularity)
194 require.Equal(t, scopy.Pos{Row: 0, Col: 2}, selection.Anchor)
195 require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active)
196 require.True(t, pointer.dragging)
197 require.False(t, pointer.wordDrag)
198}
199
200func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) {
201 d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
202
203 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
204 ac.overlays.copyMu.Lock()
205 require.True(t, ac.overlays.copyPointer.valid)
206 epoch := ac.overlays.copyPointerEpoch
207 ac.overlays.copyMu.Unlock()
208
209 copyMouseInput(d, sess, ac, "/")
210 require.True(t, ac.overlays.copySearchActive())
211 copyMouseInput(d, sess, ac, "\x1b[<0;1;2m")
212
213 ac.overlays.copyMu.Lock()
214 require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input")
215 require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch")
216 ac.overlays.copyMu.Unlock()
217
218 copyMouseInput(d, sess, ac, "\x03")
219 require.False(t, ac.overlays.copySearchActive())
220 copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M")
221
222 ac.overlays.copyMu.Lock()
223 selection := ac.overlays.copyMode.Selection()
224 ac.overlays.copyMu.Unlock()
225 require.True(t, selection.Enabled)
226 require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor)
227 require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active)
228}
229
230func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) {
231 for _, tc := range []struct {
232 name string
233 text string
234 setup func(*Daemon)
235 }{
236 {name: "whitespace", text: "alpha beta"},
237 {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) {
238 cfg := domain.Defaults()
239 cfg.Copy.WordSeparators = "/"
240 d.ApplyConfig(cfg)
241 }},
242 } {
243 t.Run(tc.name, func(t *testing.T) {
244 d, sess, ac, clock := mouseCopyHarness(t, tc.text)
245 d.exitCopyMode(ac)
246 if tc.setup != nil {
247 tc.setup(d)
248 }
249
250 copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
251 clock.Advance(time.Millisecond)
252 copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
253
254 ac.overlays.copyMu.Lock()
255 mode := ac.overlays.copyMode
256 selection := mode.Selection()
257 cursor := mode.Cursor()
258 ac.overlays.copyMu.Unlock()
259 require.False(t, selection.Enabled)
260 require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor)
261 })
262 }
263}
264
265func TestCopyWordDragAndOSC52(t *testing.T) {
266 d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
267 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
268 clock.Advance(time.Millisecond)
269 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m")
270
271 ac.overlays.copyMu.Lock()
272 mode := ac.overlays.copyMode
273 selection := mode.Selection()
274 text := mode.SelectedText()
275 ac.overlays.copyMu.Unlock()
276 want := "alpha beta\ngamma"
277 require.Equal(t, scopy.Word, selection.Granularity)
278 require.Equal(t, want, text)
279 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
280
281 copyMouseInput(d, sess, ac, "y")
282 require.False(t, ac.overlays.copyActive())
283}
284
285func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) {
286 rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc})
287 rt.copyClick = copyClickCandidate{valid: true, pane: p}
288}
289
290func TestCopyPointerResetClearsClick(t *testing.T) {
291 p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
292 doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators)
293 for _, tc := range []struct {
294 name string
295 reset func(*overlayRuntime)
296 holdsCopyMu bool
297 }{
298 {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }, holdsCopyMu: true},
299 {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }, holdsCopyMu: true},
300 {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }},
301 } {
302 t.Run(tc.name, func(t *testing.T) {
303 rt := newOverlayRuntime(&attachedClient{})
304 rt.copyMu.Lock()
305 rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc)
306 seedCopyInteractionLocked(rt, p, doc)
307 if !tc.holdsCopyMu {
308 rt.copyMu.Unlock()
309 tc.reset(rt)
310 rt.copyMu.Lock()
311 } else {
312 tc.reset(rt)
313 }
314 require.False(t, rt.copyPointer.valid)
315 require.False(t, rt.copyClick.valid)
316 rt.copyMu.Unlock()
317 })
318 }
319}
320
321func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
322 p, release := newBlockingPTY(t)
323 defer release()
324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325 clock := &manualMouseClock{now: time.Unix(1, 0)}
326 d.clock = clock
327 copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta"))
328 d.enterCopyMode(sess, ac)
329 mustOutputData(t, sends)
330
331 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
332 clock.Advance(time.Millisecond)
333 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
334 mustOutputData(t, sends)
335 copyMouseInput(d, sess, ac, "y")
336 // The selection repaint can have been queued before the synchronous OSC 52
337 // output; consume it, then assert the wire payload itself byte-for-byte.
338 mustOutputData(t, sends)
339 require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends))
340}
341
342func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
343 for _, tc := range []struct {
344 name string
345 run func(*Daemon, *session, *attachedClient)
346 }{
347 {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }},
348 {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
349 {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351 p := testAttachmentTab(sess).focusedPane()
352 p.mu.Lock()
353 doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354 p.mu.Unlock()
355 require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))
356 }},
357 } {
358 t.Run(tc.name, func(t *testing.T) {
359 d, sess, ac, _ := mouseCopyHarness(t, "word")
360 ac.overlays.copyMu.Lock()
361 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
362 ac.overlays.copyMu.Unlock()
363 tc.run(d, sess, ac)
364 ac.overlays.copyMu.Lock()
365 require.False(t, ac.overlays.copyPointer.valid)
366 require.False(t, ac.overlays.copyClick.valid)
367 ac.overlays.copyMu.Unlock()
368 })
369 }
370}