From 41613586c503b24033e25a8a5f1943ee3527b184 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 06:20:57 -0400 Subject: [PATCH] feat: theme service with 7 built-in terminal color schemes Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/theme/builtins.go | 94 ++++++++++++++++++++++++++++++++++ internal/theme/service.go | 85 ++++++++++++++++++++++++++++++ internal/theme/service_test.go | 60 ++++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 internal/theme/builtins.go create mode 100644 internal/theme/service.go create mode 100644 internal/theme/service_test.go diff --git a/internal/theme/builtins.go b/internal/theme/builtins.go new file mode 100644 index 0000000..c216685 --- /dev/null +++ b/internal/theme/builtins.go @@ -0,0 +1,94 @@ +package theme + +type Theme struct { + ID int64 `json:"id"` + Name string `json:"name"` + Foreground string `json:"foreground"` + Background string `json:"background"` + Cursor string `json:"cursor"` + Black string `json:"black"` + Red string `json:"red"` + Green string `json:"green"` + Yellow string `json:"yellow"` + Blue string `json:"blue"` + Magenta string `json:"magenta"` + Cyan string `json:"cyan"` + White string `json:"white"` + BrightBlack string `json:"brightBlack"` + BrightRed string `json:"brightRed"` + BrightGreen string `json:"brightGreen"` + BrightYellow string `json:"brightYellow"` + BrightBlue string `json:"brightBlue"` + BrightMagenta string `json:"brightMagenta"` + BrightCyan string `json:"brightCyan"` + BrightWhite string `json:"brightWhite"` + SelectionBg string `json:"selectionBg,omitempty"` + SelectionFg string `json:"selectionFg,omitempty"` + IsBuiltin bool `json:"isBuiltin"` +} + +var BuiltinThemes = []Theme{ + { + Name: "Dracula", IsBuiltin: true, + Foreground: "#f8f8f2", Background: "#282a36", Cursor: "#f8f8f2", + Black: "#21222c", Red: "#ff5555", Green: "#50fa7b", Yellow: "#f1fa8c", + Blue: "#bd93f9", Magenta: "#ff79c6", Cyan: "#8be9fd", White: "#f8f8f2", + BrightBlack: "#6272a4", BrightRed: "#ff6e6e", BrightGreen: "#69ff94", + BrightYellow: "#ffffa5", BrightBlue: "#d6acff", BrightMagenta: "#ff92df", + BrightCyan: "#a4ffff", BrightWhite: "#ffffff", + }, + { + Name: "Nord", IsBuiltin: true, + Foreground: "#d8dee9", Background: "#2e3440", Cursor: "#d8dee9", + Black: "#3b4252", Red: "#bf616a", Green: "#a3be8c", Yellow: "#ebcb8b", + Blue: "#81a1c1", Magenta: "#b48ead", Cyan: "#88c0d0", White: "#e5e9f0", + BrightBlack: "#4c566a", BrightRed: "#bf616a", BrightGreen: "#a3be8c", + BrightYellow: "#ebcb8b", BrightBlue: "#81a1c1", BrightMagenta: "#b48ead", + BrightCyan: "#8fbcbb", BrightWhite: "#eceff4", + }, + { + Name: "Monokai", IsBuiltin: true, + Foreground: "#f8f8f2", Background: "#272822", Cursor: "#f8f8f0", + Black: "#272822", Red: "#f92672", Green: "#a6e22e", Yellow: "#f4bf75", + Blue: "#66d9ef", Magenta: "#ae81ff", Cyan: "#a1efe4", White: "#f8f8f2", + BrightBlack: "#75715e", BrightRed: "#f92672", BrightGreen: "#a6e22e", + BrightYellow: "#f4bf75", BrightBlue: "#66d9ef", BrightMagenta: "#ae81ff", + BrightCyan: "#a1efe4", BrightWhite: "#f9f8f5", + }, + { + Name: "One Dark", IsBuiltin: true, + Foreground: "#abb2bf", Background: "#282c34", Cursor: "#528bff", + Black: "#282c34", Red: "#e06c75", Green: "#98c379", Yellow: "#e5c07b", + Blue: "#61afef", Magenta: "#c678dd", Cyan: "#56b6c2", White: "#abb2bf", + BrightBlack: "#545862", BrightRed: "#e06c75", BrightGreen: "#98c379", + BrightYellow: "#e5c07b", BrightBlue: "#61afef", BrightMagenta: "#c678dd", + BrightCyan: "#56b6c2", BrightWhite: "#c8ccd4", + }, + { + Name: "Solarized Dark", IsBuiltin: true, + Foreground: "#839496", Background: "#002b36", Cursor: "#839496", + Black: "#073642", Red: "#dc322f", Green: "#859900", Yellow: "#b58900", + Blue: "#268bd2", Magenta: "#d33682", Cyan: "#2aa198", White: "#eee8d5", + BrightBlack: "#002b36", BrightRed: "#cb4b16", BrightGreen: "#586e75", + BrightYellow: "#657b83", BrightBlue: "#839496", BrightMagenta: "#6c71c4", + BrightCyan: "#93a1a1", BrightWhite: "#fdf6e3", + }, + { + Name: "Gruvbox Dark", IsBuiltin: true, + Foreground: "#ebdbb2", Background: "#282828", Cursor: "#ebdbb2", + Black: "#282828", Red: "#cc241d", Green: "#98971a", Yellow: "#d79921", + Blue: "#458588", Magenta: "#b16286", Cyan: "#689d6a", White: "#a89984", + BrightBlack: "#928374", BrightRed: "#fb4934", BrightGreen: "#b8bb26", + BrightYellow: "#fabd2f", BrightBlue: "#83a598", BrightMagenta: "#d3869b", + BrightCyan: "#8ec07c", BrightWhite: "#ebdbb2", + }, + { + Name: "MobaXTerm Classic", IsBuiltin: true, + Foreground: "#ececec", Background: "#242424", Cursor: "#b4b4c0", + Black: "#000000", Red: "#aa4244", Green: "#7e8d53", Yellow: "#e4b46d", + Blue: "#6e9aba", Magenta: "#9e5085", Cyan: "#80d5cf", White: "#cccccc", + BrightBlack: "#808080", BrightRed: "#cc7b7d", BrightGreen: "#a5b17c", + BrightYellow: "#ecc995", BrightBlue: "#96b6cd", BrightMagenta: "#c083ac", + BrightCyan: "#a9e2de", BrightWhite: "#cccccc", + }, +} diff --git a/internal/theme/service.go b/internal/theme/service.go new file mode 100644 index 0000000..3a2bfdf --- /dev/null +++ b/internal/theme/service.go @@ -0,0 +1,85 @@ +package theme + +import ( + "database/sql" + "fmt" +) + +type ThemeService struct { + db *sql.DB +} + +func NewThemeService(db *sql.DB) *ThemeService { + return &ThemeService{db: db} +} + +func (s *ThemeService) SeedBuiltins() error { + for _, t := range BuiltinThemes { + _, err := s.db.Exec( + `INSERT OR IGNORE INTO themes (name, foreground, background, cursor, + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, bright_blue, + bright_magenta, bright_cyan, bright_white, selection_bg, selection_fg, is_builtin) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)`, + t.Name, t.Foreground, t.Background, t.Cursor, + t.Black, t.Red, t.Green, t.Yellow, t.Blue, t.Magenta, t.Cyan, t.White, + t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue, + t.BrightMagenta, t.BrightCyan, t.BrightWhite, t.SelectionBg, t.SelectionFg, + ) + if err != nil { + return fmt.Errorf("seed theme %s: %w", t.Name, err) + } + } + return nil +} + +func (s *ThemeService) List() ([]Theme, error) { + rows, err := s.db.Query( + `SELECT id, name, foreground, background, cursor, + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, bright_blue, + bright_magenta, bright_cyan, bright_white, + COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin + FROM themes ORDER BY is_builtin DESC, name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var themes []Theme + for rows.Next() { + var t Theme + if err := rows.Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor, + &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White, + &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue, + &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite, + &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin); err != nil { + return nil, err + } + themes = append(themes, t) + } + if err := rows.Err(); err != nil { + return nil, err + } + return themes, nil +} + +func (s *ThemeService) GetByName(name string) (*Theme, error) { + var t Theme + err := s.db.QueryRow( + `SELECT id, name, foreground, background, cursor, + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, bright_blue, + bright_magenta, bright_cyan, bright_white, + COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin + FROM themes WHERE name = ?`, name, + ).Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor, + &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White, + &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue, + &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite, + &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin) + if err != nil { + return nil, fmt.Errorf("get theme %s: %w", name, err) + } + return &t, nil +} diff --git a/internal/theme/service_test.go b/internal/theme/service_test.go new file mode 100644 index 0000000..2355885 --- /dev/null +++ b/internal/theme/service_test.go @@ -0,0 +1,60 @@ +package theme + +import ( + "path/filepath" + "testing" + + "github.com/vstockwell/wraith/internal/db" +) + +func setupTestDB(t *testing.T) *ThemeService { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return NewThemeService(d) +} + +func TestSeedBuiltins(t *testing.T) { + svc := setupTestDB(t) + if err := svc.SeedBuiltins(); err != nil { + t.Fatalf("SeedBuiltins() error: %v", err) + } + + themes, err := svc.List() + if err != nil { + t.Fatalf("List() error: %v", err) + } + if len(themes) != len(BuiltinThemes) { + t.Errorf("len(themes) = %d, want %d", len(themes), len(BuiltinThemes)) + } +} + +func TestSeedBuiltinsIdempotent(t *testing.T) { + svc := setupTestDB(t) + svc.SeedBuiltins() + svc.SeedBuiltins() + + themes, _ := svc.List() + if len(themes) != len(BuiltinThemes) { + t.Errorf("len(themes) = %d after double seed, want %d", len(themes), len(BuiltinThemes)) + } +} + +func TestGetByName(t *testing.T) { + svc := setupTestDB(t) + svc.SeedBuiltins() + + theme, err := svc.GetByName("Dracula") + if err != nil { + t.Fatalf("GetByName() error: %v", err) + } + if theme.Background != "#282a36" { + t.Errorf("Background = %q, want %q", theme.Background, "#282a36") + } +}