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