detached 588bb23

feat(client): model atomic palette generations

commit
588bb233cc3546a3a7cec0e61e3ab0044b6e7e56
author
brice · · 2026-07-18 06:15 UTC
parent
ec95daf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
669669 return welcomedResult(errLinkOffline)
670670 }
671671 case request := <-colorQueryCh:
672- if _, err := term.Out().Write(paletteBoundaryQuery); err != nil {
672+ if _, err := term.Out().Write([]byte(paletteBoundaryQuery)); err != nil {
673673 return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674674 }
675675 if err := term.Flush(); err != nil {
@@ -869,8 +869,6 @@ const (
869869 paletteAwaitingQuery
870870 )
871871
872-var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873-
874872 // paletteBoundaryResponses accepts valid DECRQM status values for private
875873 // mode 2031. The private mode number correlates this reply with the query
876874 // 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 paletteAwaitingQuery869 paletteAwaitingQuery
870 )870 )
871 871
872-var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873-
874 // paletteBoundaryResponses accepts valid DECRQM status values for private872 // paletteBoundaryResponses accepts valid DECRQM status values for private
875 // mode 2031. The private mode number correlates this reply with the query873 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close