1package daemon
2
3import (
4 "context"
5 "time"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/picker"
10)
11
12// noteAttention records attention for a tab. If the tab is currently visible,
13// it is kept long enough to render one pulse before paint acknowledges it. The
14// caller must not hold tab.mu.
15func (d *Daemon) noteAttention(sess *session, tb *tab) {
16 if sess == nil || tb == nil {
17 return
18 }
19
20 now := d.clock.Now()
21 if now.IsZero() {
22 now = time.Now()
23 }
24
25 sess.mu.Lock()
26 if !tb.attention {
27 tb.attentionAt = now
28 }
29 tb.attention = true
30 for _, attachment := range sess.snapshotAttachmentsLocked() {
31 view := attachment.viewSnapshot()
32 if domain.TabStableID(tb.stableID) == view.tabID {
33 tb.attentionVisiblePaint = true
34 break
35 }
36 }
37 sess.mu.Unlock()
38
39 // Do not repaint here: this runs on the PTY reader goroutine, and paint
40 // can block on a slow client's socket (Transport.Send has no deadline) —
41 // that would let one wedged client stall a different session's reader.
42 // The animator's first tick (<=120ms) delivers the repaint, and pulse
43 // frame 0 renders the bell as blank anyway, so nothing is visibly lost
44 // by not painting immediately.
45 d.pokeAttentionTicker()
46}
47
48// jumpAttention switches to the oldest pending attention target, local tab
49// first and then another session's. Returning nil never implies a switch
50// happened — it also covers "no target exists", which is routine and not an
51// error. Only a failure to reach a target that does exist is a genuine error.
52func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53 return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
54}
55
56func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
57 if sess == nil || ac == nil {
58 return nil
59 }
60 if idx, ok := oldestAttentionTab(sess); ok {
61 if sess.switchAttachmentTab(ac, idx) {
62 d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, token.lease)
63 d.invalidateRender(sess, ac, true, "attention.go")
64 }
65 return nil
66 }
67
68 target, ok := d.oldestOtherSessionAttention(sess)
69 if !ok {
70 return nil
71 }
72 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
73 if token.ac == nil {
74 return d.switchToTarget(sess, ac, pickerTarget)
75 }
76 return d.switchActiveTargetForAttachment(token, pickerTarget)
77}
78
79func oldestAttentionTab(sess *session) (int, bool) {
80 idx, _, ok := oldestAttentionTabWithTime(sess)
81 return idx, ok
82}
83
84type attentionTarget struct {
85 sessionID domain.SessionID
86 tabIndex int
87 at time.Time
88}
89
90func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
91 d.mu.Lock()
92 sessions := make([]*session, 0, len(d.sessions))
93 for _, sess := range d.sessions {
94 if sess != nil && sess != current {
95 sessions = append(sessions, sess)
96 }
97 }
98 d.mu.Unlock()
99
100 var target attentionTarget
101 found := false
102 for _, sess := range sessions {
103 idx, at, ok := oldestAttentionTabWithTime(sess)
104 if !ok {
105 continue
106 }
107 if !found || at.Before(target.at) {
108 target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
109 found = true
110 }
111 }
112 return target, found
113}
114
115func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
116 sess.mu.Lock()
117 defer sess.mu.Unlock()
118 idx := -1
119 var oldest time.Time
120 for i, tb := range sess.tabs {
121 if !tb.attention {
122 continue
123 }
124 if idx == -1 || tb.attentionAt.Before(oldest) {
125 idx = i
126 oldest = tb.attentionAt
127 }
128 }
129 return idx, oldest, idx != -1
130}
131
132func (d *Daemon) repaintAttachedClients(sess *session) {
133 for _, ac := range sess.snapshotAttachments() {
134 d.invalidateRender(sess, ac, false, "attention.go")
135 }
136}
137
138func (d *Daemon) repaintAllAttachedClients() {
139 d.mu.Lock()
140 sessions := sessionsSnapshot(d.sessions)
141 d.mu.Unlock()
142
143 for _, sess := range sessions {
144 d.repaintAttachedClients(sess)
145 }
146}
147
148func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
149 if tb == nil || ac == nil {
150 return false
151 }
152 s.mu.Lock()
153 defer s.mu.Unlock()
154 view := ac.viewSnapshot()
155 if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
156 return false
157 }
158 if tb.attentionVisiblePaint && !visible {
159 return false
160 }
161 tb.attention = false
162 tb.attentionAt = time.Time{}
163 tb.attentionVisiblePaint = false
164 return true
165}
166
167func (s *session) anyAttention() bool {
168 s.mu.Lock()
169 defer s.mu.Unlock()
170 for _, tb := range s.tabs {
171 if tb.attention {
172 return true
173 }
174 }
175 return false
176}
177
178func (d *Daemon) pokeAttentionTicker() {
179 if d == nil || d.animWake == nil {
180 return
181 }
182 select {
183 case d.animWake <- struct{}{}:
184 default:
185 }
186}
187
188func (d *Daemon) attentionFrame() int {
189 if d == nil {
190 return 0
191 }
192 d.attnMu.Lock()
193 defer d.attnMu.Unlock()
194 return d.animFrame
195}
196
197func (d *Daemon) attentionAnimator(ctx context.Context) {
198 active := false
199 var timer ports.Timer
200 for {
201 if !active {
202 if !d.anyAttention() {
203 d.setAttentionFrame(0)
204 select {
205 case <-ctx.Done():
206 return
207 case <-d.animWake:
208 continue
209 }
210 }
211 active = true
212 }
213
214 if timer == nil {
215 timer = d.clock.NewTimer(pulseFrameInterval)
216 } else {
217 timer.Reset(pulseFrameInterval)
218 }
219
220 select {
221 case <-ctx.Done():
222 if timer != nil {
223 timer.Stop()
224 }
225 return
226 case <-d.animWake:
227 if !timer.Stop() {
228 select {
229 case <-timer.C():
230 default:
231 }
232 }
233 case <-timer.C():
234 d.advanceAttentionFrame()
235 d.repaintAllAttachedClients()
236 }
237
238 if !d.anyAttention() {
239 d.setAttentionFrame(0)
240 d.repaintAllAttachedClients()
241 active = false
242 }
243 }
244}
245
246func (d *Daemon) anyAttention() bool {
247 d.mu.Lock()
248 sessions := sessionsSnapshot(d.sessions)
249 d.mu.Unlock()
250
251 for _, sess := range sessions {
252 if sess.anyAttention() {
253 return true
254 }
255 }
256 return false
257}
258
259func (d *Daemon) advanceAttentionFrame() {
260 d.attnMu.Lock()
261 d.animFrame = (d.animFrame + 1) % pulseFrameCount
262 d.attnMu.Unlock()
263}
264
265func (d *Daemon) setAttentionFrame(frame int) {
266 d.attnMu.Lock()
267 d.animFrame = frame
268 d.attnMu.Unlock()
269}