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