detached 5c34841

feat(theme): parse OSC 4 palette responses in Scanner

commit
5c348419660d60dc31fa4707224da08030493575
author
brice · · 2026-07-17 08:16 UTC
parent
442f1fc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +221 -37
M internal/usecase/client/client.go +3 -0
@@ -891,6 +891,9 @@ func (p *stdinPump) run() {
891891 }
892892 })
893893 sendTheme(current)
894+ }, func(int, renderer.RGB) {
895+ // Palette inheritance is wired by the theme update path; retain a
896+ // no-op callback here until that path owns palette updates.
894897 }, func(light bool) {
895898 current := themeState.update(func(current *ports.Theme) {
896899 current.SchemeKnown = true
@@ -891,6 +891,9 @@ func (p *stdinPump) run() {
891 }891 }
892 })892 })
893 sendTheme(current)893 sendTheme(current)
894+ }, func(int, renderer.RGB) {
895+ // Palette inheritance is wired by the theme update path; retain a
896+ // no-op callback here until that path owns palette updates.
894 }, func(light bool) {897 }, func(light bool) {
895 current := themeState.update(func(current *ports.Theme) {898 current := themeState.update(func(current *ports.Theme) {
896 current.SchemeKnown = true899 current.SchemeKnown = true
A internal/usecase/theme/palette_scan_test.go +141 -0
@@ -0,0 +1,141 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "reflect"
6+ "strconv"
7+ "testing"
8+
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+type paletteEvent struct {
13+ slot int
14+ rgb renderer.RGB
15+}
16+
17+func TestScannerExtractsPaletteResponses(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ chunks []string
21+ wantPalette []paletteEvent
22+ wantColors []int
23+ wantOut string
24+ }{
25+ {
26+ name: "BEL hash and ST rgb",
27+ chunks: []string{"a\x1b]4;0;#112233\x07b\x1b]4;15;rgb:4455/6677/8899\x1b\\c"},
28+ wantPalette: []paletteEvent{{slot: 0, rgb: renderer.RGB{R: 0x11, G: 0x22, B: 0x33}}, {slot: 15, rgb: renderer.RGB{R: 0x44, G: 0x66, B: 0x88}}},
29+ wantOut: "abc",
30+ },
31+ {
32+ name: "split prefix body and terminator",
33+ chunks: []string{"a\x1b]4;", "1;rgb:12", "34/abcd/ffff\x1b", "\\b"},
34+ wantPalette: []paletteEvent{{slot: 1, rgb: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}}},
35+ wantOut: "ab",
36+ },
37+ {
38+ name: "interleaved defaults and multiple replies",
39+ chunks: []string{"a\x1b]10;#010203\x07\x1b]4;2;#102030\x07\x1b]11;#040506\x07\x1b]4;2;rgb:aa/bb/cc\x07z"},
40+ wantPalette: []paletteEvent{{slot: 2, rgb: renderer.RGB{R: 0x10, G: 0x20, B: 0x30}}, {slot: 2, rgb: renderer.RGB{R: 0xaa, G: 0xbb, B: 0xcc}}},
41+ wantColors: []int{10, 11},
42+ wantOut: "az",
43+ },
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ var s Scanner
49+ var palette []paletteEvent
50+ var colors []int
51+ var out bytes.Buffer
52+ for _, chunk := range tt.chunks {
53+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {
54+ colors = append(colors, kind)
55+ }, func(slot int, rgb renderer.RGB) {
56+ palette = append(palette, paletteEvent{slot: slot, rgb: rgb})
57+ }, func(bool) {}, func(b []byte) {
58+ out.Write(b)
59+ })
60+ }
61+ if !reflect.DeepEqual(palette, tt.wantPalette) {
62+ t.Fatalf("palette=%+v want %+v", palette, tt.wantPalette)
63+ }
64+ if !reflect.DeepEqual(colors, tt.wantColors) {
65+ t.Fatalf("colors=%v want %v", colors, tt.wantColors)
66+ }
67+ if out.String() != tt.wantOut {
68+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
69+ }
70+ })
71+ }
72+}
73+
74+func TestScannerReportsEveryValidPaletteSlot(t *testing.T) {
75+ var data bytes.Buffer
76+ for slot := 0; slot < 16; slot++ {
77+ data.WriteString("\x1b]4;")
78+ data.WriteString(strconv.Itoa(slot))
79+ data.WriteString(";#010203\x07")
80+ }
81+
82+ var s Scanner
83+ var slots []int
84+ var out bytes.Buffer
85+ s.Scan(data.Bytes(), func(int, renderer.RGB) {}, func(slot int, rgb renderer.RGB) {
86+ if rgb != (renderer.RGB{R: 1, G: 2, B: 3}) {
87+ t.Fatalf("slot %d rgb=%+v", slot, rgb)
88+ }
89+ slots = append(slots, slot)
90+ }, func(bool) {}, func(b []byte) {
91+ out.Write(b)
92+ })
93+
94+ if want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; !reflect.DeepEqual(slots, want) {
95+ t.Fatalf("slots=%v want %v", slots, want)
96+ }
97+ if out.Len() != 0 {
98+ t.Fatalf("passthrough=%q want empty", out.String())
99+ }
100+}
101+
102+func TestScannerForwardsMalformedPaletteResponses(t *testing.T) {
103+ const malformed = "\x1b]4;;#010203\x07" +
104+ "\x1b]4;-1;#010203\x07" +
105+ "\x1b]4;cat;#010203\x1b\\" +
106+ "\x1b]4;16;#010203\x07" +
107+ "\x1b]4;999999999999999999;#010203\x07" +
108+ "\x1b]4;3;not-a-color\x07"
109+
110+ var s Scanner
111+ var out bytes.Buffer
112+ paletteCalls := 0
113+ s.Scan([]byte("before"+malformed+"after"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {
114+ paletteCalls++
115+ }, func(bool) {}, func(b []byte) {
116+ out.Write(b)
117+ })
118+
119+ if paletteCalls != 0 {
120+ t.Fatalf("palette callbacks=%d want 0", paletteCalls)
121+ }
122+ if got, want := out.String(), "before"+malformed+"after"; got != want {
123+ t.Fatalf("passthrough=%q want %q", got, want)
124+ }
125+}
126+
127+func TestScannerFlushesTruncatedPaletteResponseAtPendingLimit(t *testing.T) {
128+ var s Scanner
129+ var out bytes.Buffer
130+ partial := append([]byte("\x1b]4;15;rgb:"), bytes.Repeat([]byte("a"), maxPending)...)
131+ s.Scan(partial, func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
132+ out.Write(b)
133+ })
134+ s.Scan([]byte("z"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
135+ out.Write(b)
136+ })
137+
138+ if got, want := out.String(), string(partial)+"z"; got != want {
139+ t.Fatalf("passthrough=%q want %q", got, want)
140+ }
141+}
@@ -0,0 +1,141 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "reflect"
6+ "strconv"
7+ "testing"
8+
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+type paletteEvent struct {
13+ slot int
14+ rgb renderer.RGB
15+}
16+
17+func TestScannerExtractsPaletteResponses(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ chunks []string
21+ wantPalette []paletteEvent
22+ wantColors []int
23+ wantOut string
24+ }{
25+ {
26+ name: "BEL hash and ST rgb",
27+ chunks: []string{"a\x1b]4;0;#112233\x07b\x1b]4;15;rgb:4455/6677/8899\x1b\\c"},
28+ wantPalette: []paletteEvent{{slot: 0, rgb: renderer.RGB{R: 0x11, G: 0x22, B: 0x33}}, {slot: 15, rgb: renderer.RGB{R: 0x44, G: 0x66, B: 0x88}}},
29+ wantOut: "abc",
30+ },
31+ {
32+ name: "split prefix body and terminator",
33+ chunks: []string{"a\x1b]4;", "1;rgb:12", "34/abcd/ffff\x1b", "\\b"},
34+ wantPalette: []paletteEvent{{slot: 1, rgb: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}}},
35+ wantOut: "ab",
36+ },
37+ {
38+ name: "interleaved defaults and multiple replies",
39+ chunks: []string{"a\x1b]10;#010203\x07\x1b]4;2;#102030\x07\x1b]11;#040506\x07\x1b]4;2;rgb:aa/bb/cc\x07z"},
40+ wantPalette: []paletteEvent{{slot: 2, rgb: renderer.RGB{R: 0x10, G: 0x20, B: 0x30}}, {slot: 2, rgb: renderer.RGB{R: 0xaa, G: 0xbb, B: 0xcc}}},
41+ wantColors: []int{10, 11},
42+ wantOut: "az",
43+ },
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ var s Scanner
49+ var palette []paletteEvent
50+ var colors []int
51+ var out bytes.Buffer
52+ for _, chunk := range tt.chunks {
53+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {
54+ colors = append(colors, kind)
55+ }, func(slot int, rgb renderer.RGB) {
56+ palette = append(palette, paletteEvent{slot: slot, rgb: rgb})
57+ }, func(bool) {}, func(b []byte) {
58+ out.Write(b)
59+ })
60+ }
61+ if !reflect.DeepEqual(palette, tt.wantPalette) {
62+ t.Fatalf("palette=%+v want %+v", palette, tt.wantPalette)
63+ }
64+ if !reflect.DeepEqual(colors, tt.wantColors) {
65+ t.Fatalf("colors=%v want %v", colors, tt.wantColors)
66+ }
67+ if out.String() != tt.wantOut {
68+ t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
69+ }
70+ })
71+ }
72+}
73+
74+func TestScannerReportsEveryValidPaletteSlot(t *testing.T) {
75+ var data bytes.Buffer
76+ for slot := 0; slot < 16; slot++ {
77+ data.WriteString("\x1b]4;")
78+ data.WriteString(strconv.Itoa(slot))
79+ data.WriteString(";#010203\x07")
80+ }
81+
82+ var s Scanner
83+ var slots []int
84+ var out bytes.Buffer
85+ s.Scan(data.Bytes(), func(int, renderer.RGB) {}, func(slot int, rgb renderer.RGB) {
86+ if rgb != (renderer.RGB{R: 1, G: 2, B: 3}) {
87+ t.Fatalf("slot %d rgb=%+v", slot, rgb)
88+ }
89+ slots = append(slots, slot)
90+ }, func(bool) {}, func(b []byte) {
91+ out.Write(b)
92+ })
93+
94+ if want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; !reflect.DeepEqual(slots, want) {
95+ t.Fatalf("slots=%v want %v", slots, want)
96+ }
97+ if out.Len() != 0 {
98+ t.Fatalf("passthrough=%q want empty", out.String())
99+ }
100+}
101+
102+func TestScannerForwardsMalformedPaletteResponses(t *testing.T) {
103+ const malformed = "\x1b]4;;#010203\x07" +
104+ "\x1b]4;-1;#010203\x07" +
105+ "\x1b]4;cat;#010203\x1b\\" +
106+ "\x1b]4;16;#010203\x07" +
107+ "\x1b]4;999999999999999999;#010203\x07" +
108+ "\x1b]4;3;not-a-color\x07"
109+
110+ var s Scanner
111+ var out bytes.Buffer
112+ paletteCalls := 0
113+ s.Scan([]byte("before"+malformed+"after"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {
114+ paletteCalls++
115+ }, func(bool) {}, func(b []byte) {
116+ out.Write(b)
117+ })
118+
119+ if paletteCalls != 0 {
120+ t.Fatalf("palette callbacks=%d want 0", paletteCalls)
121+ }
122+ if got, want := out.String(), "before"+malformed+"after"; got != want {
123+ t.Fatalf("passthrough=%q want %q", got, want)
124+ }
125+}
126+
127+func TestScannerFlushesTruncatedPaletteResponseAtPendingLimit(t *testing.T) {
128+ var s Scanner
129+ var out bytes.Buffer
130+ partial := append([]byte("\x1b]4;15;rgb:"), bytes.Repeat([]byte("a"), maxPending)...)
131+ s.Scan(partial, func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
132+ out.Write(b)
133+ })
134+ s.Scan([]byte("z"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) {
135+ out.Write(b)
136+ })
137+
138+ if got, want := out.String(), string(partial)+"z"; got != want {
139+ t.Fatalf("passthrough=%q want %q", got, want)
140+ }
141+}
M internal/usecase/theme/theme.go +64 -24
@@ -120,12 +120,13 @@ func parseHexByte(s string) (uint8, bool) {
120120 return uint8(v), true
121121 }
122122
123-// Scanner decodes OSC 10/11 color responses while preserving ordinary bytes.
123+// Scanner decodes terminal color responses while preserving ordinary bytes.
124124 type Scanner struct {
125125 pending []byte
126126 }
127127
128-// Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.
128+// Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
129+// responses terminated by BEL or ST.
129130 // All non-matching bytes are emitted through onBytes in original order, and a
130131 // contiguous run of ordinary bytes (including any ESC that does not start a
131132 // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
@@ -133,7 +134,7 @@ type Scanner struct {
133134 // callers never see it split. Partial OSC color responses are buffered across
134135 // calls, but the buffer is bounded so an unterminated OSC cannot block
135136 // unrelated input forever.
136-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
137+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
137138 if len(s.pending) > 0 {
138139 combined := make([]byte, 0, len(s.pending)+len(data))
139140 combined = append(combined, s.pending...)
@@ -164,7 +165,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
164165 return
165166 }
166167
167- completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
168+ kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
168169 if !completePrefix {
169170 if possiblePrefix {
170171 if byteStart < i {
@@ -185,20 +186,30 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
185186 onBytes(data[byteStart:i])
186187 }
187188
188- termStart, termEnd := findTerminator(data[i+5:])
189+ termStart, termEnd := findTerminator(data[i+prefixLen:])
189190 if termStart < 0 {
190191 s.bufferOrFlush(data[i:], onBytes)
191192 return
192193 }
193- termStart += i + 5
194- termEnd += i + 5
194+ termStart += i + prefixLen
195+ termEnd += i + prefixLen
195196 raw := data[i:termEnd]
196- kind := int(data[i+3]-'0') + 10
197- rgb, ok := ParseXColor(string(data[i+5 : termStart]))
198- if ok {
199- onColor(kind, rgb)
200- } else {
201- onBytes(raw)
197+ body := string(data[i+prefixLen : termStart])
198+ switch kind {
199+ case 4:
200+ slot, rgb, ok := parsePaletteResponse(body)
201+ if ok {
202+ onPalette(slot, rgb)
203+ } else {
204+ onBytes(raw)
205+ }
206+ default:
207+ rgb, ok := ParseXColor(body)
208+ if ok {
209+ onColor(kind, rgb)
210+ } else {
211+ onBytes(raw)
212+ }
202213 }
203214 i = termEnd - 1
204215 byteStart = termEnd
@@ -218,23 +229,52 @@ func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
218229 s.pending = append(s.pending, partial...)
219230 }
220231
221-func colorOSCPrefix(data []byte) (complete bool, possible bool) {
232+func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) {
222233 if len(data) < 2 {
223- return false, false
224- }
225- prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")}
226- for _, prefix := range prefixes {
227- if len(data) >= len(prefix) {
228- if bytes.Equal(data[:len(prefix)], prefix) {
229- return true, true
234+ return 0, 0, false, false
235+ }
236+ prefixes := []struct {
237+ kind int
238+ prefix []byte
239+ }{
240+ {kind: 10, prefix: []byte("\x1b]10;")},
241+ {kind: 11, prefix: []byte("\x1b]11;")},
242+ {kind: 4, prefix: []byte("\x1b]4;")},
243+ }
244+ for _, candidate := range prefixes {
245+ if len(data) >= len(candidate.prefix) {
246+ if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) {
247+ return candidate.kind, len(candidate.prefix), true, true
230248 }
231249 continue
232250 }
233- if bytes.Equal(data, prefix[:len(data)]) {
234- return false, true
251+ if bytes.Equal(data, candidate.prefix[:len(data)]) {
252+ return candidate.kind, len(candidate.prefix), false, true
253+ }
254+ }
255+ return 0, 0, false, false
256+}
257+
258+func parsePaletteResponse(body string) (int, renderer.RGB, bool) {
259+ indexEnd := strings.IndexByte(body, ';')
260+ if indexEnd < 1 {
261+ return 0, renderer.RGB{}, false
262+ }
263+ slot := 0
264+ for _, b := range []byte(body[:indexEnd]) {
265+ if b < '0' || b > '9' {
266+ return 0, renderer.RGB{}, false
267+ }
268+ slot = slot*10 + int(b-'0')
269+ if slot > 15 {
270+ return 0, renderer.RGB{}, false
235271 }
236272 }
237- return false, false
273+ rgb, ok := ParseXColor(body[indexEnd+1:])
274+ if !ok {
275+ return 0, renderer.RGB{}, false
276+ }
277+ return slot, rgb, true
238278 }
239279
240280 // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
@@ -120,12 +120,13 @@ func parseHexByte(s string) (uint8, bool) {
120 return uint8(v), true120 return uint8(v), true
121 }121 }
122 122
123-// Scanner decodes OSC 10/11 color responses while preserving ordinary bytes.123+// Scanner decodes terminal color responses while preserving ordinary bytes.
124 type Scanner struct {124 type Scanner struct {
125 pending []byte125 pending []byte
126 }126 }
127 127
128-// Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.128+// Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
129+// responses terminated by BEL or ST.
129 // All non-matching bytes are emitted through onBytes in original order, and a130 // All non-matching bytes are emitted through onBytes in original order, and a
130 // contiguous run of ordinary bytes (including any ESC that does not start a131 // contiguous run of ordinary bytes (including any ESC that does not start a
131 // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse132 // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
@@ -133,7 +134,7 @@ type Scanner struct {
133 // callers never see it split. Partial OSC color responses are buffered across134 // callers never see it split. Partial OSC color responses are buffered across
134 // calls, but the buffer is bounded so an unterminated OSC cannot block135 // calls, but the buffer is bounded so an unterminated OSC cannot block
135 // unrelated input forever.136 // unrelated input forever.
136-func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {137+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
137 if len(s.pending) > 0 {138 if len(s.pending) > 0 {
138 combined := make([]byte, 0, len(s.pending)+len(data))139 combined := make([]byte, 0, len(s.pending)+len(data))
139 combined = append(combined, s.pending...)140 combined = append(combined, s.pending...)
@@ -164,7 +165,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
164 return165 return
165 }166 }
166 167
167- completePrefix, possiblePrefix := colorOSCPrefix(data[i:])168+ kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
168 if !completePrefix {169 if !completePrefix {
169 if possiblePrefix {170 if possiblePrefix {
170 if byteStart < i {171 if byteStart < i {
@@ -185,20 +186,30 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
185 onBytes(data[byteStart:i])186 onBytes(data[byteStart:i])
186 }187 }
187 188
188- termStart, termEnd := findTerminator(data[i+5:])189+ termStart, termEnd := findTerminator(data[i+prefixLen:])
189 if termStart < 0 {190 if termStart < 0 {
190 s.bufferOrFlush(data[i:], onBytes)191 s.bufferOrFlush(data[i:], onBytes)
191 return192 return
192 }193 }
193- termStart += i + 5194+ termStart += i + prefixLen
194- termEnd += i + 5195+ termEnd += i + prefixLen
195 raw := data[i:termEnd]196 raw := data[i:termEnd]
196- kind := int(data[i+3]-'0') + 10197+ body := string(data[i+prefixLen : termStart])
197- rgb, ok := ParseXColor(string(data[i+5 : termStart]))198+ switch kind {
198- if ok {199+ case 4:
199- onColor(kind, rgb)200+ slot, rgb, ok := parsePaletteResponse(body)
200- } else {201+ if ok {
201- onBytes(raw)202+ onPalette(slot, rgb)
203+ } else {
204+ onBytes(raw)
205+ }
206+ default:
207+ rgb, ok := ParseXColor(body)
208+ if ok {
209+ onColor(kind, rgb)
210+ } else {
211+ onBytes(raw)
212+ }
202 }213 }
203 i = termEnd - 1214 i = termEnd - 1
204 byteStart = termEnd215 byteStart = termEnd
@@ -218,23 +229,52 @@ func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
218 s.pending = append(s.pending, partial...)229 s.pending = append(s.pending, partial...)
219 }230 }
220 231
221-func colorOSCPrefix(data []byte) (complete bool, possible bool) {232+func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) {
222 if len(data) < 2 {233 if len(data) < 2 {
223- return false, false234+ return 0, 0, false, false
224- }235+ }
225- prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")}236+ prefixes := []struct {
226- for _, prefix := range prefixes {237+ kind int
227- if len(data) >= len(prefix) {238+ prefix []byte
228- if bytes.Equal(data[:len(prefix)], prefix) {239+ }{
229- return true, true240+ {kind: 10, prefix: []byte("\x1b]10;")},
241+ {kind: 11, prefix: []byte("\x1b]11;")},
242+ {kind: 4, prefix: []byte("\x1b]4;")},
243+ }
244+ for _, candidate := range prefixes {
245+ if len(data) >= len(candidate.prefix) {
246+ if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) {
247+ return candidate.kind, len(candidate.prefix), true, true
230 }248 }
231 continue249 continue
232 }250 }
233- if bytes.Equal(data, prefix[:len(data)]) {251+ if bytes.Equal(data, candidate.prefix[:len(data)]) {
234- return false, true252+ return candidate.kind, len(candidate.prefix), false, true
253+ }
254+ }
255+ return 0, 0, false, false
256+}
257+
258+func parsePaletteResponse(body string) (int, renderer.RGB, bool) {
259+ indexEnd := strings.IndexByte(body, ';')
260+ if indexEnd < 1 {
261+ return 0, renderer.RGB{}, false
262+ }
263+ slot := 0
264+ for _, b := range []byte(body[:indexEnd]) {
265+ if b < '0' || b > '9' {
266+ return 0, renderer.RGB{}, false
267+ }
268+ slot = slot*10 + int(b-'0')
269+ if slot > 15 {
270+ return 0, renderer.RGB{}, false
235 }271 }
236 }272 }
237- return false, false273+ rgb, ok := ParseXColor(body[indexEnd+1:])
274+ if !ok {
275+ return 0, renderer.RGB{}, false
276+ }
277+ return slot, rgb, true
238 }278 }
239 279
240 // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev280 // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
M internal/usecase/theme/theme_test.go +13 -13
@@ -50,7 +50,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
5050 kind int
5151 rgb renderer.RGB
5252 }{kind: kind, rgb: rgb})
53- }, func(light bool) {}, func(b []byte) { out.Write(b) })
53+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
5454
5555 if out.String() != "abc" {
5656 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -73,8 +73,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
7373 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
7474 onBytes := func(b []byte) { out.Write(b) }
7575
76- s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)
77- s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)
76+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
77+ s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
7878
7979 if out.String() != "xy" {
8080 t.Fatalf("passthrough=%q want xy", out.String())
@@ -112,7 +112,7 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
112112 var out bytes.Buffer
113113
114114 for _, chunk := range tt.chunks {
115- s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
115+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
116116 }
117117
118118 if out.String() != tt.wantOut {
@@ -130,7 +130,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
130130 var out bytes.Buffer
131131 schemes := 0
132132
133- s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
133+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
134134
135135 if out.String() != "a\x1b[" {
136136 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
@@ -140,7 +140,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
140140 }
141141
142142 out.Reset()
143- s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
143+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
144144
145145 if out.String() != "A" {
146146 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
@@ -152,7 +152,7 @@ func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
152152 var out bytes.Buffer
153153 colors := 0
154154
155- s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
155+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
156156
157157 if colors != 0 {
158158 t.Fatalf("colors=%d want 0", colors)
@@ -168,7 +168,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
168168 var out bytes.Buffer
169169 colors := 0
170170
171- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })
171+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
172172
173173 if colors != 0 {
174174 t.Fatalf("colors=%d want 0", colors)
@@ -184,7 +184,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
184184 colors := 0
185185
186186 in := []byte("\x1b[<0;1;1M")
187- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {
187+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
188188 chunks = append(chunks, append([]byte(nil), b...))
189189 })
190190
@@ -204,7 +204,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
204204 var chunks [][]byte
205205
206206 in := []byte("\x1b[A")
207- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
207+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
208208 chunks = append(chunks, append([]byte(nil), b...))
209209 })
210210
@@ -229,7 +229,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
229229 kind int
230230 rgb renderer.RGB
231231 }{kind: kind, rgb: rgb})
232- }, func(light bool) {}, func(b []byte) {
232+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
233233 chunks = append(chunks, append([]byte(nil), b...))
234234 })
235235
@@ -254,8 +254,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
254254 var s Scanner
255255 var out bytes.Buffer
256256
257- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
258- s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
257+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
258+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
259259
260260 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
261261 t.Fatalf("overflow passthrough=%q", out.String())
@@ -50,7 +50,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) {
50 kind int50 kind int
51 rgb renderer.RGB51 rgb renderer.RGB
52 }{kind: kind, rgb: rgb})52 }{kind: kind, rgb: rgb})
53- }, func(light bool) {}, func(b []byte) { out.Write(b) })53+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
54 54
55 if out.String() != "abc" {55 if out.String() != "abc" {
56 t.Fatalf("passthrough=%q want %q", out.String(), "abc")56 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
@@ -73,8 +73,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) {
73 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }73 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
74 onBytes := func(b []byte) { out.Write(b) }74 onBytes := func(b []byte) { out.Write(b) }
75 75
76- s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes)76+ s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
77- s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes)77+ s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
78 78
79 if out.String() != "xy" {79 if out.String() != "xy" {
80 t.Fatalf("passthrough=%q want xy", out.String())80 t.Fatalf("passthrough=%q want xy", out.String())
@@ -112,7 +112,7 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
112 var out bytes.Buffer112 var out bytes.Buffer
113 113
114 for _, chunk := range tt.chunks {114 for _, chunk := range tt.chunks {
115- s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })115+ s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) })
116 }116 }
117 117
118 if out.String() != tt.wantOut {118 if out.String() != tt.wantOut {
@@ -130,7 +130,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
130 var out bytes.Buffer130 var out bytes.Buffer
131 schemes := 0131 schemes := 0
132 132
133- s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })133+ s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
134 134
135 if out.String() != "a\x1b[" {135 if out.String() != "a\x1b[" {
136 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")136 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
@@ -140,7 +140,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
140 }140 }
141 141
142 out.Reset()142 out.Reset()
143- s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })143+ s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) })
144 144
145 if out.String() != "A" {145 if out.String() != "A" {
146 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")146 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
@@ -152,7 +152,7 @@ func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
152 var out bytes.Buffer152 var out bytes.Buffer
153 colors := 0153 colors := 0
154 154
155- s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })155+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
156 156
157 if colors != 0 {157 if colors != 0 {
158 t.Fatalf("colors=%d want 0", colors)158 t.Fatalf("colors=%d want 0", colors)
@@ -168,7 +168,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
168 var out bytes.Buffer168 var out bytes.Buffer
169 colors := 0169 colors := 0
170 170
171- s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) })171+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
172 172
173 if colors != 0 {173 if colors != 0 {
174 t.Fatalf("colors=%d want 0", colors)174 t.Fatalf("colors=%d want 0", colors)
@@ -184,7 +184,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
184 colors := 0184 colors := 0
185 185
186 in := []byte("\x1b[<0;1;1M")186 in := []byte("\x1b[<0;1;1M")
187- s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) {187+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
188 chunks = append(chunks, append([]byte(nil), b...))188 chunks = append(chunks, append([]byte(nil), b...))
189 })189 })
190 190
@@ -204,7 +204,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) {
204 var chunks [][]byte204 var chunks [][]byte
205 205
206 in := []byte("\x1b[A")206 in := []byte("\x1b[A")
207- s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {207+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
208 chunks = append(chunks, append([]byte(nil), b...))208 chunks = append(chunks, append([]byte(nil), b...))
209 })209 })
210 210
@@ -229,7 +229,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
229 kind int229 kind int
230 rgb renderer.RGB230 rgb renderer.RGB
231 }{kind: kind, rgb: rgb})231 }{kind: kind, rgb: rgb})
232- }, func(light bool) {}, func(b []byte) {232+ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
233 chunks = append(chunks, append([]byte(nil), b...))233 chunks = append(chunks, append([]byte(nil), b...))
234 })234 })
235 235
@@ -254,8 +254,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
254 var s Scanner254 var s Scanner
255 var out bytes.Buffer255 var out bytes.Buffer
256 256
257- s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })257+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
258- s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })258+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
259 259
260 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {260 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
261 t.Fatalf("overflow passthrough=%q", out.String())261 t.Fatalf("overflow passthrough=%q", out.String())

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