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