v0.3.0 da0887c

theme.go RawHistoryFolder

1// Package theme reads a color theme: the 16 ANSI colors plus a background and
2// a foreground. It understands the config files of kitty, foot, ghostty and
3// alacritty, so the bar can look like the terminal you use.
4package theme
5
6import (
7	"fmt"
8	"os"
9	"path/filepath"
10	"regexp"
11	"strconv"
12	"strings"
13)
14
15// RGB is a color.
16type RGB = [3]uint8
17
18// Theme is a set of colors. A color a source does not set keeps the value of
19// the theme it was filled from (see Default and Fill).
20type Theme struct {
21	Background, Foreground RGB
22	Palette                [16]RGB
23	// Source says where the colors came from, for logs.
24	Source string
25	// Files lists every file that was read, includes and named themes
26	// included, so a caller can watch them for changes.
27	Files []string
28
29	hasBG, hasFG bool
30	hasPal       [16]bool
31}
32
33// Default is the built-in dark theme, used for any color a source leaves out.
34func Default() Theme {
35	return Theme{
36		Background: RGB{0x1e, 0x1e, 0x2e}, Foreground: RGB{0xcd, 0xd6, 0xf4},
37		Palette: [16]RGB{
38			{0x45, 0x47, 0x5a}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
39			{0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xba, 0xc2, 0xde},
40			{0x58, 0x5b, 0x70}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
41			{0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xa6, 0xad, 0xc8},
42		},
43		Source: "built-in",
44	}
45}
46
47// Fill returns t with every color it does not define taken from def.
48func (t Theme) Fill(def Theme) Theme {
49	if !t.hasBG {
50		t.Background = def.Background
51	}
52	if !t.hasFG {
53		t.Foreground = def.Foreground
54	}
55	for i := range t.Palette {
56		if !t.hasPal[i] {
57			t.Palette[i] = def.Palette[i]
58		}
59	}
60	return t
61}
62
63// dedupFiles removes repeated entries, keeping the first of each.
64func dedupFiles(files []string) []string {
65	seen := make(map[string]bool, len(files))
66	out := files[:0:0]
67	for _, f := range files {
68		if !seen[f] {
69			seen[f] = true
70			out = append(out, f)
71		}
72	}
73	return out
74}
75
76// Defines reports how many of the 18 colors the source set.
77func (t Theme) Defines() int {
78	n := 0
79	if t.hasBG {
80		n++
81	}
82	if t.hasFG {
83		n++
84	}
85	for _, h := range t.hasPal {
86		if h {
87			n++
88		}
89	}
90	return n
91}
92
93func (t *Theme) setBG(c RGB)         { t.Background, t.hasBG = c, true }
94func (t *Theme) setFG(c RGB)         { t.Foreground, t.hasFG = c, true }
95func (t *Theme) setPal(i int, c RGB) { t.Palette[i], t.hasPal[i] = c, true }
96
97// overlay applies every color src defines on top of t.
98func (t *Theme) overlay(src Theme) {
99	t.Files = append(t.Files, src.Files...)
100	if src.hasBG {
101		t.setBG(src.Background)
102	}
103	if src.hasFG {
104		t.setFG(src.Foreground)
105	}
106	for i, h := range src.hasPal {
107		if h {
108			t.setPal(i, src.Palette[i])
109		}
110	}
111}
112
113var hexRe = regexp.MustCompile(`^(?:#|0x|0X)?([0-9a-fA-F]{6})$`)
114
115// parseColor accepts #rrggbb, rrggbb and 0xrrggbb, with optional quotes.
116func parseColor(s string) (RGB, bool) {
117	s = strings.Trim(strings.TrimSpace(s), `"'`)
118	m := hexRe.FindStringSubmatch(s)
119	if m == nil {
120		return RGB{}, false
121	}
122	v, _ := strconv.ParseUint(m[1], 16, 32)
123	return RGB{uint8(v >> 16), uint8(v >> 8), uint8(v)}, true
124}
125
126// expand resolves a leading ~ and, for a relative path, joins it to dir.
127func expand(p, dir string) string {
128	p = strings.Trim(strings.TrimSpace(p), `"'`)
129	if p == "~" || strings.HasPrefix(p, "~/") {
130		home, _ := os.UserHomeDir()
131		p = filepath.Join(home, strings.TrimPrefix(p, "~"))
132	}
133	if !filepath.IsAbs(p) {
134		p = filepath.Join(dir, p)
135	}
136	return p
137}
138
139// maxDepth bounds include chains, so a loop cannot hang the bar.
140const maxDepth = 8
141
142// Load reads the theme file at path. The format is recognized from the file:
143// a .toml file is alacritty, a file with "palette = N=#rrggbb" or "theme =" lines is
144// ghostty, one with a [colors] section is foot, and anything else is kitty.
145func Load(path string) (Theme, error) {
146	f, err := detectFormat(path)
147	if err != nil {
148		return Theme{}, err
149	}
150	t, err := f.load(path)
151	if err != nil {
152		return Theme{}, err
153	}
154	if t.Defines() == 0 {
155		return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)
156	}
157	t.Source = fmt.Sprintf("%s (%s)", path, f.name)
158	t.Files = dedupFiles(t.Files)
159	return t, nil
160}
161
162var (
163	ghosttyHint = regexp.MustCompile(`(?m)^\s*(palette\s*=\s*\d+\s*=|theme\s*=)`)
164	footHint    = regexp.MustCompile(`(?m)^\s*\[colors`)
165)
166
167func detectFormat(path string) (format, error) {
168	data, err := os.ReadFile(path)
169	if err != nil {
170		return format{}, fmt.Errorf("theme: %w", err)
171	}
172	switch {
173	case strings.HasSuffix(path, ".toml"):
174		return formats["alacritty"], nil
175	case ghosttyHint.Match(data):
176		return formats["ghostty"], nil
177	case footHint.Match(data):
178		return formats["foot"], nil
179	}
180	return formats["kitty"], nil
181}
182
183type format struct {
184	name string
185	load func(path string) (Theme, error)
186}
187
188var formats = map[string]format{}
189
190func init() {
191	formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}
192	formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}
193	formats["ghostty"] = format{"ghostty", func(p string) (Theme, error) { return loadGhostty(p, 0) }}
194	formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}
195}
196
197// Resolve turns the bar.theme setting into a complete theme. spec is "auto"
198// (the terminal in use) or the path of a theme or config file; a relative
199// path is taken from baseDir. Colors the source leaves out come from Default.
200func Resolve(spec, baseDir string, env Env) (Theme, error) {
201	def := Default()
202	var t Theme
203	var err error
204	if spec == "auto" {
205		t, err = Auto(env)
206	} else {
207		t, err = Load(expand(spec, baseDir))
208	}
209	if err != nil {
210		return def, err
211	}
212	t = t.Fill(def)
213	t.Files = dedupFiles(t.Files)
214	return t, nil
215}
216
217// Mix returns a blended with b: t=0 is a, t=1 is b.
218func Mix(a, b RGB, t float64) RGB {
219	var o RGB
220	for i := range o {
221		o[i] = uint8(float64(a[i]) + (float64(b[i])-float64(a[i]))*t + 0.5)
222	}
223	return o
224}
225
226// Hex formats a color as #rrggbb.
227func Hex(c RGB) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
228
229// EnvVars returns the colors as environment entries for scripts:
230// NEFERBAR_BACKGROUND, NEFERBAR_FOREGROUND, NEFERBAR_ACCENT and
231// NEFERBAR_COLOR0..15, each #rrggbb. bg and fg are the colors the bar really
232// uses; accent is the number of the palette color chosen for highlights.
233func (t Theme) EnvVars(bg, fg RGB, accent int) []string {
234	out := []string{"NEFERBAR_BACKGROUND=" + Hex(bg), "NEFERBAR_FOREGROUND=" + Hex(fg),
235		"NEFERBAR_ACCENT=" + Hex(t.Palette[accent&15])}
236	for i, c := range t.Palette {
237		out = append(out, fmt.Sprintf("NEFERBAR_COLOR%d=%s", i, Hex(c)))
238	}
239	return out
240}
241
242// Equal reports whether two themes have the same colors.
243func (t Theme) Equal(o Theme) bool {
244	return t.Background == o.Background && t.Foreground == o.Foreground && t.Palette == o.Palette
245}

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