1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "strconv"
6 "time"
7
8 renderer "github.com/bnema/vev-vt"
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/internal/usecase/keys"
13 "github.com/bnema/vev/internal/usecase/mouse"
14 themeui "github.com/bnema/vev/internal/usecase/theme"
15 "github.com/bnema/vev/internal/usecase/ui"
16 "github.com/bnema/vev/internal/usecase/visualsearch"
17)
18
19var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
20
21const copyDoubleClickInterval = 500 * time.Millisecond
22
23func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }
24
25// isCopyDoubleClickLocked requires rt.copyMu, not d.mu.
26func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {
27 candidate := rt.copyClick
28 return candidate.valid && !candidate.dragged && candidate.pane == pane &&
29 sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) &&
30 now.Sub(candidate.at) <= copyDoubleClickInterval
31}
32
33func copyTargetPane(rt *overlayRuntime) *pane {
34 if rt == nil {
35 return nil
36 }
37 rt.copyMu.Lock()
38 defer rt.copyMu.Unlock()
39 if rt.copyMode == nil {
40 return nil
41 }
42 return rt.copyPane
43}
44
45func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
46 rt := ac.overlays
47 rt.copyMu.Lock()
48 changed, exit := rt.moveCopyWheelLocked(delta)
49 rt.copyMu.Unlock()
50 if !changed && !exit {
51 return
52 }
53 // Moving within the immutable copy document does not invalidate live pane
54 // captures. The compositor still redraws the overlay; leaving it resets live
55 // state so attachment-local snapshots cannot survive the return to the PTY.
56 d.invalidateRender(sess, ac, exit, "copymode.go")
57}
58
59func (rt *overlayRuntime) moveCopyWheelLocked(delta int) (changed, exit bool) {
60 if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
61 return false, false
62 }
63 changed = rt.copyMode.ScrollRows(delta)
64 exit = delta > 0 && rt.copyMode.AtBottom()
65 if exit {
66 rt.clearCopyModeLocked()
67 }
68 return changed, exit
69}
70
71func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
72 tb := sess.tabForAttachment(ac)
73 if tb == nil {
74 return
75 }
76 tb.mu.Lock()
77 p := tb.terminalTargetLocked()
78 tb.mu.Unlock()
79 if p == nil {
80 return
81 }
82 p.mu.Lock()
83 snapshot := scopy.NewSnapshotFromScreen(p.history, p.screen)
84 p.mu.Unlock()
85 cfg := d.currentCopyConfig()
86 document := scopy.NewDocument(snapshot, cfg.WordSeparators)
87 if !d.publishCopyMode(sess, ac, tb, p, document, nil, nil) {
88 return
89 }
90 d.invalidateRender(sess, ac, true, "copymode.go")
91}
92
93// publishCopyMode serializes overlay publication after pane membership is
94// revalidated. Document is the sole immutable copy payload.
95func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document *scopy.Document, prepare func(*scopy.Mode), activate func(*overlayRuntime, *scopy.Mode)) bool {
96 if sess == nil || ac == nil || tb == nil || p == nil || document == nil {
97 return false
98 }
99 mode := scopy.NewMode(document)
100 if prepare != nil {
101 prepare(mode)
102 }
103 rt := ac.overlays
104 rt.copyMu.Lock()
105 d.stopCopyPendingTimerLocked(ac)
106 rt.copyPending = nil
107 rt.copyMode = nil
108 rt.copyCandidate = mode
109 rt.copyDocument = document
110 rt.copyPane = p
111 rt.copySearch = nil
112 rt.copySearchPending = nil
113 if activate != nil {
114 rt.clearCopyPointerForTransferLocked()
115 } else {
116 rt.invalidateCopyPointerLocked(true)
117 }
118 pointerEpoch := rt.copyPointerEpoch
119 rt.copyMu.Unlock()
120 if d.beforeCopyModeRevalidate != nil {
121 d.beforeCopyModeRevalidate()
122 }
123 valid := sess.tabForAttachment(ac) == tb
124 if valid {
125 tb.mu.Lock()
126 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
127 tb.mu.Unlock()
128 }
129 rt.copyMu.Lock()
130 defer rt.copyMu.Unlock()
131 if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {
132 return false
133 }
134 if pointerEpoch != rt.copyPointerEpoch {
135 rt.discardCopyCandidateLocked(mode)
136 return false
137 }
138 if !valid {
139 rt.clearCopyModeLocked()
140 return false
141 }
142 rt.copyCandidate = nil
143 rt.copyMode = mode
144 if activate != nil {
145 activate(rt, mode)
146 }
147 return true
148}
149
150// copyMouse receives an already mapped client-frame position. It revalidates
151// the immutable snapshot under copyMu before changing selection state, so a
152// concurrent scroll or pointer lifecycle transition cannot apply stale rows.
153func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse, snapshot copyMouseInputSnapshot, geometry copyMouseGeometry) {
154 rt := ac.overlays
155 if ev.Button != mouse.Left {
156 return
157 }
158 rt.copyMu.Lock()
159 if rt.copyMode != snapshot.mode || rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document ||
160 rt.copyPointerEpoch != snapshot.epoch || rt.copyMode == nil || rt.copyDocument != rt.copyMode.Document() ||
161 rt.copyMode.ViewportTop != snapshot.viewportTop {
162 rt.copyMu.Unlock()
163 return
164 }
165 changed := false
166 switch ev.Type {
167 case mouse.Press:
168 changed = rt.copyMode.SetPosition(mapped.pos)
169 pointer := rt.copyPointer
170 if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {
171 rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos})
172 pointer = rt.copyPointer
173 }
174 now := d.clock.Now()
175 if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) {
176 pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos)
177 changed = pointer.wordDrag || changed
178 rt.copyClick = copyClickCandidate{}
179 } else {
180 rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now}
181 }
182 rt.copyPointer = pointer
183 case mouse.Motion:
184 pointer := rt.copyPointer
185 if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
186 if !pointer.dragging {
187 if !pointer.wordDrag {
188 rt.copyMode.StartCharacterSelection(pointer.press)
189 }
190 pointer.dragging = true
191 }
192 if rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
193 rt.copyClick.dragged = true
194 }
195 if pointer.wordDrag {
196 changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
197 } else {
198 changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
199 }
200 rt.copyPointer = pointer
201 }
202 case mouse.Release:
203 pointer := rt.copyPointer
204 if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging {
205 if pointer.wordDrag {
206 changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
207 } else {
208 changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
209 }
210 }
211 rt.invalidateCopyPointerLocked(false)
212 }
213 rt.copyMu.Unlock()
214 if changed {
215 d.invalidateRender(sess, ac, true, "copymode.go")
216 }
217}
218
219func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220 rt := ac.overlays
221 sess := ac.currentSession()
222 if sess == nil {
223 return
224 }
225 rt.copyMu.Lock()
226 if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
227 rt.copyPending = nil
228 d.stopCopyPendingTimerLocked(ac)
229 rt.copyMu.Unlock()
230 return
231 }
232 if len(rt.copyPending) > 0 {
233 d.stopCopyPendingTimerLocked(ac)
234 data = append(append([]byte(nil), rt.copyPending...), data...)
235 rt.copyPending = nil
236 }
237 if rt.copySearch != nil {
238 changed, close, accepted := d.routeCopySearchInputLocked(rt, data)
239 rt.copyMu.Unlock()
240 if changed || close || accepted {
241 d.invalidateRender(sess, ac, true, "copymode.go")
242 }
243 return
244 }
245 changed, copyOut, exit := false, false, false
246 for i := 0; i < len(data); i++ {
247 switch data[i] {
248 case 'h':
249 rt.copyMode.Left()
250 changed = true
251 case 'j':
252 rt.copyMode.Down()
253 changed = true
254 case 'k':
255 rt.copyMode.Up()
256 changed = true
257 case 'l':
258 rt.copyMode.Right()
259 changed = true
260 case 'w':
261 rt.copyMode.WordNext()
262 changed = true
263 case 'b':
264 rt.copyMode.WordBackward()
265 changed = true
266 case 'e':
267 rt.copyMode.WordEnd()
268 changed = true
269 case 'g':
270 rt.copyMode.Top()
271 changed = true
272 case 'G':
273 rt.copyMode.Bottom()
274 changed = true
275 case ' ', 'v':
276 rt.copyMode.ToggleLineSelection()
277 changed = true
278 case '/':
279 rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot())
280 rt.copySearchPending = nil
281 changed = true
282 if i+1 < len(data) {
283 a, b, c := d.routeCopySearchInputLocked(rt, data[i+1:])
284 changed = changed || a || c
285 _ = b
286 }
287 i = len(data)
288 case 'n':
289 changed = rt.copyMode.NextSearchMatch(1) || changed
290 case 'N':
291 changed = rt.copyMode.NextSearchMatch(-1) || changed
292 case '\r', '\n', 'y':
293 copyOut = true
294 exit = true
295 case 'q', 0x03, 0x1b:
296 if data[i] == 0x1b {
297 tail := data[i:]
298 consumed, ok := routeCopyEscape(rt.copyMode, tail)
299 if ok {
300 i += consumed - 1
301 changed = true
302 continue
303 }
304 if len(tail) == 1 {
305 d.retainCopyESCLocked(ac)
306 break
307 }
308 if isCopyEscapePrefix(tail) {
309 rt.copyPending = append(rt.copyPending[:0], tail...)
310 break
311 }
312 }
313 exit = true
314 }
315 }
316 text := ""
317 if copyOut {
318 text = rt.copyMode.SelectedText()
319 }
320 if exit {
321 d.stopCopyPendingTimerLocked(ac)
322 rt.clearCopyModeLocked()
323 }
324 rt.copyMu.Unlock()
325 if copyOut && text != "" {
326 chunks := scopy.OSC52(text)
327 for _, chunk := range chunks {
328 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
329 if err != nil {
330 d.detachOnSendError(sess, ac, failed)
331 return
332 }
333 }
334 rt.copyMu.Lock()
335 if len(chunks) > 0 {
336 rt.statusFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
337 } else {
338 rt.statusFeedback = "selection too large to copy"
339 }
340 rt.copyMu.Unlock()
341 }
342 if exit || changed {
343 d.invalidateRender(sess, ac, true, "copymode.go")
344 }
345}
346
347// routeCopySearchInputLocked requires rt.copyMu, not d.mu.
348func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {
349 routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {
350 if _, ok := rt.copySearch.Selected(); ok {
351 accepted = rt.copyMode.SetSearchMatches(rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
352 closeSearch = accepted
353 }
354 }, cancel: func() { closeSearch = true }, up: func() { rt.copySearch.Up(); changed = true }, down: func() { rt.copySearch.Down(); changed = true }})
355 if changed && !closeSearch {
356 d.previewCopySearchSelectionLocked(rt)
357 }
358 if closeSearch {
359 rt.copySearch = nil
360 rt.copySearchPending = nil
361 }
362 return
363}
364
365// previewCopySearchSelectionLocked requires rt.copyMu, not d.mu.
366func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
367 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
368 return
369 }
370 if match, ok := rt.copySearch.Selected(); ok {
371 rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})
372 }
373}
374
375// retainCopyESCLocked requires ac.overlays.copyMu, not d.mu.
376func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
377 rt := ac.overlays
378 mode := rt.copyMode
379 rt.copyPending = append(rt.copyPending[:0], keys.ESC)
380 rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
381 rt.copyMu.Lock()
382 if rt.copyESC.timer != timer {
383 rt.copyMu.Unlock()
384 return
385 }
386 rt.copyPending = nil
387 rt.copyESC.timer = nil
388 rt.copyESC.done = nil
389 if rt.copyMode != mode || rt.copyMode == nil {
390 rt.copyMu.Unlock()
391 return
392 }
393 rt.clearCopyModeLocked()
394 rt.copyMu.Unlock()
395 if sess := ac.currentSession(); sess != nil {
396 d.invalidateRender(sess, ac, true, "copymode.go")
397 }
398 })
399}
400
401// stopCopyPendingTimerLocked requires ac.overlays.copyMu, not d.mu.
402func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }
403func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
404 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
405 switch data[2] {
406 case 'A':
407 m.Up()
408 return 3, true
409 case 'B':
410 m.Down()
411 return 3, true
412 }
413 }
414 if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
415 switch data[2] {
416 case '5':
417 m.Page(-1)
418 return 4, true
419 case '6':
420 m.Page(1)
421 return 4, true
422 }
423 }
424 return 0, false
425}
426func isCopyEscapePrefix(data []byte) bool {
427 return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
428}
429func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, styles themeui.Styles) (renderer.Frame, []renderer.Damage) {
430 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
431 return frame, nil
432 }
433 statusY := frame.Height - 1
434 frame.FillRow(statusY, 0, frame.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
435 mode.RenderRows(func(y int, row []renderer.Cell) {
436 if y == mode.Document().Height() {
437 frame.WriteRow(statusY, 0, row[:min(frame.Width, len(row))])
438 return
439 }
440 if y >= target.Height || target.Y+y < 0 || target.Y+y >= statusY {
441 return
442 }
443 dstX := max(target.X, 0)
444 srcX := max(-target.X, 0)
445 width := min(target.Width-srcX, len(row)-srcX, frame.Width-dstX)
446 if width > 0 {
447 frame.WriteRow(target.Y+y, dstX, row[srcX:srcX+width])
448 }
449 }, styles.CopyStatus, styles.Selection)
450 return frame, []renderer.Damage{renderer.FullRedraw()}
451}