v0.3.0 da0887c

formats.go RawHistoryFolder

1package theme
2
3import (
4	"bufio"
5	"os"
6	"path/filepath"
7	"strconv"
8	"strings"
9
10	"github.com/BurntSushi/toml"
11)
12
13// lines returns the non-empty lines of a file, without trailing whitespace.
14func lines(path string) ([]string, error) {
15	f, err := os.Open(path)
16	if err != nil {
17		return nil, err
18	}
19	defer f.Close()
20	var out []string
21	sc := bufio.NewScanner(f)
22	for sc.Scan() {
23		if l := strings.TrimSpace(sc.Text()); l != "" {
24			out = append(out, l)
25		}
26	}
27	return out, sc.Err()
28}
29
30// splitFirstWord splits "key   value" at the first run of spaces or tabs.
31func splitFirstWord(l string) (key, val string) {
32	i := strings.IndexAny(l, " \t")
33	if i < 0 {
34		return l, ""
35	}
36	return l[:i], strings.TrimSpace(l[i:])
37}
38
39// ---- kitty: "key value" lines, "include file" ------------------------------
40
41func loadKitty(path string, depth int) (Theme, error) {
42	var t Theme
43	ls, err := lines(path)
44	if err != nil {
45		return t, err
46	}
47	t.Files = append(t.Files, path)
48	for _, l := range ls {
49		if strings.HasPrefix(l, "#") {
50			continue
51		}
52		key, val := splitFirstWord(l)
53		switch {
54		case key == "include" && depth < maxDepth:
55			inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)
56			if err == nil { // a missing include is skipped, as kitty would warn
57				t.overlay(inc)
58			}
59		case key == "background":
60			if c, ok := parseColor(val); ok {
61				t.setBG(c)
62			}
63		case key == "foreground":
64			if c, ok := parseColor(val); ok {
65				t.setFG(c)
66			}
67		case strings.HasPrefix(key, "color"):
68			if n, err := strconv.Atoi(strings.TrimPrefix(key, "color")); err == nil && n >= 0 && n < 16 {
69				if c, ok := parseColor(val); ok {
70					t.setPal(n, c)
71				}
72			}
73		}
74	}
75	return t, nil
76}
77
78// ---- foot: ini, [colors] and [colors-dark], "include=file" -----------------
79
80// footLayers reads one foot file. [colors-dark] wins over the legacy [colors],
81// as in foot, and an include is merged layer by layer, as if its text were
82// pasted where the include line is.
83func footLayers(path string, depth int) (colors, dark Theme, err error) {
84	ls, err := lines(path)
85	if err != nil {
86		return colors, dark, err
87	}
88	colors.Files = append(colors.Files, path)
89	section := ""
90	for _, l := range ls {
91		if strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") {
92			continue
93		}
94		if strings.HasPrefix(l, "[") {
95			section = strings.Trim(l, "[] ")
96			continue
97		}
98		key, val, ok := strings.Cut(l, "=")
99		if !ok {
100			continue
101		}
102		key, val = strings.TrimSpace(key), strings.TrimSpace(val)
103		if key == "include" {
104			if depth < maxDepth {
105				if c, d, err := footLayers(expand(val, filepath.Dir(path)), depth+1); err == nil {
106					colors.overlay(c)
107					dark.overlay(d)
108				}
109			}
110			continue
111		}
112		var dst *Theme
113		switch section {
114		case "colors":
115			dst = &colors
116		case "colors-dark":
117			dst = &dark
118		default:
119			continue
120		}
121		val, _, _ = strings.Cut(val, " ") // a trailing "# comment"
122		c, ok := parseColor(val)
123		if !ok {
124			continue
125		}
126		switch {
127		case key == "background":
128			dst.setBG(c)
129		case key == "foreground":
130			dst.setFG(c)
131		case strings.HasPrefix(key, "regular"):
132			if n, err := strconv.Atoi(key[len("regular"):]); err == nil && n >= 0 && n < 8 {
133				dst.setPal(n, c)
134			}
135		case strings.HasPrefix(key, "bright"):
136			if n, err := strconv.Atoi(key[len("bright"):]); err == nil && n >= 0 && n < 8 {
137				dst.setPal(8+n, c)
138			}
139		}
140	}
141	return colors, dark, nil
142}
143
144func loadFoot(path string, depth int) (Theme, error) {
145	colors, dark, err := footLayers(path, depth)
146	if err != nil {
147		return Theme{}, err
148	}
149	var t Theme
150	t.overlay(colors)
151	t.overlay(dark)
152	return t, nil
153}
154
155// ---- ghostty: "key = value", "palette = N=#rrggbb", "theme = name" ---------
156
157func ghosttyDirs(config string) []string {
158	home, _ := os.UserHomeDir()
159	return []string{
160		filepath.Join(filepath.Dir(config), "themes"),
161		filepath.Join(home, ".local", "share", "ghostty", "themes"),
162		"/usr/share/ghostty/themes",
163		"/usr/local/share/ghostty/themes",
164	}
165}
166
167func loadGhostty(path string, depth int) (Theme, error) {
168	var own Theme
169	themeName := ""
170	ls, err := lines(path)
171	if err != nil {
172		return own, err
173	}
174	own.Files = append(own.Files, path)
175	for _, l := range ls {
176		if strings.HasPrefix(l, "#") {
177			continue
178		}
179		key, val, ok := strings.Cut(l, "=")
180		if !ok {
181			continue
182		}
183		key, val = strings.TrimSpace(key), strings.TrimSpace(val)
184		switch key {
185		case "theme":
186			themeName = strings.Trim(val, `"'`)
187		case "background":
188			if c, ok := parseColor(val); ok {
189				own.setBG(c)
190			}
191		case "foreground":
192			if c, ok := parseColor(val); ok {
193				own.setFG(c)
194			}
195		case "palette":
196			idx, hex, ok := strings.Cut(val, "=")
197			if !ok {
198				continue
199			}
200			if n, err := strconv.Atoi(strings.TrimSpace(idx)); err == nil && n >= 0 && n < 16 {
201				if c, ok := parseColor(hex); ok {
202					own.setPal(n, c)
203				}
204			}
205		}
206	}
207	// The named theme is applied first; the config's own colors win over it.
208	var t Theme
209	if themeName != "" {
210		if file := findGhosttyTheme(path, themeName); file != "" && depth < maxDepth {
211			if th, err := loadGhostty(file, depth+1); err == nil {
212				t.overlay(th)
213			}
214		}
215	}
216	t.overlay(own)
217	return t, nil
218}
219
220// findGhosttyTheme resolves "dark:x,light:y" to x and looks the name up.
221func findGhosttyTheme(config, name string) string {
222	for _, part := range strings.Split(name, ",") {
223		if k, v, ok := strings.Cut(part, ":"); ok {
224			if strings.TrimSpace(k) == "dark" {
225				name = strings.TrimSpace(v)
226			}
227		}
228	}
229	if k, v, ok := strings.Cut(name, ":"); ok && (k == "dark" || k == "light") {
230		name = v
231	}
232	if filepath.IsAbs(name) {
233		return name
234	}
235	for _, dir := range ghosttyDirs(config) {
236		if p := filepath.Join(dir, name); fileExists(p) {
237			return p
238		}
239	}
240	return ""
241}
242
243// ---- alacritty: toml, import = [files], [colors.*] -------------------------
244
245type alacrittyFile struct {
246	Import  []string `toml:"import"` // older configs
247	General struct {
248		Import []string `toml:"import"`
249	} `toml:"general"`
250	Colors struct {
251		Primary struct {
252			Background string `toml:"background"`
253			Foreground string `toml:"foreground"`
254		} `toml:"primary"`
255		Normal map[string]string `toml:"normal"`
256		Bright map[string]string `toml:"bright"`
257	} `toml:"colors"`
258}
259
260var alacrittyNames = [8]string{"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"}
261
262func loadAlacritty(path string, depth int) (Theme, error) {
263	var t Theme
264	var f alacrittyFile
265	if _, err := toml.DecodeFile(path, &f); err != nil {
266		return t, err
267	}
268	t.Files = append(t.Files, path)
269	// Imports come first, so the file's own colors win over them.
270	if depth < maxDepth {
271		for _, imp := range append(f.General.Import, f.Import...) {
272			if inc, err := loadAlacritty(expand(imp, filepath.Dir(path)), depth+1); err == nil {
273				t.overlay(inc)
274			}
275		}
276	}
277	if c, ok := parseColor(f.Colors.Primary.Background); ok {
278		t.setBG(c)
279	}
280	if c, ok := parseColor(f.Colors.Primary.Foreground); ok {
281		t.setFG(c)
282	}
283	for i, name := range alacrittyNames {
284		if c, ok := parseColor(f.Colors.Normal[name]); ok {
285			t.setPal(i, c)
286		}
287		if c, ok := parseColor(f.Colors.Bright[name]); ok {
288			t.setPal(8+i, c)
289		}
290	}
291	return t, nil
292}
293
294func fileExists(p string) bool {
295	st, err := os.Stat(p)
296	return err == nil && !st.IsDir()
297}

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 5

Tags 4

↑↓ moveenter openesc close