1package client
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/domain/terminalcap"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/usecase/colorprofile"
13 "github.com/bnema/vev/internal/usecase/keys/kittykey"
14 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
15)
16
17// Move-destination picker over a live attachment.
18//
19// The serving daemon still owns move semantics: it captures the source when
20// MFP/MAT runs, publishes the destination rows in a PickerSnapshot, resolves
21// the opaque key of a typed PickerSelection, and confirms the end of the
22// interaction with PickerClosed. The attachment worker only presents it: it
23// composes the box over the attachment through the foreground's overlay slot,
24// decodes the input the overlay owns, and answers with the existing typed
25// PickerSelection/PickerClose replies. Zero picker bytes reach the session.
26//
27// Every method runs on the worker goroutine, which is the overlay's only
28// owner, so it holds no lock of its own.
29
30// movePickerInputGeneration is the fixed input generation of the worker-owned
31// move overlay; the interaction ID alone names the namespace.
32const movePickerInputGeneration = uint64(1)
33
34type movePickerOverlay struct {
35 namespace pickerInteraction
36 intent protocol.PickerIntent
37 loop *pickerLoop
38 consumer pickerConsumer
39 renderer *pickerRenderer
40 // presenting reports that the foreground overlay slot is held for this
41 // interaction.
42 presenting bool
43}
44
45func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay {
46 return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))}
47}
48
49// offer opens the namespace a move offer names. A superseding offer retires
50// the previous interaction, whose rows can no longer be committed; the caller
51// releases the overlay when superseded reports true.
52func (m *movePickerOverlay) offer(offer protocol.PickerOffer) (superseded bool) {
53 superseded = m.presenting && m.namespace.interaction != offer.InteractionID
54 if superseded {
55 m.retire()
56 }
57 m.namespace.setOpen(true, offer.InteractionID)
58 m.namespace.setIntent(offer.InteractionID, offer.Intent)
59 m.intent = offer.Intent
60 m.loop = nil
61 return superseded
62}
63
64// admit folds one daemon publication into the model. It reports whether the
65// snapshot belongs to the open interaction and is newer than what is shown.
66func (m *movePickerOverlay) admit(snapshot protocol.PickerSnapshot) bool {
67 if !m.namespace.open || m.intent == protocol.PickerIntentNavigation || m.intent == 0 {
68 return false
69 }
70 if !m.namespace.admitSnapshot(snapshot, true) {
71 return false
72 }
73 if m.loop == nil {
74 // Moves always open most-recent first so the likely destination is on
75 // top; the session picker's remembered sort does not apply here.
76 m.loop = pickerLoopFromSnapshot(snapshot, m.intent, pickerusecase.SortRecent)
77 m.consumer.setOwned(snapshot.InteractionID, movePickerInputGeneration)
78 return true
79 }
80 m.loop.replaceLines(snapshot)
81 return true
82}
83
84// interaction names the open namespace, or zero.
85func (m *movePickerOverlay) interaction() uint64 {
86 if !m.namespace.open {
87 return 0
88 }
89 return m.namespace.interaction
90}
91
92// closedBy reports whether a daemon close retires the open interaction.
93func (m *movePickerOverlay) closedBy(interaction uint64) bool {
94 return m.namespace.open && m.namespace.interaction == interaction
95}
96
97// retire ends the interaction locally. A late snapshot for it is refused.
98func (m *movePickerOverlay) retire() {
99 if m.namespace.open {
100 m.namespace.setOpen(false, m.namespace.interaction)
101 }
102 m.loop = nil
103 m.presenting = false
104 m.consumer.clear()
105}
106
107// input decodes one owned delivery and applies it to the model.
108func (m *movePickerOverlay) input(data []byte, actionID uint64) (pickerOp, bool) {
109 outcome, consumed := m.consumer.consume(terminalReadResult{data: data, actionID: actionID})
110 return m.apply(outcome, consumed)
111}
112
113// flush resolves a withheld lone escape once its window elapsed.
114func (m *movePickerOverlay) flush() (pickerOp, bool) {
115 outcome, consumed := m.consumer.flushPending()
116 return m.apply(outcome, consumed)
117}
118
119func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (pickerOp, bool) {
120 if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {
121 return pickerOp{}, false
122 }
123 return m.loop.apply(outcome.events)
124}
125
126// selection builds the typed move for the cursor row, refusing rows the daemon
127// did not authorise for a move.
128func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSelection, bool) {
129 action, ok := m.loop.selectedAction()
130 if !ok || action != protocol.PickerActionMove {
131 return protocol.PickerSelection{}, false
132 }
133 return m.loop.selection(action, causeActionID)
134}
135
136// render composes the box for one terminal size.
137func (m *movePickerOverlay) render(size domain.Size) []byte {
138 if m.loop == nil {
139 return nil
140 }
141 return m.renderer.render(m.loop, size, emptyPickerPreview())
142}
143
144// attachmentMovePicker binds the move overlay and the navigation request to
145// one attached worker loop: it owns the foreground overlay slot while a move
146// interaction is presented and answers the daemon on the worker's stream.
147type attachmentMovePicker struct {
148 worker *sessionAttachmentWorker
149 fg AttachmentForeground
150 overlay attachmentOverlayForeground
151 stream ports.BrokerLogicalConnection
152 size domain.Size
153 move *movePickerOverlay
154 // escapeTimer bounds a withheld lone escape while the move overlay owns
155 // input; nil while nothing is withheld.
156 escapeTimer ports.Timer
157}
158
159// offer handles one daemon PickerOffer. A navigation offer asks the
160// supervisor for the client's session picker over this attachment; a move
161// offer opens the namespace whose snapshot the overlay will present.
162func (p *attachmentMovePicker) offer(ctx context.Context, offer protocol.PickerOffer) error {
163 if p.overlay == nil {
164 // No overlay seam: never leave the daemon swallowing input for an
165 // interaction nothing presents.
166 return p.decline(ctx, offer)
167 }
168 if offer.Intent == protocol.PickerIntentNavigation {
169 if p.move.presenting {
170 return nil
171 }
172 p.overlay.requestNavigationPicker()
173 return nil
174 }
175 if p.overlay.overlayKind() == attachmentOverlayNavigation {
176 // The session picker owns the terminal; a move picker cannot be
177 // presented under it, so release the daemon's interaction at once.
178 return p.decline(ctx, offer)
179 }
180 if p.move.offer(offer) {
181 p.stopEscape()
182 p.overlay.clearOverlay(attachmentOverlayMove)
183 }
184 return nil
185}
186
187// decline closes a move interaction the client will not present.
188func (p *attachmentMovePicker) decline(ctx context.Context, offer protocol.PickerOffer) error {
189 if offer.Intent == protocol.PickerIntentNavigation {
190 return nil
191 }
192 return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: offer.InteractionID})
193}
194
195// snapshot presents one daemon publication. The first admitted snapshot takes
196// the foreground overlay slot; a refused slot declines the interaction.
197func (p *attachmentMovePicker) snapshot(ctx context.Context, state outputApplyState, snapshot protocol.PickerSnapshot) error {
198 if p.overlay == nil || !p.move.admit(snapshot) {
199 return nil
200 }
201 if !p.move.presenting {
202 if !p.overlay.setOverlay(attachmentOverlayMove, nil) {
203 interaction := p.move.interaction()
204 p.move.retire()
205 return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
206 }
207 p.move.presenting = true
208 p.move.renderer.invalidate()
209 }
210 return p.paint(state)
211}
212
213// closed retires the interaction the daemon closed and returns the terminal to
214// the attachment.
215func (p *attachmentMovePicker) closed(interaction uint64) {
216 if !p.move.closedBy(interaction) {
217 return
218 }
219 p.release()
220}
221
222func (p *attachmentMovePicker) release() {
223 presenting := p.move.presenting
224 p.move.retire()
225 p.stopEscape()
226 if presenting && p.overlay != nil {
227 p.overlay.clearOverlay(attachmentOverlayMove)
228 }
229}
230
231// consumeInput offers one authorized delivery to the overlay that owns input.
232// It reports consumed when the bytes must not reach the session, and
233// supervised when the supervisor-owned navigation picker consumed them: its
234// action is then settled by the supervisor, never fenced through the daemon.
235func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {
236 // Overlays decode legacy keys; the session path keeps kitty sequences.
237 input.Data = kittykey.Translate(input.Data, 0)
238 if p.move.presenting {
239 p.stopEscape()
240 op, changed := p.move.input(input.Data, input.actionID)
241 if err := p.applyOp(ctx, state, op, changed); err != nil {
242 return true, false, err
243 }
244 p.armEscape()
245 return true, false, nil
246 }
247 if p.overlay != nil && p.overlay.divertInput(input.Data, input.actionID) {
248 return true, true, nil
249 }
250 return false, false, nil
251}
252
253// applyOp carries out one decoded decision: close sends the typed close and
254// releases the terminal, commit sends the typed move, and a changed model
255// repaints the box.
256func (p *attachmentMovePicker) applyOp(ctx context.Context, state outputApplyState, op pickerOp, changed bool) error {
257 if !p.move.presenting {
258 return nil
259 }
260 if op.close {
261 interaction := p.move.interaction()
262 p.release()
263 return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
264 }
265 if op.commit {
266 if selection, ok := p.move.selection(0); ok {
267 if err := p.worker.send(ctx, p.fg, p.stream, selection); err != nil {
268 return err
269 }
270 }
271 }
272 if changed {
273 return p.paint(state)
274 }
275 return nil
276}
277
278// resize repaints the presented box at the new terminal size.
279func (p *attachmentMovePicker) resize(_ context.Context, state outputApplyState, size domain.Size) error {
280 p.size = size
281 if !p.move.presenting {
282 return nil
283 }
284 return p.paint(state)
285}
286
287func (p *attachmentMovePicker) paint(state outputApplyState) error {
288 frame := p.move.render(p.size)
289 if len(frame) == 0 {
290 return nil
291 }
292 uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached)
293 if err := p.overlay.overlayOutput(uiContext, frame); err != nil {
294 return fmt.Errorf("publishing move picker: %w", err)
295 }
296 return nil
297}
298
299// escape is the armed lone-escape window, or nil.
300func (p *attachmentMovePicker) escape() <-chan time.Time {
301 if p.escapeTimer == nil {
302 return nil
303 }
304 return p.escapeTimer.C()
305}
306
307func (p *attachmentMovePicker) armEscape() {
308 if p.escapeTimer != nil || !p.move.presenting || !p.move.consumer.hasPending() {
309 return
310 }
311 timer := p.worker.cfg.Clock.NewTimer(pickerEscapeDeadline)
312 if supervisorNil(timer) {
313 return
314 }
315 p.escapeTimer = timer
316}
317
318// escapeFired forgets the timer whose window just elapsed.
319func (p *attachmentMovePicker) escapeFired() { p.escapeTimer = nil }
320
321func (p *attachmentMovePicker) stopEscape() {
322 if p.escapeTimer == nil {
323 return
324 }
325 stopSupervisorTimer(p.escapeTimer)
326 p.escapeTimer = nil
327}