1package client
2
3import (
4 "time"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/ports"
8)
9
10// Palette queries always have a DECRQM boundary. The first marker drains
11// replies from a replaced generation; the second completes the color batch.
12const paletteBoundaryQuery = "\x1b[?2031$p"
13
14const paletteColorBatch = "\x1b]10;?\x07\x1b]11;?\x07" +
15 "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" +
16 paletteBoundaryQuery
17
18// paletteGenerationDeadline is a local terminal capability fallback, not a
19// user-facing timeout. Timer ownership belongs to the attach loop.
20const paletteGenerationDeadline = 200 * time.Millisecond
21
22// paletteMarkerAmbiguityDeadline bounds how long an Escape byte may remain
23// withheld while it could still become a DECRQM completion response. Match
24// the existing local input-prefix bound so a keypress remains prompt.
25const paletteMarkerAmbiguityDeadline = pasteFlushDelay
26
27type paletteGenerationID uint64
28
29type paletteGenerationPhase uint8
30
31const (
32 generationIdle paletteGenerationPhase = iota
33 generationDraining
34 generationCollecting
35 generationFinalized
36)
37
38type paletteGeneration struct {
39 id paletteGenerationID
40 phase paletteGenerationPhase
41 theme ports.Theme
42 expectedMarkers int
43 awaitingLateDrainBoundary bool
44}
45
46type paletteGenerationEventKind uint8
47
48const (
49 paletteEventMarker paletteGenerationEventKind = iota
50 paletteEventDrainDeadline
51 paletteEventCompletionDeadline
52 paletteEventForeground
53 paletteEventBackground
54 paletteEventPalette
55 paletteEventScheme
56)
57
58type paletteGenerationEvent struct {
59 id paletteGenerationID
60 kind paletteGenerationEventKind
61 rgb renderer.RGB
62 slot uint8
63 light bool
64}
65
66// paletteGenerationAction is deliberately declarative. The attach loop is
67// the sole terminal writer and protocol sender; timer bridges only enqueue a
68// generation-tagged event back to that loop.
69type paletteGenerationActionKind uint8
70
71const (
72 paletteActionPublishCleared paletteGenerationActionKind = iota
73 paletteActionWriteDrain
74 paletteActionWriteBatch
75 paletteActionArmDrainDeadline
76 paletteActionArmCompletionDeadline
77 paletteActionCancelDrainDeadline
78 paletteActionCancelCompletionDeadline
79 paletteActionPublishFinal
80)
81
82type paletteGenerationAction struct {
83 kind paletteGenerationActionKind
84 id paletteGenerationID
85 bytes string
86 theme ports.Theme
87 deadline time.Duration
88}
89
90// paletteGenerationCoordinator has no I/O, clocks, channels, or goroutines.
91// All state is scoped to one monotonically increasing generation.
92type paletteGenerationCoordinator struct {
93 next paletteGenerationID
94 current paletteGeneration
95 active bool
96 finalized ports.Theme
97}
98
99func newPaletteGenerationCoordinator() *paletteGenerationCoordinator {
100 return &paletteGenerationCoordinator{}
101}
102
103// start clears the last definitive palette before issuing either the initial
104// batch or a replacement drain. The retained foreground/background and
105// capability avoid a full neutral flash, while the accumulator begins with no
106// defaults or palette values so a definitive result never mixes generations.
107func (c *paletteGenerationCoordinator) start(retained ports.Theme, replacement bool) []paletteGenerationAction {
108 actions := c.cancelCurrent()
109 c.next++
110 id := c.next
111
112 cleared := retained
113 cleared.PaletteKnown = 0
114 cleared.Palette = [16]renderer.RGB{}
115 c.current = paletteGeneration{
116 id: id,
117 phase: generationCollecting,
118 theme: ports.Theme{
119 TrueColor: retained.TrueColor,
120 SchemeKnown: retained.SchemeKnown,
121 Light: retained.Light,
122 },
123 }
124 c.active = true
125 actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared})
126 if replacement {
127 c.current.phase = generationDraining
128 actions = append(actions,
129 paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery},
130 paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline},
131 )
132 return actions
133 }
134 return append(actions,
135 paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch},
136 paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline},
137 )
138}
139
140func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction {
141 if !c.active {
142 return nil
143 }
144 id := c.current.id
145 c.active = false
146 c.current.phase = generationFinalized
147 return []paletteGenerationAction{
148 {kind: paletteActionCancelDrainDeadline, id: id},
149 {kind: paletteActionCancelCompletionDeadline, id: id},
150 }
151}
152
153func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction {
154 if !c.active || event.id != c.current.id {
155 return nil
156 }
157 switch event.kind {
158 case paletteEventMarker:
159 return c.onMarker()
160 case paletteEventDrainDeadline:
161 if c.current.phase != generationDraining {
162 return nil
163 }
164 c.current.phase = generationCollecting
165 // A late drain response may still arrive after the batch marker. Exclude
166 // OSC reports until its boundary is consumed: otherwise stale drain-era
167 // reports could become part of this generation's definitive snapshot.
168 c.current.expectedMarkers = 2
169 c.current.awaitingLateDrainBoundary = true
170 return []paletteGenerationAction{
171 {kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch},
172 {kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline},
173 }
174 case paletteEventCompletionDeadline:
175 if c.current.phase != generationCollecting {
176 return nil
177 }
178 return c.finalize()
179 case paletteEventForeground:
180 if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
181 c.current.theme.HasForeground = true
182 c.current.theme.Foreground = event.rgb
183 }
184 case paletteEventBackground:
185 if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
186 c.current.theme.HasBackground = true
187 c.current.theme.Background = event.rgb
188 }
189 case paletteEventPalette:
190 if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary && event.slot < 16 {
191 c.current.theme.PaletteKnown |= uint16(1) << event.slot
192 c.current.theme.Palette[event.slot] = event.rgb
193 }
194 }
195 return nil
196}
197
198func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction {
199 switch c.current.phase {
200 case generationDraining:
201 c.current.phase = generationCollecting
202 c.current.expectedMarkers = 1
203 return []paletteGenerationAction{
204 {kind: paletteActionCancelDrainDeadline, id: c.current.id},
205 {kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch},
206 {kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline},
207 }
208 case generationCollecting:
209 if c.current.expectedMarkers == 0 {
210 c.current.expectedMarkers = 1
211 }
212 if c.current.awaitingLateDrainBoundary {
213 c.current.awaitingLateDrainBoundary = false
214 }
215 c.current.expectedMarkers--
216 if c.current.expectedMarkers == 0 {
217 return c.finalize()
218 }
219 }
220 return nil
221}
222
223func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
224 if !c.active || c.current.phase != generationCollecting {
225 return nil
226 }
227 c.current.phase = generationFinalized
228 c.active = false
229 c.finalized = c.current.theme
230 return []paletteGenerationAction{
231 {kind: paletteActionCancelDrainDeadline, id: c.current.id},
232 {kind: paletteActionCancelCompletionDeadline, id: c.current.id},
233 {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
234 }
235}
236
237func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
238 return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
239}
240
241func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
242 c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
243}
244
245func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
246 c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
247}
248
249func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
250 c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
251}
252
253func (c *paletteGenerationCoordinator) finalizedTheme() ports.Theme { return c.finalized }
254
255// paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
256// possible prefixes across reads, forwarding all ordinary input exactly once.
257type paletteMarkerScanner struct{ pending []byte }
258
259var paletteMarkerResponses = [][]byte{
260 []byte("\x1b[?2031;0$y"),
261 []byte("\x1b[?2031;1$y"),
262 []byte("\x1b[?2031;2$y"),
263 []byte("\x1b[?2031;3$y"),
264 []byte("\x1b[?2031;4$y"),
265}
266
267func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
268 for i, b := range data {
269 s.pending = append(s.pending, b)
270 matched, possible := false, false
271 for _, response := range paletteMarkerResponses {
272 if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
273 continue
274 }
275 if len(s.pending) == len(response) {
276 matched = true
277 } else {
278 possible = true
279 }
280 }
281 if matched {
282 s.pending = nil
283 onMarker()
284 continue
285 }
286 if possible {
287 continue
288 }
289 if s.pending[0] != '\x1b' {
290 onBytes(s.pending[:1])
291 s.pending = s.pending[1:]
292 continue
293 }
294 // This is not a marker. Forward the entire remaining read as one
295 // contiguous input run so CSI mouse reports and escape sequences keep
296 // their framing.
297 s.pending = append(s.pending, data[i+1:]...)
298 onBytes(s.pending)
299 s.pending = nil
300 return
301 }
302}
303func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
304 if len(s.pending) == 0 {
305 return
306 }
307 onBytes(s.pending)
308 s.pending = nil
309}
310
311// takePending transfers only an undecided DECRQM prefix to a replacement
312// scanner. Callers use it during cancellation after all other callbacks from
313// the source read have been committed, preventing both loss and replay.
314func (s *paletteMarkerScanner) takePending() []byte {
315 pending := append([]byte(nil), s.pending...)
316 s.pending = nil
317 return pending
318}
319
320func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }