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