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}