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