summaryrefslogtreecommitdiff
path: root/vrsite/themes.go
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite/themes.go')
-rw-r--r--vrsite/themes.go445
1 files changed, 445 insertions, 0 deletions
diff --git a/vrsite/themes.go b/vrsite/themes.go
new file mode 100644
index 0000000..f185fab
--- /dev/null
+++ b/vrsite/themes.go
@@ -0,0 +1,445 @@
+// themes.go — the themes this machine already has. Zed keeps families of them
+// in JSON, Helix keeps one TOML each with a palette and single inheritance;
+// both are read into the same *Theme, so the browser can wear either.
+//
+// Discovery is best-effort: a theme that will not parse is skipped, never
+// fatal. What the site is built with stays what -theme and -light-theme say —
+// these only widen what the picker can offer.
+package main
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+)
+
+// themeSet is those themes plus the two files the browser switches with: one
+// CSS block per theme, and the list the picker draws from.
+type themeSet struct {
+ List []*Theme
+ CSS []byte // themes.css — one :root[data-theme="…"] block per theme
+ JS []byte // themes.js — window.VRSITE_THEMES, the picker's menu
+}
+
+func newThemeSet(dark, light *Theme) *themeSet {
+ return themesFrom(discoverThemes(), dark, light)
+}
+
+// themesFrom writes the two files for a given list of themes. The keys the
+// site paints with come from the themes it was built with, since those are the
+// ones the stylesheet has classes for.
+func themesFrom(list []*Theme, dark, light *Theme) *themeSet {
+ ts := &themeSet{List: list}
+ keys := syntaxKeys(dark, light)
+ var css, js bytes.Buffer
+ writeThemesCSS(&css, ts.List, keys)
+ writeThemesJS(&js, ts.List)
+ ts.CSS, ts.JS = css.Bytes(), js.Bytes()
+ return ts
+}
+
+func (ts *themeSet) count(source string) int {
+ n := 0
+ for _, t := range ts.List {
+ if t.Source == source {
+ n++
+ }
+ }
+ return n
+}
+
+// discoverThemes reads every theme both editors can see, user directories
+// first so a theme you installed wins the id over one that merely ships.
+func discoverThemes() []*Theme {
+ var out []*Theme
+ seen := map[string]bool{}
+ add := func(t *Theme) {
+ if t == nil || t.ID == "" || t.Bg == "" || t.Fg == "" || seen[t.ID] {
+ return
+ }
+ seen[t.ID] = true
+ out = append(out, t)
+ }
+ for _, path := range globAll(zedThemeGlobs()) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ fam, err := parseZedFamily(b)
+ if err != nil {
+ continue
+ }
+ for _, t := range fam {
+ add(t)
+ }
+ }
+ dirs := helixThemeDirs()
+ for _, dir := range dirs {
+ for _, path := range globAll([]string{filepath.Join(dir, "*.toml")}) {
+ add(loadHelixTheme(path, dirs))
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Source != out[j].Source {
+ return out[i].Source < out[j].Source
+ }
+ return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
+ })
+ return out
+}
+
+func globAll(pats []string) []string {
+ var out []string
+ for _, p := range pats {
+ m, _ := filepath.Glob(p)
+ sort.Strings(m)
+ out = append(out, m...)
+ }
+ return out
+}
+
+func configHome() string {
+ if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
+ return d
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".config")
+}
+
+func dataHome() string {
+ if d := os.Getenv("XDG_DATA_HOME"); d != "" {
+ return d
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".local", "share")
+}
+
+// zedThemeGlobs: themes you dropped in yourself, then the ones extensions
+// brought. Zed's own built-ins live inside the binary, so they are not here.
+func zedThemeGlobs() []string {
+ home, _ := os.UserHomeDir()
+ mac := filepath.Join(home, "Library", "Application Support", "Zed")
+ return []string{
+ filepath.Join(configHome(), "zed", "themes", "*.json"),
+ filepath.Join(mac, "themes", "*.json"),
+ filepath.Join(dataHome(), "zed", "extensions", "installed", "*", "themes", "*.json"),
+ filepath.Join(mac, "extensions", "installed", "*", "themes", "*.json"),
+ }
+}
+
+// helixThemeDirs: yours first, then whichever runtime the installed hx uses —
+// found the way helix itself looks, by walking out from the binary.
+func helixThemeDirs() []string {
+ home, _ := os.UserHomeDir()
+ dirs := []string{filepath.Join(configHome(), "helix", "themes")}
+ var runtimes []string
+ if r := os.Getenv("HELIX_RUNTIME"); r != "" {
+ runtimes = append(runtimes, r)
+ }
+ runtimes = append(runtimes,
+ filepath.Join(configHome(), "helix", "runtime"),
+ filepath.Join(dataHome(), "helix", "runtime"))
+ if bin, err := exec.LookPath("hx"); err == nil {
+ if bin, err = filepath.EvalSymlinks(bin); err == nil {
+ dir := filepath.Dir(bin)
+ runtimes = append(runtimes,
+ filepath.Join(dir, "runtime"),
+ filepath.Join(filepath.Dir(dir), "lib", "helix", "runtime"),
+ filepath.Join(filepath.Dir(dir), "share", "helix", "runtime"))
+ }
+ }
+ runtimes = append(runtimes,
+ "/usr/lib/helix/runtime", "/usr/share/helix/runtime",
+ "/usr/local/lib/helix/runtime", "/opt/homebrew/opt/helix/libexec/runtime",
+ filepath.Join(home, ".cargo", "share", "helix", "runtime"))
+ seen := map[string]bool{}
+ for _, r := range runtimes {
+ d := filepath.Join(r, "themes")
+ if seen[d] {
+ continue
+ }
+ seen[d] = true
+ if fi, err := os.Stat(d); err == nil && fi.IsDir() {
+ dirs = append(dirs, d)
+ }
+ }
+ return dirs
+}
+
+// ── helix ─────────────────────────────────────────────────────────────────
+//
+// A helix theme is scope -> style, with colors named out of a [palette] at the
+// bottom, and may inherit one other theme by name. Inheritance merges both
+// halves before anything resolves, which is what lets a child theme restate
+// the palette alone (catppuccin_latte is catppuccin_mocha with new colors).
+
+type helixStyle struct {
+ fg, bg string // palette names, hex, or one of the sixteen terminal names
+ italic bool
+ bold bool
+}
+
+type helixRaw struct {
+ scopes map[string]helixStyle
+ palette map[string]string
+}
+
+func loadHelixTheme(path string, dirs []string) *Theme {
+ raw := readHelixRaw(path, dirs, 0)
+ if raw == nil {
+ return nil
+ }
+ name := strings.TrimSuffix(filepath.Base(path), ".toml")
+ return helixTheme(name, raw)
+}
+
+func readHelixRaw(path string, dirs []string, depth int) *helixRaw {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ var doc map[string]any
+ if err := toml.Unmarshal(b, &doc); err != nil {
+ return nil
+ }
+ raw := &helixRaw{scopes: map[string]helixStyle{}, palette: map[string]string{}}
+ if parent, _ := doc["inherits"].(string); parent != "" && depth < 8 {
+ if p := findHelixTheme(parent, dirs); p != "" {
+ if base := readHelixRaw(p, dirs, depth+1); base != nil {
+ raw = base
+ }
+ }
+ }
+ if pal, ok := doc["palette"].(map[string]any); ok {
+ for k, v := range pal {
+ if s, ok := v.(string); ok {
+ raw.palette[k] = s
+ }
+ }
+ }
+ delete(doc, "palette")
+ delete(doc, "inherits")
+ flattenHelix("", doc, raw.scopes)
+ return raw
+}
+
+func findHelixTheme(name string, dirs []string) string {
+ if name == "" || strings.ContainsAny(name, "/\\.") {
+ return ""
+ }
+ for _, d := range dirs {
+ p := filepath.Join(d, name+".toml")
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+ }
+ return ""
+}
+
+// flattenHelix walks the decoded document into scope -> style. A table is a
+// style when it holds only style keys; anything else is a dotted scope name
+// someone wrote out as a table, so it keeps descending.
+func flattenHelix(prefix string, m map[string]any, into map[string]helixStyle) {
+ for k, v := range m {
+ key := k
+ if prefix != "" {
+ key = prefix + "." + k
+ }
+ switch v := v.(type) {
+ case string:
+ into[key] = helixStyle{fg: v}
+ case map[string]any:
+ if st, ok := helixStyleOf(v); ok {
+ into[key] = st
+ } else {
+ flattenHelix(key, v, into)
+ }
+ }
+ }
+}
+
+func helixStyleOf(m map[string]any) (helixStyle, bool) {
+ var st helixStyle
+ for k, v := range m {
+ switch k {
+ case "fg":
+ st.fg, _ = v.(string)
+ case "bg":
+ st.bg, _ = v.(string)
+ case "modifiers":
+ mods, _ := v.([]any)
+ for _, mod := range mods {
+ switch mod {
+ case "italic":
+ st.italic = true
+ case "bold":
+ st.bold = true
+ }
+ }
+ case "underline", "underline_style", "underline_color":
+ // decoration we have no use for, but still a style
+ default:
+ return helixStyle{}, false
+ }
+ }
+ return st, true
+}
+
+// the sixteen names a helix theme may use without a palette entry
+var termHex = map[string]string{
+ "black": "#000000", "red": "#cd3131", "green": "#0dbc79", "yellow": "#e5e510",
+ "blue": "#2472c8", "magenta": "#bc3fbc", "cyan": "#11a8cd", "gray": "#8a8a8a",
+ "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543",
+ "light-blue": "#3b8eea", "light-magenta": "#d670d6", "light-cyan": "#29b8db",
+ "light-gray": "#c0c0c0", "white": "#e5e5e5",
+}
+
+func (r *helixRaw) color(name string) string {
+ if name == "" || name == "default" {
+ return ""
+ }
+ if c, ok := r.palette[name]; ok {
+ name = c
+ }
+ if strings.HasPrefix(name, "#") {
+ return hex6(okColor(name))
+ }
+ return termHex[name]
+}
+
+// fg / bg take the first of several scopes that names one, so a theme that
+// skips a scope is answered by whatever it does say. Scope names fall back
+// along their dots the way helix resolves them.
+func (r *helixRaw) fg(scopes ...string) string { return r.pick(true, scopes) }
+func (r *helixRaw) bg(scopes ...string) string { return r.pick(false, scopes) }
+
+func (r *helixRaw) pick(wantFg bool, scopes []string) string {
+ for _, scope := range scopes {
+ for key := scope; key != ""; {
+ if st, ok := r.scopes[key]; ok {
+ c := st.bg
+ if wantFg {
+ c = st.fg
+ }
+ if c := r.color(c); c != "" {
+ return c
+ }
+ }
+ i := strings.LastIndex(key, ".")
+ if i < 0 {
+ break
+ }
+ key = key[:i]
+ }
+ }
+ return ""
+}
+
+// zed syntax keys helix spells differently; everything else either matches or
+// falls back along its dots (punctuation.bracket -> punctuation).
+var helixAlias = map[string]string{
+ "number": "constant.numeric",
+ "boolean": "constant.builtin.boolean",
+ "property": "variable.other.member",
+ "preproc": "keyword.directive",
+ "primary": "variable",
+ "enum": "type.enum",
+ "string.escape": "constant.character.escape",
+ "string.regex": "string.regexp",
+ "variable.special": "variable.builtin",
+ "comment.doc": "comment.block.documentation",
+ "link_uri": "markup.link.url",
+ "link_text": "markup.link.text",
+ "title": "markup.heading",
+ "emphasis": "markup.italic",
+ "emphasis.strong": "markup.bold",
+}
+
+func helixTheme(name string, raw *helixRaw) *Theme {
+ bg := raw.bg("ui.background", "ui.window")
+ fg := raw.fg("ui.text", "ui.window")
+ if bg == "" || fg == "" {
+ return nil // a theme that paints on the terminal's colors, not ours
+ }
+ t := &Theme{
+ ID: "helix/" + slug(name), Source: "helix", Name: name,
+ Appearance: "dark", Bg: bg, Fg: fg,
+ Syntax: map[string]SyntaxStyle{},
+ }
+ if lum(bg) > 0.5 {
+ t.Appearance = "light"
+ }
+ // A helix theme dresses an editor, not a web page, so it is read for what
+ // the page needs and fill() closes the rest. The quiet backgrounds come
+ // first: a selected menu row is often the theme's loudest color.
+ t.Panel = raw.bg("ui.statusline", "ui.menu", "ui.popup")
+ t.Hover = raw.bg("ui.cursorline.primary", "ui.selection", "ui.menu.selected")
+ t.Border = raw.fg("ui.window")
+ t.LineNo = raw.fg("ui.linenr")
+ t.Muted = raw.fg("comment", "ui.linenr")
+ t.Accent = raw.fg("function", "keyword", "type")
+ t.Note = raw.fg("warning", "diagnostic.warning", "constant.numeric")
+
+ for scope, st := range raw.scopes {
+ if strings.HasPrefix(scope, "ui.") {
+ continue
+ }
+ if c := raw.color(st.fg); c != "" {
+ t.Syntax[scope] = syntaxStyle(c, st.italic, st.bold)
+ }
+ }
+ for key, scope := range helixAlias {
+ if _, ok := t.Syntax[key]; ok {
+ continue
+ }
+ if st, ok := t.Syntax[resolveKey(t, scope)]; ok {
+ t.Syntax[key] = st
+ }
+ }
+ t.fill()
+ t.setPlayers(t.syntaxColors())
+ return t
+}
+
+func syntaxStyle(color string, italic, bold bool) SyntaxStyle {
+ s := SyntaxStyle{Color: color}
+ if italic {
+ it := "italic"
+ s.FontStyle = &it
+ }
+ if bold {
+ w := 700.0
+ s.FontWeight = &w
+ }
+ return s
+}
+
+func first(vals ...string) string {
+ for _, v := range vals {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// slug is a theme's id in an attribute and a URL: ascii, lowercase, dashed.
+func slug(s string) string {
+ var b strings.Builder
+ dash := false
+ for _, r := range strings.ToLower(s) {
+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
+ b.WriteRune(r)
+ dash = false
+ } else if !dash && b.Len() > 0 {
+ b.WriteByte('-')
+ dash = true
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}