1package theme
2
3import (
4 "bytes"
5 "reflect"
6 "testing"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/stretchr/testify/require"
10)
11
12func TestParseXColor(t *testing.T) {
13 tests := []struct {
14 name string
15 in string
16 want renderer.RGB
17 ok bool
18 }{
19 {name: "mixed component widths scale independently", in: "rgb:1/24/86", want: renderer.RGB{R: 0x11, G: 0x24, B: 0x86}, ok: true},
20 {name: "one, three, and four digit components", in: "rgb:f/abc/1234", want: renderer.RGB{R: 0xff, G: 0xab, B: 0x12}, ok: true},
21 {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
22 {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true},
23 {name: "empty component rejected", in: "rgb:/24/86", ok: false},
24 {name: "too-wide component rejected", in: "rgb:12345/24/86", ok: false},
25 {name: "non-hex component rejected", in: "rgb:1/2g/86", ok: false},
26 {name: "missing component rejected", in: "rgb:1/24", ok: false},
27 {name: "extra component rejected", in: "rgb:1/24/86/ff", ok: false},
28 {name: "garbage rejected", in: "not-a-color", ok: false},
29 {name: "bad hex rejected", in: "#xyzxyz", ok: false},
30 }
31
32 for _, tt := range tests {
33 t.Run(tt.name, func(t *testing.T) {
34 got, ok := ParseXColor(tt.in)
35 if ok != tt.ok {
36 t.Fatalf("ok=%v want %v", ok, tt.ok)
37 }
38 if ok && got != tt.want {
39 t.Fatalf("rgb=%+v want %+v", got, tt.want)
40 }
41 })
42 }
43}
44
45func TestScannerExtractsBELAndSTColors(t *testing.T) {
46 var s Scanner
47 var colors []struct {
48 kind int
49 rgb renderer.RGB
50 }
51 var out bytes.Buffer
52
53 s.Scan([]byte("a\x1b]10;#112233\x07b\x1b]11;rgb:4455/6677/8899\x1b\\c"), func(kind int, rgb renderer.RGB) {
54 colors = append(colors, struct {
55 kind int
56 rgb renderer.RGB
57 }{kind: kind, rgb: rgb})
58 }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) })
59
60 if out.String() != "abc" {
61 t.Fatalf("passthrough=%q want %q", out.String(), "abc")
62 }
63 if len(colors) != 2 {
64 t.Fatalf("colors len=%d want 2", len(colors))
65 }
66 if colors[0].kind != 10 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
67 t.Fatalf("first color=%+v", colors[0])
68 }
69 if colors[1].kind != 11 || colors[1].rgb != (renderer.RGB{R: 0x44, G: 0x66, B: 0x88}) {
70 t.Fatalf("second color=%+v", colors[1])
71 }
72}
73
74func TestScannerHandlesSplitSequences(t *testing.T) {
75 var s Scanner
76 var got []renderer.RGB
77 var out bytes.Buffer
78 onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
79 onBytes := func(b []byte) { out.Write(b) }
80
81 s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
82 s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes)
83
84 if out.String() != "xy" {
85 t.Fatalf("passthrough=%q want xy", out.String())
86 }
87 if len(got) != 1 || got[0] != (renderer.RGB{R: 0x12, G: 0x34, B: 0x56}) {
88 t.Fatalf("colors=%+v", got)
89 }
90}
91
92func TestScannerHandlesSplitSchemeNotifications(t *testing.T) {
93 tests := []struct {
94 name string
95 chunks []string
96 wantOut string
97 wantSchemes []bool
98 }{
99 {
100 name: "split dark then complete light",
101 chunks: []string{"a\x1b[?997", ";1nb\x1b[?997;2nc"},
102 wantOut: "abc",
103 wantSchemes: []bool{false, true},
104 },
105 {
106 name: "split light",
107 chunks: []string{"x\x1b[?997;", "2ny"},
108 wantOut: "xy",
109 wantSchemes: []bool{true},
110 },
111 }
112
113 for _, tt := range tests {
114 t.Run(tt.name, func(t *testing.T) {
115 var s Scanner
116 var schemes []bool
117 var out bytes.Buffer
118
119 for _, chunk := range tt.chunks {
120 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) })
121 }
122
123 if out.String() != tt.wantOut {
124 t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut)
125 }
126 if !reflect.DeepEqual(schemes, tt.wantSchemes) {
127 t.Fatalf("schemes=%v want %v", schemes, tt.wantSchemes)
128 }
129 })
130 }
131}
132
133func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) {
134 var s Scanner
135 var out bytes.Buffer
136 schemes := 0
137
138 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) })
139
140 if out.String() != "a\x1b[" {
141 t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[")
142 }
143 if schemes != 0 {
144 t.Fatalf("schemes=%d want 0", schemes)
145 }
146
147 out.Reset()
148 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) })
149
150 if out.String() != "A" {
151 t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A")
152 }
153}
154
155func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
156 var s Scanner
157 var out bytes.Buffer
158 colors := 0
159
160 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) })
161
162 if colors != 0 {
163 t.Fatalf("colors=%d want 0", colors)
164 }
165 want := "ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"
166 if out.String() != want {
167 t.Fatalf("passthrough=%q want %q", out.String(), want)
168 }
169}
170
171func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
172 var s Scanner
173 var out bytes.Buffer
174 colors := 0
175
176 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) })
177
178 if colors != 0 {
179 t.Fatalf("colors=%d want 0", colors)
180 }
181 if out.String() != "\x1b" {
182 t.Fatalf("passthrough=%q want ESC", out.String())
183 }
184}
185
186func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
187 var s Scanner
188 var chunks [][]byte
189 colors := 0
190
191 in := []byte("\x1b[<0;1;1M")
192 s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
193 chunks = append(chunks, append([]byte(nil), b...))
194 })
195
196 if colors != 0 {
197 t.Fatalf("colors=%d want 0", colors)
198 }
199 if len(chunks) != 1 {
200 t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
201 }
202 if !bytes.Equal(chunks[0], in) {
203 t.Fatalf("chunk=%q want %q", chunks[0], in)
204 }
205}
206
207func TestScannerDoesNotSplitArrowKey(t *testing.T) {
208 var s Scanner
209 var chunks [][]byte
210
211 in := []byte("\x1b[A")
212 s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
213 chunks = append(chunks, append([]byte(nil), b...))
214 })
215
216 if len(chunks) != 1 {
217 t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
218 }
219 if !bytes.Equal(chunks[0], in) {
220 t.Fatalf("chunk=%q want %q", chunks[0], in)
221 }
222}
223
224func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
225 var s Scanner
226 var chunks [][]byte
227 var colors []struct {
228 kind int
229 rgb renderer.RGB
230 }
231
232 s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) {
233 colors = append(colors, struct {
234 kind int
235 rgb renderer.RGB
236 }{kind: kind, rgb: rgb})
237 }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) {
238 chunks = append(chunks, append([]byte(nil), b...))
239 })
240
241 if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
242 t.Fatalf("colors=%+v", colors)
243 }
244
245 var joined bytes.Buffer
246 for _, c := range chunks {
247 joined.Write(c)
248 }
249 if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want {
250 t.Fatalf("joined=%q want %q", joined.String(), want)
251 }
252
253 if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" {
254 t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef")
255 }
256}
257
258func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
259 var s Scanner
260 var out bytes.Buffer
261
262 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) })
263 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) })
264
265 if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
266 t.Fatalf("overflow passthrough=%q", out.String())
267 }
268}
269
270func TestDimmer(t *testing.T) {
271 theme := Theme{
272 Foreground: renderer.RGB{R: 240, G: 240, B: 240},
273 Background: renderer.RGB{R: 10, G: 20, B: 30},
274 HasFG: true, HasBG: true, Known: true, TrueColor: true,
275 }
276 dimmer := NewDimmer(theme, WithForegroundDimming(55))
277
278 t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
279 style := renderer.Style{
280 Foreground: -1, Background: -1,
281 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
282 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
283 }
284
285 got := dimmer.Dim(style)
286
287 require.Equal(t, renderer.RGB{R: 36, G: 105, B: 173}, got.BackgroundRGB)
288 require.Equal(t, renderer.RGB{R: 132, G: 103, B: 118}, got.ForegroundRGB)
289 })
290
291 t.Run("resolves inverse and custom underline colors before fading", func(t *testing.T) {
292 style := renderer.Style{
293 Bold: true, Italic: true, Inverse: true, Attrs: renderer.AttrUnderline,
294 Foreground: -1, Background: -1,
295 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
296 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
297 HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 200, G: 10, B: 100},
298 }
299
300 got := dimmer.Dim(style)
301
302 require.False(t, got.Inverse)
303 require.Equal(t, renderer.RGB{R: 166, G: 72, B: 43}, got.BackgroundRGB)
304 require.Equal(t, renderer.RGB{R: 114, G: 107, B: 136}, got.ForegroundRGB)
305 require.Equal(t, renderer.RGB{R: 181, G: 44, B: 69}, got.UnderlineColorRGB)
306 require.True(t, got.Bold)
307 require.True(t, got.Italic)
308 require.Equal(t, renderer.AttrUnderline, got.Attrs)
309 })
310
311 t.Run("resolves indexed and default colors", func(t *testing.T) {
312 style := renderer.Style{
313 Foreground: 196,
314 Background: -1,
315 HasUnderlineColor: true,
316 UnderlineColor: 46,
317 }
318
319 got := dimmer.Dim(style)
320
321 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, got.BackgroundRGB)
322 require.Equal(t, renderer.RGB{R: 120, G: 11, B: 17}, got.ForegroundRGB)
323 require.True(t, got.HasUnderlineColorRGB)
324 require.Equal(t, renderer.RGB{R: 6, G: 126, B: 17}, got.UnderlineColorRGB)
325 })
326
327 t.Run("clamps percentages", func(t *testing.T) {
328 style := renderer.Style{
329 Foreground: -1, Background: -1,
330 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
331 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
332 }
333
334 got := NewDimmer(theme, WithBackgroundDimming(-1), WithForegroundDimming(101)).Dim(style)
335
336 require.Equal(t, style.BackgroundRGB, got.BackgroundRGB)
337 require.Equal(t, style.BackgroundRGB, got.ForegroundRGB)
338 })
339
340 t.Run("leaves style unchanged without a usable truecolor theme", func(t *testing.T) {
341 style := renderer.Style{Foreground: 196, Background: 21, Inverse: true}
342 require.Equal(t, style, NewDimmer(Theme{}, WithForegroundDimming(55)).Dim(style))
343 })
344}
345
346func TestDimmerDefaults(t *testing.T) {
347 theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
348 tests := []struct {
349 name string
350 in renderer.Style
351 want renderer.Style
352 }{
353 {
354 name: "default foreground and background dim to theme colors",
355 in: renderer.DefaultStyle(),
356 want: renderer.Style{
357 Foreground: -1,
358 Background: -1,
359 HasForegroundRGB: true,
360 ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
361 HasBackgroundRGB: true,
362 BackgroundRGB: theme.Background,
363 },
364 },
365 {
366 name: "indexed foreground is mapped before dimming",
367 in: renderer.Style{Foreground: 196, Background: -1},
368 want: renderer.Style{
369 Foreground: 196,
370 Background: -1,
371 HasForegroundRGB: true,
372 ForegroundRGB: Blend(renderer.RGB{R: 255, G: 0, B: 0}, theme.Background, 0.35),
373 HasBackgroundRGB: true,
374 BackgroundRGB: theme.Background,
375 },
376 },
377 {
378 name: "indexed foregrounds remain distinct",
379 in: renderer.Style{Foreground: 46, Background: -1},
380 want: renderer.Style{
381 Foreground: 46,
382 Background: -1,
383 HasForegroundRGB: true,
384 ForegroundRGB: Blend(renderer.RGB{R: 0, G: 255, B: 0}, theme.Background, 0.35),
385 HasBackgroundRGB: true,
386 BackgroundRGB: theme.Background,
387 },
388 },
389 {
390 name: "indexed background is mapped before dimming",
391 in: renderer.Style{Foreground: -1, Background: 21},
392 want: renderer.Style{
393 Foreground: -1,
394 Background: 21,
395 HasForegroundRGB: true,
396 ForegroundRGB: renderer.RGB{R: 131, G: 132, B: 192},
397 HasBackgroundRGB: true,
398 BackgroundRGB: renderer.RGB{R: 4, G: 7, B: 176},
399 },
400 },
401 {
402 name: "rgb foreground and background are dimmed",
403 in: renderer.Style{
404 Foreground: -1,
405 Background: -1,
406 HasForegroundRGB: true,
407 ForegroundRGB: renderer.RGB{R: 100, G: 50, B: 25},
408 HasBackgroundRGB: true,
409 BackgroundRGB: renderer.RGB{R: 20, G: 100, B: 180},
410 },
411 want: renderer.Style{
412 Foreground: -1,
413 Background: -1,
414 HasForegroundRGB: true,
415 ForegroundRGB: renderer.RGB{R: 71, G: 58, B: 61},
416 HasBackgroundRGB: true,
417 BackgroundRGB: renderer.RGB{R: 17, G: 72, B: 128},
418 },
419 },
420 }
421
422 for _, tt := range tests {
423 t.Run(tt.name, func(t *testing.T) {
424 got := NewDimmer(theme).Dim(tt.in)
425 if !got.Equal(tt.want) {
426 t.Fatalf("Dimmer.Dim()=%+v want %+v", got, tt.want)
427 }
428 })
429 }
430}
431
432// TestDimmerUsesReportedPalette verifies that Dim resolves indexed ANSI
433// colors (slots 0-15) through the terminal's reported palette when known,
434// so dimming a pane blends toward what the client actually rendered rather
435// than the hardcoded classic xterm table. It falls back to the xterm table
436// when the palette is unreported, disabled, or the slot is outside 0-15.
437func TestDimmerUsesReportedPalette(t *testing.T) {
438 background := renderer.RGB{R: 10, G: 20, B: 30}
439 foreground := renderer.RGB{R: 200, G: 200, B: 200}
440 baseTheme := Theme{
441 Foreground: foreground, Background: background,
442 HasFG: true, HasBG: true, Known: true, TrueColor: true,
443 }
444
445 reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446 reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
447 reportedSlot6 := renderer.RGB{R: 50, G: 150, B: 150}
448
449 withPalette := baseTheme
450 withPalette.UsePalette = true
451 withPalette.Palette[2] = reportedSlot2
452 withPalette.Palette[4] = reportedSlot4
453 withPalette.Palette[6] = reportedSlot6
454 withPalette.PaletteKnown = 1<<2 | 1<<4 | 1<<6
455
456 unknownBits := baseTheme
457 unknownBits.UsePalette = true
458 unknownBits.Palette[2] = reportedSlot2
459 unknownBits.Palette[4] = reportedSlot4
460 unknownBits.Palette[6] = reportedSlot6
461 // PaletteKnown left zero: reported RGBs exist but no bit says they're valid.
462
463 paletteDisabled := baseTheme
464 paletteDisabled.UsePalette = false
465 paletteDisabled.Palette[2] = reportedSlot2
466 paletteDisabled.PaletteKnown = 1 << 2
467
468 dimmer := NewDimmer(baseTheme)
469 dimBG := func(raw renderer.RGB) renderer.RGB { return blendPercent(raw, background, dimmer.backgroundPercent) }
470 dimFG := func(raw, bg renderer.RGB) renderer.RGB { return blendPercent(raw, bg, dimmer.foregroundPercent) }
471 ptr := func(c renderer.RGB) *renderer.RGB { return &c }
472
473 underlined := renderer.Style{
474 Foreground: -1, Background: -1,
475 HasUnderlineColor: true, UnderlineColor: 6,
476 }
477
478 cases := []struct {
479 name string
480 theme Theme
481 style renderer.Style
482 // Raw colors Dim's blends must start from; expected values are
483 // derived from them with the same blend math Dim uses.
484 fgSource renderer.RGB
485 bgSource renderer.RGB
486 underlineSource *renderer.RGB // nil when the style has no underline color
487 forbidFGSource *renderer.RGB // a raw color Dim must NOT have blended from
488 }{
489 {
490 name: "indexed foreground blends from the reported palette color when known",
491 theme: withPalette,
492 style: renderer.Style{Foreground: 2, Background: -1},
493 fgSource: reportedSlot2,
494 bgSource: background,
495 forbidFGSource: ptr(xterm256Color(2)),
496 },
497 {
498 name: "indexed foreground falls back to xterm when the palette bit is unknown",
499 theme: unknownBits,
500 style: renderer.Style{Foreground: 2, Background: -1},
501 fgSource: xterm256Color(2),
502 bgSource: background,
503 },
504 {
505 name: "indexed foreground falls back to xterm when UsePalette is disabled",
506 theme: paletteDisabled,
507 style: renderer.Style{Foreground: 2, Background: -1},
508 fgSource: xterm256Color(2),
509 bgSource: background,
510 },
511 {
512 name: "indexed background blends from the reported palette color when known",
513 theme: withPalette,
514 style: renderer.Style{Foreground: -1, Background: 4},
515 fgSource: foreground,
516 bgSource: reportedSlot4,
517 },
518 {
519 name: "indexed background falls back to xterm when the palette bit is unknown",
520 theme: unknownBits,
521 style: renderer.Style{Foreground: -1, Background: 4},
522 fgSource: foreground,
523 bgSource: xterm256Color(4),
524 },
525 {
526 name: "indexed underline blends from the reported palette color when known",
527 theme: withPalette,
528 style: underlined,
529 fgSource: foreground,
530 bgSource: background,
531 underlineSource: ptr(reportedSlot6),
532 },
533 {
534 name: "indexed underline falls back to xterm when the palette bit is unknown",
535 theme: unknownBits,
536 style: underlined,
537 fgSource: foreground,
538 bgSource: background,
539 underlineSource: ptr(xterm256Color(6)),
540 },
541 {
542 name: "256-cube index is untouched by a reported palette",
543 theme: withPalette,
544 style: renderer.Style{Foreground: 196, Background: -1},
545 fgSource: xterm256Color(196),
546 bgSource: background,
547 },
548 }
549
550 for _, tc := range cases {
551 t.Run(tc.name, func(t *testing.T) {
552 got := NewDimmer(tc.theme).Dim(tc.style)
553
554 dimmedBG := dimBG(tc.bgSource)
555 require.Equal(t, dimmedBG, got.BackgroundRGB)
556 require.Equal(t, dimFG(tc.fgSource, dimmedBG), got.ForegroundRGB)
557 if tc.forbidFGSource != nil {
558 require.NotEqual(t, dimFG(*tc.forbidFGSource, dimmedBG), got.ForegroundRGB, "must not use the xterm table when the palette is known")
559 }
560 if tc.underlineSource != nil {
561 require.True(t, got.HasUnderlineColorRGB)
562 require.Equal(t, dimFG(*tc.underlineSource, dimmedBG), got.UnderlineColorRGB)
563 }
564 })
565 }
566}
567
568func TestStyleHelpersFallbackAndThemed(t *testing.T) {
569 unknown := Theme{}
570 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
571 t.Fatalf("status fallback=%+v", got)
572 }
573 if got := BorderStyle(Theme{Known: true}); !got.Equal(renderer.DefaultStyle()) {
574 t.Fatalf("border fallback=%+v", got)
575 }
576 if got := MutedTextStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
577 t.Fatalf("muted fallback=%+v", got)
578 }
579 inverse := renderer.DefaultStyle()
580 inverse.Inverse = true
581 if got := AccentStyle(unknown); !got.Equal(inverse) {
582 t.Fatalf("accent fallback=%+v want inverse", got)
583 }
584 if got := SelectionStyle(unknown); !got.Equal(inverse) {
585 t.Fatalf("selection fallback=%+v want inverse", got)
586 }
587
588 theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
589 if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want {
590 t.Fatalf("blend=%+v want %+v", got, want)
591 }
592 status := StatusBarStyle(theme)
593 if !status.HasBackgroundRGB || status.BackgroundRGB != (renderer.RGB{R: 33, G: 42, B: 50}) || !status.HasForegroundRGB || status.ForegroundRGB != theme.Foreground {
594 t.Fatalf("status themed=%+v", status)
595 }
596 accent := AccentStyle(theme)
597 if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {
598 t.Fatalf("accent themed=%+v", accent)
599 }
600 border := BorderStyle(theme)
601 if !border.HasForegroundRGB || border.ForegroundRGB != (renderer.RGB{R: 124, G: 128, B: 132}) {
602 t.Fatalf("border themed=%+v", border)
603 }
604 muted := MutedTextStyle(theme)
605 if !muted.HasForegroundRGB || muted.ForegroundRGB != (renderer.RGB{R: 115, G: 119, B: 124}) {
606 t.Fatalf("muted themed=%+v", muted)
607 }
608}
609
610func TestEmphasisStyle(t *testing.T) {
611 usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
612 base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}
613
614 tests := []struct {
615 name string
616 theme Theme
617 base renderer.Style
618 want renderer.Style
619 }{
620 {
621 name: "usable theme applies bold",
622 theme: usableTheme,
623 base: base,
624 want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
625 },
626 {
627 name: "unknown theme returns base unchanged",
628 theme: Theme{},
629 base: base,
630 want: base,
631 },
632 {
633 name: "theme missing truecolor returns base unchanged",
634 theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
635 base: base,
636 want: base,
637 },
638 }
639 for _, tt := range tests {
640 t.Run(tt.name, func(t *testing.T) {
641 if got := EmphasisStyle(tt.base, tt.theme); !got.Equal(tt.want) {
642 t.Fatalf("EmphasisStyle()=%+v want %+v", got, tt.want)
643 }
644 })
645 }
646}
647
648func TestMutedVariantStyle(t *testing.T) {
649 usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
650 rgbBase := renderer.Style{
651 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
652 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
653 }
654 fgOnlyBase := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}}
655 noRGBBase := renderer.DefaultStyle()
656
657 tests := []struct {
658 name string
659 base renderer.Style
660 theme Theme
661 want renderer.Style
662 }{
663 {
664 name: "usable theme blends fg toward own bg",
665 base: rgbBase,
666 theme: usableTheme,
667 want: renderer.Style{
668 HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, renderer.RGB{R: 0, G: 0, B: 0}, mutedVariantBlend),
669 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
670 },
671 },
672 {
673 name: "non-usable theme returns base unchanged",
674 base: rgbBase,
675 theme: Theme{},
676 want: rgbBase,
677 },
678 {
679 name: "base without RGB background falls back to theme background",
680 base: fgOnlyBase,
681 theme: usableTheme,
682 want: renderer.Style{
683 HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, usableTheme.Background, mutedVariantBlend),
684 },
685 },
686 {
687 name: "base without RGB background and theme without background returns base unchanged",
688 base: fgOnlyBase,
689 theme: Theme{Known: true, TrueColor: true, HasFG: true},
690 want: fgOnlyBase,
691 },
692 {
693 name: "base without RGB foreground returns base unchanged",
694 base: noRGBBase,
695 theme: usableTheme,
696 want: noRGBBase,
697 },
698 }
699 for _, tt := range tests {
700 t.Run(tt.name, func(t *testing.T) {
701 if got := MutedVariantStyle(tt.base, tt.theme); !got.Equal(tt.want) {
702 t.Fatalf("MutedVariantStyle()=%+v want %+v", got, tt.want)
703 }
704 })
705 }
706}