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