feat: custom themes

This commit is contained in:
adamdottv
2025-04-30 11:05:59 -05:00
parent a42175c067
commit 91ae9b33d3
7 changed files with 384 additions and 108 deletions

View File

@@ -68,7 +68,8 @@ type LSPConfig struct {
// TUIConfig defines the configuration for the Terminal User Interface.
type TUIConfig struct {
Theme string `json:"theme,omitempty"`
Theme string `json:"theme,omitempty"`
CustomTheme map[string]any `json:"customTheme,omitempty"`
}
// Config is the main configuration structure for the application.
@@ -747,16 +748,16 @@ func UpdateTheme(themeName string) error {
}
// Parse the JSON
var configMap map[string]interface{}
var configMap map[string]any
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
tuiConfig, ok := configMap["tui"].(map[string]interface{})
tuiConfig, ok := configMap["tui"].(map[string]any)
if !ok {
// TUI config doesn't exist yet, create it
configMap["tui"] = map[string]interface{}{"theme": themeName}
configMap["tui"] = map[string]any{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName

View File

@@ -11,4 +11,3 @@ const (
SpinnerIcon string = "..."
LoadingIcon string = "⟳"
)

View File

@@ -25,6 +25,9 @@ var globalManager = &Manager{
currentName: "",
}
// Default theme instance for custom theme defaulting
var defaultThemeColors = NewOpenCodeTheme()
// RegisterTheme adds a new theme to the registry.
// If this is the first theme registered, it becomes the default.
func RegisterTheme(name string, theme Theme) {
@@ -46,7 +49,22 @@ func SetTheme(name string) error {
defer globalManager.mu.Unlock()
delete(styles.Registry, "charm")
if _, exists := globalManager.themes[name]; !exists {
// Handle custom theme
if name == "custom" {
cfg := config.Get()
if cfg == nil || cfg.TUI.CustomTheme == nil || len(cfg.TUI.CustomTheme) == 0 {
return fmt.Errorf("custom theme selected but no custom theme colors defined in config")
}
customTheme, err := LoadCustomTheme(cfg.TUI.CustomTheme)
if err != nil {
return fmt.Errorf("failed to load custom theme: %w", err)
}
// Register the custom theme
globalManager.themes["custom"] = customTheme
} else if _, exists := globalManager.themes[name]; !exists {
return fmt.Errorf("theme '%s' not found", name)
}
@@ -111,6 +129,87 @@ func GetTheme(name string) Theme {
return globalManager.themes[name]
}
// LoadCustomTheme creates a new theme instance based on the custom theme colors
// defined in the configuration. It uses the default OpenCode theme as a base
// and overrides colors that are specified in the customTheme map.
func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
// Create a new theme based on the default OpenCode theme
theme := NewOpenCodeTheme()
// Process each color in the custom theme map
for key, value := range customTheme {
adaptiveColor, err := ParseAdaptiveColor(value)
if err != nil {
logging.Warn("Invalid color definition in custom theme", "key", key, "error", err)
continue // Skip this color but continue processing others
}
// Set the color in the theme based on the key
switch strings.ToLower(key) {
case "primary":
theme.PrimaryColor = adaptiveColor
case "secondary":
theme.SecondaryColor = adaptiveColor
case "accent":
theme.AccentColor = adaptiveColor
case "error":
theme.ErrorColor = adaptiveColor
case "warning":
theme.WarningColor = adaptiveColor
case "success":
theme.SuccessColor = adaptiveColor
case "info":
theme.InfoColor = adaptiveColor
case "text":
theme.TextColor = adaptiveColor
case "textmuted":
theme.TextMutedColor = adaptiveColor
case "textemphasized":
theme.TextEmphasizedColor = adaptiveColor
case "background":
theme.BackgroundColor = adaptiveColor
case "backgroundsecondary":
theme.BackgroundSecondaryColor = adaptiveColor
case "backgrounddarker":
theme.BackgroundDarkerColor = adaptiveColor
case "bordernormal":
theme.BorderNormalColor = adaptiveColor
case "borderfocused":
theme.BorderFocusedColor = adaptiveColor
case "borderdim":
theme.BorderDimColor = adaptiveColor
case "diffadded":
theme.DiffAddedColor = adaptiveColor
case "diffremoved":
theme.DiffRemovedColor = adaptiveColor
case "diffcontext":
theme.DiffContextColor = adaptiveColor
case "diffhunkheader":
theme.DiffHunkHeaderColor = adaptiveColor
case "diffhighlightadded":
theme.DiffHighlightAddedColor = adaptiveColor
case "diffhighlightremoved":
theme.DiffHighlightRemovedColor = adaptiveColor
case "diffaddedbg":
theme.DiffAddedBgColor = adaptiveColor
case "diffremovedbg":
theme.DiffRemovedBgColor = adaptiveColor
case "diffcontextbg":
theme.DiffContextBgColor = adaptiveColor
case "difflinenumber":
theme.DiffLineNumberColor = adaptiveColor
case "diffaddedlinenumberbg":
theme.DiffAddedLineNumberBgColor = adaptiveColor
case "diffremovedlinenumberbg":
theme.DiffRemovedLineNumberBgColor = adaptiveColor
default:
logging.Warn("Unknown color key in custom theme", "key", key)
}
}
return theme, nil
}
// updateConfigTheme updates the theme setting in the configuration file
func updateConfigTheme(themeName string) error {
// Use the config package to update the theme

View File

@@ -1,6 +1,9 @@
package theme
import (
"fmt"
"regexp"
"github.com/charmbracelet/lipgloss"
)
@@ -205,4 +208,50 @@ func (t *BaseTheme) SyntaxString() lipgloss.AdaptiveColor { return t.SyntaxStrin
func (t *BaseTheme) SyntaxNumber() lipgloss.AdaptiveColor { return t.SyntaxNumberColor }
func (t *BaseTheme) SyntaxType() lipgloss.AdaptiveColor { return t.SyntaxTypeColor }
func (t *BaseTheme) SyntaxOperator() lipgloss.AdaptiveColor { return t.SyntaxOperatorColor }
func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor }
func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor }
// ParseAdaptiveColor parses a color value from the config file into a lipgloss.AdaptiveColor.
// It accepts either a string (hex color) or a map with "dark" and "light" keys.
func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
// Regular expression to validate hex color format
hexColorRegex := regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// Case 1: String value (same color for both dark and light modes)
if hexColor, ok := value.(string); ok {
if !hexColorRegex.MatchString(hexColor) {
return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid hex color format: %s", hexColor)
}
return lipgloss.AdaptiveColor{
Dark: hexColor,
Light: hexColor,
}, nil
}
// Case 2: Map with dark and light keys
if colorMap, ok := value.(map[string]any); ok {
darkVal, darkOk := colorMap["dark"]
lightVal, lightOk := colorMap["light"]
if !darkOk || !lightOk {
return lipgloss.AdaptiveColor{}, fmt.Errorf("color map must contain both 'dark' and 'light' keys")
}
darkHex, darkIsString := darkVal.(string)
lightHex, lightIsString := lightVal.(string)
if !darkIsString || !lightIsString {
return lipgloss.AdaptiveColor{}, fmt.Errorf("color values must be strings")
}
if !hexColorRegex.MatchString(darkHex) || !hexColorRegex.MatchString(lightHex) {
return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid hex color format")
}
return lipgloss.AdaptiveColor{
Dark: darkHex,
Light: lightHex,
}, nil
}
return lipgloss.AdaptiveColor{}, fmt.Errorf("color must be either a hex string or an object with dark/light keys")
}