overlay_runtime.go RawHistoryFolder
1package daemon
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 scopy "github.com/bnema/vev/internal/usecase/copy"
11 "github.com/bnema/vev/internal/usecase/notices"
12 "github.com/bnema/vev/internal/usecase/palette"
13 "github.com/bnema/vev/internal/usecase/picker"
14 promptui "github.com/bnema/vev/internal/usecase/prompt"
15 "github.com/bnema/vev/internal/usecase/visualsearch"
16)
17
18type overlayRuntime struct {
19 ac *attachedClient
20
21 pickerMu sync.Mutex
22 picker *picker.Model
23 // pickerGeneration identifies one open lifecycle. It advances only when a
24 // picker is published, so delayed close and registration work can prove it
25 // still owns the exact lifecycle it captured.
26 pickerGeneration uint64
27 pickerTitle string
28 pickerIntent pickerIntent
29 pickerSource moveSourceLocator
30 pickerPreview *tab
31 pickerPreviewSession *session
32 pickerRemotePreview picker.Preview
33 pickerRemotePreviewCancel context.CancelFunc
34 pickerPreviewGeneration uint64
35 pickerPending []byte
36 pickerESC pendingByteTimer
37
38 // Test-only, unsynchronized lifecycle seams. Assign them before picker
39 // publication or goroutine startup. Hooks run without pickerMu or
40 // remoteCatalog.mu held.
41 beforeRemotePickerRegistration func()
42 afterPickerRefreshBuild func(*picker.Model)
43
44 paletteMu sync.Mutex
45 palette *palette.Model
46 paletteRouteSnapshot ports.RecentRouteSnapshot
47 paletteGeneration uint64
48 paletteHints palette.ContextualHints
49 paletteFeedback string
50 palettePending []byte
51
52 promptMu sync.Mutex
53 prompt *promptui.Model
54 promptSubmit func(string) error
55 promptTransitionSubmit func(string, attachmentConnectionToken) error
56 promptPending []byte
57
58 resizeMu sync.Mutex
59 resizeActive bool
60 resizePending []byte
61 resizeESC pendingByteTimer
62
63 copyMu sync.Mutex
64 copyMode *scopy.Mode
65 copyCandidate *scopy.Mode
66 copyDocument *scopy.Document
67 copyPane *pane
68 copyPending []byte
69 copyESC pendingByteTimer
70 copySearch *visualsearch.Model
71 copySearchPending []byte
72 statusFeedback string
73 copyPointer copyPointerState
74 copyClick copyClickCandidate
75 copyPointerEpoch uint64
76
77 // noticeMu is the innermost overlay lock: it guards the toast fields below
78 // and nothing is ever locked, sent, or rendered while it is held. The only
79 // permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
80 // The notification history overlay's fields share this lock rather than
81 // adding one of their own: both concern notice presentation for this
82 // client and neither is ever held across a render call.
83 noticeMu sync.Mutex
84 noticeToasts []noticeToast
85 noticeOverflow int
86 // noticeSeq numbers toast entries so an already-fired expiry timer cannot
87 // dismiss the refreshed entry that replaced the one it belonged to.
88 noticeSeq uint64
89
90 // noticesOverlay is the `notifications` command's history modal. nil when
91 // closed.
92 noticesOverlay *notices.Model
93 noticesPending []byte
94 noticesESC pendingByteTimer
95}
96
97type copyPointerState struct {
98 valid bool
99 epoch uint64
100 pane *pane
101 document *scopy.Document
102 geometry copyMouseGeometry
103 press scopy.Pos
104 dragging bool
105 wordDrag bool
106}
107
108type copyClickCandidate struct {
109 valid bool
110 pane *pane
111 pos scopy.Pos
112 at time.Time
113 dragged bool
114}
115
116func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
117 return &overlayRuntime{ac: ac}
118}
119
120func (rt *overlayRuntime) Active() bool {
121 if rt == nil || rt.ac == nil {
122 return false
123 }
124 return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
125}
126
127func (rt *overlayRuntime) promptActive() bool {
128 if rt == nil {
129 return false
130 }
131 rt.promptMu.Lock()
132 defer rt.promptMu.Unlock()
133 return rt.prompt != nil
134}
135
136func (rt *overlayRuntime) paletteActive() bool {
137 if rt == nil {
138 return false
139 }
140 rt.paletteMu.Lock()
141 defer rt.paletteMu.Unlock()
142 return rt.palette != nil
143}
144
145func (rt *overlayRuntime) pickerActive() bool {
146 if rt == nil {
147 return false
148 }
149 rt.pickerMu.Lock()
150 defer rt.pickerMu.Unlock()
151 return rt.picker != nil
152}
153
154func (rt *overlayRuntime) noticesActive() bool {
155 if rt == nil {
156 return false
157 }
158 rt.noticeMu.Lock()
159 defer rt.noticeMu.Unlock()
160 return rt.noticesOverlay != nil
161}
162
163func (rt *overlayRuntime) resizeModeActive() bool {
164 if rt == nil {
165 return false
166 }
167 rt.resizeMu.Lock()
168 defer rt.resizeMu.Unlock()
169 return rt.resizeActive
170}
171
172func (rt *overlayRuntime) copyActive() bool {
173 if rt == nil {
174 return false
175 }
176 rt.copyMu.Lock()
177 defer rt.copyMu.Unlock()
178 return rt.copyMode != nil
179}
180
181func (rt *overlayRuntime) copySearchActive() bool {
182 if rt == nil {
183 return false
184 }
185 rt.copyMu.Lock()
186 defer rt.copyMu.Unlock()
187 return rt.copySearch != nil
188}
189
190func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
191 rt.copyPointerEpoch++
192 pointer.epoch = rt.copyPointerEpoch
193 pointer.valid = true
194 rt.copyPointer = pointer
195}
196
197func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
198 rt.copyPointerEpoch++
199 rt.copyPointer = copyPointerState{}
200 if clearClick {
201 rt.copyClick = copyClickCandidate{}
202 }
203}
204
205// clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
206// teardown occurring while publication revalidates can invalidate the transfer.
207func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
208 rt.copyPointer = copyPointerState{}
209 rt.copyClick = copyClickCandidate{}
210}
211
212func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
213 if rt.copyCandidate != candidate {
214 return
215 }
216 rt.copyCandidate = nil
217 rt.copyDocument = nil
218 rt.copyPane = nil
219 rt.copySearch = nil
220 rt.copySearchPending = nil
221}
222
223func (rt *overlayRuntime) clearCopyModeLocked() {
224 rt.copyMode = nil
225 rt.copyCandidate = nil
226 rt.copyDocument = nil
227 rt.copyPane = nil
228 rt.copySearch = nil
229 rt.copySearchPending = nil
230 rt.invalidateCopyPointerLocked(true)
231}
232
233func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
234 if rt == nil || p == nil {
235 return false
236 }
237 rt.copyMu.Lock()
238 defer rt.copyMu.Unlock()
239 active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
240 prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
241 if active {
242 rt.clearCopyModeLocked()
243 } else if prePublication {
244 rt.invalidateCopyPointerLocked(true)
245 }
246 return active || prePublication
247}
248
249func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
250 if rt == nil || rt.ac == nil {
251 return false
252 }
253 ac := rt.ac
254 var effect *attachmentEffectTicket
255 if len(effects) != 0 {
256 effect = effects[0]
257 }
258 if rt.promptActive() {
259 d.handlePromptInput(ac, data, effect)
260 return true
261 }
262 if rt.paletteActive() {
263 d.handlePaletteInput(ac, data, effect)
264 return true
265 }
266 if rt.pickerActive() {
267 d.handlePickerInput(ac, data, effect)
268 return true
269 }
270 if rt.noticesActive() {
271 d.handleNoticesInput(ac, data)
272 return true
273 }
274 if rt.resizeModeActive() {
275 d.handleResizeInput(ac, data)
276 return true
277 }
278 if rt.copyActive() {
279 d.handleCopyInput(ac, data)
280 return true
281 }
282 return false
283}
284
285type overlayRenderSnapshot struct {
286 rt *overlayRuntime
287
288 copyActive bool
289 copyMode *scopy.Mode
290 copyPane *pane
291 copySearchModel *visualsearch.Model
292 statusFeedback string
293 resizeActive bool
294
295 pickerActive bool
296 pickerModel *picker.Model
297 pickerTitle string
298 previewTab *tab
299 remotePreview picker.Preview
300
301 noticesOverlayActive bool
302 noticesOverlayModel *notices.Model
303
304 paletteActive bool
305 paletteModel *palette.Model
306 // paletteHints is a copy captured under paletteMu. Rendering must use this
307 // immutable interaction snapshot rather than consult live session state.
308 paletteHints *palette.ContextualHints
309 paletteFeedback string
310 paletteRouteSnapshot ports.RecentRouteSnapshot
311 paletteLocked bool
312
313 promptActive bool
314 promptModel *promptui.Model
315 promptLocked bool
316
317 notices []domain.Notification
318 noticeOverflow int
319}
320
321// SnapshotForRender captures the overlay state needed by paint.
322//
323// The snapshot owns any prompt or palette locks it had to keep held so the
324// returned model pointers stay stable during composition. Callers must release
325// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
326// after acquisition and before any path that may re-enter rendering.
327func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
328 snap := &overlayRenderSnapshot{rt: rt}
329 if rt == nil {
330 return snap
331 }
332
333 // noticeMu is innermost and never held across render, so it is taken and
334 // released here rather than tracked like paletteMu/promptMu below.
335 rt.noticeMu.Lock()
336 if len(rt.noticeToasts) > 0 {
337 snap.notices = make([]domain.Notification, len(rt.noticeToasts))
338 for i, t := range rt.noticeToasts {
339 snap.notices[i] = t.n
340 }
341 }
342 snap.noticeOverflow = rt.noticeOverflow
343 snap.noticesOverlayActive = rt.noticesOverlay != nil
344 snap.noticesOverlayModel = rt.noticesOverlay.Clone()
345 rt.noticeMu.Unlock()
346
347 rt.copyMu.Lock()
348 snap.copyActive = rt.copyMode != nil
349 snap.copyPane = rt.copyPane
350 if rt.copyMode != nil {
351 copyModeValue := *rt.copyMode
352 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
353 snap.copyMode = ©ModeValue
354 }
355 snap.copySearchModel = rt.copySearch.Clone()
356 snap.statusFeedback = rt.statusFeedback
357 if snap.statusFeedback != "" && !snap.copyActive {
358 rt.statusFeedback = ""
359 }
360 rt.copyMu.Unlock()
361
362 rt.resizeMu.Lock()
363 snap.resizeActive = rt.resizeActive
364 rt.resizeMu.Unlock()
365
366 rt.pickerMu.Lock()
367 snap.pickerActive = rt.picker != nil
368 snap.pickerModel = rt.picker.Clone()
369 snap.pickerTitle = rt.pickerTitle
370 snap.previewTab = rt.pickerPreview
371 snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview)
372 rt.pickerMu.Unlock()
373
374 rt.paletteMu.Lock()
375 snap.paletteModel = rt.palette
376 snap.paletteActive = snap.paletteModel != nil
377 if snap.paletteActive {
378 hints := rt.paletteHints
379 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
380 snap.paletteHints = &hints
381 snap.paletteFeedback = rt.paletteFeedback
382 snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383 snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
384 snap.paletteLocked = true
385 } else {
386 rt.paletteMu.Unlock()
387 }
388
389 rt.promptMu.Lock()
390 snap.promptModel = rt.prompt
391 snap.promptActive = snap.promptModel != nil
392 if snap.promptActive {
393 snap.promptLocked = true
394 } else {
395 rt.promptMu.Unlock()
396 }
397
398 return snap
399}
400
401func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
402 if snap == nil {
403 return
404 }
405 snap.Unlock()
406}
407
408func (snap *overlayRenderSnapshot) Unlock() {
409 if snap == nil || snap.rt == nil {
410 return
411 }
412 if snap.promptLocked {
413 snap.rt.promptMu.Unlock()
414 snap.promptLocked = false
415 }
416 if snap.paletteLocked {
417 snap.rt.paletteMu.Unlock()
418 snap.paletteLocked = false
419 }
420}