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, 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:  "known theme without truecolor applies bold",
634			theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
635			base:  base,
636			want:  renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
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}

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