1package daemon
2
3import (
4 "sync"
5 "time"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/notices"
11 "github.com/bnema/vev/internal/usecase/palette"
12 "github.com/bnema/vev/internal/usecase/picker"
13 promptui "github.com/bnema/vev/internal/usecase/prompt"
14 "github.com/bnema/vev/internal/usecase/ui"
15 "github.com/bnema/vev/internal/usecase/visualsearch"
16)
17
18type overlayRuntime struct {
19 ac *attachedClient
20
21 pickerMu sync.Mutex
22 // pickerPublishMu serializes snapshot publication for this attachment. A
23 // publisher reserves the sole right to publish, builds, validates, and
24 // sends its snapshot, and only then commits the revision it announced. It
25 // is held across the transport send so two concurrent publishers cannot
26 // interleave or reorder authoritative revisions. pickerMu is still the
27 // state lock and is never held across the send; the lock order is
28 // pickerPublishMu -> pickerMu, and it is never taken while sendMu is held.
29 pickerPublishMu sync.Mutex
30 // picker* carries the serving-daemon side of the picker interaction,
31 // guarded by pickerMu. Open tracks the current interaction namespace;
32 // interaction is the latest admitted open ID (late selections for closed
33 // interactions reject); revisions are the per-source published versions
34 // (older or duplicate revisions discard); keys maps opaque row keys of the
35 // serving source to resolved targets; intent and moverSource are the
36 // daemon-side facts captured at open; requestID echoes a client client picker request.
37 pickerOpen bool
38 pickerInteraction uint64
39 pickerRevisions map[string]uint64
40 pickerKeys map[string]picker.Target
41 // pickerSourcePublished distinguishes "never published" from "published an
42 // empty set" for the current interaction. pickerRecent and pickerGrouped
43 // are the exact projections last published: a refresh whose projections
44 // equal them is not authority-relevant, so it must not bump the revision
45 // nor send a snapshot the client never needed to see.
46 pickerSourcePublished bool
47 pickerRecent protocol.PickerProjection
48 pickerGrouped protocol.PickerProjection
49 pickerIntent protocol.PickerIntent
50 pickerMoveSource moveSourceLocator
51 pickerRequestID uint64
52 // pickerOpenedAt is the daemon-clock instant the current interaction was
53 // offered. Raw input that still arrives after pickerAcquisitionGrace
54 // proves the client never took the interaction, so it is released instead
55 // of swallowing input forever.
56 pickerOpenedAt time.Time
57 // pickerNavigationOffers numbers the fire-and-forget navigation offers
58 // that ask the client to open its own session picker over this live
59 // attachment. They open no daemon interaction, so they never touch
60 // pickerInteraction.
61 pickerNavigationOffers uint64
62
63 paletteMu sync.Mutex
64 palette *palette.Model
65 paletteRouteSnapshot protocol.RecentRouteSnapshot
66 paletteGeneration uint64
67 paletteHints palette.ContextualHints
68 palettePreview string
69 paletteFeedback string
70 palettePending []byte
71 // paletteInventory* carries the serving-daemon side of navigation
72 // inventory, guarded by paletteMu alongside the palette model. Open
73 // tracks the current interaction namespace; publication is the latest
74 // admitted publication generation (older/duplicates discard); groups
75 // holds the sanitized relayed rows; selected freezes the chosen row
76 // across select-close; demandSent records open-demand emission.
77 paletteInventoryOpen bool
78 paletteInventoryInteraction uint64
79 paletteInventoryPublication uint64
80 paletteInventoryGroups []protocol.NavigationInventorySourceGroup
81 paletteInventorySelected *protocol.NavigationInventorySelection
82 paletteInventoryDemandSent bool
83
84 promptMu sync.Mutex
85 prompt *promptui.Model
86 promptSubmit func(string) error
87 promptTransitionSubmit func(string, *attachmentEffect) error
88 promptPending []byte
89
90 resizeMu sync.Mutex
91 resizeActive bool
92 resizePending []byte
93 resizeESC pendingByteTimer
94
95 copyMu sync.Mutex
96 copyMode *scopy.Mode
97 copyCandidate *scopy.Mode
98 copyDocument *scopy.Document
99 copyPane *pane
100 copyPending []byte
101 copyESC pendingByteTimer
102 copyScroll copyScrollAnimation
103 copySearch *visualsearch.Model
104 copySearchPending []byte
105 statusFeedback string
106 copyPointer copyPointerState
107 copyClick copyClickCandidate
108 copyPointerEpoch uint64
109
110 // noticeMu is the innermost overlay lock: it guards the toast fields below
111 // and nothing is ever locked, sent, or rendered while it is held. The only
112 // permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
113 // The notification history overlay's fields share this lock rather than
114 // adding one of their own: both concern notice presentation for this
115 // client and neither is ever held across a render call.
116 noticeMu sync.Mutex
117 // noticeQueue is this client's toast stack and its waiting notices. nil
118 // until the first notice.
119 noticeQueue *ui.ToastQueue[domain.Notification]
120 // noticeTimer fires at the queue's next expiry. A stale fire is harmless:
121 // it only re-evaluates the queue at the current time.
122 noticeTimer pendingByteTimer
123 noticeDue time.Time
124
125 // noticesOverlay is the `notifications` command's history modal. nil when
126 // closed.
127 noticesOverlay *notices.Model
128 noticesPending []byte
129 noticesESC pendingByteTimer
130}
131
132type copyPointerState struct {
133 valid bool
134 epoch uint64
135 pane *pane
136 document *scopy.Document
137 geometry copyMouseGeometry
138 press scopy.Pos
139 dragging bool
140 wordDrag bool
141}
142
143type copyClickCandidate struct {
144 valid bool
145 pane *pane
146 pos scopy.Pos
147 at time.Time
148 dragged bool
149}
150
151func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
152 return &overlayRuntime{ac: ac}
153}
154
155func (rt *overlayRuntime) Active() bool {
156 if rt == nil || rt.ac == nil {
157 return false
158 }
159 return rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
160}
161
162func (rt *overlayRuntime) promptActive() bool {
163 if rt == nil {
164 return false
165 }
166 rt.promptMu.Lock()
167 defer rt.promptMu.Unlock()
168 return rt.prompt != nil
169}
170
171func (rt *overlayRuntime) paletteActive() bool {
172 if rt == nil {
173 return false
174 }
175 rt.paletteMu.Lock()
176 defer rt.paletteMu.Unlock()
177 return rt.palette != nil
178}
179
180// pickerClientActive reports whether a picker interaction is open on this
181// attachment. Such an interaction owns the user input: the daemon must not
182// route raw key or mouse events to the session while it is open. Its typed
183// PickerSelection/PickerClose messages are the only effect it may have, and
184// they arrive on the control path, not here.
185func (rt *overlayRuntime) pickerClientActive() bool {
186 if rt == nil {
187 return false
188 }
189 rt.pickerMu.Lock()
190 defer rt.pickerMu.Unlock()
191 return rt.pickerOpen
192}
193
194func (rt *overlayRuntime) noticesActive() bool {
195 if rt == nil {
196 return false
197 }
198 rt.noticeMu.Lock()
199 defer rt.noticeMu.Unlock()
200 return rt.noticesOverlay != nil
201}
202
203func (rt *overlayRuntime) resizeModeActive() bool {
204 if rt == nil {
205 return false
206 }
207 rt.resizeMu.Lock()
208 defer rt.resizeMu.Unlock()
209 return rt.resizeActive
210}
211
212func (rt *overlayRuntime) copyActive() bool {
213 if rt == nil {
214 return false
215 }
216 rt.copyMu.Lock()
217 defer rt.copyMu.Unlock()
218 return rt.copyMode != nil
219}
220
221func (rt *overlayRuntime) copySearchActive() bool {
222 if rt == nil {
223 return false
224 }
225 rt.copyMu.Lock()
226 defer rt.copyMu.Unlock()
227 return rt.copySearch != nil
228}
229
230func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
231 rt.copyPointerEpoch++
232 pointer.epoch = rt.copyPointerEpoch
233 pointer.valid = true
234 rt.copyPointer = pointer
235}
236
237func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
238 rt.stopCopyScrollLocked()
239 rt.copyPointerEpoch++
240 rt.copyPointer = copyPointerState{}
241 if clearClick {
242 rt.copyClick = copyClickCandidate{}
243 }
244}
245
246// clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
247// teardown occurring while publication revalidates can invalidate the transfer.
248func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
249 rt.copyPointer = copyPointerState{}
250 rt.copyClick = copyClickCandidate{}
251}
252
253func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
254 if rt.copyCandidate != candidate {
255 return
256 }
257 rt.copyCandidate = nil
258 rt.copyDocument = nil
259 rt.copyPane = nil
260 rt.copySearch = nil
261 rt.copySearchPending = nil
262}
263
264func (rt *overlayRuntime) clearCopyModeLocked() {
265 rt.copyMode = nil
266 rt.copyCandidate = nil
267 rt.copyDocument = nil
268 rt.copyPane = nil
269 rt.copySearch = nil
270 rt.copySearchPending = nil
271 rt.invalidateCopyPointerLocked(true)
272}
273
274func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
275 if rt == nil || p == nil {
276 return false
277 }
278 rt.copyMu.Lock()
279 defer rt.copyMu.Unlock()
280 active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
281 prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
282 if active {
283 rt.clearCopyModeLocked()
284 } else if prePublication {
285 rt.invalidateCopyPointerLocked(true)
286 }
287 return active || prePublication
288}
289
290func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffect) bool {
291 if rt == nil || rt.ac == nil {
292 return false
293 }
294 ac := rt.ac
295 rt.copyMu.Lock()
296 rt.stopCopyScrollLocked()
297 rt.copyMu.Unlock()
298 var effect *attachmentEffect
299 if len(effects) != 0 {
300 effect = effects[0]
301 }
302 if rt.promptActive() {
303 d.handlePromptInput(ac, data, effect)
304 return true
305 }
306 if rt.paletteActive() {
307 d.handlePaletteInput(ac, data, effect)
308 return true
309 }
310 if rt.noticesActive() {
311 d.handleNoticesInput(ac, data)
312 return true
313 }
314 if rt.resizeModeActive() {
315 d.handleResizeInput(ac, data)
316 return true
317 }
318 if rt.copyActive() {
319 d.handleCopyInput(ac, data)
320 return true
321 }
322 return false
323}
324
325type overlayRenderSnapshot struct {
326 rt *overlayRuntime
327
328 copyActive bool
329 copyMode *scopy.Mode
330 copyPane *pane
331 copySearchModel *visualsearch.Model
332 statusFeedback string
333 resizeActive bool
334
335 noticesOverlayActive bool
336 noticesOverlayModel *notices.Model
337
338 paletteActive bool
339 paletteModel *palette.Model
340 // paletteHints is a copy captured under paletteMu. Rendering must use this
341 // immutable interaction snapshot rather than consult live session state.
342 paletteHints *palette.ContextualHints
343 palettePreview string
344 paletteFeedback string
345 paletteRouteSnapshot protocol.RecentRouteSnapshot
346 paletteLocked bool
347
348 promptActive bool
349 promptModel *promptui.Model
350 promptLocked bool
351
352 notices []domain.Notification
353 noticeOverflow int
354}
355
356// SnapshotForRender captures the overlay state needed by paint.
357//
358// The snapshot owns any prompt or palette locks it had to keep held so the
359// returned model pointers stay stable during composition. Callers must release
360// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
361// after acquisition and before any path that may re-enter rendering.
362func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
363 snap := &overlayRenderSnapshot{rt: rt}
364 if rt == nil {
365 return snap
366 }
367
368 // noticeMu is innermost and never held across render, so it is taken and
369 // released here rather than tracked like paletteMu/promptMu below.
370 rt.noticeMu.Lock()
371 if rt.noticeQueue != nil {
372 for _, t := range rt.noticeQueue.Peek() {
373 snap.notices = append(snap.notices, t.Value)
374 }
375 snap.noticeOverflow = rt.noticeQueue.Pending()
376 }
377 snap.noticesOverlayActive = rt.noticesOverlay != nil
378 snap.noticesOverlayModel = rt.noticesOverlay.Clone()
379 rt.noticeMu.Unlock()
380
381 rt.copyMu.Lock()
382 snap.copyActive = rt.copyMode != nil
383 snap.copyPane = rt.copyPane
384 if rt.copyMode != nil {
385 copyModeValue := *rt.copyMode
386 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
387 snap.copyMode = ©ModeValue
388 }
389 snap.copySearchModel = rt.copySearch.Clone()
390 snap.statusFeedback = rt.statusFeedback
391 if snap.statusFeedback != "" && !snap.copyActive {
392 rt.statusFeedback = ""
393 }
394 rt.copyMu.Unlock()
395
396 rt.resizeMu.Lock()
397 snap.resizeActive = rt.resizeActive
398 rt.resizeMu.Unlock()
399
400 rt.paletteMu.Lock()
401 snap.paletteModel = rt.palette
402 snap.paletteActive = snap.paletteModel != nil
403 if snap.paletteActive {
404 hints := rt.paletteHints
405 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
406 snap.paletteHints = &hints
407 snap.palettePreview = rt.palettePreview
408 snap.paletteFeedback = rt.paletteFeedback
409 snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
410 snap.paletteRouteSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
411 snap.paletteLocked = true
412 } else {
413 rt.paletteMu.Unlock()
414 }
415
416 rt.promptMu.Lock()
417 snap.promptModel = rt.prompt
418 snap.promptActive = snap.promptModel != nil
419 if snap.promptActive {
420 snap.promptLocked = true
421 } else {
422 rt.promptMu.Unlock()
423 }
424
425 return snap
426}
427
428func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
429 if snap == nil {
430 return
431 }
432 snap.Unlock()
433}
434
435func (snap *overlayRenderSnapshot) Unlock() {
436 if snap == nil || snap.rt == nil {
437 return
438 }
439 if snap.promptLocked {
440 snap.rt.promptMu.Unlock()
441 snap.promptLocked = false
442 }
443 if snap.paletteLocked {
444 snap.rt.paletteMu.Unlock()
445 snap.paletteLocked = false
446 }
447}