v0.2.5 16b95cb

theme_loader.go RawHistoryFolder

1package tui
2
3import (
4	"encoding/json"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"sort"
10	"strings"
11	"time"
12)
13
14// ThemeOption is one selectable theme discovered under $ZOT_HOME/themes.
15// Value is stored in config.json.
16type ThemeOption struct {
17	Value       string
18	Label       string
19	Description string
20	Path        string
21	Builtin     bool
22}
23
24// ThemeFile is the user-editable JSON shape loaded from
25// $ZOT_HOME/themes/*.json. It carries metadata plus separate overrides
26// for dark and light terminals.
27type ThemeFile struct {
28	Name        string              `json:"name"`
29	Description string              `json:"description"`
30	Colors      ThemeFileColorModes `json:"colors"`
31	Overrides   ThemeOverrides      `json:"-"`
32}
33
34func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
35	type alias ThemeFile
36	var a alias
37	if err := json.Unmarshal(data, &a); err != nil {
38		return err
39	}
40	// Allow a tiny theme file with overrides at the top level, e.g.
41	// {"spinner_frames":[".","o"],"spinner_messages":["working"]}.
42	// Metadata fields are ignored by ThemeOverrides because they do not
43	// have matching json tags.
44	_ = json.Unmarshal(data, &a.Overrides)
45	*tf = ThemeFile(a)
46	return nil
47}
48
49type ThemeFileColorModes struct {
50	Base     ThemeOverrides `json:"-"`
51	Dark     ThemeOverrides `json:"dark"`
52	Light    ThemeOverrides `json:"light"`
53	HasDark  bool           `json:"-"`
54	HasLight bool           `json:"-"`
55}
56
57func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
58	var raw map[string]json.RawMessage
59	if err := json.Unmarshal(data, &raw); err != nil {
60		return err
61	}
62	if v, ok := raw["dark"]; ok {
63		m.HasDark = true
64		_ = json.Unmarshal(v, &m.Dark)
65	}
66	if v, ok := raw["light"]; ok {
67		m.HasLight = true
68		_ = json.Unmarshal(v, &m.Light)
69	}
70	// Also allow colors to directly contain overrides shared by both
71	// modes, e.g. {"colors":{"accent":204}}.
72	var base ThemeOverrides
73	_ = json.Unmarshal(data, &base)
74	m.Base = base
75	return nil
76}
77
78// ThemeOverrides is intentionally pointer-based so a theme file can
79// override only the colors it cares about and inherit the built-in
80// dark/light defaults for everything else.
81type ThemeOverrides struct {
82	FG                *int                 `json:"fg,omitempty"`
83	Muted             *int                 `json:"muted,omitempty"`
84	Accent            *int                 `json:"accent,omitempty"`
85	User              *int                 `json:"user,omitempty"`
86	UserBubbleBG      *TerminalColorValue  `json:"user_bubble_bg,omitempty"`
87	UserBubbleFG      *int                 `json:"user_bubble_fg,omitempty"`
88	Assistant         *int                 `json:"assistant,omitempty"`
89	Tool              *int                 `json:"tool,omitempty"`
90	ToolOut           *int                 `json:"tool_out,omitempty"`
91	Error             *int                 `json:"error,omitempty"`
92	Warning           *int                 `json:"warning,omitempty"`
93	Spinner           *int                 `json:"spinner,omitempty"`
94	SelectionBG       *int                 `json:"selection_bg,omitempty"`
95	SelectionFG       *int                 `json:"selection_fg,omitempty"`
96	SpinnerFrames     []string             `json:"spinner_frames,omitempty"`
97	SpinnerMessages   []string             `json:"spinner_messages,omitempty"`
98	SpinnerIntervalMS *int                 `json:"spinner_interval_ms,omitempty"`
99	SyntaxBaseStyle   *string              `json:"syntax_base_style,omitempty"`
100	Syntax            SyntaxThemeOverrides `json:"syntax,omitempty"`
101}
102
103type SyntaxThemeOverrides struct {
104	Keyword             *string `json:"keyword,omitempty"`
105	KeywordConstant     *string `json:"keyword_constant,omitempty"`
106	KeywordDeclaration  *string `json:"keyword_declaration,omitempty"`
107	KeywordNamespace    *string `json:"keyword_namespace,omitempty"`
108	KeywordReserved     *string `json:"keyword_reserved,omitempty"`
109	KeywordType         *string `json:"keyword_type,omitempty"`
110	NameBuiltin         *string `json:"name_builtin,omitempty"`
111	NameFunction        *string `json:"name_function,omitempty"`
112	NameClass           *string `json:"name_class,omitempty"`
113	NameDecorator       *string `json:"name_decorator,omitempty"`
114	LiteralString       *string `json:"literal_string,omitempty"`
115	LiteralStringEscape *string `json:"literal_string_escape,omitempty"`
116	LiteralNumber       *string `json:"literal_number,omitempty"`
117	Comment             *string `json:"comment,omitempty"`
118	CommentPreproc      *string `json:"comment_preproc,omitempty"`
119	Operator            *string `json:"operator,omitempty"`
120	Punctuation         *string `json:"punctuation,omitempty"`
121	Text                *string `json:"text,omitempty"`
122}
123
124// TerminalColorValue accepts any of these JSON forms:
125//
126//	24                         // xterm-256 color index
127//	"#42454b"                  // RGB hex
128//	{"mode":"ansi","index":100}
129//	{"mode":"rgb","r":66,"g":69,"b":75}
130//	{"mode":"256","index":254}
131type TerminalColorValue struct {
132	TerminalColor
133}
134
135func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
136	var index int
137	if err := json.Unmarshal(data, &index); err == nil {
138		c.TerminalColor = Color256(index)
139		return nil
140	}
141	var s string
142	if err := json.Unmarshal(data, &s); err == nil {
143		if rgb, ok := parseHexColor(s); ok {
144			c.TerminalColor = rgb
145			return nil
146		}
147		return fmt.Errorf("invalid terminal color %q", s)
148	}
149	var obj struct {
150		Mode  string `json:"mode"`
151		Index int    `json:"index"`
152		R     int    `json:"r"`
153		G     int    `json:"g"`
154		B     int    `json:"b"`
155	}
156	if err := json.Unmarshal(data, &obj); err != nil {
157		return err
158	}
159	switch strings.ToLower(obj.Mode) {
160	case "", "256", "color256", "xterm256":
161		c.TerminalColor = Color256(obj.Index)
162	case "ansi":
163		c.TerminalColor = ColorANSI(obj.Index)
164	case "rgb", "truecolor":
165		c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B)
166	default:
167		return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
168	}
169	return nil
170}
171
172// LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
173// to detected. Empty/auto/default keeps the built-in detected theme.
174// If preferred is set, it may be a theme name, a basename without
175// .json, or an absolute/relative path.
176func DetectThemeWithCustom(zotHome, preferred string, timeout time.Duration) (Theme, string, error) {
177	detected := DetectThemeFromBackground(timeout)
178	return LoadThemeFromHome(zotHome, preferred, detected)
179}
180
181func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string, error) {
182	path, err := resolveThemePath(zotHome, preferred)
183	if err != nil || path == "" {
184		return detected, "", err
185	}
186	switch path {
187	case "builtin:dark":
188		return Dark, "dark", nil
189	case "builtin:light":
190		return Light, "light", nil
191	}
192	b, err := os.ReadFile(path)
193	if err != nil {
194		return detected, "", err
195	}
196	var tf ThemeFile
197	if err := json.Unmarshal(b, &tf); err != nil {
198		return detected, "", fmt.Errorf("parse theme %s: %w", path, err)
199	}
200	if tf.Name == "" {
201		tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
202	}
203	base := applyThemeOverrides(detected, tf.Overrides)
204	base = applyThemeOverrides(base, tf.Colors.Base)
205	if isLightTheme(detected) {
206		if tf.Colors.HasLight {
207			base = applyThemeOverrides(base, tf.Colors.Light)
208		} else if tf.Colors.HasDark {
209			base = applyThemeOverrides(base, tf.Colors.Dark)
210		}
211	} else {
212		if tf.Colors.HasDark {
213			base = applyThemeOverrides(base, tf.Colors.Dark)
214		} else if tf.Colors.HasLight {
215			base = applyThemeOverrides(base, tf.Colors.Light)
216		}
217	}
218	return base, tf.Name, nil
219}
220
221// AvailableThemes returns built-in and user-installed themes suitable
222// for a settings picker. Invalid JSON files are skipped.
223func AvailableThemes(zotHome string) []ThemeOption {
224	out := []ThemeOption{
225		{Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true},
226		{Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
227		{Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
228	}
229	seen := map[string]bool{"auto": true, "dark": true, "light": true}
230	paths, _ := themeFilesIn(filepath.Join(zotHome, "themes"))
231	sort.Strings(paths)
232	for _, path := range paths {
233		b, err := os.ReadFile(path)
234		if err != nil {
235			continue
236		}
237		var tf ThemeFile
238		if err := json.Unmarshal(b, &tf); err != nil {
239			continue
240		}
241		value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
242		if value == "" || seen[value] {
243			continue
244		}
245		desc := tf.Description
246		if desc == "" {
247			desc = path
248		}
249		out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path})
250		seen[value] = true
251	}
252	return out
253}
254
255// ThemeOptionFromFile parses one theme JSON file for picker display.
256// value is what will be stored in config; pass an absolute path for
257// extension-owned themes so they can be loaded without copying into
258// $ZOT_HOME/themes.
259func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) {
260	b, err := os.ReadFile(path)
261	if err != nil {
262		return ThemeOption{}, false
263	}
264	var tf ThemeFile
265	if err := json.Unmarshal(b, &tf); err != nil {
266		return ThemeOption{}, false
267	}
268	if value == "" {
269		value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
270	}
271	label := value
272	if tf.Name != "" {
273		label = tf.Name
274	}
275	desc := tf.Description
276	if source != "" {
277		if desc != "" {
278			desc = "from " + source + " — " + desc
279		} else {
280			desc = "from " + source
281		}
282	}
283	if desc == "" {
284		desc = path
285	}
286	return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true
287}
288
289func ThemeExists(zotHome, preferred string) bool {
290	path, err := resolveThemePath(zotHome, preferred)
291	return err == nil && path != ""
292}
293
294func resolveThemePath(zotHome, preferred string) (string, error) {
295	preferred = strings.TrimSpace(preferred)
296	switch strings.ToLower(preferred) {
297	case "", "auto", "default", "system":
298		return "", nil
299	case "dark":
300		return "builtin:dark", nil
301	case "light":
302		return "builtin:light", nil
303	}
304	if preferred != "" && !strings.HasPrefix(preferred, "builtin:") {
305		candidates := []string{preferred}
306		if filepath.Ext(preferred) == "" {
307			candidates = append(candidates,
308				filepath.Join(zotHome, "themes", preferred+".json"),
309			)
310		} else if !filepath.IsAbs(preferred) {
311			candidates = append(candidates,
312				filepath.Join(zotHome, "themes", preferred),
313			)
314		}
315		for _, c := range candidates {
316			if _, err := os.Stat(c); err == nil {
317				return c, nil
318			}
319		}
320		return "", fmt.Errorf("theme %q not found", preferred)
321	}
322
323	return "", nil
324}
325
326func themeFilesIn(dir string) ([]string, error) {
327	entries, err := os.ReadDir(dir)
328	if errors.Is(err, os.ErrNotExist) {
329		return nil, nil
330	}
331	if err != nil {
332		return nil, err
333	}
334	var out []string
335	for _, e := range entries {
336		if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" {
337			continue
338		}
339		out = append(out, filepath.Join(dir, e.Name()))
340	}
341	return out, nil
342}
343
344func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
345	if o.FG != nil {
346		th.FG = *o.FG
347	}
348	if o.Muted != nil {
349		th.Muted = *o.Muted
350	}
351	if o.Accent != nil {
352		th.Accent = *o.Accent
353	}
354	if o.User != nil {
355		th.User = *o.User
356	}
357	if o.UserBubbleBG != nil {
358		th.UserBubbleBG = o.UserBubbleBG.TerminalColor
359	}
360	if o.UserBubbleFG != nil {
361		th.UserBubbleFG = *o.UserBubbleFG
362	}
363	if o.Assistant != nil {
364		th.Assistant = *o.Assistant
365	}
366	if o.Tool != nil {
367		th.Tool = *o.Tool
368	}
369	if o.ToolOut != nil {
370		th.ToolOut = *o.ToolOut
371	}
372	if o.Error != nil {
373		th.Error = *o.Error
374	}
375	if o.Warning != nil {
376		th.Warning = *o.Warning
377	}
378	if o.Spinner != nil {
379		th.Spinner = *o.Spinner
380	}
381	if o.SelectionBG != nil {
382		th.SelectionBG = *o.SelectionBG
383	}
384	if o.SelectionFG != nil {
385		th.SelectionFG = *o.SelectionFG
386	}
387	if len(o.SpinnerFrames) > 0 {
388		th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
389	}
390	if len(o.SpinnerMessages) > 0 {
391		th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...)
392	}
393	if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
394		th.SpinnerIntervalMS = *o.SpinnerIntervalMS
395	}
396	if o.SyntaxBaseStyle != nil {
397		th.SyntaxBaseStyle = *o.SyntaxBaseStyle
398	}
399	th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax)
400	return th
401}
402
403func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
404	if o.Keyword != nil {
405		s.Keyword = *o.Keyword
406	}
407	if o.KeywordConstant != nil {
408		s.KeywordConstant = *o.KeywordConstant
409	}
410	if o.KeywordDeclaration != nil {
411		s.KeywordDeclaration = *o.KeywordDeclaration
412	}
413	if o.KeywordNamespace != nil {
414		s.KeywordNamespace = *o.KeywordNamespace
415	}
416	if o.KeywordReserved != nil {
417		s.KeywordReserved = *o.KeywordReserved
418	}
419	if o.KeywordType != nil {
420		s.KeywordType = *o.KeywordType
421	}
422	if o.NameBuiltin != nil {
423		s.NameBuiltin = *o.NameBuiltin
424	}
425	if o.NameFunction != nil {
426		s.NameFunction = *o.NameFunction
427	}
428	if o.NameClass != nil {
429		s.NameClass = *o.NameClass
430	}
431	if o.NameDecorator != nil {
432		s.NameDecorator = *o.NameDecorator
433	}
434	if o.LiteralString != nil {
435		s.LiteralString = *o.LiteralString
436	}
437	if o.LiteralStringEscape != nil {
438		s.LiteralStringEscape = *o.LiteralStringEscape
439	}
440	if o.LiteralNumber != nil {
441		s.LiteralNumber = *o.LiteralNumber
442	}
443	if o.Comment != nil {
444		s.Comment = *o.Comment
445	}
446	if o.CommentPreproc != nil {
447		s.CommentPreproc = *o.CommentPreproc
448	}
449	if o.Operator != nil {
450		s.Operator = *o.Operator
451	}
452	if o.Punctuation != nil {
453		s.Punctuation = *o.Punctuation
454	}
455	if o.Text != nil {
456		s.Text = *o.Text
457	}
458	return s
459}
460
461func IsLightTheme(th Theme) bool {
462	return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG
463}
464
465func isLightTheme(th Theme) bool { return IsLightTheme(th) }
466
467func parseHexColor(s string) (TerminalColor, bool) {
468	s = strings.TrimPrefix(strings.TrimSpace(s), "#")
469	if len(s) != 6 {
470		return TerminalColor{}, false
471	}
472	var rgb [3]int
473	for i := 0; i < 3; i++ {
474		v, ok := parseHexByte(s[i*2 : i*2+2])
475		if !ok {
476			return TerminalColor{}, false
477		}
478		rgb[i] = v
479	}
480	return ColorRGB(rgb[0], rgb[1], rgb[2]), true
481}
482
483func parseHexByte(s string) (int, bool) {
484	var n int
485	for _, r := range s {
486		n *= 16
487		switch {
488		case r >= '0' && r <= '9':
489			n += int(r - '0')
490		case r >= 'a' && r <= 'f':
491			n += int(r-'a') + 10
492		case r >= 'A' && r <= 'F':
493			n += int(r-'A') + 10
494		default:
495			return 0, false
496		}
497	}
498	return n, true
499}

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 477

↑↓ moveenter openesc close