v0.7.0 3110ba7

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 $ZUT_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// $ZUT_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_interval_ms":120}.
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                *TerminalColorValue  `json:"fg,omitempty"`
83	Muted             *TerminalColorValue  `json:"muted,omitempty"`
84	Accent            *TerminalColorValue  `json:"accent,omitempty"`
85	Background        *TerminalColorValue  `json:"background,omitempty"`
86	User              *TerminalColorValue  `json:"user,omitempty"`
87	UserBubbleBG      *TerminalColorValue  `json:"user_bubble_bg,omitempty"`
88	UserBubbleFG      *TerminalColorValue  `json:"user_bubble_fg,omitempty"`
89	Assistant         *TerminalColorValue  `json:"assistant,omitempty"`
90	Tool              *TerminalColorValue  `json:"tool,omitempty"`
91	ToolOut           *TerminalColorValue  `json:"tool_out,omitempty"`
92	Error             *TerminalColorValue  `json:"error,omitempty"`
93	Warning           *TerminalColorValue  `json:"warning,omitempty"`
94	Spinner           *TerminalColorValue  `json:"spinner,omitempty"`
95	ThinkingMax       *TerminalColorValue  `json:"thinking_max,omitempty"`
96	ThinkingMaxCamel  *TerminalColorValue  `json:"thinkingMax,omitempty"`
97	SelectionBG       *TerminalColorValue  `json:"selection_bg,omitempty"`
98	SelectionFG       *TerminalColorValue  `json:"selection_fg,omitempty"`
99	SpinnerFrames     []string             `json:"spinner_frames,omitempty"`
100	SpinnerIntervalMS *int                 `json:"spinner_interval_ms,omitempty"`
101	SyntaxBaseStyle   *string              `json:"syntax_base_style,omitempty"`
102	Syntax            SyntaxThemeOverrides `json:"syntax,omitempty"`
103}
104
105type SyntaxThemeOverrides struct {
106	Keyword             *string `json:"keyword,omitempty"`
107	KeywordConstant     *string `json:"keyword_constant,omitempty"`
108	KeywordDeclaration  *string `json:"keyword_declaration,omitempty"`
109	KeywordNamespace    *string `json:"keyword_namespace,omitempty"`
110	KeywordReserved     *string `json:"keyword_reserved,omitempty"`
111	KeywordType         *string `json:"keyword_type,omitempty"`
112	NameBuiltin         *string `json:"name_builtin,omitempty"`
113	NameFunction        *string `json:"name_function,omitempty"`
114	NameClass           *string `json:"name_class,omitempty"`
115	NameDecorator       *string `json:"name_decorator,omitempty"`
116	LiteralString       *string `json:"literal_string,omitempty"`
117	LiteralStringEscape *string `json:"literal_string_escape,omitempty"`
118	LiteralNumber       *string `json:"literal_number,omitempty"`
119	Comment             *string `json:"comment,omitempty"`
120	CommentPreproc      *string `json:"comment_preproc,omitempty"`
121	Operator            *string `json:"operator,omitempty"`
122	Punctuation         *string `json:"punctuation,omitempty"`
123	Text                *string `json:"text,omitempty"`
124}
125
126// TerminalColorValue accepts any of these JSON forms:
127//
128//	24                         // xterm-256 color index
129//	"#42454b"                  // RGB hex
130//	{"mode":"ansi","index":100}
131//	{"mode":"rgb","r":66,"g":69,"b":75}
132//	{"mode":"256","index":254}
133type TerminalColorValue struct {
134	TerminalColor
135}
136
137func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
138	var index int
139	if err := json.Unmarshal(data, &index); err == nil {
140		c.TerminalColor = Color256(index)
141		return nil
142	}
143	var s string
144	if err := json.Unmarshal(data, &s); err == nil {
145		if rgb, ok := parseHexColor(s); ok {
146			c.TerminalColor = rgb
147			return nil
148		}
149		return fmt.Errorf("invalid terminal color %q", s)
150	}
151	var obj struct {
152		Mode  string `json:"mode"`
153		Index int    `json:"index"`
154		R     int    `json:"r"`
155		G     int    `json:"g"`
156		B     int    `json:"b"`
157	}
158	if err := json.Unmarshal(data, &obj); err != nil {
159		return err
160	}
161	switch strings.ToLower(obj.Mode) {
162	case "", "256", "color256", "xterm256":
163		c.TerminalColor = Color256(obj.Index)
164	case "ansi":
165		c.TerminalColor = ColorANSI(obj.Index)
166	case "rgb", "truecolor":
167		c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B)
168	default:
169		return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
170	}
171	return nil
172}
173
174// InheritedTheme returns a theme derived from the terminal snapshot carried
175// by detected. It prefers the terminal's reported ANSI palette for semantic
176// accents, derives muted and selected surfaces by fading toward the terminal
177// background, and uses truecolor output only when the terminal advertises it.
178func InheritedTheme(detected Theme) Theme {
179	base := Dark
180	if detected.Terminal.SchemeKnown {
181		if detected.Terminal.Light {
182			base = Light
183		}
184	} else if IsLightTheme(detected) {
185		base = Light
186	}
187	t := withTerminalProfile(base, detected)
188	t.Inherited = true
189
190	if t.Terminal.HasForeground {
191		t.FG = t.Terminal.Foreground
192	}
193
194	accentFallback := t.Accent
195	if IsLightTheme(t) {
196		accentFallback = Light.Accent
197	}
198	t.Accent = terminalPaletteColor(t.Terminal, accentFallback,
199		ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta)
200	t.Assistant = t.Accent
201	t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen)
202	t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed)
203	t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow)
204	t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta)
205	t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta)
206	t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow)
207
208	if t.Terminal.HasForeground && t.Terminal.HasBackground {
209		t.Muted = t.DimColor(t.FG, 45)
210		t.ToolOut = t.Muted
211		background := t.Terminal.Background
212		foreground := t.Terminal.Foreground
213		t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
214		t.SelectionBG = blendTerminalColors(background, foreground, 25)
215	}
216
217	t.UserBubbleFG = t.FG
218	t.SelectionFG = t.FG
219
220	// Keep the terminal's configured background untouched. Painting an
221	// inherited RGB background across every row would make terminal theme
222	// changes and scrollback behavior less predictable.
223	t.Background = nil
224	return t
225}
226
227func withTerminalProfile(base, detected Theme) Theme {
228	base.Terminal = detected.Terminal
229	return base
230}
231
232// Standard ANSI palette slots used as inherited role preferences.
233const (
234	ansiRed           = 1
235	ansiGreen         = 2
236	ansiYellow        = 3
237	ansiBlue          = 4
238	ansiMagenta       = 5
239	ansiCyan          = 6
240	ansiBrightRed     = 9
241	ansiBrightGreen   = 10
242	ansiBrightYellow  = 11
243	ansiBrightBlue    = 12
244	ansiBrightMagenta = 13
245	ansiBrightCyan    = 14
246)
247
248func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor {
249	for _, index := range preferred {
250		if _, ok := profile.PaletteColor(index); ok {
251			// Keep the palette slot as the semantic value. Truecolor
252			// resolution expands it back to the terminal-reported RGB;
253			// 256-color output preserves the slot rather than re-quantizing
254			// the reported RGB to a nearby cube entry.
255			return Color256(index)
256		}
257	}
258	return fallback
259}
260
261func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
262	fromRGB, _ := rgbForTerminalColor(from)
263	toRGB, _ := rgbForTerminalColor(to)
264	percent = clampPercent(percent)
265	return ColorRGB(
266		blendChannel(fromRGB[0], toRGB[0], percent),
267		blendChannel(fromRGB[1], toRGB[1], percent),
268		blendChannel(fromRGB[2], toRGB[2], percent),
269	)
270}
271
272// LoadThemeFromHome applies a custom theme from $ZUT_HOME/themes/*.json
273// to detected. Empty/auto/default keeps the built-in detected theme.
274// If preferred is set, it may be a theme name, a basename without
275// .json, or an absolute/relative path.
276func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
277	detected := DetectThemeFromBackground(timeout)
278	return LoadThemeFromHome(zutHome, preferred, detected)
279}
280
281func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
282	path, err := resolveThemePath(zutHome, preferred)
283	if err != nil || path == "" {
284		return detected, "", err
285	}
286	switch path {
287	case "builtin:inherited":
288		return InheritedTheme(detected), "inherited", nil
289	case "builtin:dark":
290		return withTerminalProfile(Dark, detected), "dark", nil
291	case "builtin:light":
292		return withTerminalProfile(Light, detected), "light", nil
293	}
294	b, err := os.ReadFile(path)
295	if err != nil {
296		return detected, "", err
297	}
298	var tf ThemeFile
299	if err := json.Unmarshal(b, &tf); err != nil {
300		return detected, "", fmt.Errorf("parse theme %s: %w", path, err)
301	}
302	if tf.Name == "" {
303		tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
304	}
305	base := applyThemeOverrides(detected, tf.Overrides)
306	base = applyThemeOverrides(base, tf.Colors.Base)
307	if isLightTheme(detected) {
308		if tf.Colors.HasLight {
309			base = applyThemeOverrides(base, tf.Colors.Light)
310		} else if tf.Colors.HasDark {
311			base = applyThemeOverrides(base, tf.Colors.Dark)
312		}
313	} else {
314		if tf.Colors.HasDark {
315			base = applyThemeOverrides(base, tf.Colors.Dark)
316		} else if tf.Colors.HasLight {
317			base = applyThemeOverrides(base, tf.Colors.Light)
318		}
319	}
320	return base, tf.Name, nil
321}
322
323// AvailableThemes returns built-in and user-installed themes suitable
324// for a settings picker. Invalid JSON files are skipped.
325func AvailableThemes(zutHome string) []ThemeOption {
326	out := []ThemeOption{
327		{Value: "auto", Label: "auto", Description: "detect terminal background and use zut defaults", Builtin: true},
328		{Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true},
329		{Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
330		{Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
331	}
332	seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}
333	paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
334	sort.Strings(paths)
335	for _, path := range paths {
336		b, err := os.ReadFile(path)
337		if err != nil {
338			continue
339		}
340		var tf ThemeFile
341		if err := json.Unmarshal(b, &tf); err != nil {
342			continue
343		}
344		value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
345		if value == "" || seen[value] {
346			continue
347		}
348		desc := tf.Description
349		if desc == "" {
350			desc = path
351		}
352		out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path})
353		seen[value] = true
354	}
355	return out
356}
357
358// ThemeOptionFromFile parses one theme JSON file for picker display.
359// value is what will be stored in config; pass an absolute path for
360// extension-owned themes so they can be loaded without copying into
361// $ZUT_HOME/themes.
362func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) {
363	b, err := os.ReadFile(path)
364	if err != nil {
365		return ThemeOption{}, false
366	}
367	var tf ThemeFile
368	if err := json.Unmarshal(b, &tf); err != nil {
369		return ThemeOption{}, false
370	}
371	if value == "" {
372		value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
373	}
374	label := value
375	if tf.Name != "" {
376		label = tf.Name
377	}
378	desc := tf.Description
379	if source != "" {
380		if desc != "" {
381			desc = "from " + source + " — " + desc
382		} else {
383			desc = "from " + source
384		}
385	}
386	if desc == "" {
387		desc = path
388	}
389	return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true
390}
391
392func ThemeExists(zutHome, preferred string) bool {
393	path, err := resolveThemePath(zutHome, preferred)
394	return err == nil && path != ""
395}
396
397func resolveThemePath(zutHome, preferred string) (string, error) {
398	preferred = strings.TrimSpace(preferred)
399	switch strings.ToLower(preferred) {
400	case "", "auto", "default", "system":
401		return "", nil
402	case "inherited", "terminal", "terminal-inherited":
403		return "builtin:inherited", nil
404	case "dark":
405		return "builtin:dark", nil
406	case "light":
407		return "builtin:light", nil
408	}
409	if preferred != "" && !strings.HasPrefix(preferred, "builtin:") {
410		candidates := []string{preferred}
411		if filepath.Ext(preferred) == "" {
412			candidates = append(candidates,
413				filepath.Join(zutHome, "themes", preferred+".json"),
414			)
415		} else if !filepath.IsAbs(preferred) {
416			candidates = append(candidates,
417				filepath.Join(zutHome, "themes", preferred),
418			)
419		}
420		for _, c := range candidates {
421			if _, err := os.Stat(c); err == nil {
422				return c, nil
423			}
424		}
425		return "", fmt.Errorf("theme %q not found", preferred)
426	}
427
428	return "", nil
429}
430
431func themeFilesIn(dir string) ([]string, error) {
432	entries, err := os.ReadDir(dir)
433	if errors.Is(err, os.ErrNotExist) {
434		return nil, nil
435	}
436	if err != nil {
437		return nil, err
438	}
439	var out []string
440	for _, e := range entries {
441		if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" {
442			continue
443		}
444		out = append(out, filepath.Join(dir, e.Name()))
445	}
446	return out, nil
447}
448
449func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
450	if o.FG != nil {
451		th.FG = o.FG.TerminalColor
452	}
453	if o.Muted != nil {
454		th.Muted = o.Muted.TerminalColor
455	}
456	if o.Accent != nil {
457		th.Accent = o.Accent.TerminalColor
458	}
459	if o.Background != nil {
460		bg := o.Background.TerminalColor
461		th.Background = &bg
462	}
463	if o.User != nil {
464		th.User = o.User.TerminalColor
465	}
466	if o.UserBubbleBG != nil {
467		th.UserBubbleBG = o.UserBubbleBG.TerminalColor
468	}
469	if o.UserBubbleFG != nil {
470		th.UserBubbleFG = o.UserBubbleFG.TerminalColor
471	}
472	if o.Assistant != nil {
473		th.Assistant = o.Assistant.TerminalColor
474	}
475	if o.Tool != nil {
476		th.Tool = o.Tool.TerminalColor
477	}
478	if o.ToolOut != nil {
479		th.ToolOut = o.ToolOut.TerminalColor
480	}
481	if o.Error != nil {
482		th.Error = o.Error.TerminalColor
483	}
484	if o.Warning != nil {
485		th.Warning = o.Warning.TerminalColor
486	}
487	if o.Spinner != nil {
488		th.Spinner = o.Spinner.TerminalColor
489	}
490	if o.ThinkingMax != nil {
491		th.ThinkingMax = o.ThinkingMax.TerminalColor
492	} else if o.ThinkingMaxCamel != nil {
493		th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
494	}
495	if o.SelectionBG != nil {
496		th.SelectionBG = o.SelectionBG.TerminalColor
497	}
498	if o.SelectionFG != nil {
499		th.SelectionFG = o.SelectionFG.TerminalColor
500	}
501	if len(o.SpinnerFrames) > 0 {
502		th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
503	}
504	if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
505		th.SpinnerIntervalMS = *o.SpinnerIntervalMS
506	}
507	if o.SyntaxBaseStyle != nil {
508		th.SyntaxBaseStyle = *o.SyntaxBaseStyle
509	}
510	th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax)
511	return th
512}
513
514func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
515	if o.Keyword != nil {
516		s.Keyword = *o.Keyword
517	}
518	if o.KeywordConstant != nil {
519		s.KeywordConstant = *o.KeywordConstant
520	}
521	if o.KeywordDeclaration != nil {
522		s.KeywordDeclaration = *o.KeywordDeclaration
523	}
524	if o.KeywordNamespace != nil {
525		s.KeywordNamespace = *o.KeywordNamespace
526	}
527	if o.KeywordReserved != nil {
528		s.KeywordReserved = *o.KeywordReserved
529	}
530	if o.KeywordType != nil {
531		s.KeywordType = *o.KeywordType
532	}
533	if o.NameBuiltin != nil {
534		s.NameBuiltin = *o.NameBuiltin
535	}
536	if o.NameFunction != nil {
537		s.NameFunction = *o.NameFunction
538	}
539	if o.NameClass != nil {
540		s.NameClass = *o.NameClass
541	}
542	if o.NameDecorator != nil {
543		s.NameDecorator = *o.NameDecorator
544	}
545	if o.LiteralString != nil {
546		s.LiteralString = *o.LiteralString
547	}
548	if o.LiteralStringEscape != nil {
549		s.LiteralStringEscape = *o.LiteralStringEscape
550	}
551	if o.LiteralNumber != nil {
552		s.LiteralNumber = *o.LiteralNumber
553	}
554	if o.Comment != nil {
555		s.Comment = *o.Comment
556	}
557	if o.CommentPreproc != nil {
558		s.CommentPreproc = *o.CommentPreproc
559	}
560	if o.Operator != nil {
561		s.Operator = *o.Operator
562	}
563	if o.Punctuation != nil {
564		s.Punctuation = *o.Punctuation
565	}
566	if o.Text != nil {
567		s.Text = *o.Text
568	}
569	return s
570}
571
572func IsLightTheme(th Theme) bool {
573	if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {
574		return true
575	}
576	return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light
577}
578
579func isLightTheme(th Theme) bool { return IsLightTheme(th) }
580
581func parseHexColor(s string) (TerminalColor, bool) {
582	s = strings.TrimPrefix(strings.TrimSpace(s), "#")
583	if len(s) != 6 {
584		return TerminalColor{}, false
585	}
586	var rgb [3]int
587	for i := 0; i < 3; i++ {
588		v, ok := parseHexByte(s[i*2 : i*2+2])
589		if !ok {
590			return TerminalColor{}, false
591		}
592		rgb[i] = v
593	}
594	return ColorRGB(rgb[0], rgb[1], rgb[2]), true
595}
596
597func parseHexByte(s string) (int, bool) {
598	var n int
599	for _, r := range s {
600		n *= 16
601		switch {
602		case r >= '0' && r <= '9':
603			n += int(r - '0')
604		case r >= 'a' && r <= 'f':
605			n += int(r-'a') + 10
606		case r >= 'A' && r <= 'F':
607			n += int(r-'A') + 10
608		default:
609			return 0, false
610		}
611	}
612	return n, true
613}

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