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}