mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
404 lines
10 KiB
Go
404 lines
10 KiB
Go
package theme
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image/color"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss/v2"
|
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
|
)
|
|
|
|
//go:embed themes/*.json
|
|
var themesFS embed.FS
|
|
|
|
type JSONTheme struct {
|
|
Defs map[string]any `json:"defs,omitempty"`
|
|
Theme map[string]any `json:"theme"`
|
|
}
|
|
|
|
type LoadedTheme struct {
|
|
BaseTheme
|
|
name string
|
|
}
|
|
|
|
type colorRef struct {
|
|
value any
|
|
resolved bool
|
|
}
|
|
|
|
func LoadThemesFromJSON() error {
|
|
entries, err := themesFS.ReadDir("themes")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read themes directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
themeName := strings.TrimSuffix(entry.Name(), ".json")
|
|
data, err := themesFS.ReadFile(path.Join("themes", entry.Name()))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err)
|
|
}
|
|
theme, err := parseJSONTheme(themeName, data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse theme %s: %w", themeName, err)
|
|
}
|
|
RegisterTheme(themeName, theme)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoadThemesFromDirectories loads themes from user directories in the correct override order.
|
|
// The hierarchy is (from lowest to highest priority):
|
|
// 1. Built-in themes (embedded)
|
|
// 2. USER_CONFIG/opencode/themes/*.json
|
|
// 3. PROJECT_ROOT/.opencode/themes/*.json
|
|
// 4. CWD/.opencode/themes/*.json
|
|
func LoadThemesFromDirectories(userConfig, projectRoot, cwd string) error {
|
|
if err := LoadThemesFromJSON(); err != nil {
|
|
return fmt.Errorf("failed to load built-in themes: %w", err)
|
|
}
|
|
|
|
dirs := []string{
|
|
filepath.Join(userConfig, "themes"),
|
|
filepath.Join(projectRoot, ".opencode", "themes"),
|
|
}
|
|
if cwd != projectRoot {
|
|
dirs = append(dirs, filepath.Join(cwd, ".opencode", "themes"))
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
if err := loadThemesFromDirectory(dir); err != nil {
|
|
fmt.Printf("Warning: Failed to load themes from %s: %v\n", dir, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadThemesFromDirectory(dir string) error {
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
return nil // Directory doesn't exist, which is fine
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
|
|
themeName := strings.TrimSuffix(entry.Name(), ".json")
|
|
filePath := filepath.Join(dir, entry.Name())
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to read theme file %s: %v\n", filePath, err)
|
|
continue
|
|
}
|
|
|
|
theme, err := parseJSONTheme(themeName, data)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to parse theme %s: %v\n", filePath, err)
|
|
continue
|
|
}
|
|
|
|
RegisterTheme(themeName, theme)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseJSONTheme(name string, data []byte) (Theme, error) {
|
|
var jsonTheme JSONTheme
|
|
if err := json.Unmarshal(data, &jsonTheme); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
|
}
|
|
theme := &LoadedTheme{
|
|
name: name,
|
|
}
|
|
colorMap := make(map[string]*colorRef)
|
|
for key, value := range jsonTheme.Defs {
|
|
colorMap[key] = &colorRef{value: value, resolved: false}
|
|
}
|
|
for key, value := range jsonTheme.Theme {
|
|
colorMap[key] = &colorRef{value: value, resolved: false}
|
|
}
|
|
resolver := &colorResolver{
|
|
colors: colorMap,
|
|
visited: make(map[string]bool),
|
|
}
|
|
for key, value := range jsonTheme.Theme {
|
|
resolved, err := resolver.resolveColor(key, value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve color %s: %w", key, err)
|
|
}
|
|
adaptiveColor, err := parseResolvedColor(resolved)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse color %s: %w", key, err)
|
|
}
|
|
if err := setThemeColor(theme, key, adaptiveColor); err != nil {
|
|
return nil, fmt.Errorf("failed to set color %s: %w", key, err)
|
|
}
|
|
}
|
|
|
|
return theme, nil
|
|
}
|
|
|
|
type colorResolver struct {
|
|
colors map[string]*colorRef
|
|
visited map[string]bool
|
|
}
|
|
|
|
func (r *colorResolver) resolveColor(key string, value any) (any, error) {
|
|
if r.visited[key] {
|
|
return nil, fmt.Errorf("circular reference detected for color %s", key)
|
|
}
|
|
r.visited[key] = true
|
|
defer func() { r.visited[key] = false }()
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
if strings.HasPrefix(v, "#") || v == "none" {
|
|
return v, nil
|
|
}
|
|
return r.resolveReference(v)
|
|
case float64:
|
|
return v, nil
|
|
case map[string]any:
|
|
resolved := make(map[string]any)
|
|
|
|
if dark, ok := v["dark"]; ok {
|
|
resolvedDark, err := r.resolveColorValue(dark)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve dark variant: %w", err)
|
|
}
|
|
resolved["dark"] = resolvedDark
|
|
}
|
|
|
|
if light, ok := v["light"]; ok {
|
|
resolvedLight, err := r.resolveColorValue(light)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve light variant: %w", err)
|
|
}
|
|
resolved["light"] = resolvedLight
|
|
}
|
|
|
|
return resolved, nil
|
|
default:
|
|
return nil, fmt.Errorf("invalid color value type: %T", value)
|
|
}
|
|
}
|
|
|
|
func (r *colorResolver) resolveColorValue(value any) (any, error) {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if strings.HasPrefix(v, "#") || v == "none" {
|
|
return v, nil
|
|
}
|
|
return r.resolveReference(v)
|
|
case float64:
|
|
return v, nil
|
|
default:
|
|
return nil, fmt.Errorf("invalid color value type: %T", value)
|
|
}
|
|
}
|
|
|
|
func (r *colorResolver) resolveReference(ref string) (any, error) {
|
|
colorRef, exists := r.colors[ref]
|
|
if !exists {
|
|
return nil, fmt.Errorf("color reference '%s' not found", ref)
|
|
}
|
|
|
|
if colorRef.resolved {
|
|
return colorRef.value, nil
|
|
}
|
|
|
|
resolved, err := r.resolveColor(ref, colorRef.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
colorRef.value = resolved
|
|
colorRef.resolved = true
|
|
|
|
return resolved, nil
|
|
}
|
|
|
|
func parseResolvedColor(value any) (compat.AdaptiveColor, error) {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if v == "none" {
|
|
return compat.AdaptiveColor{
|
|
Dark: lipgloss.NoColor{},
|
|
Light: lipgloss.NoColor{},
|
|
}, nil
|
|
}
|
|
return compat.AdaptiveColor{
|
|
Dark: lipgloss.Color(v),
|
|
Light: lipgloss.Color(v),
|
|
}, nil
|
|
case float64:
|
|
colorStr := fmt.Sprintf("%d", int(v))
|
|
return compat.AdaptiveColor{
|
|
Dark: lipgloss.Color(colorStr),
|
|
Light: lipgloss.Color(colorStr),
|
|
}, nil
|
|
case map[string]any:
|
|
dark, darkOk := v["dark"]
|
|
light, lightOk := v["light"]
|
|
|
|
if !darkOk || !lightOk {
|
|
return compat.AdaptiveColor{}, fmt.Errorf("color object must have both 'dark' and 'light' keys")
|
|
}
|
|
darkColor, err := parseColorValue(dark)
|
|
if err != nil {
|
|
return compat.AdaptiveColor{}, fmt.Errorf("failed to parse dark color: %w", err)
|
|
}
|
|
lightColor, err := parseColorValue(light)
|
|
if err != nil {
|
|
return compat.AdaptiveColor{}, fmt.Errorf("failed to parse light color: %w", err)
|
|
}
|
|
return compat.AdaptiveColor{
|
|
Dark: darkColor,
|
|
Light: lightColor,
|
|
}, nil
|
|
default:
|
|
return compat.AdaptiveColor{}, fmt.Errorf("invalid resolved color type: %T", value)
|
|
}
|
|
}
|
|
|
|
func parseColorValue(value any) (color.Color, error) {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if v == "none" {
|
|
return lipgloss.NoColor{}, nil
|
|
}
|
|
return lipgloss.Color(v), nil
|
|
case float64:
|
|
return lipgloss.Color(fmt.Sprintf("%d", int(v))), nil
|
|
default:
|
|
return nil, fmt.Errorf("invalid color value type: %T", value)
|
|
}
|
|
}
|
|
|
|
func setThemeColor(theme *LoadedTheme, key string, color compat.AdaptiveColor) error {
|
|
switch key {
|
|
case "primary":
|
|
theme.PrimaryColor = color
|
|
case "secondary":
|
|
theme.SecondaryColor = color
|
|
case "accent":
|
|
theme.AccentColor = color
|
|
case "error":
|
|
theme.ErrorColor = color
|
|
case "warning":
|
|
theme.WarningColor = color
|
|
case "success":
|
|
theme.SuccessColor = color
|
|
case "info":
|
|
theme.InfoColor = color
|
|
case "text":
|
|
theme.TextColor = color
|
|
case "textMuted":
|
|
theme.TextMutedColor = color
|
|
case "background":
|
|
theme.BackgroundColor = color
|
|
case "backgroundPanel":
|
|
theme.BackgroundPanelColor = color
|
|
case "backgroundElement":
|
|
theme.BackgroundElementColor = color
|
|
case "border":
|
|
theme.BorderColor = color
|
|
case "borderActive":
|
|
theme.BorderActiveColor = color
|
|
case "borderSubtle":
|
|
theme.BorderSubtleColor = color
|
|
case "diffAdded":
|
|
theme.DiffAddedColor = color
|
|
case "diffRemoved":
|
|
theme.DiffRemovedColor = color
|
|
case "diffContext":
|
|
theme.DiffContextColor = color
|
|
case "diffHunkHeader":
|
|
theme.DiffHunkHeaderColor = color
|
|
case "diffHighlightAdded":
|
|
theme.DiffHighlightAddedColor = color
|
|
case "diffHighlightRemoved":
|
|
theme.DiffHighlightRemovedColor = color
|
|
case "diffAddedBg":
|
|
theme.DiffAddedBgColor = color
|
|
case "diffRemovedBg":
|
|
theme.DiffRemovedBgColor = color
|
|
case "diffContextBg":
|
|
theme.DiffContextBgColor = color
|
|
case "diffLineNumber":
|
|
theme.DiffLineNumberColor = color
|
|
case "diffAddedLineNumberBg":
|
|
theme.DiffAddedLineNumberBgColor = color
|
|
case "diffRemovedLineNumberBg":
|
|
theme.DiffRemovedLineNumberBgColor = color
|
|
case "markdownText":
|
|
theme.MarkdownTextColor = color
|
|
case "markdownHeading":
|
|
theme.MarkdownHeadingColor = color
|
|
case "markdownLink":
|
|
theme.MarkdownLinkColor = color
|
|
case "markdownLinkText":
|
|
theme.MarkdownLinkTextColor = color
|
|
case "markdownCode":
|
|
theme.MarkdownCodeColor = color
|
|
case "markdownBlockQuote":
|
|
theme.MarkdownBlockQuoteColor = color
|
|
case "markdownEmph":
|
|
theme.MarkdownEmphColor = color
|
|
case "markdownStrong":
|
|
theme.MarkdownStrongColor = color
|
|
case "markdownHorizontalRule":
|
|
theme.MarkdownHorizontalRuleColor = color
|
|
case "markdownListItem":
|
|
theme.MarkdownListItemColor = color
|
|
case "markdownListEnumeration":
|
|
theme.MarkdownListEnumerationColor = color
|
|
case "markdownImage":
|
|
theme.MarkdownImageColor = color
|
|
case "markdownImageText":
|
|
theme.MarkdownImageTextColor = color
|
|
case "markdownCodeBlock":
|
|
theme.MarkdownCodeBlockColor = color
|
|
case "syntaxComment":
|
|
theme.SyntaxCommentColor = color
|
|
case "syntaxKeyword":
|
|
theme.SyntaxKeywordColor = color
|
|
case "syntaxFunction":
|
|
theme.SyntaxFunctionColor = color
|
|
case "syntaxVariable":
|
|
theme.SyntaxVariableColor = color
|
|
case "syntaxString":
|
|
theme.SyntaxStringColor = color
|
|
case "syntaxNumber":
|
|
theme.SyntaxNumberColor = color
|
|
case "syntaxType":
|
|
theme.SyntaxTypeColor = color
|
|
case "syntaxOperator":
|
|
theme.SyntaxOperatorColor = color
|
|
case "syntaxPunctuation":
|
|
theme.SyntaxPunctuationColor = color
|
|
default:
|
|
// Ignore unknown keys for forward compatibility
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|