1package client
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "sync"
8 "time"
9
10 renderer "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/usecase/theme"
14)
15
16// attachmentInput is the attached loop's terminal input path, ported from the
17// old client's stdin pump. Each physical read is demultiplexed before anything
18// reaches the session:
19//
20// - theme.Scanner strips OSC 10/11/4 color replies and CSI ?997 scheme
21// notifications;
22// - paletteMarkerScanner strips DECRQM 2031 boundary replies;
23// - the remaining ordinary bytes go to an overlay when one owns input, or
24// through the paste coalescer, which keeps a bracketed paste in one Input.
25//
26// Color replies and markers drive the palette generation, whose queries are
27// written through the foreground and whose results are sent as
28// protocol.Theme. Only the attached loop calls these methods. The coalescer's
29// timers run on their own goroutines, but they only queue bytes and wake the
30// loop, so the loop stays the single sender.
31type attachmentInput struct {
32 worker *sessionAttachmentWorker
33 fg AttachmentForeground
34 stream ports.BrokerLogicalConnection
35 picker *attachmentMovePicker
36 clock ports.Clock
37 seq uint64
38
39 scanner theme.Scanner
40 markers paletteMarkerScanner
41 // markerTimer bounds how long an undecided DECRQM prefix (a lone Escape
42 // included) is withheld before it is forwarded as ordinary input.
43 markerTimer ports.Timer
44
45 paste *pasteCoalescer
46 clipboard *clipboardIntercept
47 mu sync.Mutex
48 queued []protocol.ClientMessage
49 wake chan struct{}
50
51 // palette is nil when detection is disabled: no retained theme or no
52 // query seam on the foreground. Replies are stripped either way.
53 palette *paletteGenerationCoordinator
54 themes *terminalThemeState
55 query attachmentQueryForeground
56 drain paletteDeadline
57 completion paletteDeadline
58}
59
60// paletteDeadline is the one armed timer of a deadline kind. The coordinator
61// cancels a replaced generation's deadlines before arming the next, so one
62// slot per kind is enough.
63type paletteDeadline struct {
64 id paletteGenerationID
65 timer ports.Timer
66}
67
68func (d *paletteDeadline) arm(clock ports.Clock, id paletteGenerationID, delay time.Duration) {
69 d.stop()
70 d.id = id
71 d.timer = clock.NewTimer(delay)
72}
73
74func (d *paletteDeadline) cancel(id paletteGenerationID) {
75 if d.timer != nil && d.id == id {
76 d.stop()
77 }
78}
79
80func (d *paletteDeadline) stop() {
81 if d.timer != nil {
82 d.timer.Stop()
83 }
84 *d = paletteDeadline{}
85}
86
87func (d *paletteDeadline) c() <-chan time.Time {
88 if d.timer == nil {
89 return nil
90 }
91 return d.timer.C()
92}
93
94// fired consumes the slot after its timer delivered, returning the event the
95// coordinator expects.
96func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerationEvent {
97 event := paletteGenerationEvent{id: d.id, kind: kind}
98 *d = paletteDeadline{}
99 return event
100}
101
102func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
103 in := &attachmentInput{
104 worker: w,
105 fg: fg,
106 stream: stream,
107 picker: picker,
108 clock: w.cfg.Clock,
109 wake: make(chan struct{}, 1),
110 themes: w.cfg.Theme,
111 }
112 in.paste = newPasteCoalescer(in.clock, in.enqueue)
113 if !w.cfg.Request.Local && w.cfg.Clipboard != nil {
114 in.clipboard = &clipboardIntercept{
115 ctx: ctx, coalescer: in.paste, reader: w.cfg.Clipboard,
116 log: slog.Default(), next: in.paste.Scan,
117 sendImage: func(mime string, data []byte) {
118 in.enqueueImage(mime, data)
119 },
120 sendNotice: func(action uint8) { in.enqueueNotice(action) },
121 }
122 }
123 if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {
124 in.query = query
125 in.palette = newPaletteGenerationCoordinator()
126 }
127 return in
128}
129
130// enqueue is the coalescer's emit callback. It may run on a timer goroutine,
131// so it only queues and wakes the attached loop.
132func (in *attachmentInput) enqueue(data []byte) {
133 if len(data) == 0 {
134 return
135 }
136 in.queue(protocol.Input{Data: append([]byte(nil), data...)})
137}
138
139func (in *attachmentInput) enqueueImage(mime string, data []byte) {
140 in.queue(protocol.ImagePush{Mime: mime, Data: append([]byte(nil), data...)})
141}
142
143func (in *attachmentInput) enqueueNotice(action uint8) {
144 in.queue(protocol.ClientNotice{Action: action})
145}
146
147func (in *attachmentInput) queue(message protocol.ClientMessage) {
148 in.mu.Lock()
149 in.queued = append(in.queued, message)
150 in.mu.Unlock()
151 select {
152 case in.wake <- struct{}{}:
153 default:
154 }
155}
156
157// start publishes the cleared theme and writes the first palette query. The
158// retained definitive colors avoid a neutral flash on a replacement
159// attachment.
160func (in *attachmentInput) start(ctx context.Context) error {
161 if in.palette == nil {
162 return nil
163 }
164 retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor })
165 return in.apply(ctx, in.palette.start(retained, false))
166}
167
168// close stops every timer. Undecided bytes are dropped with the attachment,
169// as the owner boundary drops every other attachment-held byte.
170func (in *attachmentInput) close() {
171 in.paste.Close()
172 if in.markerTimer != nil {
173 in.markerTimer.Stop()
174 in.markerTimer = nil
175 }
176 in.drain.stop()
177 in.completion.stop()
178}
179
180// read demultiplexes one physical terminal read.
181func (in *attachmentInput) read(ctx context.Context, state outputApplyState, data []byte) error {
182 var ordinary []byte
183 if err := in.disarmMarker(ctx, state); err != nil {
184 return err
185 }
186 // Every callback of one read keeps the generation it started with: a
187 // scheme notification early in the read starts a replacement, and an old
188 // marker later in the same read must not advance it.
189 var id paletteGenerationID
190 if in.palette != nil {
191 id = in.palette.current.id
192 }
193 var events []paletteGenerationEvent
194 in.scanner.Scan(data, func(kind int, rgb renderer.RGB) {
195 event := paletteEventForeground
196 if kind == 11 {
197 event = paletteEventBackground
198 }
199 events = append(events, paletteGenerationEvent{id: id, kind: event, rgb: rgb})
200 }, func(slot int, rgb renderer.RGB) {
201 if slot8, ok := paletteSlot(slot); ok {
202 events = append(events, paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot8, rgb: rgb})
203 }
204 }, func(light bool) {
205 events = append(events, paletteGenerationEvent{id: id, kind: paletteEventScheme, light: light})
206 }, func(bytes []byte) {
207 in.markers.scan(bytes, func(b []byte) { ordinary = append(ordinary, b...) }, func() {
208 events = append(events, paletteGenerationEvent{id: id, kind: paletteEventMarker})
209 })
210 })
211 for _, event := range events {
212 if err := in.handle(ctx, event); err != nil {
213 return err
214 }
215 }
216 if in.markers.hasPendingPrefix() {
217 in.markerTimer = in.clock.NewTimer(paletteMarkerAmbiguityDeadline)
218 }
219 return in.route(ctx, state, ordinary)
220}
221
222// flushHeld forwards every undecided ordinary byte before an automation batch,
223// so driver input never overtakes a keypress still being disambiguated. An
224// open bracketed paste is left alone.
225func (in *attachmentInput) flushHeld(ctx context.Context, state outputApplyState) error {
226 if in.markerTimer != nil {
227 in.markerTimer.Stop()
228 in.markerTimer = nil
229 }
230 var ordinary []byte
231 collect := func(b []byte) { ordinary = append(ordinary, b...) }
232 in.scanner.EndBatch(func(b []byte) {
233 in.markers.scan(b, collect, func() {})
234 })
235 in.markers.flush(collect)
236 if err := in.route(ctx, state, ordinary); err != nil {
237 return err
238 }
239 in.paste.EndBatch()
240 return in.flush(ctx)
241}
242
243// disarmMarker stops the ambiguity deadline before a later read. A deadline
244// that already fired wins, so bytes due as ordinary input are never
245// retroactively consumed as a marker.
246func (in *attachmentInput) disarmMarker(ctx context.Context, state outputApplyState) error {
247 if in.markerTimer == nil {
248 return nil
249 }
250 timer := in.markerTimer
251 select {
252 case <-timer.C():
253 return in.markerExpired(ctx, state)
254 default:
255 }
256 if !timer.Stop() {
257 return in.markerExpired(ctx, state)
258 }
259 in.markerTimer = nil
260 return nil
261}
262
263func (in *attachmentInput) markerC() <-chan time.Time {
264 if in.markerTimer == nil {
265 return nil
266 }
267 return in.markerTimer.C()
268}
269
270// markerExpired forwards a withheld DECRQM prefix as ordinary input.
271func (in *attachmentInput) markerExpired(ctx context.Context, state outputApplyState) error {
272 in.markerTimer = nil
273 var ordinary []byte
274 in.markers.flush(func(b []byte) { ordinary = append(ordinary, b...) })
275 return in.route(ctx, state, ordinary)
276}
277
278// route hands ordinary bytes to the overlay that owns input, or to the paste
279// coalescer on their way to the session.
280func (in *attachmentInput) route(ctx context.Context, state outputApplyState, data []byte) error {
281 if len(data) == 0 {
282 return nil
283 }
284 consumed, _, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
285 if err != nil || consumed {
286 return err
287 }
288 if in.clipboard != nil {
289 in.clipboard.Scan(data)
290 } else {
291 in.paste.Scan(data)
292 }
293 return in.flush(ctx)
294}
295
296// flush sends every queued coalescer emission as one session Input each.
297func (in *attachmentInput) flush(ctx context.Context) error {
298 in.mu.Lock()
299 queued := in.queued
300 in.queued = nil
301 in.mu.Unlock()
302 for _, message := range queued {
303 switch m := message.(type) {
304 case protocol.Input:
305 m.InputSeq = in.nextSeq()
306 message = m
307 case protocol.ImagePush:
308 m.InputSeq = in.nextSeq()
309 message = m
310 }
311 if err := in.send(ctx, message); err != nil {
312 return err
313 }
314 }
315 return nil
316}
317
318func (in *attachmentInput) nextSeq() uint64 {
319 in.seq++
320 return in.seq
321}
322
323func (in *attachmentInput) send(ctx context.Context, message protocol.ClientMessage) error {
324 return in.worker.send(ctx, in.fg, in.stream, message)
325}
326
327// handle applies one palette event. A scheme notification starts a
328// replacement generation from the retained colors.
329func (in *attachmentInput) handle(ctx context.Context, event paletteGenerationEvent) error {
330 if in.palette == nil {
331 return nil
332 }
333 if event.kind == paletteEventScheme {
334 retained := in.themes.update(func(current *protocol.Theme) {
335 current.SchemeKnown = true
336 current.Light = event.light
337 })
338 return in.apply(ctx, in.palette.start(retained, true))
339 }
340 return in.apply(ctx, in.palette.handle(event))
341}
342
343// drainFired and completionFired feed an expired palette deadline back to the
344// coordinator.
345func (in *attachmentInput) drainFired(ctx context.Context) error {
346 return in.handle(ctx, in.drain.fired(paletteEventDrainDeadline))
347}
348
349func (in *attachmentInput) completionFired(ctx context.Context) error {
350 return in.handle(ctx, in.completion.fired(paletteEventCompletionDeadline))
351}
352
353func (in *attachmentInput) apply(ctx context.Context, actions []paletteGenerationAction) error {
354 for _, action := range actions {
355 switch action.kind {
356 case paletteActionPublishCleared:
357 if err := in.send(ctx, action.theme); err != nil {
358 return fmt.Errorf("vev: publishing theme: %w", err)
359 }
360 case paletteActionPublishFinal:
361 in.themes.update(func(current *protocol.Theme) { *current = action.theme })
362 if err := in.send(ctx, action.theme); err != nil {
363 return fmt.Errorf("vev: publishing theme: %w", err)
364 }
365 case paletteActionWriteDrain, paletteActionWriteBatch:
366 if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil {
367 return fmt.Errorf("vev: writing palette query: %w", err)
368 }
369 case paletteActionArmDrainDeadline:
370 in.drain.arm(in.clock, action.id, action.deadline)
371 case paletteActionArmCompletionDeadline:
372 in.completion.arm(in.clock, action.id, action.deadline)
373 case paletteActionCancelDrainDeadline:
374 in.drain.cancel(action.id)
375 case paletteActionCancelCompletionDeadline:
376 in.completion.cancel(action.id)
377 }
378 }
379 return nil
380}