Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
Expand All @@ -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)
Expand Down
25 changes: 17 additions & 8 deletions internal/diffview/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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
}
Expand Down
160 changes: 160 additions & 0 deletions internal/diffview/theme.go
Original file line number Diff line number Diff line change
@@ -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")
}
48 changes: 48 additions & 0 deletions internal/diffview/theme_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}