v0.2.0 42a5079

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

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