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