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