1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "bytes"
6 "errors"
7 "strconv"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/keys"
11 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/mouse"
13)
14
15func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) {
16 // Do not acknowledge client-side echo prediction here: input has only been
17 // accepted/routed, not necessarily echoed by the PTY and incorporated into a
18 // rendered screen state. Until prediction is implemented against rendered
19 // output state, Echo must remain conservative.
20 d.handleInput(sess, ac, data)
21}
22
23func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24 if !token.attachmentEffectCurrent() {
25 return
26 }
27 d.handleInputForAttachment(token, data)
28}
29
30func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
31 ac.initOverlays()
32 ac.mouseScan.Scan(data,
33 func(ev mouse.Event) { d.handleMouse(ac, ev) },
34 func(b []byte) {
35 if ac.overlays.HandleInput(d, b) {
36 return
37 }
38 ac.keys.Route(b)
39 },
40 )
41}
42
43func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
44 ac := token.ac
45 ac.initOverlays()
46 ac.mouseScan.Scan(data,
47 func(ev mouse.Event) {
48 if token.attachmentEffectCurrent() {
49 d.handleMouse(ac, ev)
50 }
51 },
52 func(b []byte) {
53 if !token.attachmentEffectCurrent() {
54 return
55 }
56 if ac.overlays.HandleInput(d, b, token.effect) {
57 return
58 }
59 if !token.attachmentEffectCurrent() {
60 return
61 }
62 ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
63 },
64 )
65}
66
67func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
68 d.handleMouseMutation(ac, ev)
69}
70
71func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
72 frameEvent := ev
73 ac.initOverlays()
74 rt := ac.overlays
75 if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() {
76 return
77 }
78 sess := ac.currentSession()
79 if sess == nil {
80 invalidateRejectedLeftPointer(rt, ev)
81 return
82 }
83 tb := sess.tabForAttachment(ac)
84 if tb == nil {
85 invalidateRejectedLeftPointer(rt, ev)
86 return
87 }
88
89 if d.handleCopyMouse(sess, ac, tb, ev) {
90 return
91 }
92 // A fresh drag is pinned to its press geometry. Handle later events before
93 // normal terminal routing so crossing a split cannot retarget its document.
94 if ev.Button == mouse.Left && ev.Type != mouse.Press && d.handleFreshCopyPointer(sess, ac, ev) {
95 return
96 }
97
98 // Copy-mode publication deliberately revalidates outside the dispatch lock:
99 // a concurrent release or pane close must be able to invalidate its candidate.
100 var routed mouseRoute
101 _ = sess.runMutation(func() error {
102 routed = d.routeMouseMutation(sess, ac, ev)
103 return nil
104 })
105 if routed.handled {
106 return
107 }
108 if routed.p == nil {
109 invalidateRejectedLeftPointer(rt, ev)
110 return
111 }
112 if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) {
113 return
114 }
115 d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused)
116}
117
118type mouseRoute struct {
119 tb *tab
120 p *pane
121 event mouse.Event
122 translated bool
123 hoveredFocused bool
124 handled bool
125}
126
127func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute {
128 route := mouseRoute{event: ev, hoveredFocused: true}
129 tb := sess.tabForAttachment(ac)
130 if tb == nil {
131 route.handled = true
132 return route
133 }
134 route.tb = tb
135 contentRow := ev.Row - clientTopBarRows
136 tb.mu.Lock()
137 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
138 if floatingVisible {
139 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
140 tb.mu.Unlock()
141 route.handled = true
142 return route
143 }
144 tb.mu.Unlock()
145 route.p = floating
146 route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
147 route.translated = true
148 return route
149 }
150 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
151 multi := len(tb.panes) > 1
152 focusedID := mouseFocusedPaneIDLocked(ac, tb)
153 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
154 if !isMouseFocusPress(ev) {
155 tb.mu.Unlock()
156 route.handled = true
157 return route
158 }
159 tb.mu.Unlock()
160 _, focused := d.focusMousePane(sess, ac, tb, pl.ID)
161 if focused == nil {
162 route.handled = true
163 return route
164 }
165 invalidateRejectedLeftPointer(ac.overlays, ev)
166 if pl.ID != focusedID {
167 d.exitCopyMode(ac)
168 d.refreshPaneTitleOnFocus(sess, pl.ID)
169 }
170 d.invalidateRender(sess, ac, true, "input.go")
171 route.handled = true
172 return route
173 }
174 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
175 oldFocus := focusedID
176 shouldFocus := isMouseFocusPress(ev)
177 p := tb.panes[pl.ID]
178 hoveredFocused := pl.ID == oldFocus
179 tb.mu.Unlock()
180 if shouldFocus {
181 _, p = d.focusMousePane(sess, ac, tb, pl.ID)
182 }
183 if p == nil {
184 route.handled = true
185 return route
186 }
187 if shouldFocus && pl.ID != oldFocus {
188 d.exitCopyMode(ac)
189 d.refreshPaneTitleOnFocus(sess, pl.ID)
190 d.invalidateRender(sess, ac, true, "input.go")
191 }
192 route.p = p
193 route.hoveredFocused = hoveredFocused
194 if multi {
195 route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
196 route.translated = true
197 }
198 return route
199 }
200 if multi {
201 tb.mu.Unlock()
202 route.handled = true
203 return route
204 }
205 tb.mu.Unlock()
206 _, route.p = sess.paneForAttachment(ac)
207 return route
208}
209
210func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID {
211 if ac != nil && tb != nil {
212 view := ac.viewSnapshot()
213 if domain.TabStableID(tb.stableID) == view.tabID {
214 for _, p := range tb.panes {
215 if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
216 return p.id
217 }
218 }
219 }
220 }
221 if tb == nil || tb.tree == nil {
222 return ""
223 }
224 return tb.tree.Focus
225}
226
227// focusMousePane runs inside the caller's session mutation boundary.
228func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) {
229 var changed bool
230 var focused *pane
231 tb.mu.Lock()
232 if tb.tree != nil {
233 focused = tb.panes[id]
234 if focused != nil {
235 changed = focusPlacementLocked(tb, id)
236 }
237 }
238 tb.mu.Unlock()
239 if focused == nil {
240 return false, nil
241 }
242 sess.mu.Lock()
243 sess.setAttachmentPaneLocked(ac, tb, focused)
244 sess.mu.Unlock()
245 if changed {
246 d.applyTabLayout(sess, tb)
247 }
248 return changed, focused
249}
250
251func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
252 if p == nil {
253 return
254 }
255 p.mu.Lock()
256 childRows := p.screen.Frame.Height
257 mouseMode, mouseSGR := p.screen.MouseMode()
258 altScreen := p.screen.AltScreenActive()
259 p.mu.Unlock()
260
261 if mouseMode != 0 {
262 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
263 return
264 }
265 _ = sess.runMutation(func() error {
266 if translated {
267 d.writeToPane(sess, p, ev.Raw)
268 } else {
269 d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
270 }
271 return nil
272 })
273 return
274 }
275
276 switch ev.Button {
277 case mouse.WheelUp:
278 if altScreen {
279 _ = sess.runMutation(func() error {
280 d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
281 return nil
282 })
283 return
284 }
285 if !hoveredFocused {
286 return
287 }
288 d.enterCopyMode(sess, ac)
289 d.copyWheel(sess, ac, -3)
290 case mouse.WheelDown:
291 if altScreen {
292 _ = sess.runMutation(func() error {
293 d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
294 return nil
295 })
296 }
297 }
298}
299
300func isMouseFocusPress(ev mouse.Event) bool {
301 return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
302}
303
304func clampInt(v, lo, hi int) int {
305 if v < lo {
306 return lo
307 }
308 if v > hi {
309 return hi
310 }
311 return v
312}
313
314func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
315 if p == nil || p.pty == nil {
316 return
317 }
318 if _, err := p.pty.Write(data); err != nil {
319 name := ""
320 if sess != nil {
321 sess.mu.Lock()
322 name = sess.name
323 sess.mu.Unlock()
324 }
325 d.log.Error("pty write failed", "err", err, "session", name)
326 d.notify(sess, domain.NoticeError, domain.NoticeInputDropped,
327 "input not delivered to pane", err)
328 }
329}
330
331func sgrRowOffset(raw []byte, delta int) []byte {
332 return sgrOffset(raw, 0, delta)
333}
334
335func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
336 if len(raw) < len("\x1b[<0;1;1M") {
337 return raw
338 }
339 end := len(raw) - 1
340 if raw[0] != '\x1b' || raw[1] != '[' || raw[2] != '<' || (raw[end] != 'M' && raw[end] != 'm') {
341 return raw
342 }
343
344 parts := bytes.Split(raw[3:end], []byte(";"))
345 if len(parts) != 3 {
346 return raw
347 }
348 cx, err := strconv.Atoi(string(parts[1]))
349 if err != nil {
350 return raw
351 }
352 cy, err := strconv.Atoi(string(parts[2]))
353 if err != nil {
354 return raw
355 }
356 cx += colDelta
357 cy += rowDelta
358 if cx < 1 || cy < 1 {
359 return raw
360 }
361
362 out := make([]byte, 0, len(raw)+4)
363 out = append(out, raw[:3]...)
364 out = append(out, parts[0]...)
365 out = append(out, ';')
366 out = strconv.AppendInt(out, int64(cx), 10)
367 out = append(out, ';')
368 out = strconv.AppendInt(out, int64(cy), 10)
369 out = append(out, raw[end])
370 return out
371}
372
373type daemonKeyHandler struct {
374 d *Daemon
375 ac *attachedClient
376 actions daemonActionRunner
377 connectionToken attachmentConnectionToken
378}
379
380// acquireAttachmentEffect preserves a synchronous frame's existing admission and
381// gives delayed router callbacks a fresh ticket for the exact captured attachment.
382// Direct/headless callers without an attachment token retain their existing behavior.
383func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384 if h.connectionToken.ac != nil {
385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386 return h.connectionToken.sess, effect, false
387 }
388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
389 if h.d.afterDelayedKeyEffectAttempt != nil {
390 h.d.afterDelayedKeyEffectAttempt(admitted)
391 }
392 if !admitted {
393 return nil, nil, false
394 }
395 if h.d.afterAttachmentEffectAdmitted != nil {
396 token := h.connectionToken
397 token.effect = effect
398 h.d.afterAttachmentEffectAdmitted(token)
399 }
400 // Keep this defensive check even though token admission normally requires a session.
401 if h.connectionToken.sess == nil {
402 effect.End()
403 return nil, nil, false
404 }
405 return h.connectionToken.sess, effect, true
406 }
407 sess := h.ac.currentSession()
408 if sess == nil {
409 return nil, nil, false
410 }
411 return sess, nil, false
412}
413
414func (h daemonKeyHandler) Forward(data []byte) {
415 sess, effect, owned := h.acquireAttachmentEffect()
416 if sess == nil {
417 return
418 }
419 if owned {
420 defer effect.End()
421 }
422 _ = sess.runMutation(func() error {
423 tb := sess.tabForAttachment(h.ac)
424 if tb == nil {
425 return nil
426 }
427 tb.mu.Lock()
428 p := tb.terminalTargetLocked()
429 tb.mu.Unlock()
430 h.d.writeToPane(sess, p, data)
431 return nil
432 })
433}
434
435func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
436 sess, effect, owned := h.acquireAttachmentEffect()
437 if sess == nil {
438 return
439 }
440 if owned {
441 defer effect.End()
442 }
443 runAction := func(request daemonActionRequest) {
444 request.effect = effect
445 runner := h.actions
446 if runner == nil {
447 runner = daemonActions{d: h.d}
448 }
449 if err := sess.runMutation(func() error {
450 request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
451 return runner.Run(request)
452 }); err != nil {
453 if errors.Is(err, errDaemonActionNoChange) {
454 return
455 }
456 var userErr *domain.UserError
457 if !errors.As(err, &userErr) {
458 err = resizeUserError(err)
459 }
460 h.d.reportError(sess, err)
461 return
462 }
463 if h.actions == nil {
464 finishDaemonActionForClient(h.d, request, h.ac, "input.go")
465 }
466 }
467 switch action {
468 case keys.ActionOpenPalette:
469 h.d.enterPalette(sess, h.ac)
470 case keys.ActionJumpAttention:
471 if effect == nil {
472 if err := h.d.jumpAttention(sess, h.ac); err != nil {
473 h.d.reportError(sess, err)
474 }
475 return
476 }
477 effect.bindActionEnd(h.d, "jump-attention")
478 token := h.connectionToken
479 token.effect = effect
480 if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil {
481 h.d.reportError(sess, err)
482 }
483 case keys.ActionToggleFloatingPane:
484 if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil {
485 h.d.log.Warn("toggle floating pane failed", "err", err)
486 h.d.reportError(sess, err)
487 }
488 case keys.ActionFocusPaneLeft:
489 h.focus(sess, layout.Left, effect)
490 case keys.ActionFocusPaneRight:
491 h.focus(sess, layout.Right, effect)
492 case keys.ActionFocusPaneUp:
493 h.focus(sess, layout.Up, effect)
494 case keys.ActionFocusPaneDown:
495 h.focus(sess, layout.Down, effect)
496 case keys.ActionGrowPaneWidth:
497 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
498 case keys.ActionShrinkPaneWidth:
499 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
500 case keys.ActionGrowPaneHeight:
501 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
502 case keys.ActionShrinkPaneHeight:
503 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
504 case keys.ActionEqualizePanes:
505 runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
506 case keys.ActionConsumeOrExpelPaneLeft:
507 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
508 case keys.ActionConsumeOrExpelPaneRight:
509 runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
510 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
511 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
512 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
513 idx := int(action - keys.ActionSwitchTab1)
514 _ = sess.runMutation(func() error {
515 if sess.switchAttachmentTabForDispatch(h.ac, idx) {
516 h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
517 h.d.invalidateRender(sess, h.ac, true, "input.go")
518 }
519 return nil
520 })
521 }
522}
523
524func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
525 err := h.d.focusDir(sess, h.ac, dir, effect)
526 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
527 h.d.reportError(sess, err)
528 }
529}