theme_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close