1package client
2
3import (
4 "bytes"
5 "sync"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9)
10
11// Terminal focus.
12//
13// The client turns on focus reporting (DEC mode 1004) with its other terminal
14// modes. The terminal then sends ESC [ I when its window gains focus and
15// ESC [ O when it loses it. terminalFocusScanner strips both reports from the
16// input stream and keeps the latest state, so any client feature can read
17// Focus() and the attached loop tells the daemon about each change.
18//
19// A terminal without focus reporting sends nothing: focus stays unknown and
20// every feature keeps its pre-focus behavior.
21
22var (
23 terminalFocusInReport = []byte("\x1b[I")
24 terminalFocusOutReport = []byte("\x1b[O")
25)
26
27// TerminalFocusState is the client's one terminal focus. The terminal input
28// pump is its only writer; any client feature may read it or wait for a
29// change. It outlives attachments, so a new attachment starts from the focus
30// the terminal last reported.
31type TerminalFocusState struct {
32 mu sync.Mutex
33 focus domain.TerminalFocus
34 changed chan struct{}
35}
36
37func newTerminalFocusState() *TerminalFocusState {
38 return &TerminalFocusState{changed: make(chan struct{})}
39}
40
41// Focus returns the latest reported focus. A nil state is unknown.
42func (s *TerminalFocusState) Focus() domain.TerminalFocus {
43 if s == nil {
44 return domain.TerminalFocusUnknown
45 }
46 s.mu.Lock()
47 defer s.mu.Unlock()
48 return s.focus
49}
50
51// Watch returns the current focus and a channel closed on the next change.
52func (s *TerminalFocusState) Watch() (domain.TerminalFocus, <-chan struct{}) {
53 if s == nil {
54 return domain.TerminalFocusUnknown, nil
55 }
56 s.mu.Lock()
57 defer s.mu.Unlock()
58 return s.focus, s.changed
59}
60
61func (s *TerminalFocusState) set(focus domain.TerminalFocus) {
62 s.mu.Lock()
63 defer s.mu.Unlock()
64 if focus == s.focus {
65 return
66 }
67 s.focus = focus
68 close(s.changed)
69 s.changed = make(chan struct{})
70}
71
72// attachmentFocusForeground is the optional seam through which an attached
73// loop reads the terminal focus.
74type attachmentFocusForeground interface {
75 terminalFocus() *TerminalFocusState
76}
77
78func (f *attachmentForeground) terminalFocus() *TerminalFocusState {
79 if f == nil || f.input == nil {
80 return nil
81 }
82 return f.input.focus
83}
84
85// attachmentTerminalFocus returns the focus state of fg, or nil.
86func attachmentTerminalFocus(fg AttachmentForeground) *TerminalFocusState {
87 if focus, ok := fg.(attachmentFocusForeground); ok {
88 return focus.terminalFocus()
89 }
90 return nil
91}
92
93// focusReporter sends the terminal focus to the daemon: the current state once
94// the attachment starts, then every change. Unknown focus is never sent.
95type focusReporter struct {
96 state *TerminalFocusState
97 sent domain.TerminalFocus
98 changed <-chan struct{}
99}
100
101// next returns the message to send now, if any, and re-arms the change wake.
102func (r *focusReporter) next() (protocol.TerminalFocus, bool) {
103 focus, changed := r.state.Watch()
104 r.changed = changed
105 if focus == domain.TerminalFocusUnknown || focus == r.sent {
106 return protocol.TerminalFocus{}, false
107 }
108 r.sent = focus
109 return protocol.TerminalFocus{Focus: focus}, true
110}
111
112// stripTerminalFocusReports removes complete focus reports from data and
113// records the last one in state. It never withholds a prefix: a terminal
114// writes each report whole, while a lone Escape or Alt+[ typed by the user
115// must reach the session without delay. Other bytes keep their order. A
116// pasted literal report, or one split across reads, is accepted as rare.
117func stripTerminalFocusReports(state *TerminalFocusState, data []byte) []byte {
118 at, focus := nextTerminalFocusReport(data)
119 if at < 0 {
120 return data
121 }
122 out := make([]byte, 0, len(data))
123 for at >= 0 {
124 out = append(out, data[:at]...)
125 state.set(focus)
126 data = data[at+len(terminalFocusInReport):]
127 at, focus = nextTerminalFocusReport(data)
128 }
129 return append(out, data...)
130}
131
132// nextTerminalFocusReport returns the offset of the first complete report in
133// data, or -1.
134func nextTerminalFocusReport(data []byte) (int, domain.TerminalFocus) {
135 in := bytes.Index(data, terminalFocusInReport)
136 out := bytes.Index(data, terminalFocusOutReport)
137 switch {
138 case in < 0 && out < 0:
139 return -1, domain.TerminalFocusUnknown
140 case out < 0 || (in >= 0 && in < out):
141 return in, domain.TerminalFocusFocused
142 default:
143 return out, domain.TerminalFocusUnfocused
144 }
145}