(Feat): Initial Commit, Termdoku
This commit is contained in:
292
internal/theme/theme.go
Normal file
292
internal/theme/theme.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
type Palette struct {
|
||||
Background string
|
||||
Foreground string
|
||||
GridLine string
|
||||
CellBaseBG string
|
||||
CellBaseFG string
|
||||
CellFixedFG string
|
||||
CellFixedBG string
|
||||
CellSelectedBG string
|
||||
CellSelectedFG string
|
||||
CellDuplicateBG string
|
||||
CellConflictBG string
|
||||
Accent string
|
||||
}
|
||||
|
||||
type Theme struct {
|
||||
Name string
|
||||
Palette Palette
|
||||
}
|
||||
|
||||
// Light defines a theme inspired by Solarized Light.
|
||||
func Light() Theme {
|
||||
return Theme{
|
||||
Name: "solarized-light",
|
||||
Palette: Palette{
|
||||
Background: "#fdf6e3", // base3
|
||||
Foreground: "#586e75", // base01
|
||||
GridLine: "#93a1a1", // base1
|
||||
CellBaseBG: "",
|
||||
CellBaseFG: "#586e75", // base01
|
||||
CellFixedFG: "#839496", // base0
|
||||
CellFixedBG: "",
|
||||
CellSelectedBG: "#eee8d5", // base2
|
||||
CellSelectedFG: "#586e75", // base01
|
||||
CellDuplicateBG: "#f5e8c1", // Slightly darker variant
|
||||
CellConflictBG: "#ffe0e0", // Reddish conflict, still light
|
||||
Accent: "#dc322f", // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Darcula returns a dark theme inspired by Darcula.
|
||||
func Darcula() Theme {
|
||||
return Theme{
|
||||
Name: "dracula",
|
||||
Palette: Palette{
|
||||
Background: "#282a36", // background
|
||||
Foreground: "#f8f8f2", // foreground
|
||||
GridLine: "#44475a", // current line
|
||||
CellBaseBG: "",
|
||||
CellBaseFG: "#f8f8f2", // foreground
|
||||
CellFixedFG: "#6272a4", // comment
|
||||
CellFixedBG: "",
|
||||
CellSelectedBG: "#44475a", // current line
|
||||
CellSelectedFG: "#f8f8f2", // foreground
|
||||
CellDuplicateBG: "#50fa7b", // green - using a highlight for duplicate
|
||||
CellConflictBG: "#ff5555", // red
|
||||
Accent: "#bd93f9", // purple
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTheme automatically detects the terminal background and returns appropriate theme
|
||||
func DetectTheme() Theme {
|
||||
if hasLightBackground() {
|
||||
return Light()
|
||||
}
|
||||
|
||||
return Darcula()
|
||||
}
|
||||
|
||||
// hasLightBackground attempts to detect if the terminal has a light background
|
||||
func hasLightBackground() bool {
|
||||
// Primary detection using termenv
|
||||
if !termenv.HasDarkBackground() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Additional environment variable checks
|
||||
term := strings.ToLower(os.Getenv("TERM"))
|
||||
colorterm := strings.ToLower(os.Getenv("COLORTERM"))
|
||||
termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
|
||||
|
||||
// Check for light theme indicators in environment variables
|
||||
lightIndicators := []string{"light", "bright", "white"}
|
||||
for _, indicator := range lightIndicators {
|
||||
if strings.Contains(term, indicator) ||
|
||||
strings.Contains(colorterm, indicator) ||
|
||||
strings.Contains(termProgram, indicator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check iTerm2 profile
|
||||
if iterm2Profile := strings.ToLower(os.Getenv("ITERM_PROFILE")); iterm2Profile != "" {
|
||||
for _, indicator := range lightIndicators {
|
||||
if strings.Contains(iterm2Profile, indicator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AdaptiveColors provides theme-aware color mappings
|
||||
type AdaptiveColors struct {
|
||||
theme Theme
|
||||
}
|
||||
|
||||
func NewAdaptiveColors(t Theme) AdaptiveColors {
|
||||
return AdaptiveColors{theme: t}
|
||||
}
|
||||
|
||||
// GetDifficultyColors returns colors for each difficulty level adapted to the theme
|
||||
func (ac AdaptiveColors) GetDifficultyColors() map[string]string {
|
||||
if ac.theme.Name == "solarized-light" {
|
||||
// Solarized Light theme colors
|
||||
return map[string]string{
|
||||
"Easy": "#2aa198", // cyan
|
||||
"Normal": "#859900", // green
|
||||
"Hard": "#cb4b16", // orange
|
||||
"Lunatic": "#6c71c4", // violet
|
||||
"Daily": "#859900", // green
|
||||
}
|
||||
}
|
||||
// Dracula theme colors
|
||||
return map[string]string{
|
||||
"Easy": "#8be9fd", // cyan
|
||||
"Normal": "#50fa7b", // green
|
||||
"Hard": "#ffb86c", // orange
|
||||
"Lunatic": "#bd93f9", // purple
|
||||
"Daily": "#50fa7b", // green
|
||||
}
|
||||
}
|
||||
|
||||
// GetGradientColors returns gradient color pairs adapted to the theme
|
||||
func (ac AdaptiveColors) GetGradientColors() map[string][2]string {
|
||||
if ac.theme.Name == "solarized-light" {
|
||||
// Solarized Light theme gradients
|
||||
return map[string][2]string{
|
||||
"banner": {"#6c71c4", "#b58900"}, // violet to yellow
|
||||
"easy": {"#2aa198", "#268bd2"}, // cyan to blue
|
||||
"normal": {"#859900", "#cb4b16"}, // green to orange
|
||||
"daily": {"#859900", "#cb4b16"}, // green to orange
|
||||
"hard": {"#dc322f", "#cb4b16"}, // red to orange
|
||||
"lunatic": {"#6c71c4", "#d33682"}, // violet to magenta
|
||||
"complete": {"#859900", "#268bd2"}, // success green to blue
|
||||
}
|
||||
}
|
||||
// Dracula theme gradients
|
||||
return map[string][2]string{
|
||||
"banner": {"#bd93f9", "#ff79c6"}, // purple to pink
|
||||
"easy": {"#8be9fd", "#6272a4"}, // cyan to comment
|
||||
"normal": {"#50fa7b", "#ffb86c"}, // green to orange
|
||||
"daily": {"#50fa7b", "#ffb86c"}, // green to orange
|
||||
"hard": {"#ffb86c", "#ff5555"}, // orange to red
|
||||
"lunatic": {"#bd93f9", "#ff79c6"}, // purple to pink
|
||||
"complete": {"#50fa7b", "#ff79c6"}, // green to pink
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccentColors returns various accent colors adapted to the theme
|
||||
func (ac AdaptiveColors) GetAccentColors() map[string]string {
|
||||
if ac.theme.Name == "solarized-light" {
|
||||
// Solarized Light theme accents
|
||||
return map[string]string{
|
||||
"selected": "#cb4b16", // orange
|
||||
"panel": "#839496", // base0
|
||||
"success": "#859900", // green
|
||||
"error": "#dc322f", // red
|
||||
}
|
||||
}
|
||||
// Dracula theme accents
|
||||
return map[string]string{
|
||||
"selected": "#ff79c6", // pink
|
||||
"panel": "#44475a", // current line
|
||||
"success": "#50fa7b", // green
|
||||
"error": "#ff5555", // red
|
||||
}
|
||||
}
|
||||
|
||||
func BaseStyle(t Theme) lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(t.Palette.Foreground)).Background(lipgloss.Color(t.Palette.Background))
|
||||
}
|
||||
|
||||
// LoadCustomTheme loads a custom theme from ~/.termdoku/themes/
|
||||
func LoadCustomTheme(name string) (Theme, error) {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
themePath := filepath.Join(h, ".termdoku", "themes", name+".toml")
|
||||
data, err := os.ReadFile(themePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Theme{}, errors.New("custom theme not found: " + name)
|
||||
}
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
var theme Theme
|
||||
if err := toml.Unmarshal(data, &theme); err != nil {
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
// Set name if not specified in file
|
||||
if theme.Name == "" {
|
||||
theme.Name = name
|
||||
}
|
||||
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
// GetTheme returns the appropriate theme based on config or auto-detection
|
||||
func GetTheme(configTheme string) Theme {
|
||||
switch configTheme {
|
||||
case "light":
|
||||
return Light()
|
||||
case "dark":
|
||||
return Darcula()
|
||||
case "auto", "":
|
||||
return DetectTheme()
|
||||
default:
|
||||
if theme, err := LoadCustomTheme(configTheme); err == nil {
|
||||
return theme
|
||||
}
|
||||
// Fall back to auto-detection
|
||||
return DetectTheme()
|
||||
}
|
||||
}
|
||||
|
||||
// CreateExampleTheme creates an example custom theme file for users
|
||||
func CreateExampleTheme() error {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
themesDir := filepath.Join(h, ".termdoku", "themes")
|
||||
if err := os.MkdirAll(themesDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
examplePath := filepath.Join(themesDir, "example.toml")
|
||||
|
||||
// Don't overwrite if exists
|
||||
if _, err := os.Stat(examplePath); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Example theme inspired by Monokai
|
||||
example := Theme{
|
||||
Name: "monokai-example",
|
||||
Palette: Palette{
|
||||
Background: "#272822",
|
||||
Foreground: "#f8f8f2",
|
||||
GridLine: "#49483e",
|
||||
CellBaseBG: "",
|
||||
CellBaseFG: "#f8f8f2",
|
||||
CellFixedFG: "#75715e",
|
||||
CellFixedBG: "",
|
||||
CellSelectedBG: "#3e3d32",
|
||||
CellSelectedFG: "#f8f8f2",
|
||||
CellDuplicateBG: "#a6e22e", // Green for duplicates
|
||||
CellConflictBG: "#f92672", // Pink/Red for conflicts
|
||||
Accent: "#e6db74", // Yellow accent
|
||||
},
|
||||
}
|
||||
|
||||
data, err := toml.Marshal(example)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(examplePath, data, 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user