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 SchemeKnown: retained.SchemeKnown,
145 Light: retained.Light,
146 },
147 }
148 c.active = true
149 actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared})
150 if replacement {
151 c.current.phase = generationDraining
152 actions = append(actions,
153 paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery},
154 paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline},
155 )
156 return actions
157 }
158 return append(actions,
159 paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch},
160 paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline},
161 )
162}
163
164func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction {
165 if !c.active {
166 return nil
167 }
168 id := c.current.id
169 c.active = false
170 c.current.phase = generationFinalized
171 return []paletteGenerationAction{
172 {kind: paletteActionCancelDrainDeadline, id: id},
173 {kind: paletteActionCancelCompletionDeadline, id: id},
174 }
175}
176
177func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction {
178 if !c.active || event.id != c.current.id {
179 return nil
180 }
181 switch event.kind {
182 case paletteEventMarker:
183 return c.onMarker()
184 case paletteEventDrainDeadline:
185 if c.current.phase != generationDraining {
186 return nil
187 }
188 c.current.phase = generationCollecting
189 // A late drain response may still arrive after the batch marker. Exclude
190 // OSC reports until its boundary is consumed: otherwise stale drain-era
191 // reports could become part of this generation's definitive snapshot.
192 c.current.expectedMarkers = 2
193 c.current.awaitingLateDrainBoundary = true
194 return []paletteGenerationAction{
195 {kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch},
196 {kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline},
197 }
198 case paletteEventCompletionDeadline:
199 if c.current.phase != generationCollecting {
200 return nil
201 }
202 return c.finalize()
203 case paletteEventForeground:
204 if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
205 c.current.theme.HasForeground = true
206 c.current.theme.Foreground = event.rgb
207 }
208 case paletteEventBackground:
209 if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
210 c.current.theme.HasBackground = true
211 c.current.theme.Background = event.rgb
212 }
213 case paletteEventPalette:
214 if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary && event.slot < 16 {
215 c.current.theme.PaletteKnown |= uint16(1) << event.slot
216 c.current.theme.Palette[event.slot] = event.rgb
217 }
218 }
219 return nil
220}
221
222func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction {
223 switch c.current.phase {
224 case generationDraining:
225 c.current.phase = generationCollecting
226 c.current.expectedMarkers = 1
227 return []paletteGenerationAction{
228 {kind: paletteActionCancelDrainDeadline, id: c.current.id},
229 {kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch},
230 {kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline},
231 }
232 case generationCollecting:
233 if c.current.expectedMarkers == 0 {
234 c.current.expectedMarkers = 1
235 }
236 if c.current.awaitingLateDrainBoundary {
237 c.current.awaitingLateDrainBoundary = false
238 }
239 c.current.expectedMarkers--
240 if c.current.expectedMarkers == 0 {
241 return c.finalize()
242 }
243 }
244 return nil
245}
246
247func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
248 if !c.active || c.current.phase != generationCollecting {
249 return nil
250 }
251 c.current.phase = generationFinalized
252 c.active = false
253 c.finalized = c.current.theme
254 return []paletteGenerationAction{
255 {kind: paletteActionCancelDrainDeadline, id: c.current.id},
256 {kind: paletteActionCancelCompletionDeadline, id: c.current.id},
257 {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
258 }
259}
260
261// paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
262// possible prefixes across reads, forwarding all ordinary input exactly once.
263type paletteMarkerScanner struct{ pending []byte }
264
265var paletteMarkerResponses = [][]byte{
266 []byte("\x1b[?2031;0$y"),
267 []byte("\x1b[?2031;1$y"),
268 []byte("\x1b[?2031;2$y"),
269 []byte("\x1b[?2031;3$y"),
270 []byte("\x1b[?2031;4$y"),
271}
272
273func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
274 for i, b := range data {
275 s.pending = append(s.pending, b)
276 matched, possible := false, false
277 for _, response := range paletteMarkerResponses {
278 if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
279 continue
280 }
281 if len(s.pending) == len(response) {
282 matched = true
283 } else {
284 possible = true
285 }
286 }
287 if matched {
288 s.pending = nil
289 onMarker()
290 continue
291 }
292 if possible {
293 continue
294 }
295 if s.pending[0] != '\x1b' {
296 onBytes(s.pending[:1])
297 s.pending = s.pending[1:]
298 continue
299 }
300 // This is not a marker. Forward the entire remaining read as one
301 // contiguous input run so CSI mouse reports and escape sequences keep
302 // their framing.
303 s.pending = append(s.pending, data[i+1:]...)
304 onBytes(s.pending)
305 s.pending = nil
306 return
307 }
308}
309func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
310 if len(s.pending) == 0 {
311 return
312 }
313 onBytes(s.pending)
314 s.pending = nil
315}
316
317func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }