1package theme
2
3import (
4 "bytes"
5 "strconv"
6 "strings"
7
8 vt "github.com/bnema/vev-vt"
9 renderer "github.com/bnema/vev-vt/ansi"
10)
11
12const maxPending = 64
13
14// Theme describes terminal default colors and ANSI palette entries.
15type Theme struct {
16 Foreground renderer.RGB
17 Background renderer.RGB
18 Palette [16]renderer.RGB
19 PaletteKnown uint16
20 HasFG bool
21 HasBG bool
22 Known bool
23 SchemeKnown bool
24 Light bool
25 UsePalette bool
26 // DimByAttribute makes Dimmer use the faint attribute instead of blending
27 // colors. The daemon sets it per attachment for ANSI16 and monochrome
28 // output, where RGB blends would quantize badly or vanish.
29 DimByAttribute bool
30}
31
32// PaletteColor returns a palette color only when palette inheritance is
33// enabled and the terminal reported the requested ANSI slot.
34func (t Theme) PaletteColor(slot int) (renderer.RGB, bool) {
35 if !t.UsePalette || slot < 0 || slot >= len(t.Palette) || t.PaletteKnown&(uint16(1)<<slot) == 0 {
36 return renderer.RGB{}, false
37 }
38 return t.Palette[slot], true
39}
40
41var (
42 BuiltinDark = Theme{
43 Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8},
44 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
45 HasFG: true,
46 HasBG: true,
47 Known: true,
48 SchemeKnown: true,
49 Light: false,
50 }
51 BuiltinLight = Theme{
52 Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20},
53 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
54 HasFG: true,
55 HasBG: true,
56 Known: true,
57 SchemeKnown: true,
58 Light: true,
59 }
60)
61
62// ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
63func ParseXColor(s string) (renderer.RGB, bool) {
64 if strings.HasPrefix(s, "#") {
65 if len(s) != 7 {
66 return renderer.RGB{}, false
67 }
68 r, ok := parseHexByte(s[1:3])
69 if !ok {
70 return renderer.RGB{}, false
71 }
72 g, ok := parseHexByte(s[3:5])
73 if !ok {
74 return renderer.RGB{}, false
75 }
76 b, ok := parseHexByte(s[5:7])
77 if !ok {
78 return renderer.RGB{}, false
79 }
80 return renderer.RGB{R: r, G: g, B: b}, true
81 }
82
83 if !strings.HasPrefix(s, "rgb:") {
84 return renderer.RGB{}, false
85 }
86 parts := strings.Split(s[len("rgb:"):], "/")
87 if len(parts) != 3 {
88 return renderer.RGB{}, false
89 }
90 var out [3]uint8
91 for i, part := range parts {
92 component, ok := parseXColorComponent(part)
93 if !ok {
94 return renderer.RGB{}, false
95 }
96 out[i] = component
97 }
98 return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true
99}
100
101// parseXColorComponent scales an XParseColor component of one to four hex
102// digits to an 8-bit channel.
103func parseXColorComponent(s string) (uint8, bool) {
104 if len(s) < 1 || len(s) > 4 {
105 return 0, false
106 }
107 value, err := strconv.ParseUint(s, 16, 16)
108 if err != nil {
109 return 0, false
110 }
111 maxValue := uint64((1 << (4 * len(s))) - 1)
112 return uint8(value * 255 / maxValue), true
113}
114
115func parseHexByte(s string) (uint8, bool) {
116 v, err := strconv.ParseUint(s, 16, 8)
117 if err != nil {
118 return 0, false
119 }
120 return uint8(v), true
121}
122
123// Scanner decodes terminal color responses while preserving ordinary bytes.
124type Scanner struct {
125 pending []byte
126}
127
128// EndBatch forwards an admitted batch's undecided suffix as ordinary input.
129// It neither invents a terminal response nor closes the reusable scanner.
130func (s *Scanner) EndBatch(onBytes func([]byte)) {
131 pending := s.pending
132 s.pending = nil
133 if len(pending) != 0 {
134 onBytes(pending)
135 }
136}
137
138// Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
139// responses terminated by BEL or ST.
140// All non-matching bytes are emitted through onBytes in original order, and a
141// contiguous run of ordinary bytes (including any ESC that does not start a
142// color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
143// reports or arrow keys) is always delivered as a single onBytes call so
144// callers never see it split. Partial OSC color responses are buffered across
145// calls, but the buffer is bounded so an unterminated OSC cannot block
146// unrelated input forever.
147func (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)) {
148 if len(s.pending) > 0 {
149 combined := make([]byte, 0, len(s.pending)+len(data))
150 combined = append(combined, s.pending...)
151 combined = append(combined, data...)
152 data = combined
153 s.pending = nil
154 }
155
156 byteStart := 0
157 for i := 0; i < len(data); i++ {
158 if data[i] != '\x1b' {
159 continue
160 }
161
162 if complete, possible, light := schemeCSINotification(data[i:]); complete {
163 if byteStart < i {
164 onBytes(data[byteStart:i])
165 }
166 onScheme(light)
167 i += len(vt.ColorSchemeReportDark) - 1
168 byteStart = i + 1
169 continue
170 } else if possible {
171 if byteStart < i {
172 onBytes(data[byteStart:i])
173 }
174 s.bufferOrFlush(data[i:], onBytes)
175 return
176 }
177
178 kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
179 if !completePrefix {
180 if possiblePrefix {
181 if byteStart < i {
182 onBytes(data[byteStart:i])
183 }
184 s.bufferOrFlush(data[i:], onBytes)
185 return
186 }
187 // Not the start of a color-OSC sequence: leave this ESC inside
188 // the current passthrough run instead of splitting it out. It
189 // may be the introducer of an unrelated escape sequence (SGR
190 // mouse report, arrow key, etc.) that must reach the reader
191 // intact.
192 continue
193 }
194
195 if byteStart < i {
196 onBytes(data[byteStart:i])
197 }
198
199 termStart, termEnd := findTerminator(data[i+prefixLen:])
200 if termStart < 0 {
201 s.bufferOrFlush(data[i:], onBytes)
202 return
203 }
204 termStart += i + prefixLen
205 termEnd += i + prefixLen
206 raw := data[i:termEnd]
207 body := string(data[i+prefixLen : termStart])
208 switch kind {
209 case 4:
210 slot, rgb, ok := parsePaletteResponse(body)
211 if ok {
212 onPalette(slot, rgb)
213 } else {
214 onBytes(raw)
215 }
216 default:
217 rgb, ok := ParseXColor(body)
218 if ok {
219 onColor(kind, rgb)
220 } else {
221 onBytes(raw)
222 }
223 }
224 i = termEnd - 1
225 byteStart = termEnd
226 }
227
228 if byteStart < len(data) {
229 onBytes(data[byteStart:])
230 }
231}
232
233func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
234 if len(partial) > maxPending {
235 onBytes(partial)
236 s.pending = nil
237 return
238 }
239 s.pending = append(s.pending, partial...)
240}
241
242func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) {
243 if len(data) < 2 {
244 return 0, 0, false, false
245 }
246 prefixes := []struct {
247 kind int
248 prefix []byte
249 }{
250 {kind: 10, prefix: []byte("\x1b]10;")},
251 {kind: 11, prefix: []byte("\x1b]11;")},
252 {kind: 4, prefix: []byte("\x1b]4;")},
253 }
254 for _, candidate := range prefixes {
255 if len(data) >= len(candidate.prefix) {
256 if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) {
257 return candidate.kind, len(candidate.prefix), true, true
258 }
259 continue
260 }
261 if bytes.Equal(data, candidate.prefix[:len(data)]) {
262 return candidate.kind, len(candidate.prefix), false, true
263 }
264 }
265 return 0, 0, false, false
266}
267
268func parsePaletteResponse(body string) (int, renderer.RGB, bool) {
269 indexEnd := strings.IndexByte(body, ';')
270 if indexEnd < 1 {
271 return 0, renderer.RGB{}, false
272 }
273 slot := 0
274 for _, b := range []byte(body[:indexEnd]) {
275 if b < '0' || b > '9' {
276 return 0, renderer.RGB{}, false
277 }
278 slot = slot*10 + int(b-'0')
279 if slot > 15 {
280 return 0, renderer.RGB{}, false
281 }
282 }
283 rgb, ok := ParseXColor(body[indexEnd+1:])
284 if !ok {
285 return 0, renderer.RGB{}, false
286 }
287 return slot, rgb, true
288}
289
290// schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
291// recognizes. Package-level so schemeCSINotification (called per ESC byte in
292// the stdin hot path) doesn't allocate on every call.
293var schemeCSIPrefixes = []struct {
294 seq []byte
295 light bool
296}{
297 {seq: []byte(vt.ColorSchemeReportDark), light: false},
298 {seq: []byte(vt.ColorSchemeReportLight), light: true},
299}
300
301func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
302 // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is
303 // something a user can type; unlike OSC's "ESC ]" it must never be
304 // withheld as a partial match with nothing following, or those keystrokes
305 // would be buffered and never forwarded. Partial buffering only starts
306 // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this
307 // guard cannot reject one.
308 if len(data) < 3 {
309 return false, false, false
310 }
311 for _, prefix := range schemeCSIPrefixes {
312 if len(data) >= len(prefix.seq) {
313 if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
314 return true, true, prefix.light
315 }
316 continue
317 }
318 if bytes.Equal(data, prefix.seq[:len(data)]) {
319 return false, true, false
320 }
321 }
322 return false, false, false
323}
324
325func findTerminator(data []byte) (start int, end int) {
326 for i := range data {
327 switch data[i] {
328 case '\x07':
329 return i, i + 1
330 case '\x1b':
331 if i+1 < len(data) && data[i+1] == '\\' {
332 return i, i + 2
333 }
334 }
335 }
336 return -1, -1
337}
338
339// Blend linearly interpolates between a and b.
340func Blend(a, b renderer.RGB, t float64) renderer.RGB {
341 if t < 0 {
342 t = 0
343 }
344 if t > 1 {
345 t = 1
346 }
347 return renderer.RGB{
348 R: blendByte(a.R, b.R, t),
349 G: blendByte(a.G, b.G, t),
350 B: blendByte(a.B, b.B, t),
351 }
352}
353
354func blendByte(a, b uint8, t float64) uint8 {
355 return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5)
356}
357
358func StatusBarStyle(t Theme) renderer.Style {
359 if !usable(t) {
360 return renderer.DefaultStyle()
361 }
362 style := renderer.DefaultStyle()
363 style.HasForegroundRGB = true
364 style.ForegroundRGB = t.Foreground
365 style.HasBackgroundRGB = true
366 style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.12)
367 return style
368}
369
370// The compatibility helpers below retain the neutral styles for isolated
371// callers. Accent-aware construction goes exclusively through Resolve.
372func AccentStyle(t Theme) renderer.Style {
373 return neutralStyles(t).SurfaceActive
374}
375
376func BorderStyle(t Theme) renderer.Style {
377 return neutralStyles(t).BorderMuted
378}
379
380func MutedTextStyle(t Theme) renderer.Style {
381 return legacyMutedText(t)
382}
383
384// mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
385// foreground toward its own background.
386const mutedVariantBlend = 0.4
387
388// EmphasisStyle returns base with bold applied when the client theme supports
389// styled output; otherwise base is returned unchanged.
390func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
391 if !usable(t) {
392 return base
393 }
394 out := base
395 out.Bold = true
396 return out
397}
398
399// MutedVariantStyle fades base's foreground toward its own background.
400func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
401 if !usable(t) || !base.HasForegroundRGB {
402 return base
403 }
404 background := base.BackgroundRGB
405 if !base.HasBackgroundRGB {
406 if !t.HasBG {
407 return base
408 }
409 background = t.Background
410 }
411 out := base
412 out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend)
413 return out
414}
415
416const defaultDimmingPercent = 35
417
418// Dimmer transforms resolved terminal colors for subdued UI states. Construct
419// it once and reuse it while rendering cells with the same theme and policy.
420type Dimmer struct {
421 theme Theme
422 backgroundPercent int
423 foregroundPercent int
424}
425
426type dimmingTarget uint8
427
428const (
429 dimBackground dimmingTarget = iota
430 dimForeground
431)
432
433// DimmerOption customizes a Dimmer without allocating a closure.
434type DimmerOption struct {
435 target dimmingTarget
436 percent int
437}
438
439// WithBackgroundDimming overrides the default 35 percent background fade.
440func WithBackgroundDimming(percent int) DimmerOption {
441 return DimmerOption{target: dimBackground, percent: percent}
442}
443
444// WithForegroundDimming overrides the default 35 percent foreground fade.
445func WithForegroundDimming(percent int) DimmerOption {
446 return DimmerOption{target: dimForeground, percent: percent}
447}
448
449// NewDimmer returns a reusable dimmer with optional per-channel overrides.
450//
451// Keep this function inlinable: it sits on the per-cell render path, and adding
452// fields or work pushes it over the inline budget (80). After changing it,
453// check `go build -gcflags=-m=2 ./internal/usecase/theme 2>&1 | grep NewDimmer`
454// and rerun BenchmarkComposeCapturedFrame in internal/usecase/daemon.
455func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
456 d := Dimmer{
457 theme: t,
458 backgroundPercent: defaultDimmingPercent,
459 foregroundPercent: defaultDimmingPercent,
460 }
461 for _, opt := range opts {
462 switch opt.target {
463 case dimBackground:
464 d.backgroundPercent = opt.percent
465 case dimForeground:
466 d.foregroundPercent = opt.percent
467 }
468 }
469 d.backgroundPercent = clampPercent(d.backgroundPercent)
470 d.foregroundPercent = clampPercent(d.foregroundPercent)
471 return d
472}
473
474// Dim fades a style's resolved background toward the terminal background,
475// then fades its foreground and custom underline toward that dimmed background.
476func (d Dimmer) Dim(style renderer.Style) renderer.Style {
477 if d.theme.DimByAttribute {
478 style.Attrs |= renderer.AttrDim
479 return style
480 }
481 if !usable(d.theme) {
482 return style
483 }
484
485 foreground := resolveForeground(style, d.theme)
486 background := resolveBackground(style, d.theme)
487 if style.Inverse {
488 foreground, background = background, foreground
489 }
490 background = blendPercent(background, d.theme.Background, d.backgroundPercent)
491
492 out := style
493 out.Inverse = false
494 out.HasForegroundRGB = true
495 out.ForegroundRGB = blendPercent(foreground, background, d.foregroundPercent)
496 out.HasBackgroundRGB = true
497 out.BackgroundRGB = background
498 if style.HasUnderlineColorRGB || style.HasUnderlineColor {
499 underline := style.UnderlineColorRGB
500 if !style.HasUnderlineColorRGB {
501 underline = indexedToRGB(d.theme, style.UnderlineColor)
502 }
503 out.HasUnderlineColor = false
504 out.HasUnderlineColorRGB = true
505 out.UnderlineColorRGB = blendPercent(underline, background, d.foregroundPercent)
506 }
507 return out
508}
509
510func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
511 if style.HasForegroundRGB {
512 return style.ForegroundRGB
513 }
514 if style.Foreground >= 0 {
515 return indexedToRGB(t, style.Foreground)
516 }
517 return t.Foreground
518}
519
520func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
521 if style.HasBackgroundRGB {
522 return style.BackgroundRGB
523 }
524 if style.Background >= 0 {
525 return indexedToRGB(t, style.Background)
526 }
527 return t.Background
528}
529
530// indexedToRGB resolves an ANSI color index through the terminal's reported
531// palette when available, so dimming blends toward what the client actually
532// renders rather than the hardcoded classic xterm table. It falls back to
533// the xterm table for the 256-cube, for unreported/unknown slots, and when
534// palette inheritance is disabled.
535func indexedToRGB(t Theme, index int) renderer.RGB {
536 if rgb, ok := t.PaletteColor(index); ok {
537 return rgb
538 }
539 return xterm256Color(index)
540}
541
542func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
543 return renderer.RGB{
544 R: blendBytePercent(a.R, b.R, percent),
545 G: blendBytePercent(a.G, b.G, percent),
546 B: blendBytePercent(a.B, b.B, percent),
547 }
548}
549
550func blendBytePercent(a, b uint8, percent int) uint8 {
551 return uint8((int(a)*(100-percent) + int(b)*percent + 50) / 100)
552}
553
554func clampPercent(percent int) int {
555 if percent < 0 {
556 return 0
557 }
558 if percent > 100 {
559 return 100
560 }
561 return percent
562}
563
564var (
565 xterm256Base = [...]renderer.RGB{
566 {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
567 {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
568 {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
569 {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
570 }
571 xterm256Levels = [...]uint8{0, 95, 135, 175, 215, 255}
572)
573
574func xterm256Color(index int) renderer.RGB {
575 if index < 0 {
576 return renderer.RGB{}
577 }
578 if index < len(xterm256Base) {
579 return xterm256Base[index]
580 }
581 if index < 232 {
582 index -= 16
583 return renderer.RGB{R: xterm256Levels[index/36], G: xterm256Levels[(index/6)%6], B: xterm256Levels[index%6]}
584 }
585 if index < 256 {
586 level := uint8(8 + (index-232)*10)
587 return renderer.RGB{R: level, G: level, B: level}
588 }
589 return xterm256Base[index%len(xterm256Base)]
590}
591
592func SelectionStyle(t Theme) renderer.Style {
593 if !usable(t) {
594 return inverseStyle()
595 }
596 return AccentStyle(t)
597}
598
599func usable(t Theme) bool {
600 return t.Known && t.HasFG && t.HasBG
601}
602
603func inverseStyle() renderer.Style {
604 style := renderer.DefaultStyle()
605 style.Inverse = true
606 return style
607}