11package theme
22
3- import "github.com/charmbracelet/lipgloss"
3+ import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "strings"
10+
11+ "github.com/charmbracelet/lipgloss"
12+ "github.com/p10node/k10s/internal/config"
13+ "sigs.k8s.io/yaml"
14+ )
415
516type Theme struct {
617 Name string
@@ -20,6 +31,122 @@ type Theme struct {
2031
2132func c (s string ) lipgloss.Color { return lipgloss .Color (s ) }
2233
34+ var (
35+ validThemeName = regexp .MustCompile (`^[a-z0-9][a-z0-9_-]*$` )
36+ validThemeColor = regexp .MustCompile (`^#[0-9a-fA-F]{6}$` )
37+ )
38+
39+ // Dir returns the custom-theme directory. K10S_THEME_DIR overrides the
40+ // default themes/ directory beside config.yaml.
41+ func Dir () string {
42+ if dir := os .Getenv ("K10S_THEME_DIR" ); dir != "" {
43+ return dir
44+ }
45+ return filepath .Join (filepath .Dir (config .Path ()), "themes" )
46+ }
47+
48+ // Load returns the built-in themes followed by custom themes from Dir.
49+ func Load () ([]Theme , error ) {
50+ themes := append ([]Theme (nil ), Themes ... )
51+ custom , err := LoadDir (Dir ())
52+ if err != nil && os .IsNotExist (err ) {
53+ return themes , nil
54+ }
55+ seen := make (map [string ]bool , len (themes )+ len (custom ))
56+ for _ , t := range themes {
57+ seen [t .Name ] = true
58+ }
59+ var duplicateErrs []error
60+ for _ , t := range custom {
61+ if seen [t .Name ] {
62+ duplicateErrs = append (duplicateErrs , fmt .Errorf ("duplicate theme name %q" , t .Name ))
63+ continue
64+ }
65+ seen [t .Name ] = true
66+ themes = append (themes , t )
67+ }
68+ return themes , errors .Join (append ([]error {err }, duplicateErrs ... )... )
69+ }
70+
71+ type themeFile struct {
72+ Name string `json:"name"`
73+ Bg string `json:"bg"`
74+ Fg string `json:"fg"`
75+ Subtle string `json:"subtle"`
76+ Border string `json:"border"`
77+ BorderOn string `json:"border_on"`
78+ Accent string `json:"accent"`
79+ Accent2 string `json:"accent2"`
80+ Ok string `json:"ok"`
81+ Warn string `json:"warn"`
82+ Err string `json:"err"`
83+ SelBg string `json:"sel_bg"`
84+ SelFg string `json:"sel_fg"`
85+ }
86+
87+ // LoadDir reads custom theme YAML files from dir in filename order.
88+ func LoadDir (dir string ) ([]Theme , error ) {
89+ entries , err := os .ReadDir (dir )
90+ if err != nil {
91+ return nil , err
92+ }
93+
94+ var (
95+ out []Theme
96+ errs []error
97+ )
98+ for _ , entry := range entries {
99+ ext := strings .ToLower (filepath .Ext (entry .Name ()))
100+ if entry .IsDir () || (ext != ".yaml" && ext != ".yml" ) {
101+ continue
102+ }
103+ data , err := os .ReadFile (filepath .Join (dir , entry .Name ()))
104+ if err != nil {
105+ errs = append (errs , fmt .Errorf ("%s: %w" , entry .Name (), err ))
106+ continue
107+ }
108+ var f themeFile
109+ if err := yaml .UnmarshalStrict (data , & f ); err != nil {
110+ errs = append (errs , fmt .Errorf ("%s: %w" , entry .Name (), err ))
111+ continue
112+ }
113+ if err := f .validate (); err != nil {
114+ errs = append (errs , fmt .Errorf ("%s: %w" , entry .Name (), err ))
115+ continue
116+ }
117+ out = append (out , Theme {
118+ Name : f .Name , Bg : c (f .Bg ), Fg : c (f .Fg ), Subtle : c (f .Subtle ),
119+ Border : c (f .Border ), BorderOn : c (f .BorderOn ), Accent : c (f .Accent ), Accent2 : c (f .Accent2 ),
120+ Ok : c (f .Ok ), Warn : c (f .Warn ), Err : c (f .Err ), SelBg : c (f .SelBg ), SelFg : c (f .SelFg ),
121+ })
122+ }
123+ return out , errors .Join (errs ... )
124+ }
125+
126+ func (f themeFile ) validate () error {
127+ fields := map [string ]string {
128+ "name" : f .Name , "bg" : f .Bg , "fg" : f .Fg , "subtle" : f .Subtle ,
129+ "border" : f .Border , "border_on" : f .BorderOn , "accent" : f .Accent ,
130+ "accent2" : f .Accent2 , "ok" : f .Ok , "warn" : f .Warn , "err" : f .Err ,
131+ "sel_bg" : f .SelBg , "sel_fg" : f .SelFg ,
132+ }
133+ for name , value := range fields {
134+ if strings .TrimSpace (value ) == "" {
135+ return fmt .Errorf ("missing required field %q" , name )
136+ }
137+ }
138+ if ! validThemeName .MatchString (f .Name ) {
139+ return fmt .Errorf ("name %q must use lowercase letters, numbers, hyphens, or underscores" , f .Name )
140+ }
141+ delete (fields , "name" )
142+ for name , value := range fields {
143+ if ! validThemeColor .MatchString (value ) {
144+ return fmt .Errorf ("field %q must be a #RRGGBB color, got %q" , name , value )
145+ }
146+ }
147+ return nil
148+ }
149+
23150var Themes = []Theme {
24151 {
25152 Name : "tokyo-night" ,
0 commit comments