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}