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}
275 dimmer := NewDimmer(theme, WithForegroundDimming(55))
276
277 t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
278 style := renderer.Style{
279 Foreground: -1, Background: -1,
280 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
281 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
282 }
283
284 got := dimmer.Dim(style)
285
286 require.Equal(t, renderer.RGB{R: 36, G: 105, B: 173}, got.BackgroundRGB)
287 require.Equal(t, renderer.RGB{R: 132, G: 103, B: 118}, got.ForegroundRGB)
288 })
289
290 t.Run("resolves inverse and custom underline colors before fading", func(t *testing.T) {
291 style := renderer.Style{
292 Bold: true, Italic: true, Inverse: true, Attrs: renderer.AttrUnderline,
293 Foreground: -1, Background: -1,
294 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
295 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
296 HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 200, G: 10, B: 100},
297 }
298
299 got := dimmer.Dim(style)
300
301 require.False(t, got.Inverse)
302 require.Equal(t, renderer.RGB{R: 166, G: 72, B: 43}, got.BackgroundRGB)
303 require.Equal(t, renderer.RGB{R: 114, G: 107, B: 136}, got.ForegroundRGB)
304 require.Equal(t, renderer.RGB{R: 181, G: 44, B: 69}, got.UnderlineColorRGB)
305 require.True(t, got.Bold)
306 require.True(t, got.Italic)
307 require.Equal(t, renderer.AttrUnderline, got.Attrs)
308 })
309
310 t.Run("resolves indexed and default colors", func(t *testing.T) {
311 style := renderer.Style{
312 Foreground: 196,
313 Background: -1,
314 HasUnderlineColor: true,
315 UnderlineColor: 46,
316 }
317
318 got := dimmer.Dim(style)
319
320 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, got.BackgroundRGB)
321 require.Equal(t, renderer.RGB{R: 120, G: 11, B: 17}, got.ForegroundRGB)
322 require.True(t, got.HasUnderlineColorRGB)
323 require.Equal(t, renderer.RGB{R: 6, G: 126, B: 17}, got.UnderlineColorRGB)
324 })
325
326 t.Run("clamps percentages", func(t *testing.T) {
327 style := renderer.Style{
328 Foreground: -1, Background: -1,
329 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
330 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
331 }
332
333 got := NewDimmer(theme, WithBackgroundDimming(-1), WithForegroundDimming(101)).Dim(style)
334
335 require.Equal(t, style.BackgroundRGB, got.BackgroundRGB)
336 require.Equal(t, style.BackgroundRGB, got.ForegroundRGB)
337 })
338
339 t.Run("leaves style unchanged without a usable truecolor theme", func(t *testing.T) {
340 style := renderer.Style{Foreground: 196, Background: 21, Inverse: true}
341 require.Equal(t, style, NewDimmer(Theme{}, WithForegroundDimming(55)).Dim(style))
342 })
343}
344
345func TestDimmerDefaults(t *testing.T) {
346 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}
347 tests := []struct {
348 name string
349 in renderer.Style
350 want renderer.Style
351 }{
352 {
353 name: "default foreground and background dim to theme colors",
354 in: renderer.DefaultStyle(),
355 want: renderer.Style{
356 Foreground: -1,
357 Background: -1,
358 HasForegroundRGB: true,
359 ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
360 HasBackgroundRGB: true,
361 BackgroundRGB: theme.Background,
362 },
363 },
364 {
365 name: "indexed foreground is mapped before dimming",
366 in: renderer.Style{Foreground: 196, Background: -1},
367 want: renderer.Style{
368 Foreground: 196,
369 Background: -1,
370 HasForegroundRGB: true,
371 ForegroundRGB: Blend(renderer.RGB{R: 255, G: 0, B: 0}, theme.Background, 0.35),
372 HasBackgroundRGB: true,
373 BackgroundRGB: theme.Background,
374 },
375 },
376 {
377 name: "indexed foregrounds remain distinct",
378 in: renderer.Style{Foreground: 46, Background: -1},
379 want: renderer.Style{
380 Foreground: 46,
381 Background: -1,
382 HasForegroundRGB: true,
383 ForegroundRGB: Blend(renderer.RGB{R: 0, G: 255, B: 0}, theme.Background, 0.35),
384 HasBackgroundRGB: true,
385 BackgroundRGB: theme.Background,
386 },
387 },
388 {
389 name: "indexed background is mapped before dimming",
390 in: renderer.Style{Foreground: -1, Background: 21},
391 want: renderer.Style{
392 Foreground: -1,
393 Background: 21,
394 HasForegroundRGB: true,
395 ForegroundRGB: renderer.RGB{R: 131, G: 132, B: 192},
396 HasBackgroundRGB: true,
397 BackgroundRGB: renderer.RGB{R: 4, G: 7, B: 176},
398 },
399 },
400 {
401 name: "rgb foreground and background are dimmed",
402 in: renderer.Style{
403 Foreground: -1,
404 Background: -1,
405 HasForegroundRGB: true,
406 ForegroundRGB: renderer.RGB{R: 100, G: 50, B: 25},
407 HasBackgroundRGB: true,
408 BackgroundRGB: renderer.RGB{R: 20, G: 100, B: 180},
409 },
410 want: renderer.Style{
411 Foreground: -1,
412 Background: -1,
413 HasForegroundRGB: true,
414 ForegroundRGB: renderer.RGB{R: 71, G: 58, B: 61},
415 HasBackgroundRGB: true,
416 BackgroundRGB: renderer.RGB{R: 17, G: 72, B: 128},
417 },
418 },
419 }
420
421 for _, tt := range tests {
422 t.Run(tt.name, func(t *testing.T) {
423 got := NewDimmer(theme).Dim(tt.in)
424 if !got.Equal(tt.want) {
425 t.Fatalf("Dimmer.Dim()=%+v want %+v", got, tt.want)
426 }
427 })
428 }
429}
430
431// TestDimmerUsesReportedPalette verifies that Dim resolves indexed ANSI
432// colors (slots 0-15) through the terminal's reported palette when known,
433// so dimming a pane blends toward what the client actually rendered rather
434// than the hardcoded classic xterm table. It falls back to the xterm table
435// when the palette is unreported, disabled, or the slot is outside 0-15.
436func TestDimmerUsesReportedPalette(t *testing.T) {
437 background := renderer.RGB{R: 10, G: 20, B: 30}
438 foreground := renderer.RGB{R: 200, G: 200, B: 200}
439 baseTheme := Theme{
440 Foreground: foreground, Background: background,
441 HasFG: true, HasBG: true, Known: true}
442
443 reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
444 reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
445 reportedSlot6 := renderer.RGB{R: 50, G: 150, B: 150}
446
447 withPalette := baseTheme
448 withPalette.UsePalette = true
449 withPalette.Palette[2] = reportedSlot2
450 withPalette.Palette[4] = reportedSlot4
451 withPalette.Palette[6] = reportedSlot6
452 withPalette.PaletteKnown = 1<<2 | 1<<4 | 1<<6
453
454 unknownBits := baseTheme
455 unknownBits.UsePalette = true
456 unknownBits.Palette[2] = reportedSlot2
457 unknownBits.Palette[4] = reportedSlot4
458 unknownBits.Palette[6] = reportedSlot6
459 // PaletteKnown left zero: reported RGBs exist but no bit says they're valid.
460
461 paletteDisabled := baseTheme
462 paletteDisabled.UsePalette = false
463 paletteDisabled.Palette[2] = reportedSlot2
464 paletteDisabled.PaletteKnown = 1 << 2
465
466 dimmer := NewDimmer(baseTheme)
467 dimBG := func(raw renderer.RGB) renderer.RGB { return blendPercent(raw, background, dimmer.backgroundPercent) }
468 dimFG := func(raw, bg renderer.RGB) renderer.RGB { return blendPercent(raw, bg, dimmer.foregroundPercent) }
469 ptr := func(c renderer.RGB) *renderer.RGB { return &c }
470
471 underlined := renderer.Style{
472 Foreground: -1, Background: -1,
473 HasUnderlineColor: true, UnderlineColor: 6,
474 }
475
476 cases := []struct {
477 name string
478 theme Theme
479 style renderer.Style
480 // Raw colors Dim's blends must start from; expected values are
481 // derived from them with the same blend math Dim uses.
482 fgSource renderer.RGB
483 bgSource renderer.RGB
484 underlineSource *renderer.RGB // nil when the style has no underline color
485 forbidFGSource *renderer.RGB // a raw color Dim must NOT have blended from
486 }{
487 {
488 name: "indexed foreground blends from the reported palette color when known",
489 theme: withPalette,
490 style: renderer.Style{Foreground: 2, Background: -1},
491 fgSource: reportedSlot2,
492 bgSource: background,
493 forbidFGSource: ptr(xterm256Color(2)),
494 },
495 {
496 name: "indexed foreground falls back to xterm when the palette bit is unknown",
497 theme: unknownBits,
498 style: renderer.Style{Foreground: 2, Background: -1},
499 fgSource: xterm256Color(2),
500 bgSource: background,
501 },
502 {
503 name: "indexed foreground falls back to xterm when UsePalette is disabled",
504 theme: paletteDisabled,
505 style: renderer.Style{Foreground: 2, Background: -1},
506 fgSource: xterm256Color(2),
507 bgSource: background,
508 },
509 {
510 name: "indexed background blends from the reported palette color when known",
511 theme: withPalette,
512 style: renderer.Style{Foreground: -1, Background: 4},
513 fgSource: foreground,
514 bgSource: reportedSlot4,
515 },
516 {
517 name: "indexed background falls back to xterm when the palette bit is unknown",
518 theme: unknownBits,
519 style: renderer.Style{Foreground: -1, Background: 4},
520 fgSource: foreground,
521 bgSource: xterm256Color(4),
522 },
523 {
524 name: "indexed underline blends from the reported palette color when known",
525 theme: withPalette,
526 style: underlined,
527 fgSource: foreground,
528 bgSource: background,
529 underlineSource: ptr(reportedSlot6),
530 },
531 {
532 name: "indexed underline falls back to xterm when the palette bit is unknown",
533 theme: unknownBits,
534 style: underlined,
535 fgSource: foreground,
536 bgSource: background,
537 underlineSource: ptr(xterm256Color(6)),
538 },
539 {
540 name: "256-cube index is untouched by a reported palette",
541 theme: withPalette,
542 style: renderer.Style{Foreground: 196, Background: -1},
543 fgSource: xterm256Color(196),
544 bgSource: background,
545 },
546 }
547
548 for _, tc := range cases {
549 t.Run(tc.name, func(t *testing.T) {
550 got := NewDimmer(tc.theme).Dim(tc.style)
551
552 dimmedBG := dimBG(tc.bgSource)
553 require.Equal(t, dimmedBG, got.BackgroundRGB)
554 require.Equal(t, dimFG(tc.fgSource, dimmedBG), got.ForegroundRGB)
555 if tc.forbidFGSource != nil {
556 require.NotEqual(t, dimFG(*tc.forbidFGSource, dimmedBG), got.ForegroundRGB, "must not use the xterm table when the palette is known")
557 }
558 if tc.underlineSource != nil {
559 require.True(t, got.HasUnderlineColorRGB)
560 require.Equal(t, dimFG(*tc.underlineSource, dimmedBG), got.UnderlineColorRGB)
561 }
562 })
563 }
564}
565
566func TestStyleHelpersFallbackAndThemed(t *testing.T) {
567 unknown := Theme{}
568 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
569 t.Fatalf("status fallback=%+v", got)
570 }
571 if got := BorderStyle(Theme{Known: true}); !got.Equal(renderer.DefaultStyle()) {
572 t.Fatalf("border fallback=%+v", got)
573 }
574 if got := MutedTextStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
575 t.Fatalf("muted fallback=%+v", got)
576 }
577 inverse := renderer.DefaultStyle()
578 inverse.Inverse = true
579 if got := AccentStyle(unknown); !got.Equal(inverse) {
580 t.Fatalf("accent fallback=%+v want inverse", got)
581 }
582 if got := SelectionStyle(unknown); !got.Equal(inverse) {
583 t.Fatalf("selection fallback=%+v want inverse", got)
584 }
585
586 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}
587 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 {
588 t.Fatalf("blend=%+v want %+v", got, want)
589 }
590 status := StatusBarStyle(theme)
591 if !status.HasBackgroundRGB || status.BackgroundRGB != (renderer.RGB{R: 33, G: 42, B: 50}) || !status.HasForegroundRGB || status.ForegroundRGB != theme.Foreground {
592 t.Fatalf("status themed=%+v", status)
593 }
594 accent := AccentStyle(theme)
595 if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != theme.Foreground || accent.ForegroundRGB != theme.Background {
596 t.Fatalf("accent themed=%+v", accent)
597 }
598 border := BorderStyle(theme)
599 if !border.HasForegroundRGB || border.ForegroundRGB != (renderer.RGB{R: 124, G: 128, B: 132}) {
600 t.Fatalf("border themed=%+v", border)
601 }
602 muted := MutedTextStyle(theme)
603 if !muted.HasForegroundRGB || muted.ForegroundRGB != (renderer.RGB{R: 115, G: 119, B: 124}) {
604 t.Fatalf("muted themed=%+v", muted)
605 }
606}
607
608func TestEmphasisStyle(t *testing.T) {
609 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}
610 base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}
611
612 tests := []struct {
613 name string
614 theme Theme
615 base renderer.Style
616 want renderer.Style
617 }{
618 {
619 name: "usable theme applies bold",
620 theme: usableTheme,
621 base: base,
622 want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
623 },
624 {
625 name: "unknown theme returns base unchanged",
626 theme: Theme{},
627 base: base,
628 want: base,
629 },
630 {
631 name: "known theme without truecolor applies bold",
632 theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
633 base: base,
634 want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
635 },
636 }
637 for _, tt := range tests {
638 t.Run(tt.name, func(t *testing.T) {
639 if got := EmphasisStyle(tt.base, tt.theme); !got.Equal(tt.want) {
640 t.Fatalf("EmphasisStyle()=%+v want %+v", got, tt.want)
641 }
642 })
643 }
644}
645
646func TestMutedVariantStyle(t *testing.T) {
647 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}
648 rgbBase := renderer.Style{
649 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
650 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
651 }
652 fgOnlyBase := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}}
653 noRGBBase := renderer.DefaultStyle()
654
655 tests := []struct {
656 name string
657 base renderer.Style
658 theme Theme
659 want renderer.Style
660 }{
661 {
662 name: "usable theme blends fg toward own bg",
663 base: rgbBase,
664 theme: usableTheme,
665 want: renderer.Style{
666 HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, renderer.RGB{R: 0, G: 0, B: 0}, mutedVariantBlend),
667 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
668 },
669 },
670 {
671 name: "non-usable theme returns base unchanged",
672 base: rgbBase,
673 theme: Theme{},
674 want: rgbBase,
675 },
676 {
677 name: "base without RGB background falls back to theme background",
678 base: fgOnlyBase,
679 theme: usableTheme,
680 want: renderer.Style{
681 HasForegroundRGB: true, ForegroundRGB: Blend(renderer.RGB{R: 100, G: 100, B: 100}, usableTheme.Background, mutedVariantBlend),
682 },
683 },
684 {
685 name: "base without RGB background and theme without background returns base unchanged",
686 base: fgOnlyBase,
687 theme: Theme{Known: true, HasFG: true},
688 want: fgOnlyBase,
689 },
690 {
691 name: "base without RGB foreground returns base unchanged",
692 base: noRGBBase,
693 theme: usableTheme,
694 want: noRGBBase,
695 },
696 }
697 for _, tt := range tests {
698 t.Run(tt.name, func(t *testing.T) {
699 if got := MutedVariantStyle(tt.base, tt.theme); !got.Equal(tt.want) {
700 t.Fatalf("MutedVariantStyle()=%+v want %+v", got, tt.want)
701 }
702 })
703 }
704}