Skip to content

Commit e0c7f26

Browse files
committed
feat: implement dynamic theme loading from external YAML files and custom directories
1 parent 42b6d10 commit e0c7f26

12 files changed

Lines changed: 566 additions & 38 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,16 @@ close. No cluster data leaves your machine unless you press enter in AI
185185
mode. The only other network call k10s makes on its own is the once-a-day
186186
update check, which asks GitHub for a version number and nothing else.
187187

188-
### Seven themes, previewed live before you commit
188+
### Seven built-in themes, plus your own — previewed live
189189

190190
`tokyo-night` (default) · `catppuccin-mocha` · `dracula` · `nord` ·
191191
`gruvbox-dark` · `solarized-light` · `matrix`
192192

193193
`T` cycles, `/theme` opens a picker that **applies each theme as you move
194194
through the list** - you judge it on the real UI, not on a name - and `esc`
195-
puts back whatever you had. No hardcoded color exists anywhere in the render
196-
path, so [adding a theme is one struct](docs/themes.md).
195+
puts back whatever you had. Drop a YAML palette into `~/.k10s/themes`, restart,
196+
and it appears in the same picker — no rebuild needed. See the
197+
[custom-theme guide and installable demo](docs/themes.md).
197198

198199
### Details that only show up after a long day
199200

docs/config.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
`K10S_CONFIG=/path/to/file`, used by `cmd/shot` in tests so it never touches
55
a real user's file).
66

7+
Custom themes live in the adjacent `themes/` folder (normally
8+
`~/.k10s/themes`) and can be moved independently with `K10S_THEME_DIR`. Theme
9+
files are documented in [themes.md](themes.md); only the selected theme name
10+
is stored in `config.yaml`.
11+
712
## What's saved
813

914
```yaml
@@ -68,7 +73,7 @@ toast — `config save failed: …` — and never blocks the UI.
6873
## Load on startup
6974

7075
`New()` calls `loadConfig()` once: matches `theme` against
71-
`theme.Themes[i].Name`, applies `cli` and (for the matching context) the
76+
the combined built-in and custom theme names, applies `cli` and (for the matching context) the
7277
`namespace` directly, and fills the AI config (provider/url/model/key) from
7378
whatever is present. A missing file is not an error — the built-in defaults
7479
(tokyo-night, the kubeconfig context's own namespace, `kubectl`, Anthropic

docs/themes.md

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# Themes
22

3-
Four ways in: `T` / `ctrl+t` cycles, `/theme <name>` jumps straight to one,
4-
clicking the `theme <name> ⟳` label in the banner cycles, and bare `/theme`
5-
opens the **picker**.
3+
`T` / `ctrl+t` cycles forward/backward. `/theme` and clicking the
4+
`theme <name> ⟳` label in the banner open the **picker**.
65

76
The picker shows each theme with a swatch of its own colors and applies the
87
highlighted one **immediately**, so you judge it on the real UI rather than
@@ -19,7 +18,76 @@ and `esc` restores whatever was active before you opened it.
1918
| 5 | `solarized-light` | light background |
2019
| 6 | `matrix` | black + green |
2120

22-
## Palette contract (`internal/theme/theme.go`)
21+
## Custom themes
22+
23+
k10s loads custom `.yaml` and `.yml` files at startup and appends every valid
24+
theme to the normal theme picker and `T` / `ctrl+t` cycle. The default folder
25+
is `~/.k10s/themes` (the `themes` folder beside `config.yaml`). If
26+
`K10S_CONFIG` points elsewhere, the folder follows it; `K10S_THEME_DIR`
27+
overrides the theme folder directly.
28+
29+
### Install the demo theme
30+
31+
From a cloned repository on macOS or Linux:
32+
33+
```bash
34+
mkdir -p ~/.k10s/themes
35+
install -m 0644 examples/themes/rose-pine.yaml ~/.k10s/themes/rose-pine.yaml
36+
k10s
37+
```
38+
39+
Without cloning:
40+
41+
```bash
42+
mkdir -p ~/.k10s/themes
43+
curl -fsSL https://raw.githubusercontent.com/p10node/k10s/main/examples/themes/rose-pine.yaml \
44+
-o ~/.k10s/themes/rose-pine.yaml
45+
k10s
46+
```
47+
48+
On Windows PowerShell:
49+
50+
```powershell
51+
New-Item -ItemType Directory -Force "$HOME/.k10s/themes" | Out-Null
52+
Invoke-WebRequest `
53+
https://raw.githubusercontent.com/p10node/k10s/main/examples/themes/rose-pine.yaml `
54+
-OutFile "$HOME/.k10s/themes/rose-pine.yaml"
55+
k10s
56+
```
57+
58+
Open `/theme`, preview `rose-pine`, then press `enter` to save it. Themes are
59+
read at startup, so restart k10s after adding or editing a file. To uninstall
60+
one, delete its file and restart; if it was selected, k10s safely falls back
61+
to `tokyo-night`.
62+
63+
### Create a theme
64+
65+
Copy [the demo](../examples/themes/rose-pine.yaml), give it a unique name,
66+
and replace the colors:
67+
68+
```yaml
69+
name: my-theme
70+
bg: "#101828"
71+
fg: "#f2f4f7"
72+
subtle: "#98a2b3"
73+
border: "#344054"
74+
border_on: "#53b1fd"
75+
accent: "#53b1fd"
76+
accent2: "#b692f6"
77+
ok: "#32d583"
78+
warn: "#fec84b"
79+
err: "#f97066"
80+
sel_bg: "#1d2939"
81+
sel_fg: "#ffffff"
82+
```
83+
84+
All fields are required. Colors must use quoted `#RRGGBB` values. Names must
85+
start with a lowercase letter or number and contain only lowercase letters,
86+
numbers, `-`, or `_`. Names must not duplicate a built-in or another custom
87+
theme. Parsing is strict: a bad or unknown field is reported in the status
88+
bar, that file is skipped, and other valid custom themes still load.
89+
90+
### Palette contract (`internal/theme/theme.go`)
2391

2492
```go
2593
type Theme struct {
@@ -36,7 +104,6 @@ type Theme struct {
36104
```
37105

38106
Every render derives colors from the active theme — there are no hardcoded
39-
colors in `internal/ui`. Adding a theme = appending one struct to
40-
`theme.Themes`; it immediately appears in the `T` cycle, in `/theme`, and in
41-
the picker (whose swatch is generated from the palette, so nothing else needs
42-
touching).
107+
colors in `internal/ui`. Built-in themes live in `theme.Themes`; users add the
108+
same palette as YAML without recompiling. Both immediately appear in the `T`
109+
cycle and `/theme`, and the picker generates its swatch from the palette.

examples/themes/rose-pine.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Rose Pine — demo custom theme for k10s.
2+
# Install: mkdir -p ~/.k10s/themes && cp examples/themes/rose-pine.yaml ~/.k10s/themes/
3+
name: rose-pine
4+
bg: "#191724"
5+
fg: "#e0def4"
6+
subtle: "#6e6a86"
7+
border: "#403d52"
8+
border_on: "#c4a7e7"
9+
accent: "#c4a7e7"
10+
accent2: "#ebbcba"
11+
ok: "#9ccfd8"
12+
warn: "#f6c177"
13+
err: "#eb6f92"
14+
sel_bg: "#26233a"
15+
sel_fg: "#e0def4"

internal/theme/theme.go

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
package 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

516
type Theme struct {
617
Name string
@@ -20,6 +31,122 @@ type Theme struct {
2031

2132
func 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+
23150
var Themes = []Theme{
24151
{
25152
Name: "tokyo-night",

0 commit comments

Comments
 (0)