From 7d13baadc84d7377a352c6d58ed9deeea2c918be Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:16:07 -0500 Subject: feat: default system theme (#419) Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com> --- packages/tui/internal/theme/loader.go | 13 +- packages/tui/internal/theme/manager.go | 134 ++++++++++++++- packages/tui/internal/theme/system.go | 299 +++++++++++++++++++++++++++++++++ 3 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 packages/tui/internal/theme/system.go (limited to 'packages/tui/internal/theme') diff --git a/packages/tui/internal/theme/loader.go b/packages/tui/internal/theme/loader.go index 7df46b7fe..82c2fcd24 100644 --- a/packages/tui/internal/theme/loader.go +++ b/packages/tui/internal/theme/loader.go @@ -171,7 +171,7 @@ func (r *colorResolver) resolveColor(key string, value any) (any, error) { switch v := value.(type) { case string: - if strings.HasPrefix(v, "#") { + if strings.HasPrefix(v, "#") || v == "none" { return v, nil } return r.resolveReference(v) @@ -205,7 +205,7 @@ func (r *colorResolver) resolveColor(key string, value any) (any, error) { func (r *colorResolver) resolveColorValue(value any) (any, error) { switch v := value.(type) { case string: - if strings.HasPrefix(v, "#") { + if strings.HasPrefix(v, "#") || v == "none" { return v, nil } return r.resolveReference(v) @@ -240,6 +240,12 @@ func (r *colorResolver) resolveReference(ref string) (any, error) { 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), @@ -277,6 +283,9 @@ func parseResolvedColor(value any) (compat.AdaptiveColor, error) { 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 diff --git a/packages/tui/internal/theme/manager.go b/packages/tui/internal/theme/manager.go index 87fbc131d..583e8c491 100644 --- a/packages/tui/internal/theme/manager.go +++ b/packages/tui/internal/theme/manager.go @@ -2,19 +2,25 @@ package theme import ( "fmt" + "image/color" "slices" + "strconv" "strings" "sync" "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" ) // Manager handles theme registration, selection, and retrieval. // It maintains a registry of available themes and tracks the currently active theme. type Manager struct { - themes map[string]Theme - currentName string - mu sync.RWMutex + themes map[string]Theme + currentName string + currentUsesAnsiCache bool // Cache whether current theme uses ANSI colors + mu sync.RWMutex } // Global instance of the theme manager @@ -34,6 +40,7 @@ func RegisterTheme(name string, theme Theme) { // If this is the first theme, make it the default if globalManager.currentName == "" { globalManager.currentName = name + globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme) } } @@ -44,11 +51,13 @@ func SetTheme(name string) error { defer globalManager.mu.Unlock() delete(styles.Registry, "charm") - if _, exists := globalManager.themes[name]; !exists { + theme, exists := globalManager.themes[name] + if !exists { return fmt.Errorf("theme '%s' not found", name) } globalManager.currentName = name + globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme) return nil } @@ -84,7 +93,11 @@ func AvailableThemes() []string { names = append(names, name) } slices.SortFunc(names, func(a, b string) int { - // list system theme first + if a == "system" { + return -1 + } else if b == "system" { + return 1 + } if a == "opencode" { return -1 } else if b == "opencode" { @@ -103,3 +116,114 @@ func GetTheme(name string) Theme { return globalManager.themes[name] } + +// UpdateSystemTheme updates the system theme with terminal background info +func UpdateSystemTheme(terminalBg color.Color, isDark bool) { + globalManager.mu.Lock() + defer globalManager.mu.Unlock() + + dynamicTheme := NewSystemTheme(terminalBg, isDark) + globalManager.themes["system"] = dynamicTheme + if globalManager.currentName == "system" { + globalManager.currentUsesAnsiCache = themeUsesAnsiColors(dynamicTheme) + } +} + +// CurrentThemeUsesAnsiColors returns true if the current theme uses ANSI 0-16 colors +func CurrentThemeUsesAnsiColors() bool { + // globalManager.mu.RLock() + // defer globalManager.mu.RUnlock() + + return globalManager.currentUsesAnsiCache +} + +// isAnsiColor checks if a color represents an ANSI 0-16 color +func isAnsiColor(c color.Color) bool { + if _, ok := c.(lipgloss.NoColor); ok { + return false + } + if _, ok := c.(ansi.BasicColor); ok { + return true + } + + // For other color types, check if they represent ANSI colors + // by examining their string representation + if stringer, ok := c.(fmt.Stringer); ok { + str := stringer.String() + // Check if it's a numeric ANSI color (0-15) + if num, err := strconv.Atoi(str); err == nil && num >= 0 && num <= 15 { + return true + } + } + + return false +} + +// adaptiveColorUsesAnsi checks if an AdaptiveColor uses ANSI colors +func adaptiveColorUsesAnsi(ac compat.AdaptiveColor) bool { + if isAnsiColor(ac.Dark) { + return true + } + if isAnsiColor(ac.Light) { + return true + } + return false +} + +// themeUsesAnsiColors checks if a theme uses any ANSI 0-16 colors +func themeUsesAnsiColors(theme Theme) bool { + if theme == nil { + return false + } + + return adaptiveColorUsesAnsi(theme.Primary()) || + adaptiveColorUsesAnsi(theme.Secondary()) || + adaptiveColorUsesAnsi(theme.Accent()) || + adaptiveColorUsesAnsi(theme.Error()) || + adaptiveColorUsesAnsi(theme.Warning()) || + adaptiveColorUsesAnsi(theme.Success()) || + adaptiveColorUsesAnsi(theme.Info()) || + adaptiveColorUsesAnsi(theme.Text()) || + adaptiveColorUsesAnsi(theme.TextMuted()) || + adaptiveColorUsesAnsi(theme.Background()) || + adaptiveColorUsesAnsi(theme.BackgroundPanel()) || + adaptiveColorUsesAnsi(theme.BackgroundElement()) || + adaptiveColorUsesAnsi(theme.Border()) || + adaptiveColorUsesAnsi(theme.BorderActive()) || + adaptiveColorUsesAnsi(theme.BorderSubtle()) || + adaptiveColorUsesAnsi(theme.DiffAdded()) || + adaptiveColorUsesAnsi(theme.DiffRemoved()) || + adaptiveColorUsesAnsi(theme.DiffContext()) || + adaptiveColorUsesAnsi(theme.DiffHunkHeader()) || + adaptiveColorUsesAnsi(theme.DiffHighlightAdded()) || + adaptiveColorUsesAnsi(theme.DiffHighlightRemoved()) || + adaptiveColorUsesAnsi(theme.DiffAddedBg()) || + adaptiveColorUsesAnsi(theme.DiffRemovedBg()) || + adaptiveColorUsesAnsi(theme.DiffContextBg()) || + adaptiveColorUsesAnsi(theme.DiffLineNumber()) || + adaptiveColorUsesAnsi(theme.DiffAddedLineNumberBg()) || + adaptiveColorUsesAnsi(theme.DiffRemovedLineNumberBg()) || + adaptiveColorUsesAnsi(theme.MarkdownText()) || + adaptiveColorUsesAnsi(theme.MarkdownHeading()) || + adaptiveColorUsesAnsi(theme.MarkdownLink()) || + adaptiveColorUsesAnsi(theme.MarkdownLinkText()) || + adaptiveColorUsesAnsi(theme.MarkdownCode()) || + adaptiveColorUsesAnsi(theme.MarkdownBlockQuote()) || + adaptiveColorUsesAnsi(theme.MarkdownEmph()) || + adaptiveColorUsesAnsi(theme.MarkdownStrong()) || + adaptiveColorUsesAnsi(theme.MarkdownHorizontalRule()) || + adaptiveColorUsesAnsi(theme.MarkdownListItem()) || + adaptiveColorUsesAnsi(theme.MarkdownListEnumeration()) || + adaptiveColorUsesAnsi(theme.MarkdownImage()) || + adaptiveColorUsesAnsi(theme.MarkdownImageText()) || + adaptiveColorUsesAnsi(theme.MarkdownCodeBlock()) || + adaptiveColorUsesAnsi(theme.SyntaxComment()) || + adaptiveColorUsesAnsi(theme.SyntaxKeyword()) || + adaptiveColorUsesAnsi(theme.SyntaxFunction()) || + adaptiveColorUsesAnsi(theme.SyntaxVariable()) || + adaptiveColorUsesAnsi(theme.SyntaxString()) || + adaptiveColorUsesAnsi(theme.SyntaxNumber()) || + adaptiveColorUsesAnsi(theme.SyntaxType()) || + adaptiveColorUsesAnsi(theme.SyntaxOperator()) || + adaptiveColorUsesAnsi(theme.SyntaxPunctuation()) +} diff --git a/packages/tui/internal/theme/system.go b/packages/tui/internal/theme/system.go new file mode 100644 index 000000000..7524bb3f9 --- /dev/null +++ b/packages/tui/internal/theme/system.go @@ -0,0 +1,299 @@ +package theme + +import ( + "fmt" + "image/color" + "math" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" +) + +// SystemTheme is a dynamic theme that derives its gray scale colors +// from the terminal's background color at runtime +type SystemTheme struct { + BaseTheme + terminalBg color.Color + terminalBgIsDark bool +} + +// NewSystemTheme creates a new instance of the dynamic system theme +func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme { + theme := &SystemTheme{ + terminalBg: terminalBg, + terminalBgIsDark: isDark, + } + theme.initializeColors() + return theme +} + +// initializeColors sets up all theme colors +func (t *SystemTheme) initializeColors() { + // Generate gray scale based on terminal background + grays := t.generateGrayScale() + + // Set ANSI colors for primary colors + t.PrimaryColor = compat.AdaptiveColor{ + Dark: lipgloss.Cyan, + Light: lipgloss.Cyan, + } + t.SecondaryColor = compat.AdaptiveColor{ + Dark: lipgloss.Magenta, + Light: lipgloss.Magenta, + } + t.AccentColor = compat.AdaptiveColor{ + Dark: lipgloss.Cyan, + Light: lipgloss.Cyan, + } + + // Status colors using ANSI + t.ErrorColor = compat.AdaptiveColor{ + Dark: lipgloss.Red, + Light: lipgloss.Red, + } + t.WarningColor = compat.AdaptiveColor{ + Dark: lipgloss.Yellow, + Light: lipgloss.Yellow, + } + t.SuccessColor = compat.AdaptiveColor{ + Dark: lipgloss.Green, + Light: lipgloss.Green, + } + t.InfoColor = compat.AdaptiveColor{ + Dark: lipgloss.Cyan, + Light: lipgloss.Cyan, + } + + // Text colors + t.TextColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + // Derive muted text color from terminal foreground + t.TextMutedColor = t.generateMutedTextColor() + + // Background colors + t.BackgroundColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + t.BackgroundPanelColor = grays[2] + t.BackgroundElementColor = grays[3] + + // Border colors + t.BorderSubtleColor = grays[6] + t.BorderColor = grays[7] + t.BorderActiveColor = grays[8] + + // Diff colors using ANSI colors + t.DiffAddedColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("2"), // green + Light: lipgloss.Color("2"), + } + t.DiffRemovedColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("1"), // red + Light: lipgloss.Color("1"), + } + t.DiffContextColor = grays[7] // Use gray for context + t.DiffHunkHeaderColor = grays[7] + t.DiffHighlightAddedColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("2"), // green + Light: lipgloss.Color("2"), + } + t.DiffHighlightRemovedColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("1"), // red + Light: lipgloss.Color("1"), + } + // Use subtle gray backgrounds for diff + t.DiffAddedBgColor = grays[2] + t.DiffRemovedBgColor = grays[2] + t.DiffContextBgColor = grays[1] + t.DiffLineNumberColor = grays[6] + t.DiffAddedLineNumberBgColor = grays[3] + t.DiffRemovedLineNumberBgColor = grays[3] + + // Markdown colors using ANSI + t.MarkdownTextColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + t.MarkdownHeadingColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + t.MarkdownLinkColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("4"), // blue + Light: lipgloss.Color("4"), + } + t.MarkdownLinkTextColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("6"), // cyan + Light: lipgloss.Color("6"), + } + t.MarkdownCodeColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("2"), // green + Light: lipgloss.Color("2"), + } + t.MarkdownBlockQuoteColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("3"), // yellow + Light: lipgloss.Color("3"), + } + t.MarkdownEmphColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("3"), // yellow + Light: lipgloss.Color("3"), + } + t.MarkdownStrongColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + t.MarkdownHorizontalRuleColor = t.BorderColor + t.MarkdownListItemColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("4"), // blue + Light: lipgloss.Color("4"), + } + t.MarkdownListEnumerationColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("6"), // cyan + Light: lipgloss.Color("6"), + } + t.MarkdownImageColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("4"), // blue + Light: lipgloss.Color("4"), + } + t.MarkdownImageTextColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("6"), // cyan + Light: lipgloss.Color("6"), + } + t.MarkdownCodeBlockColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + + // Syntax colors + t.SyntaxCommentColor = t.TextMutedColor // Use same as muted text + t.SyntaxKeywordColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("5"), // magenta + Light: lipgloss.Color("5"), + } + t.SyntaxFunctionColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("4"), // blue + Light: lipgloss.Color("4"), + } + t.SyntaxVariableColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } + t.SyntaxStringColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("2"), // green + Light: lipgloss.Color("2"), + } + t.SyntaxNumberColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("3"), // yellow + Light: lipgloss.Color("3"), + } + t.SyntaxTypeColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("6"), // cyan + Light: lipgloss.Color("6"), + } + t.SyntaxOperatorColor = compat.AdaptiveColor{ + Dark: lipgloss.Color("6"), // cyan + Light: lipgloss.Color("6"), + } + t.SyntaxPunctuationColor = compat.AdaptiveColor{ + Dark: lipgloss.NoColor{}, + Light: lipgloss.NoColor{}, + } +} + +// generateGrayScale creates a gray scale based on the terminal background +func (t *SystemTheme) generateGrayScale() map[int]compat.AdaptiveColor { + grays := make(map[int]compat.AdaptiveColor) + + r, g, b, _ := t.terminalBg.RGBA() + bgR := float64(r >> 8) + bgG := float64(g >> 8) + bgB := float64(b >> 8) + + luminance := 0.299*bgR + 0.587*bgG + 0.114*bgB + + for i := 1; i <= 12; i++ { + var stepColor string + factor := float64(i) / 12.0 + + if t.terminalBgIsDark { + if luminance < 10 { + grayValue := int(factor * 0.4 * 255) + stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue) + } else { + newLum := luminance + (255-luminance)*factor*0.4 + + ratio := newLum / luminance + newR := math.Min(bgR*ratio, 255) + newG := math.Min(bgG*ratio, 255) + newB := math.Min(bgB*ratio, 255) + + stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB)) + } + } else { + if luminance > 245 { + grayValue := int(255 - factor*0.4*255) + stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue) + } else { + newLum := luminance * (1 - factor*0.4) + + ratio := newLum / luminance + newR := math.Max(bgR*ratio, 0) + newG := math.Max(bgG*ratio, 0) + newB := math.Max(bgB*ratio, 0) + + stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB)) + } + } + + grays[i] = compat.AdaptiveColor{ + Dark: lipgloss.Color(stepColor), + Light: lipgloss.Color(stepColor), + } + } + + return grays +} + +// generateMutedTextColor creates a muted gray color based on the terminal background +func (t *SystemTheme) generateMutedTextColor() compat.AdaptiveColor { + bgR, bgG, bgB, _ := t.terminalBg.RGBA() + + bgRf := float64(bgR >> 8) + bgGf := float64(bgG >> 8) + bgBf := float64(bgB >> 8) + + bgLum := 0.299*bgRf + 0.587*bgGf + 0.114*bgBf + + var grayValue int + if t.terminalBgIsDark { + if bgLum < 10 { + // Very dark/black background + // grays[3] would be around #2e (46), so we need much lighter + grayValue = 180 // #b4b4b4 + } else { + // Scale up for lighter dark backgrounds + // Ensure we're always significantly brighter than BackgroundElement + grayValue = min(int(160+(bgLum*0.3)), 200) + } + } else { + if bgLum > 245 { + // Very light/white background + // grays[3] would be around #f5 (245), so we need much darker + grayValue = 75 // #4b4b4b + } else { + // Scale down for darker light backgrounds + // Ensure we're always significantly darker than BackgroundElement + grayValue = max(int(100-((255-bgLum)*0.2)), 60) + } + } + + mutedColor := fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue) + + return compat.AdaptiveColor{ + Dark: lipgloss.Color(mutedColor), + Light: lipgloss.Color(mutedColor), + } +} -- cgit v1.2.3