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