feat(client): model atomic palette generations
- commit
- 588bb233cc3546a3a7cec0e61e3ab0044b6e7e56
- author
- brice · · 2026-07-18 06:15 UTC
- parent
- ec95daf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +525 -3
M internal/usecase/client/client.go +1 -3
| @@ -669,7 +669,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 669 | 669 | return welcomedResult(errLinkOffline) | |
| 670 | 670 | } | |
| 671 | 671 | case request := <-colorQueryCh: | |
| 672 | - | if _, err := term.Out().Write(paletteBoundaryQuery); err != nil { | |
| 672 | + | if _, err := term.Out().Write([]byte(paletteBoundaryQuery)); err != nil { | |
| 673 | 673 | return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err)) | |
| 674 | 674 | } | |
| 675 | 675 | if err := term.Flush(); err != nil { | |
| @@ -869,8 +869,6 @@ const ( | |||
| 869 | 869 | paletteAwaitingQuery | |
| 870 | 870 | ) | |
| 871 | 871 | ||
| 872 | - | var paletteBoundaryQuery = []byte("\x1b[?2031$p") | |
| 873 | - | ||
| 874 | 872 | // paletteBoundaryResponses accepts valid DECRQM status values for private | |
| 875 | 873 | // mode 2031. The private mode number correlates this reply with the query | |
| 876 | 874 | // that starts a replacement palette generation. | |
| @@ -669,7 +669,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 669 | return welcomedResult(errLinkOffline) | 669 | return welcomedResult(errLinkOffline) | ||
| 670 | } | 670 | } | ||
| 671 | case request := <-colorQueryCh: | 671 | case request := <-colorQueryCh: | ||
| 672 | - | if _, err := term.Out().Write(paletteBoundaryQuery); err != nil { | 672 | + | if _, err := term.Out().Write([]byte(paletteBoundaryQuery)); err != nil { |
| 673 | return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err)) | 673 | return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err)) | ||
| 674 | } | 674 | } | ||
| 675 | if err := term.Flush(); err != nil { | 675 | if err := term.Flush(); err != nil { | ||
| @@ -869,8 +869,6 @@ const ( | |||||
| 869 | paletteAwaitingQuery | 869 | paletteAwaitingQuery | ||
| 870 | ) | 870 | ) | ||
| 871 | 871 | ||||
| 872 | - | var paletteBoundaryQuery = []byte("\x1b[?2031$p") | |||
| 873 | - | ||||
| 874 | // paletteBoundaryResponses accepts valid DECRQM status values for private | 872 | // paletteBoundaryResponses accepts valid DECRQM status values for private | ||
| 875 | // mode 2031. The private mode number correlates this reply with the query | 873 | // mode 2031. The private mode number correlates this reply with the query | ||
| 876 | // that starts a replacement palette generation. | 874 | // that starts a replacement palette generation. | ||
A internal/usecase/client/palette_generation.go +290 -0
| @@ -0,0 +1,290 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 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. | |
| 12 | + | const paletteBoundaryQuery = "\x1b[?2031$p" | |
| 13 | + | ||
| 14 | + | const 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. | |
| 20 | + | const paletteGenerationDeadline = 200 * time.Millisecond | |
| 21 | + | ||
| 22 | + | type paletteGenerationID uint64 | |
| 23 | + | ||
| 24 | + | type paletteGenerationPhase uint8 | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | generationIdle paletteGenerationPhase = iota | |
| 28 | + | generationDraining | |
| 29 | + | generationCollecting | |
| 30 | + | generationFinalized | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | type paletteGeneration struct { | |
| 34 | + | id paletteGenerationID | |
| 35 | + | phase paletteGenerationPhase | |
| 36 | + | theme ports.Theme | |
| 37 | + | expectedMarkers int | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type paletteGenerationEventKind uint8 | |
| 41 | + | ||
| 42 | + | const ( | |
| 43 | + | paletteEventMarker paletteGenerationEventKind = iota | |
| 44 | + | paletteEventDrainDeadline | |
| 45 | + | paletteEventCompletionDeadline | |
| 46 | + | paletteEventForeground | |
| 47 | + | paletteEventBackground | |
| 48 | + | paletteEventPalette | |
| 49 | + | ) | |
| 50 | + | ||
| 51 | + | type paletteGenerationEvent struct { | |
| 52 | + | id paletteGenerationID | |
| 53 | + | kind paletteGenerationEventKind | |
| 54 | + | rgb renderer.RGB | |
| 55 | + | slot uint8 | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // paletteGenerationAction is deliberately declarative. The attach loop is | |
| 59 | + | // the sole terminal writer and protocol sender; timer bridges only enqueue a | |
| 60 | + | // generation-tagged event back to that loop. | |
| 61 | + | type paletteGenerationActionKind uint8 | |
| 62 | + | ||
| 63 | + | const ( | |
| 64 | + | paletteActionPublishCleared paletteGenerationActionKind = iota | |
| 65 | + | paletteActionWriteDrain | |
| 66 | + | paletteActionWriteBatch | |
| 67 | + | paletteActionArmDrainDeadline | |
| 68 | + | paletteActionArmCompletionDeadline | |
| 69 | + | paletteActionCancelDrainDeadline | |
| 70 | + | paletteActionCancelCompletionDeadline | |
| 71 | + | paletteActionPublishFinal | |
| 72 | + | ) | |
| 73 | + | ||
| 74 | + | type paletteGenerationAction struct { | |
| 75 | + | kind paletteGenerationActionKind | |
| 76 | + | id paletteGenerationID | |
| 77 | + | bytes string | |
| 78 | + | theme ports.Theme | |
| 79 | + | deadline time.Duration | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // paletteGenerationCoordinator has no I/O, clocks, channels, or goroutines. | |
| 83 | + | // All state is scoped to one monotonically increasing generation. | |
| 84 | + | type paletteGenerationCoordinator struct { | |
| 85 | + | next paletteGenerationID | |
| 86 | + | current paletteGeneration | |
| 87 | + | active bool | |
| 88 | + | finalized ports.Theme | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func newPaletteGenerationCoordinator() *paletteGenerationCoordinator { | |
| 92 | + | return &paletteGenerationCoordinator{} | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // start clears the last definitive palette before issuing either the initial | |
| 96 | + | // batch or a replacement drain. The retained foreground/background and | |
| 97 | + | // capability avoid a full neutral flash, while the accumulator begins with no | |
| 98 | + | // defaults or palette values so a definitive result never mixes generations. | |
| 99 | + | func (c *paletteGenerationCoordinator) start(retained ports.Theme, replacement bool) []paletteGenerationAction { | |
| 100 | + | actions := c.cancelCurrent() | |
| 101 | + | c.next++ | |
| 102 | + | id := c.next | |
| 103 | + | ||
| 104 | + | cleared := retained | |
| 105 | + | cleared.PaletteKnown = 0 | |
| 106 | + | cleared.Palette = [16]renderer.RGB{} | |
| 107 | + | c.current = paletteGeneration{ | |
| 108 | + | id: id, | |
| 109 | + | phase: generationCollecting, | |
| 110 | + | theme: ports.Theme{ | |
| 111 | + | TrueColor: retained.TrueColor, | |
| 112 | + | SchemeKnown: retained.SchemeKnown, | |
| 113 | + | Light: retained.Light, | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | c.active = true | |
| 117 | + | actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared}) | |
| 118 | + | if replacement { | |
| 119 | + | c.current.phase = generationDraining | |
| 120 | + | actions = append(actions, | |
| 121 | + | paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery}, | |
| 122 | + | paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline}, | |
| 123 | + | ) | |
| 124 | + | return actions | |
| 125 | + | } | |
| 126 | + | return append(actions, | |
| 127 | + | paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch}, | |
| 128 | + | paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline}, | |
| 129 | + | ) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction { | |
| 133 | + | if !c.active { | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | id := c.current.id | |
| 137 | + | c.active = false | |
| 138 | + | c.current.phase = generationFinalized | |
| 139 | + | return []paletteGenerationAction{ | |
| 140 | + | {kind: paletteActionCancelDrainDeadline, id: id}, | |
| 141 | + | {kind: paletteActionCancelCompletionDeadline, id: id}, | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction { | |
| 146 | + | if !c.active || event.id != c.current.id { | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | switch event.kind { | |
| 150 | + | case paletteEventMarker: | |
| 151 | + | return c.onMarker() | |
| 152 | + | case paletteEventDrainDeadline: | |
| 153 | + | if c.current.phase != generationDraining { | |
| 154 | + | return nil | |
| 155 | + | } | |
| 156 | + | c.current.phase = generationCollecting | |
| 157 | + | // A late drain response may still arrive after the batch marker. | |
| 158 | + | c.current.expectedMarkers = 2 | |
| 159 | + | return []paletteGenerationAction{ | |
| 160 | + | {kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch}, | |
| 161 | + | {kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline}, | |
| 162 | + | } | |
| 163 | + | case paletteEventCompletionDeadline: | |
| 164 | + | if c.current.phase != generationCollecting { | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | return c.finalize() | |
| 168 | + | case paletteEventForeground: | |
| 169 | + | if c.current.phase == generationCollecting { | |
| 170 | + | c.current.theme.HasForeground = true | |
| 171 | + | c.current.theme.Foreground = event.rgb | |
| 172 | + | } | |
| 173 | + | case paletteEventBackground: | |
| 174 | + | if c.current.phase == generationCollecting { | |
| 175 | + | c.current.theme.HasBackground = true | |
| 176 | + | c.current.theme.Background = event.rgb | |
| 177 | + | } | |
| 178 | + | case paletteEventPalette: | |
| 179 | + | if c.current.phase == generationCollecting && event.slot < 16 { | |
| 180 | + | c.current.theme.PaletteKnown |= uint16(1) << event.slot | |
| 181 | + | c.current.theme.Palette[event.slot] = event.rgb | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction { | |
| 188 | + | switch c.current.phase { | |
| 189 | + | case generationDraining: | |
| 190 | + | c.current.phase = generationCollecting | |
| 191 | + | c.current.expectedMarkers = 1 | |
| 192 | + | return []paletteGenerationAction{ | |
| 193 | + | {kind: paletteActionCancelDrainDeadline, id: c.current.id}, | |
| 194 | + | {kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch}, | |
| 195 | + | {kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline}, | |
| 196 | + | } | |
| 197 | + | case generationCollecting: | |
| 198 | + | if c.current.expectedMarkers == 0 { | |
| 199 | + | c.current.expectedMarkers = 1 | |
| 200 | + | } | |
| 201 | + | c.current.expectedMarkers-- | |
| 202 | + | if c.current.expectedMarkers == 0 { | |
| 203 | + | return c.finalize() | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction { | |
| 210 | + | if !c.active || c.current.phase != generationCollecting { | |
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | c.current.phase = generationFinalized | |
| 214 | + | c.active = false | |
| 215 | + | c.finalized = c.current.theme | |
| 216 | + | return []paletteGenerationAction{ | |
| 217 | + | {kind: paletteActionCancelCompletionDeadline, id: c.current.id}, | |
| 218 | + | {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized}, | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction { | |
| 223 | + | return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker}) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) { | |
| 227 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb}) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) { | |
| 231 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb}) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) { | |
| 235 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb}) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (c *paletteGenerationCoordinator) finalizedTheme() ports.Theme { return c.finalized } | |
| 239 | + | ||
| 240 | + | // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains | |
| 241 | + | // possible prefixes across reads, forwarding all ordinary input exactly once. | |
| 242 | + | type paletteMarkerScanner struct{ pending []byte } | |
| 243 | + | ||
| 244 | + | var paletteMarkerResponses = [][]byte{ | |
| 245 | + | []byte("\x1b[?2031;0$y"), | |
| 246 | + | []byte("\x1b[?2031;1$y"), | |
| 247 | + | []byte("\x1b[?2031;2$y"), | |
| 248 | + | []byte("\x1b[?2031;3$y"), | |
| 249 | + | []byte("\x1b[?2031;4$y"), | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) { | |
| 253 | + | for _, b := range data { | |
| 254 | + | s.pending = append(s.pending, b) | |
| 255 | + | for len(s.pending) > 0 { | |
| 256 | + | matched, possible := false, false | |
| 257 | + | for _, response := range paletteMarkerResponses { | |
| 258 | + | if len(s.pending) > len(response) { | |
| 259 | + | continue | |
| 260 | + | } | |
| 261 | + | if string(s.pending) != string(response[:len(s.pending)]) { | |
| 262 | + | continue | |
| 263 | + | } | |
| 264 | + | if len(s.pending) == len(response) { | |
| 265 | + | matched = true | |
| 266 | + | } else { | |
| 267 | + | possible = true | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | if matched { | |
| 271 | + | s.pending = nil | |
| 272 | + | onMarker() | |
| 273 | + | break | |
| 274 | + | } | |
| 275 | + | if possible { | |
| 276 | + | break | |
| 277 | + | } | |
| 278 | + | onBytes(s.pending[:1]) | |
| 279 | + | s.pending = s.pending[1:] | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (s *paletteMarkerScanner) flush(onBytes func([]byte)) { | |
| 285 | + | if len(s.pending) == 0 { | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | onBytes(s.pending) | |
| 289 | + | s.pending = nil | |
| 290 | + | } | |
| @@ -0,0 +1,290 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 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. | |||
| 12 | + | const paletteBoundaryQuery = "\x1b[?2031$p" | |||
| 13 | + | ||||
| 14 | + | const 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. | |||
| 20 | + | const paletteGenerationDeadline = 200 * time.Millisecond | |||
| 21 | + | ||||
| 22 | + | type paletteGenerationID uint64 | |||
| 23 | + | ||||
| 24 | + | type paletteGenerationPhase uint8 | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | generationIdle paletteGenerationPhase = iota | |||
| 28 | + | generationDraining | |||
| 29 | + | generationCollecting | |||
| 30 | + | generationFinalized | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | type paletteGeneration struct { | |||
| 34 | + | id paletteGenerationID | |||
| 35 | + | phase paletteGenerationPhase | |||
| 36 | + | theme ports.Theme | |||
| 37 | + | expectedMarkers int | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type paletteGenerationEventKind uint8 | |||
| 41 | + | ||||
| 42 | + | const ( | |||
| 43 | + | paletteEventMarker paletteGenerationEventKind = iota | |||
| 44 | + | paletteEventDrainDeadline | |||
| 45 | + | paletteEventCompletionDeadline | |||
| 46 | + | paletteEventForeground | |||
| 47 | + | paletteEventBackground | |||
| 48 | + | paletteEventPalette | |||
| 49 | + | ) | |||
| 50 | + | ||||
| 51 | + | type paletteGenerationEvent struct { | |||
| 52 | + | id paletteGenerationID | |||
| 53 | + | kind paletteGenerationEventKind | |||
| 54 | + | rgb renderer.RGB | |||
| 55 | + | slot uint8 | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // paletteGenerationAction is deliberately declarative. The attach loop is | |||
| 59 | + | // the sole terminal writer and protocol sender; timer bridges only enqueue a | |||
| 60 | + | // generation-tagged event back to that loop. | |||
| 61 | + | type paletteGenerationActionKind uint8 | |||
| 62 | + | ||||
| 63 | + | const ( | |||
| 64 | + | paletteActionPublishCleared paletteGenerationActionKind = iota | |||
| 65 | + | paletteActionWriteDrain | |||
| 66 | + | paletteActionWriteBatch | |||
| 67 | + | paletteActionArmDrainDeadline | |||
| 68 | + | paletteActionArmCompletionDeadline | |||
| 69 | + | paletteActionCancelDrainDeadline | |||
| 70 | + | paletteActionCancelCompletionDeadline | |||
| 71 | + | paletteActionPublishFinal | |||
| 72 | + | ) | |||
| 73 | + | ||||
| 74 | + | type paletteGenerationAction struct { | |||
| 75 | + | kind paletteGenerationActionKind | |||
| 76 | + | id paletteGenerationID | |||
| 77 | + | bytes string | |||
| 78 | + | theme ports.Theme | |||
| 79 | + | deadline time.Duration | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // paletteGenerationCoordinator has no I/O, clocks, channels, or goroutines. | |||
| 83 | + | // All state is scoped to one monotonically increasing generation. | |||
| 84 | + | type paletteGenerationCoordinator struct { | |||
| 85 | + | next paletteGenerationID | |||
| 86 | + | current paletteGeneration | |||
| 87 | + | active bool | |||
| 88 | + | finalized ports.Theme | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func newPaletteGenerationCoordinator() *paletteGenerationCoordinator { | |||
| 92 | + | return &paletteGenerationCoordinator{} | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // start clears the last definitive palette before issuing either the initial | |||
| 96 | + | // batch or a replacement drain. The retained foreground/background and | |||
| 97 | + | // capability avoid a full neutral flash, while the accumulator begins with no | |||
| 98 | + | // defaults or palette values so a definitive result never mixes generations. | |||
| 99 | + | func (c *paletteGenerationCoordinator) start(retained ports.Theme, replacement bool) []paletteGenerationAction { | |||
| 100 | + | actions := c.cancelCurrent() | |||
| 101 | + | c.next++ | |||
| 102 | + | id := c.next | |||
| 103 | + | ||||
| 104 | + | cleared := retained | |||
| 105 | + | cleared.PaletteKnown = 0 | |||
| 106 | + | cleared.Palette = [16]renderer.RGB{} | |||
| 107 | + | c.current = paletteGeneration{ | |||
| 108 | + | id: id, | |||
| 109 | + | phase: generationCollecting, | |||
| 110 | + | theme: ports.Theme{ | |||
| 111 | + | TrueColor: retained.TrueColor, | |||
| 112 | + | SchemeKnown: retained.SchemeKnown, | |||
| 113 | + | Light: retained.Light, | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | c.active = true | |||
| 117 | + | actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared}) | |||
| 118 | + | if replacement { | |||
| 119 | + | c.current.phase = generationDraining | |||
| 120 | + | actions = append(actions, | |||
| 121 | + | paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery}, | |||
| 122 | + | paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline}, | |||
| 123 | + | ) | |||
| 124 | + | return actions | |||
| 125 | + | } | |||
| 126 | + | return append(actions, | |||
| 127 | + | paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch}, | |||
| 128 | + | paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline}, | |||
| 129 | + | ) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction { | |||
| 133 | + | if !c.active { | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | id := c.current.id | |||
| 137 | + | c.active = false | |||
| 138 | + | c.current.phase = generationFinalized | |||
| 139 | + | return []paletteGenerationAction{ | |||
| 140 | + | {kind: paletteActionCancelDrainDeadline, id: id}, | |||
| 141 | + | {kind: paletteActionCancelCompletionDeadline, id: id}, | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction { | |||
| 146 | + | if !c.active || event.id != c.current.id { | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | switch event.kind { | |||
| 150 | + | case paletteEventMarker: | |||
| 151 | + | return c.onMarker() | |||
| 152 | + | case paletteEventDrainDeadline: | |||
| 153 | + | if c.current.phase != generationDraining { | |||
| 154 | + | return nil | |||
| 155 | + | } | |||
| 156 | + | c.current.phase = generationCollecting | |||
| 157 | + | // A late drain response may still arrive after the batch marker. | |||
| 158 | + | c.current.expectedMarkers = 2 | |||
| 159 | + | return []paletteGenerationAction{ | |||
| 160 | + | {kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch}, | |||
| 161 | + | {kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline}, | |||
| 162 | + | } | |||
| 163 | + | case paletteEventCompletionDeadline: | |||
| 164 | + | if c.current.phase != generationCollecting { | |||
| 165 | + | return nil | |||
| 166 | + | } | |||
| 167 | + | return c.finalize() | |||
| 168 | + | case paletteEventForeground: | |||
| 169 | + | if c.current.phase == generationCollecting { | |||
| 170 | + | c.current.theme.HasForeground = true | |||
| 171 | + | c.current.theme.Foreground = event.rgb | |||
| 172 | + | } | |||
| 173 | + | case paletteEventBackground: | |||
| 174 | + | if c.current.phase == generationCollecting { | |||
| 175 | + | c.current.theme.HasBackground = true | |||
| 176 | + | c.current.theme.Background = event.rgb | |||
| 177 | + | } | |||
| 178 | + | case paletteEventPalette: | |||
| 179 | + | if c.current.phase == generationCollecting && event.slot < 16 { | |||
| 180 | + | c.current.theme.PaletteKnown |= uint16(1) << event.slot | |||
| 181 | + | c.current.theme.Palette[event.slot] = event.rgb | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction { | |||
| 188 | + | switch c.current.phase { | |||
| 189 | + | case generationDraining: | |||
| 190 | + | c.current.phase = generationCollecting | |||
| 191 | + | c.current.expectedMarkers = 1 | |||
| 192 | + | return []paletteGenerationAction{ | |||
| 193 | + | {kind: paletteActionCancelDrainDeadline, id: c.current.id}, | |||
| 194 | + | {kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch}, | |||
| 195 | + | {kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline}, | |||
| 196 | + | } | |||
| 197 | + | case generationCollecting: | |||
| 198 | + | if c.current.expectedMarkers == 0 { | |||
| 199 | + | c.current.expectedMarkers = 1 | |||
| 200 | + | } | |||
| 201 | + | c.current.expectedMarkers-- | |||
| 202 | + | if c.current.expectedMarkers == 0 { | |||
| 203 | + | return c.finalize() | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction { | |||
| 210 | + | if !c.active || c.current.phase != generationCollecting { | |||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | c.current.phase = generationFinalized | |||
| 214 | + | c.active = false | |||
| 215 | + | c.finalized = c.current.theme | |||
| 216 | + | return []paletteGenerationAction{ | |||
| 217 | + | {kind: paletteActionCancelCompletionDeadline, id: c.current.id}, | |||
| 218 | + | {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized}, | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction { | |||
| 223 | + | return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker}) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) { | |||
| 227 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb}) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) { | |||
| 231 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb}) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) { | |||
| 235 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb}) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (c *paletteGenerationCoordinator) finalizedTheme() ports.Theme { return c.finalized } | |||
| 239 | + | ||||
| 240 | + | // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains | |||
| 241 | + | // possible prefixes across reads, forwarding all ordinary input exactly once. | |||
| 242 | + | type paletteMarkerScanner struct{ pending []byte } | |||
| 243 | + | ||||
| 244 | + | var paletteMarkerResponses = [][]byte{ | |||
| 245 | + | []byte("\x1b[?2031;0$y"), | |||
| 246 | + | []byte("\x1b[?2031;1$y"), | |||
| 247 | + | []byte("\x1b[?2031;2$y"), | |||
| 248 | + | []byte("\x1b[?2031;3$y"), | |||
| 249 | + | []byte("\x1b[?2031;4$y"), | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) { | |||
| 253 | + | for _, b := range data { | |||
| 254 | + | s.pending = append(s.pending, b) | |||
| 255 | + | for len(s.pending) > 0 { | |||
| 256 | + | matched, possible := false, false | |||
| 257 | + | for _, response := range paletteMarkerResponses { | |||
| 258 | + | if len(s.pending) > len(response) { | |||
| 259 | + | continue | |||
| 260 | + | } | |||
| 261 | + | if string(s.pending) != string(response[:len(s.pending)]) { | |||
| 262 | + | continue | |||
| 263 | + | } | |||
| 264 | + | if len(s.pending) == len(response) { | |||
| 265 | + | matched = true | |||
| 266 | + | } else { | |||
| 267 | + | possible = true | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | if matched { | |||
| 271 | + | s.pending = nil | |||
| 272 | + | onMarker() | |||
| 273 | + | break | |||
| 274 | + | } | |||
| 275 | + | if possible { | |||
| 276 | + | break | |||
| 277 | + | } | |||
| 278 | + | onBytes(s.pending[:1]) | |||
| 279 | + | s.pending = s.pending[1:] | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func (s *paletteMarkerScanner) flush(onBytes func([]byte)) { | |||
| 285 | + | if len(s.pending) == 0 { | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | onBytes(s.pending) | |||
| 289 | + | s.pending = nil | |||
| 290 | + | } | |||
A internal/usecase/client/palette_generation_test.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // manualPaletteClock applies only declarative coordinator timer actions. Its | |
| 14 | + | // callbacks re-enter the coordinator through generation-tagged events; they | |
| 15 | + | // never perform terminal I/O or protocol sends. | |
| 16 | + | type manualPaletteClock struct { | |
| 17 | + | drain, completion map[paletteGenerationID]bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func newManualPaletteClock() *manualPaletteClock { | |
| 21 | + | return &manualPaletteClock{drain: map[paletteGenerationID]bool{}, completion: map[paletteGenerationID]bool{}} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (c *manualPaletteClock) apply(actions []paletteGenerationAction) { | |
| 25 | + | for _, action := range actions { | |
| 26 | + | switch action.kind { | |
| 27 | + | case paletteActionArmDrainDeadline: | |
| 28 | + | c.drain[action.id] = true | |
| 29 | + | case paletteActionArmCompletionDeadline: | |
| 30 | + | c.completion[action.id] = true | |
| 31 | + | case paletteActionCancelDrainDeadline: | |
| 32 | + | delete(c.drain, action.id) | |
| 33 | + | case paletteActionCancelCompletionDeadline: | |
| 34 | + | delete(c.completion, action.id) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (c *manualPaletteClock) fireDrain(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction { | |
| 40 | + | if !c.drain[id] { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | delete(c.drain, id) | |
| 44 | + | return g.handle(paletteGenerationEvent{id: id, kind: paletteEventDrainDeadline}) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction { | |
| 48 | + | if !c.completion[id] { | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | delete(c.completion, id) | |
| 52 | + | return g.handle(paletteGenerationEvent{id: id, kind: paletteEventCompletionDeadline}) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func generationTheme() ports.Theme { | |
| 56 | + | return ports.Theme{ | |
| 57 | + | TrueColor: true, | |
| 58 | + | HasForeground: true, | |
| 59 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 60 | + | HasBackground: true, | |
| 61 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 62 | + | SchemeKnown: true, | |
| 63 | + | Light: true, | |
| 64 | + | PaletteKnown: 1 << 2, | |
| 65 | + | Palette: [16]renderer.RGB{2: {R: 7, G: 8, B: 9}}, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func actionKinds(actions []paletteGenerationAction) []paletteGenerationActionKind { | |
| 70 | + | out := make([]paletteGenerationActionKind, len(actions)) | |
| 71 | + | for i := range actions { | |
| 72 | + | out[i] = actions[i].kind | |
| 73 | + | } | |
| 74 | + | return out | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func findAction(t *testing.T, actions []paletteGenerationAction, kind paletteGenerationActionKind) paletteGenerationAction { | |
| 78 | + | t.Helper() | |
| 79 | + | for _, action := range actions { | |
| 80 | + | if action.kind == kind { | |
| 81 | + | return action | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | t.Fatalf("missing action %v in %#v", kind, actions) | |
| 85 | + | return paletteGenerationAction{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) { | |
| 89 | + | g := newPaletteGenerationCoordinator() | |
| 90 | + | clock := newManualPaletteClock() | |
| 91 | + | start := g.start(generationTheme(), false) | |
| 92 | + | clock.apply(start) | |
| 93 | + | ||
| 94 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 95 | + | paletteActionPublishCleared, | |
| 96 | + | paletteActionWriteBatch, | |
| 97 | + | paletteActionArmCompletionDeadline, | |
| 98 | + | }, actionKinds(start)) | |
| 99 | + | cleared := findAction(t, start, paletteActionPublishCleared).theme | |
| 100 | + | require.True(t, cleared.HasForeground) | |
| 101 | + | require.Equal(t, generationTheme().Foreground, cleared.Foreground) | |
| 102 | + | require.Zero(t, cleared.PaletteKnown) | |
| 103 | + | require.Zero(t, cleared.Palette) | |
| 104 | + | ||
| 105 | + | batch := findAction(t, start, paletteActionWriteBatch) | |
| 106 | + | require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07"+ | |
| 107 | + | "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"+ | |
| 108 | + | "\x1b[?2031$p", batch.bytes) | |
| 109 | + | id := batch.id | |
| 110 | + | g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12}) | |
| 111 | + | g.background(id, renderer.RGB{R: 13, G: 14, B: 15}) | |
| 112 | + | g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18}) | |
| 113 | + | final := g.marker(id) | |
| 114 | + | clock.apply(final) | |
| 115 | + | ||
| 116 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 117 | + | paletteActionCancelCompletionDeadline, | |
| 118 | + | paletteActionPublishFinal, | |
| 119 | + | }, actionKinds(final)) | |
| 120 | + | got := findAction(t, final, paletteActionPublishFinal).theme | |
| 121 | + | require.True(t, got.TrueColor) | |
| 122 | + | require.True(t, got.HasForeground) | |
| 123 | + | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground) | |
| 124 | + | require.True(t, got.HasBackground) | |
| 125 | + | require.Equal(t, uint16(1<<3), got.PaletteKnown) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestPaletteGenerationReplacementWaitsForDrain(t *testing.T) { | |
| 129 | + | g := newPaletteGenerationCoordinator() | |
| 130 | + | clock := newManualPaletteClock() | |
| 131 | + | start := g.start(generationTheme(), true) | |
| 132 | + | clock.apply(start) | |
| 133 | + | ||
| 134 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 135 | + | paletteActionPublishCleared, | |
| 136 | + | paletteActionWriteDrain, | |
| 137 | + | paletteActionArmDrainDeadline, | |
| 138 | + | }, actionKinds(start)) | |
| 139 | + | drain := findAction(t, start, paletteActionWriteDrain) | |
| 140 | + | require.Equal(t, "\x1b[?2031$p", drain.bytes) | |
| 141 | + | id := drain.id | |
| 142 | + | batch := g.marker(id) | |
| 143 | + | clock.apply(batch) | |
| 144 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 145 | + | paletteActionCancelDrainDeadline, | |
| 146 | + | paletteActionWriteBatch, | |
| 147 | + | paletteActionArmCompletionDeadline, | |
| 148 | + | }, actionKinds(batch)) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestPaletteGenerationLateDrainMarkerNeedsCompletionMarker(t *testing.T) { | |
| 152 | + | g := newPaletteGenerationCoordinator() | |
| 153 | + | clock := newManualPaletteClock() | |
| 154 | + | start := g.start(generationTheme(), true) | |
| 155 | + | clock.apply(start) | |
| 156 | + | id := findAction(t, start, paletteActionWriteDrain).id | |
| 157 | + | ||
| 158 | + | timedOut := clock.fireDrain(g, id) | |
| 159 | + | clock.apply(timedOut) | |
| 160 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 161 | + | paletteActionWriteBatch, | |
| 162 | + | paletteActionArmCompletionDeadline, | |
| 163 | + | }, actionKinds(timedOut)) | |
| 164 | + | ||
| 165 | + | lateDrain := g.marker(id) | |
| 166 | + | require.Empty(t, lateDrain, "the late drain response must not finalize the batch") | |
| 167 | + | complete := g.marker(id) | |
| 168 | + | clock.apply(complete) | |
| 169 | + | require.Equal(t, paletteActionPublishFinal, complete[len(complete)-1].kind) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestPaletteGenerationCompletionDeadlineFinalizesAfterMissingLateDrain(t *testing.T) { | |
| 173 | + | g := newPaletteGenerationCoordinator() | |
| 174 | + | clock := newManualPaletteClock() | |
| 175 | + | start := g.start(generationTheme(), true) | |
| 176 | + | clock.apply(start) | |
| 177 | + | id := findAction(t, start, paletteActionWriteDrain).id | |
| 178 | + | clock.apply(clock.fireDrain(g, id)) | |
| 179 | + | ||
| 180 | + | // One marker may be the completion marker while the timed-out drain reply | |
| 181 | + | // never arrives, so it cannot finalize by marker accounting alone. | |
| 182 | + | require.Empty(t, g.marker(id)) | |
| 183 | + | final := clock.fireCompletion(g, id) | |
| 184 | + | clock.apply(final) | |
| 185 | + | require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestPaletteGenerationStaleTimerAndMarkerAreNoOps(t *testing.T) { | |
| 189 | + | g := newPaletteGenerationCoordinator() | |
| 190 | + | clock := newManualPaletteClock() | |
| 191 | + | first := g.start(generationTheme(), true) | |
| 192 | + | clock.apply(first) | |
| 193 | + | oldID := findAction(t, first, paletteActionWriteDrain).id | |
| 194 | + | second := g.start(generationTheme(), false) | |
| 195 | + | clock.apply(second) | |
| 196 | + | newID := findAction(t, second, paletteActionWriteBatch).id | |
| 197 | + | ||
| 198 | + | require.Empty(t, g.marker(oldID)) | |
| 199 | + | require.Empty(t, clock.fireDrain(g, oldID)) | |
| 200 | + | require.Empty(t, clock.fireCompletion(g, oldID)) | |
| 201 | + | final := g.marker(newID) | |
| 202 | + | require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestPaletteMarkerScannerConsumesSplitMarkerAndPreservesInput(t *testing.T) { | |
| 206 | + | scanner := paletteMarkerScanner{} | |
| 207 | + | var got []byte | |
| 208 | + | markers := 0 | |
| 209 | + | emit := func(data []byte) { got = append(got, data...) } | |
| 210 | + | marker := func() { markers++ } | |
| 211 | + | ||
| 212 | + | scanner.scan([]byte("a\x1b[?2031;"), emit, marker) | |
| 213 | + | scanner.scan([]byte("2$yb"), emit, marker) | |
| 214 | + | scanner.flush(emit) | |
| 215 | + | ||
| 216 | + | require.Equal(t, []byte("ab"), got) | |
| 217 | + | require.Equal(t, 1, markers) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T) { | |
| 221 | + | g := newPaletteGenerationCoordinator() | |
| 222 | + | start := g.start(generationTheme(), false) | |
| 223 | + | id := findAction(t, start, paletteActionWriteBatch).id | |
| 224 | + | g.palette(id, 2, renderer.RGB{R: 1, G: 2, B: 3}) | |
| 225 | + | final := g.marker(id) | |
| 226 | + | want := findAction(t, final, paletteActionPublishFinal).theme | |
| 227 | + | ||
| 228 | + | g.palette(id, 2, renderer.RGB{R: 9, G: 9, B: 9}) | |
| 229 | + | require.Equal(t, want, g.finalizedTheme()) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) { | |
| 233 | + | require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline) | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // manualPaletteClock applies only declarative coordinator timer actions. Its | |||
| 14 | + | // callbacks re-enter the coordinator through generation-tagged events; they | |||
| 15 | + | // never perform terminal I/O or protocol sends. | |||
| 16 | + | type manualPaletteClock struct { | |||
| 17 | + | drain, completion map[paletteGenerationID]bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func newManualPaletteClock() *manualPaletteClock { | |||
| 21 | + | return &manualPaletteClock{drain: map[paletteGenerationID]bool{}, completion: map[paletteGenerationID]bool{}} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (c *manualPaletteClock) apply(actions []paletteGenerationAction) { | |||
| 25 | + | for _, action := range actions { | |||
| 26 | + | switch action.kind { | |||
| 27 | + | case paletteActionArmDrainDeadline: | |||
| 28 | + | c.drain[action.id] = true | |||
| 29 | + | case paletteActionArmCompletionDeadline: | |||
| 30 | + | c.completion[action.id] = true | |||
| 31 | + | case paletteActionCancelDrainDeadline: | |||
| 32 | + | delete(c.drain, action.id) | |||
| 33 | + | case paletteActionCancelCompletionDeadline: | |||
| 34 | + | delete(c.completion, action.id) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (c *manualPaletteClock) fireDrain(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction { | |||
| 40 | + | if !c.drain[id] { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | delete(c.drain, id) | |||
| 44 | + | return g.handle(paletteGenerationEvent{id: id, kind: paletteEventDrainDeadline}) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction { | |||
| 48 | + | if !c.completion[id] { | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | delete(c.completion, id) | |||
| 52 | + | return g.handle(paletteGenerationEvent{id: id, kind: paletteEventCompletionDeadline}) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func generationTheme() ports.Theme { | |||
| 56 | + | return ports.Theme{ | |||
| 57 | + | TrueColor: true, | |||
| 58 | + | HasForeground: true, | |||
| 59 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 60 | + | HasBackground: true, | |||
| 61 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 62 | + | SchemeKnown: true, | |||
| 63 | + | Light: true, | |||
| 64 | + | PaletteKnown: 1 << 2, | |||
| 65 | + | Palette: [16]renderer.RGB{2: {R: 7, G: 8, B: 9}}, | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func actionKinds(actions []paletteGenerationAction) []paletteGenerationActionKind { | |||
| 70 | + | out := make([]paletteGenerationActionKind, len(actions)) | |||
| 71 | + | for i := range actions { | |||
| 72 | + | out[i] = actions[i].kind | |||
| 73 | + | } | |||
| 74 | + | return out | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func findAction(t *testing.T, actions []paletteGenerationAction, kind paletteGenerationActionKind) paletteGenerationAction { | |||
| 78 | + | t.Helper() | |||
| 79 | + | for _, action := range actions { | |||
| 80 | + | if action.kind == kind { | |||
| 81 | + | return action | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | t.Fatalf("missing action %v in %#v", kind, actions) | |||
| 85 | + | return paletteGenerationAction{} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) { | |||
| 89 | + | g := newPaletteGenerationCoordinator() | |||
| 90 | + | clock := newManualPaletteClock() | |||
| 91 | + | start := g.start(generationTheme(), false) | |||
| 92 | + | clock.apply(start) | |||
| 93 | + | ||||
| 94 | + | require.Equal(t, []paletteGenerationActionKind{ | |||
| 95 | + | paletteActionPublishCleared, | |||
| 96 | + | paletteActionWriteBatch, | |||
| 97 | + | paletteActionArmCompletionDeadline, | |||
| 98 | + | }, actionKinds(start)) | |||
| 99 | + | cleared := findAction(t, start, paletteActionPublishCleared).theme | |||
| 100 | + | require.True(t, cleared.HasForeground) | |||
| 101 | + | require.Equal(t, generationTheme().Foreground, cleared.Foreground) | |||
| 102 | + | require.Zero(t, cleared.PaletteKnown) | |||
| 103 | + | require.Zero(t, cleared.Palette) | |||
| 104 | + | ||||
| 105 | + | batch := findAction(t, start, paletteActionWriteBatch) | |||
| 106 | + | require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07"+ | |||
| 107 | + | "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"+ | |||
| 108 | + | "\x1b[?2031$p", batch.bytes) | |||
| 109 | + | id := batch.id | |||
| 110 | + | g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12}) | |||
| 111 | + | g.background(id, renderer.RGB{R: 13, G: 14, B: 15}) | |||
| 112 | + | g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18}) | |||
| 113 | + | final := g.marker(id) | |||
| 114 | + | clock.apply(final) | |||
| 115 | + | ||||
| 116 | + | require.Equal(t, []paletteGenerationActionKind{ | |||
| 117 | + | paletteActionCancelCompletionDeadline, | |||
| 118 | + | paletteActionPublishFinal, | |||
| 119 | + | }, actionKinds(final)) | |||
| 120 | + | got := findAction(t, final, paletteActionPublishFinal).theme | |||
| 121 | + | require.True(t, got.TrueColor) | |||
| 122 | + | require.True(t, got.HasForeground) | |||
| 123 | + | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground) | |||
| 124 | + | require.True(t, got.HasBackground) | |||
| 125 | + | require.Equal(t, uint16(1<<3), got.PaletteKnown) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestPaletteGenerationReplacementWaitsForDrain(t *testing.T) { | |||
| 129 | + | g := newPaletteGenerationCoordinator() | |||
| 130 | + | clock := newManualPaletteClock() | |||
| 131 | + | start := g.start(generationTheme(), true) | |||
| 132 | + | clock.apply(start) | |||
| 133 | + | ||||
| 134 | + | require.Equal(t, []paletteGenerationActionKind{ | |||
| 135 | + | paletteActionPublishCleared, | |||
| 136 | + | paletteActionWriteDrain, | |||
| 137 | + | paletteActionArmDrainDeadline, | |||
| 138 | + | }, actionKinds(start)) | |||
| 139 | + | drain := findAction(t, start, paletteActionWriteDrain) | |||
| 140 | + | require.Equal(t, "\x1b[?2031$p", drain.bytes) | |||
| 141 | + | id := drain.id | |||
| 142 | + | batch := g.marker(id) | |||
| 143 | + | clock.apply(batch) | |||
| 144 | + | require.Equal(t, []paletteGenerationActionKind{ | |||
| 145 | + | paletteActionCancelDrainDeadline, | |||
| 146 | + | paletteActionWriteBatch, | |||
| 147 | + | paletteActionArmCompletionDeadline, | |||
| 148 | + | }, actionKinds(batch)) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestPaletteGenerationLateDrainMarkerNeedsCompletionMarker(t *testing.T) { | |||
| 152 | + | g := newPaletteGenerationCoordinator() | |||
| 153 | + | clock := newManualPaletteClock() | |||
| 154 | + | start := g.start(generationTheme(), true) | |||
| 155 | + | clock.apply(start) | |||
| 156 | + | id := findAction(t, start, paletteActionWriteDrain).id | |||
| 157 | + | ||||
| 158 | + | timedOut := clock.fireDrain(g, id) | |||
| 159 | + | clock.apply(timedOut) | |||
| 160 | + | require.Equal(t, []paletteGenerationActionKind{ | |||
| 161 | + | paletteActionWriteBatch, | |||
| 162 | + | paletteActionArmCompletionDeadline, | |||
| 163 | + | }, actionKinds(timedOut)) | |||
| 164 | + | ||||
| 165 | + | lateDrain := g.marker(id) | |||
| 166 | + | require.Empty(t, lateDrain, "the late drain response must not finalize the batch") | |||
| 167 | + | complete := g.marker(id) | |||
| 168 | + | clock.apply(complete) | |||
| 169 | + | require.Equal(t, paletteActionPublishFinal, complete[len(complete)-1].kind) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestPaletteGenerationCompletionDeadlineFinalizesAfterMissingLateDrain(t *testing.T) { | |||
| 173 | + | g := newPaletteGenerationCoordinator() | |||
| 174 | + | clock := newManualPaletteClock() | |||
| 175 | + | start := g.start(generationTheme(), true) | |||
| 176 | + | clock.apply(start) | |||
| 177 | + | id := findAction(t, start, paletteActionWriteDrain).id | |||
| 178 | + | clock.apply(clock.fireDrain(g, id)) | |||
| 179 | + | ||||
| 180 | + | // One marker may be the completion marker while the timed-out drain reply | |||
| 181 | + | // never arrives, so it cannot finalize by marker accounting alone. | |||
| 182 | + | require.Empty(t, g.marker(id)) | |||
| 183 | + | final := clock.fireCompletion(g, id) | |||
| 184 | + | clock.apply(final) | |||
| 185 | + | require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestPaletteGenerationStaleTimerAndMarkerAreNoOps(t *testing.T) { | |||
| 189 | + | g := newPaletteGenerationCoordinator() | |||
| 190 | + | clock := newManualPaletteClock() | |||
| 191 | + | first := g.start(generationTheme(), true) | |||
| 192 | + | clock.apply(first) | |||
| 193 | + | oldID := findAction(t, first, paletteActionWriteDrain).id | |||
| 194 | + | second := g.start(generationTheme(), false) | |||
| 195 | + | clock.apply(second) | |||
| 196 | + | newID := findAction(t, second, paletteActionWriteBatch).id | |||
| 197 | + | ||||
| 198 | + | require.Empty(t, g.marker(oldID)) | |||
| 199 | + | require.Empty(t, clock.fireDrain(g, oldID)) | |||
| 200 | + | require.Empty(t, clock.fireCompletion(g, oldID)) | |||
| 201 | + | final := g.marker(newID) | |||
| 202 | + | require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestPaletteMarkerScannerConsumesSplitMarkerAndPreservesInput(t *testing.T) { | |||
| 206 | + | scanner := paletteMarkerScanner{} | |||
| 207 | + | var got []byte | |||
| 208 | + | markers := 0 | |||
| 209 | + | emit := func(data []byte) { got = append(got, data...) } | |||
| 210 | + | marker := func() { markers++ } | |||
| 211 | + | ||||
| 212 | + | scanner.scan([]byte("a\x1b[?2031;"), emit, marker) | |||
| 213 | + | scanner.scan([]byte("2$yb"), emit, marker) | |||
| 214 | + | scanner.flush(emit) | |||
| 215 | + | ||||
| 216 | + | require.Equal(t, []byte("ab"), got) | |||
| 217 | + | require.Equal(t, 1, markers) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T) { | |||
| 221 | + | g := newPaletteGenerationCoordinator() | |||
| 222 | + | start := g.start(generationTheme(), false) | |||
| 223 | + | id := findAction(t, start, paletteActionWriteBatch).id | |||
| 224 | + | g.palette(id, 2, renderer.RGB{R: 1, G: 2, B: 3}) | |||
| 225 | + | final := g.marker(id) | |||
| 226 | + | want := findAction(t, final, paletteActionPublishFinal).theme | |||
| 227 | + | ||||
| 228 | + | g.palette(id, 2, renderer.RGB{R: 9, G: 9, B: 9}) | |||
| 229 | + | require.Equal(t, want, g.finalizedTheme()) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) { | |||
| 233 | + | require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline) | |||
| 234 | + | } | |||