v0.2.61 15563ff

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

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