package tui import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // ThemeOption is one selectable theme discovered under $ZOT_HOME/themes. // Value is stored in config.json. type ThemeOption struct { Value string Label string Description string Path string Builtin bool } // ThemeFile is the user-editable JSON shape loaded from // $ZOT_HOME/themes/*.json. It carries metadata plus separate overrides // for dark and light terminals. type ThemeFile struct { Name string `json:"name"` Description string `json:"description"` Colors ThemeFileColorModes `json:"colors"` Overrides ThemeOverrides `json:"-"` } func (tf *ThemeFile) UnmarshalJSON(data []byte) error { type alias ThemeFile var a alias if err := json.Unmarshal(data, &a); err != nil { return err } // Allow a tiny theme file with overrides at the top level, e.g. // {"spinner_frames":[".","o"],"spinner_messages":["working"]}. // Metadata fields are ignored by ThemeOverrides because they do not // have matching json tags. _ = json.Unmarshal(data, &a.Overrides) *tf = ThemeFile(a) return nil } type ThemeFileColorModes struct { Base ThemeOverrides `json:"-"` Dark ThemeOverrides `json:"dark"` Light ThemeOverrides `json:"light"` HasDark bool `json:"-"` HasLight bool `json:"-"` } func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } if v, ok := raw["dark"]; ok { m.HasDark = true _ = json.Unmarshal(v, &m.Dark) } if v, ok := raw["light"]; ok { m.HasLight = true _ = json.Unmarshal(v, &m.Light) } // Also allow colors to directly contain overrides shared by both // modes, e.g. {"colors":{"accent":204}}. var base ThemeOverrides _ = json.Unmarshal(data, &base) m.Base = base return nil } // ThemeOverrides is intentionally pointer-based so a theme file can // override only the colors it cares about and inherit the built-in // dark/light defaults for everything else. type ThemeOverrides struct { FG *int `json:"fg,omitempty"` Muted *int `json:"muted,omitempty"` Accent *int `json:"accent,omitempty"` Background *TerminalColorValue `json:"background,omitempty"` User *int `json:"user,omitempty"` UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` UserBubbleFG *int `json:"user_bubble_fg,omitempty"` Assistant *int `json:"assistant,omitempty"` Tool *int `json:"tool,omitempty"` ToolOut *int `json:"tool_out,omitempty"` Error *int `json:"error,omitempty"` Warning *int `json:"warning,omitempty"` Spinner *int `json:"spinner,omitempty"` SelectionBG *int `json:"selection_bg,omitempty"` SelectionFG *int `json:"selection_fg,omitempty"` SpinnerFrames []string `json:"spinner_frames,omitempty"` SpinnerMessages []string `json:"spinner_messages,omitempty"` SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"` Syntax SyntaxThemeOverrides `json:"syntax,omitempty"` } type SyntaxThemeOverrides struct { Keyword *string `json:"keyword,omitempty"` KeywordConstant *string `json:"keyword_constant,omitempty"` KeywordDeclaration *string `json:"keyword_declaration,omitempty"` KeywordNamespace *string `json:"keyword_namespace,omitempty"` KeywordReserved *string `json:"keyword_reserved,omitempty"` KeywordType *string `json:"keyword_type,omitempty"` NameBuiltin *string `json:"name_builtin,omitempty"` NameFunction *string `json:"name_function,omitempty"` NameClass *string `json:"name_class,omitempty"` NameDecorator *string `json:"name_decorator,omitempty"` LiteralString *string `json:"literal_string,omitempty"` LiteralStringEscape *string `json:"literal_string_escape,omitempty"` LiteralNumber *string `json:"literal_number,omitempty"` Comment *string `json:"comment,omitempty"` CommentPreproc *string `json:"comment_preproc,omitempty"` Operator *string `json:"operator,omitempty"` Punctuation *string `json:"punctuation,omitempty"` Text *string `json:"text,omitempty"` } // TerminalColorValue accepts any of these JSON forms: // // 24 // xterm-256 color index // "#42454b" // RGB hex // {"mode":"ansi","index":100} // {"mode":"rgb","r":66,"g":69,"b":75} // {"mode":"256","index":254} type TerminalColorValue struct { TerminalColor } func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { var index int if err := json.Unmarshal(data, &index); err == nil { c.TerminalColor = Color256(index) return nil } var s string if err := json.Unmarshal(data, &s); err == nil { if rgb, ok := parseHexColor(s); ok { c.TerminalColor = rgb return nil } return fmt.Errorf("invalid terminal color %q", s) } var obj struct { Mode string `json:"mode"` Index int `json:"index"` R int `json:"r"` G int `json:"g"` B int `json:"b"` } if err := json.Unmarshal(data, &obj); err != nil { return err } switch strings.ToLower(obj.Mode) { case "", "256", "color256", "xterm256": c.TerminalColor = Color256(obj.Index) case "ansi": c.TerminalColor = ColorANSI(obj.Index) case "rgb", "truecolor": c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B) default: return fmt.Errorf("unknown terminal color mode %q", obj.Mode) } return nil } // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json // to detected. Empty/auto/default keeps the built-in detected theme. // If preferred is set, it may be a theme name, a basename without // .json, or an absolute/relative path. func DetectThemeWithCustom(zotHome, preferred string, timeout time.Duration) (Theme, string, error) { detected := DetectThemeFromBackground(timeout) return LoadThemeFromHome(zotHome, preferred, detected) } func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string, error) { path, err := resolveThemePath(zotHome, preferred) if err != nil || path == "" { return detected, "", err } switch path { case "builtin:dark": return Dark, "dark", nil case "builtin:light": return Light, "light", nil } b, err := os.ReadFile(path) if err != nil { return detected, "", err } var tf ThemeFile if err := json.Unmarshal(b, &tf); err != nil { return detected, "", fmt.Errorf("parse theme %s: %w", path, err) } if tf.Name == "" { tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) } base := applyThemeOverrides(detected, tf.Overrides) base = applyThemeOverrides(base, tf.Colors.Base) if isLightTheme(detected) { if tf.Colors.HasLight { base = applyThemeOverrides(base, tf.Colors.Light) } else if tf.Colors.HasDark { base = applyThemeOverrides(base, tf.Colors.Dark) } } else { if tf.Colors.HasDark { base = applyThemeOverrides(base, tf.Colors.Dark) } else if tf.Colors.HasLight { base = applyThemeOverrides(base, tf.Colors.Light) } } return base, tf.Name, nil } // AvailableThemes returns built-in and user-installed themes suitable // for a settings picker. Invalid JSON files are skipped. func AvailableThemes(zotHome string) []ThemeOption { out := []ThemeOption{ {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true}, {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, } seen := map[string]bool{"auto": true, "dark": true, "light": true} paths, _ := themeFilesIn(filepath.Join(zotHome, "themes")) sort.Strings(paths) for _, path := range paths { b, err := os.ReadFile(path) if err != nil { continue } var tf ThemeFile if err := json.Unmarshal(b, &tf); err != nil { continue } value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) if value == "" || seen[value] { continue } desc := tf.Description if desc == "" { desc = path } out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path}) seen[value] = true } return out } // ThemeOptionFromFile parses one theme JSON file for picker display. // value is what will be stored in config; pass an absolute path for // extension-owned themes so they can be loaded without copying into // $ZOT_HOME/themes. func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) { b, err := os.ReadFile(path) if err != nil { return ThemeOption{}, false } var tf ThemeFile if err := json.Unmarshal(b, &tf); err != nil { return ThemeOption{}, false } if value == "" { value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) } label := value if tf.Name != "" { label = tf.Name } desc := tf.Description if source != "" { if desc != "" { desc = "from " + source + " — " + desc } else { desc = "from " + source } } if desc == "" { desc = path } return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true } func ThemeExists(zotHome, preferred string) bool { path, err := resolveThemePath(zotHome, preferred) return err == nil && path != "" } func resolveThemePath(zotHome, preferred string) (string, error) { preferred = strings.TrimSpace(preferred) switch strings.ToLower(preferred) { case "", "auto", "default", "system": return "", nil case "dark": return "builtin:dark", nil case "light": return "builtin:light", nil } if preferred != "" && !strings.HasPrefix(preferred, "builtin:") { candidates := []string{preferred} if filepath.Ext(preferred) == "" { candidates = append(candidates, filepath.Join(zotHome, "themes", preferred+".json"), ) } else if !filepath.IsAbs(preferred) { candidates = append(candidates, filepath.Join(zotHome, "themes", preferred), ) } for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c, nil } } return "", fmt.Errorf("theme %q not found", preferred) } return "", nil } func themeFilesIn(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var out []string for _, e := range entries { if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" { continue } out = append(out, filepath.Join(dir, e.Name())) } return out, nil } func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { if o.FG != nil { th.FG = *o.FG } if o.Muted != nil { th.Muted = *o.Muted } if o.Accent != nil { th.Accent = *o.Accent } if o.Background != nil { bg := o.Background.TerminalColor th.Background = &bg } if o.User != nil { th.User = *o.User } if o.UserBubbleBG != nil { th.UserBubbleBG = o.UserBubbleBG.TerminalColor } if o.UserBubbleFG != nil { th.UserBubbleFG = *o.UserBubbleFG } if o.Assistant != nil { th.Assistant = *o.Assistant } if o.Tool != nil { th.Tool = *o.Tool } if o.ToolOut != nil { th.ToolOut = *o.ToolOut } if o.Error != nil { th.Error = *o.Error } if o.Warning != nil { th.Warning = *o.Warning } if o.Spinner != nil { th.Spinner = *o.Spinner } if o.SelectionBG != nil { th.SelectionBG = *o.SelectionBG } if o.SelectionFG != nil { th.SelectionFG = *o.SelectionFG } if len(o.SpinnerFrames) > 0 { th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) } if len(o.SpinnerMessages) > 0 { th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...) } if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 { th.SpinnerIntervalMS = *o.SpinnerIntervalMS } if o.SyntaxBaseStyle != nil { th.SyntaxBaseStyle = *o.SyntaxBaseStyle } th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax) return th } func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme { if o.Keyword != nil { s.Keyword = *o.Keyword } if o.KeywordConstant != nil { s.KeywordConstant = *o.KeywordConstant } if o.KeywordDeclaration != nil { s.KeywordDeclaration = *o.KeywordDeclaration } if o.KeywordNamespace != nil { s.KeywordNamespace = *o.KeywordNamespace } if o.KeywordReserved != nil { s.KeywordReserved = *o.KeywordReserved } if o.KeywordType != nil { s.KeywordType = *o.KeywordType } if o.NameBuiltin != nil { s.NameBuiltin = *o.NameBuiltin } if o.NameFunction != nil { s.NameFunction = *o.NameFunction } if o.NameClass != nil { s.NameClass = *o.NameClass } if o.NameDecorator != nil { s.NameDecorator = *o.NameDecorator } if o.LiteralString != nil { s.LiteralString = *o.LiteralString } if o.LiteralStringEscape != nil { s.LiteralStringEscape = *o.LiteralStringEscape } if o.LiteralNumber != nil { s.LiteralNumber = *o.LiteralNumber } if o.Comment != nil { s.Comment = *o.Comment } if o.CommentPreproc != nil { s.CommentPreproc = *o.CommentPreproc } if o.Operator != nil { s.Operator = *o.Operator } if o.Punctuation != nil { s.Punctuation = *o.Punctuation } if o.Text != nil { s.Text = *o.Text } return s } func IsLightTheme(th Theme) bool { return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG } func isLightTheme(th Theme) bool { return IsLightTheme(th) } func parseHexColor(s string) (TerminalColor, bool) { s = strings.TrimPrefix(strings.TrimSpace(s), "#") if len(s) != 6 { return TerminalColor{}, false } var rgb [3]int for i := 0; i < 3; i++ { v, ok := parseHexByte(s[i*2 : i*2+2]) if !ok { return TerminalColor{}, false } rgb[i] = v } return ColorRGB(rgb[0], rgb[1], rgb[2]), true } func parseHexByte(s string) (int, bool) { var n int for _, r := range s { n *= 16 switch { case r >= '0' && r <= '9': n += int(r - '0') case r >= 'a' && r <= 'f': n += int(r-'a') + 10 case r >= 'A' && r <= 'F': n += int(r-'A') + 10 default: return 0, false } } return n, true }