v0.24.0 3dbf466

theme_loader_test.go RawHistoryFolder

1package tui
2
3import (
4	"os"
5	"path/filepath"
6	"strings"
7	"testing"
8)
9
10func writeThemeFile(t *testing.T, name, body string) string {
11	t.Helper()
12	home := t.TempDir()
13	if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
14		t.Fatal(err)
15	}
16	path := filepath.Join(home, "themes", name+".json")
17	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
18		t.Fatal(err)
19	}
20	return home
21}
22
23func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
24	home := writeThemeFile(t, "partial", `{"colors":{"dark":{"accent":204}}}`)
25
26	th, name, err := LoadThemeFromHome(home, "partial", Dark)
27	if err != nil {
28		t.Fatal(err)
29	}
30	if name != "partial" {
31		t.Fatalf("name = %q, want partial", name)
32	}
33	if th.Accent != Color256(204) {
34		t.Fatalf("accent = %#v, want 204", th.Accent)
35	}
36	if th.FG != TerminalDefault() {
37		t.Fatalf("fg = %#v, want terminal default", th.FG)
38	}
39	if len(th.SpinnerFrames) == 0 {
40		t.Fatal("spinner frames should be inherited")
41	}
42}
43
44func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
45	home := writeThemeFile(t, "thinking", `{"colors":{"dark":{"thinkingMax":201}}}`)
46
47	th, _, err := LoadThemeFromHome(home, "thinking", Dark)
48	if err != nil {
49		t.Fatal(err)
50	}
51	if th.ThinkingMax != Color256(201) {
52		t.Fatalf("thinking max = %#v, want 201", th.ThinkingMax)
53	}
54}
55
56func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
57	home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)
58
59	th, _, err := LoadThemeFromHome(home, "spinner", Dark)
60	if err != nil {
61		t.Fatal(err)
62	}
63	if got := len(th.SpinnerFrames); got != 2 {
64		t.Fatalf("spinner frame count = %d, want 2", got)
65	}
66	if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 {
67		t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
68	}
69	if th.Accent != TerminalPaletteSlot(12) {
70		t.Fatalf("accent = %#v, want terminal palette slot 12", th.Accent)
71	}
72}
73
74func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
75	home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
76	detected := Light
77	detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true}
78
79	th, _, err := LoadThemeFromHome(home, "darkonly", detected)
80	if err != nil {
81		t.Fatal(err)
82	}
83	if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {
84		t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)
85	}
86	if th.SpinnerIntervalMS != 120 {
87		t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
88	}
89	if th.FG != TerminalDefault() {
90		t.Fatalf("fg = %#v, want terminal default", th.FG)
91	}
92}
93
94func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
95	home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)
96
97	th, _, err := LoadThemeFromHome(home, "shared", Light)
98	if err != nil {
99		t.Fatal(err)
100	}
101	if th.Accent != Color256(204) {
102		t.Fatalf("accent = %#v, want 204", th.Accent)
103	}
104	if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {
105		t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
106	}
107	if th.FG != TerminalDefault() {
108		t.Fatalf("fg = %#v, want terminal default", th.FG)
109	}
110}
111
112func TestLoadThemeAcceptsRGBSemanticColors(t *testing.T) {
113	home := writeThemeFile(t, "rgb", `{"colors":{"dark":{"fg":"#123456","muted":{"mode":"rgb","r":18,"g":52,"b":86},"accent":{"mode":"rgb","r":200,"g":100,"b":50},"user": {"mode":"ansi","index":31},"selection_bg":{"mode":"256","index":237},"selection_fg":250}}}`)
114
115	detected := Dark
116	detected.Terminal.TrueColor = true
117	th, _, err := LoadThemeFromHome(home, "rgb", detected)
118	if err != nil {
119		t.Fatal(err)
120	}
121	if !th.Terminal.TrueColor {
122		t.Fatal("terminal color mode = 256, want truecolor")
123	}
124	if th.FG != ColorRGB(0x12, 0x34, 0x56) {
125		t.Fatalf("foreground = %#v, want RGB", th.FG)
126	}
127	if th.Accent != ColorRGB(200, 100, 50) {
128		t.Fatalf("accent = %#v, want RGB", th.Accent)
129	}
130	if th.SelectionBG != Color256(237) || th.SelectionFG != Color256(250) {
131		t.Fatalf("indexed semantic colors were not preserved: bg=%#v fg=%#v", th.SelectionBG, th.SelectionFG)
132	}
133	if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;200;100;50m") {
134		t.Fatalf("RGB accent was not rendered as truecolor: %q", got)
135	}
136}
137
138func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
139	home := writeThemeFile(t, "rgb-fallback", `{"colors":{"dark":{"accent":"#c86432"}}}`)
140
141	th, _, err := LoadThemeFromHome(home, "rgb-fallback", Dark)
142	if err != nil {
143		t.Fatal(err)
144	}
145	want := nearestXtermColor(200, 100, 50)
146	if got := th.FGColor(th.Accent, "x"); got != sgrFG(want)+"x\x1b[0m" {
147		t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
148	}
149}
150
151func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) {
152	for _, body := range []string{
153		`{"colors":{"dark":{"accent":999}}}`,
154		`{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`,
155		`{"spinner_interval_ms":1}`,
156		`{"syntax_base_style":"not-a-style"}`,
157	} {
158		home := writeThemeFile(t, "invalid", body)
159		if _, err := LoadThemeSource(home, "invalid"); err == nil {
160			t.Fatalf("LoadThemeSource(%s) succeeded", body)
161		}
162	}
163}
164
165func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
166	home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`)
167	source, err := LoadThemeSource(home, "split")
168	if err != nil {
169		t.Fatal(err)
170	}
171	profile := TerminalProfile{SchemeKnown: true, Light: true}
172	if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
173		t.Fatalf("light branch accent = %#v, want 21", got)
174	}
175	dark := TerminalProfile{SchemeKnown: true, Light: false}
176	if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) {
177		t.Fatalf("dark branch accent = %#v, want 20", got)
178	}
179}

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 1

Tags 476

↑↓ moveenter openesc close