diff options
Diffstat (limited to 'vrsite/themes.go')
| -rw-r--r-- | vrsite/themes.go | 445 |
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(), "-") +} |
