diff --git a/internal/app/model.go b/internal/app/model.go index 224dffd..9b0fd07 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -156,6 +156,7 @@ func NewModel() (Model, error) { store := comments.NewStore(gitDir) loadedComments, loadErr := store.Load() appConfig, configPath, configErr := config.Load() + diffview.InitializeTheme(appConfig.Theme) if appConfig.LeaderCommands == nil { appConfig.LeaderCommands = make(map[string]string) } diff --git a/internal/config/config.go b/internal/config/config.go index 48eb15a..bc13339 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ const ( type AppConfig struct { LeaderCommands map[string]string `json:"leader_commands"` + Theme string `json:"theme,omitempty"` } func Load() (AppConfig, string, error) { @@ -29,6 +30,7 @@ func Load() (AppConfig, string, error) { func LoadFromPath(path string) (AppConfig, error) { cfg := AppConfig{ LeaderCommands: make(map[string]string), + Theme: "auto", } data, err := os.ReadFile(path) @@ -51,6 +53,12 @@ func LoadFromPath(path string) (AppConfig, error) { cfg.LeaderCommands = make(map[string]string) } + theme, err := normalizeTheme(cfg.Theme) + if err != nil { + return AppConfig{}, err + } + cfg.Theme = theme + normalized := make(map[string]string, len(cfg.LeaderCommands)) for k, v := range cfg.LeaderCommands { key := strings.TrimSpace(k) @@ -71,6 +79,19 @@ func LoadFromPath(path string) (AppConfig, error) { return cfg, nil } +func normalizeTheme(raw string) (string, error) { + theme := strings.ToLower(strings.TrimSpace(raw)) + if theme == "" { + return "auto", nil + } + switch theme { + case "auto", "light", "dark": + return theme, nil + default: + return "", fmt.Errorf("theme %q must be one of auto, light, dark", raw) + } +} + func DefaultPath() (string, error) { home, err := configHome() if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8a8e416..c6f2911 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,6 +15,9 @@ func TestLoadFromPathMissingFile(t *testing.T) { if len(cfg.LeaderCommands) != 0 { t.Fatalf("expected empty commands, got %d", len(cfg.LeaderCommands)) } + if cfg.Theme != "auto" { + t.Fatalf("expected default theme auto, got %q", cfg.Theme) + } } func TestLoadFromPathParsesLeaderCommands(t *testing.T) { @@ -36,6 +39,22 @@ func TestLoadFromPathParsesLeaderCommands(t *testing.T) { } } +func TestLoadFromPathParsesTheme(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"theme":"LiGhT"}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg, err := LoadFromPath(path) + if err != nil { + t.Fatalf("LoadFromPath() error = %v", err) + } + if cfg.Theme != "light" { + t.Fatalf("expected light theme, got %q", cfg.Theme) + } +} + func TestLoadFromPathRejectsInvalidKey(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") @@ -48,6 +67,18 @@ func TestLoadFromPathRejectsInvalidKey(t *testing.T) { } } +func TestLoadFromPathRejectsInvalidTheme(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"theme":"sepia"}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if _, err := LoadFromPath(path); err == nil { + t.Fatalf("expected error for invalid theme") + } +} + func TestDefaultPathUsesXDGConfigHome(t *testing.T) { xdg := t.TempDir() t.Setenv("XDG_CONFIG_HOME", xdg) diff --git a/internal/diffview/render.go b/internal/diffview/render.go index b2fed27..b6f0c38 100644 --- a/internal/diffview/render.go +++ b/internal/diffview/render.go @@ -83,6 +83,15 @@ var ( commentInlineTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("236")) + syntaxKeywordColor = lipgloss.Color("141") + syntaxStringColor = lipgloss.Color("186") + syntaxCommentColor = lipgloss.Color("244") + syntaxTypeColor = lipgloss.Color("117") + syntaxFunctionColor = lipgloss.Color("221") + syntaxNumberColor = lipgloss.Color("215") + syntaxOperatorColor = lipgloss.Color("204") + syntaxPreprocessorColor = lipgloss.Color("178") + syntaxLexerCacheMu sync.RWMutex syntaxLexerCache = make(map[string]chroma.Lexer) ) @@ -456,21 +465,21 @@ func styleStateAt(pos int, ranges []textRange, syntax []syntaxRange, rangeIdx, s func applySyntaxClass(base lipgloss.Style, class syntaxClass) lipgloss.Style { switch class { case syntaxClassKeyword: - return base.Foreground(lipgloss.Color("141")) + return base.Foreground(syntaxKeywordColor) case syntaxClassString: - return base.Foreground(lipgloss.Color("186")) + return base.Foreground(syntaxStringColor) case syntaxClassComment: - return base.Foreground(lipgloss.Color("244")).Italic(true) + return base.Foreground(syntaxCommentColor).Italic(true) case syntaxClassType: - return base.Foreground(lipgloss.Color("117")) + return base.Foreground(syntaxTypeColor) case syntaxClassFunction: - return base.Foreground(lipgloss.Color("221")) + return base.Foreground(syntaxFunctionColor) case syntaxClassNumber: - return base.Foreground(lipgloss.Color("215")) + return base.Foreground(syntaxNumberColor) case syntaxClassOperator: - return base.Foreground(lipgloss.Color("204")) + return base.Foreground(syntaxOperatorColor) case syntaxClassPreprocessor: - return base.Foreground(lipgloss.Color("178")) + return base.Foreground(syntaxPreprocessorColor) default: return base } diff --git a/internal/diffview/theme.go b/internal/diffview/theme.go new file mode 100644 index 0000000..e41abcd --- /dev/null +++ b/internal/diffview/theme.go @@ -0,0 +1,160 @@ +package diffview + +import ( + "os" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// InitializeTheme configures diff colors from one of: auto, dark, light. +func InitializeTheme(mode string) { + theme := strings.ToLower(strings.TrimSpace(mode)) + if theme == "" { + theme = "auto" + } + + dark := true + switch theme { + case "dark": + dark = true + case "light": + dark = false + default: + dark = detectDarkBackground() + } + + if dark { + applyDarkTheme() + return + } + applyLightTheme() +} + +func detectDarkBackground() bool { + if dark, ok := darkFromColorFGBG(os.Getenv("COLORFGBG")); ok { + return dark + } + return termenv.HasDarkBackground() +} + +func darkFromColorFGBG(value string) (bool, bool) { + parts := strings.Split(strings.TrimSpace(value), ";") + for i := len(parts) - 1; i >= 0; i-- { + idx, err := strconv.Atoi(strings.TrimSpace(parts[i])) + if err != nil { + continue + } + return isDarkColorIndex(idx), true + } + return false, false +} + +func isDarkColorIndex(idx int) bool { + if idx < 0 { + return true + } + if idx <= 6 { + return true + } + if idx <= 15 { + return false + } + if idx >= 16 && idx <= 231 { + cube := idx - 16 + r := cube / 36 + g := (cube / 6) % 6 + b := cube % 6 + return rgbLuma(cubeChannelValue(r), cubeChannelValue(g), cubeChannelValue(b)) < 128 + } + if idx >= 232 && idx <= 255 { + gray := 8 + (idx-232)*10 + return gray < 128 + } + return idx < 128 +} + +func cubeChannelValue(v int) int { + if v <= 0 { + return 0 + } + return 55 + v*40 +} + +func rgbLuma(r, g, b int) int { + return (299*r + 587*g + 114*b) / 1000 +} + +func applyDarkTheme() { + addBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("78")).Background(lipgloss.Color("#1a2620")) + deleteBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("#2a1f21")) + changeOldBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("#252022")) + changeNewBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("#1f2523")) + contextBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + hunkBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("111")).Bold(true) + + addWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("22")).Bold(true) + deleteWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("52")).Bold(true) + cursorRowBg = lipgloss.Color("236") + + cursorGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("45")).Bold(true) + commentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("220")).Bold(true) + cursorCommentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("201")).Bold(true) + addGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("22")).Bold(true) + deleteGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("52")).Bold(true) + changeOldGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("53")).Bold(true) + changeNewGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("23")).Bold(true) + addMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("22")).Bold(true) + deleteMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("52")).Bold(true) + changeOldMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("53")).Bold(true) + changeNewMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("23")).Bold(true) + + commentInlineTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("236")) + + syntaxKeywordColor = lipgloss.Color("141") + syntaxStringColor = lipgloss.Color("186") + syntaxCommentColor = lipgloss.Color("244") + syntaxTypeColor = lipgloss.Color("117") + syntaxFunctionColor = lipgloss.Color("221") + syntaxNumberColor = lipgloss.Color("215") + syntaxOperatorColor = lipgloss.Color("204") + syntaxPreprocessorColor = lipgloss.Color("178") +} + +func applyLightTheme() { + addBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("22")).Background(lipgloss.Color("194")) + deleteBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("88")).Background(lipgloss.Color("224")) + changeOldBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Background(lipgloss.Color("223")) + changeNewBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("28")).Background(lipgloss.Color("193")) + contextBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) + hunkBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("25")).Bold(true) + + addWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("22")).Background(lipgloss.Color("121")).Bold(true) + deleteWordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("88")).Background(lipgloss.Color("217")).Bold(true) + cursorRowBg = lipgloss.Color("254") + + cursorGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("25")).Bold(true) + commentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("220")).Bold(true) + cursorCommentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("161")).Bold(true) + addGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("22")).Background(lipgloss.Color("121")).Bold(true) + deleteGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("88")).Background(lipgloss.Color("217")).Bold(true) + changeOldGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Background(lipgloss.Color("223")).Bold(true) + changeNewGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("28")).Background(lipgloss.Color("193")).Bold(true) + addMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("22")).Background(lipgloss.Color("121")).Bold(true) + deleteMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("88")).Background(lipgloss.Color("217")).Bold(true) + changeOldMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Background(lipgloss.Color("223")).Bold(true) + changeNewMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("28")).Background(lipgloss.Color("193")).Bold(true) + + commentInlineTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Background(lipgloss.Color("253")) + + syntaxKeywordColor = lipgloss.Color("55") + syntaxStringColor = lipgloss.Color("94") + syntaxCommentColor = lipgloss.Color("244") + syntaxTypeColor = lipgloss.Color("24") + syntaxFunctionColor = lipgloss.Color("130") + syntaxNumberColor = lipgloss.Color("88") + syntaxOperatorColor = lipgloss.Color("161") + syntaxPreprocessorColor = lipgloss.Color("89") +} diff --git a/internal/diffview/theme_test.go b/internal/diffview/theme_test.go new file mode 100644 index 0000000..31f7927 --- /dev/null +++ b/internal/diffview/theme_test.go @@ -0,0 +1,48 @@ +package diffview + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestDarkFromColorFGBG(t *testing.T) { + tests := []struct { + name string + value string + dark bool + ok bool + }{ + {name: "dark background", value: "15;0", dark: true, ok: true}, + {name: "light background", value: "0;15", dark: false, ok: true}, + {name: "with extra fields", value: "0;15;0", dark: true, ok: true}, + {name: "empty", value: "", dark: false, ok: false}, + {name: "non numeric", value: "foo;bar", dark: false, ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dark, ok := darkFromColorFGBG(tt.value) + if ok != tt.ok { + t.Fatalf("darkFromColorFGBG(%q) ok=%v want %v", tt.value, ok, tt.ok) + } + if dark != tt.dark { + t.Fatalf("darkFromColorFGBG(%q) dark=%v want %v", tt.value, dark, tt.dark) + } + }) + } +} + +func TestInitializeThemeExplicitModes(t *testing.T) { + defer applyDarkTheme() + + InitializeTheme("light") + if cursorRowBg != lipgloss.Color("254") { + t.Fatalf("light theme did not set expected cursor row color") + } + + InitializeTheme("dark") + if cursorRowBg != lipgloss.Color("236") { + t.Fatalf("dark theme did not set expected cursor row color") + } +}