v0.3.84 97ae0a2

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

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