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