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}