// 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 web 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(), "-") }