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}