summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/theme
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-06-26 10:16:07 -0500
committerGitHub <[email protected]>2025-06-26 10:16:07 -0500
commit7d13baadc84d7377a352c6d58ed9deeea2c918be (patch)
tree575b6897431e390ae8bf4b9ccde2803446c6c67a /packages/tui/internal/theme
parentdb24bf87c01d3fff2a9594e55e9fab0d9ef52cfe (diff)
downloadopencode-7d13baadc84d7377a352c6d58ed9deeea2c918be.tar.gz
opencode-7d13baadc84d7377a352c6d58ed9deeea2c918be.zip
feat: default system theme (#419)
Co-authored-by: adamdottv <[email protected]>
Diffstat (limited to 'packages/tui/internal/theme')
-rw-r--r--packages/tui/internal/theme/loader.go13
-rw-r--r--packages/tui/internal/theme/manager.go134
-rw-r--r--packages/tui/internal/theme/system.go299
3 files changed, 439 insertions, 7 deletions
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),
+ }
+}