diff options
Diffstat (limited to 'vrsite/theme.go')
| -rw-r--r-- | vrsite/theme.go | 315 |
1 files changed, 231 insertions, 84 deletions
diff --git a/vrsite/theme.go b/vrsite/theme.go index fc63de1..91eedbe 100644 --- a/vrsite/theme.go +++ b/vrsite/theme.go @@ -1,12 +1,18 @@ // theme.go — Zed theme JSON in, CSS custom properties out. Per-agent colors // come from the theme's player (collaborator) palette. +// +// The two themes named on the command line are what the site is built with; +// themes.go finds the rest of this machine's themes for the picker, and they +// arrive here as the same *Theme. package main import ( "encoding/json" + "errors" "fmt" "os" - "path/filepath" + "sort" + "strings" ) type SyntaxStyle struct { @@ -16,6 +22,7 @@ type SyntaxStyle struct { } type Theme struct { + ID, Source string // "zed/one-dark", "zed" | "helix" Name, Appearance string Bg, Fg, Panel, Border, LineNo, Muted, Accent string Note, Hover string @@ -26,22 +33,16 @@ type Theme struct { // defaultTheme finds a zed theme file containing a theme of the wanted // appearance; "" means fall back to the embedded One family. func defaultTheme(appearance string) string { - home, _ := os.UserHomeDir() - m, _ := filepath.Glob(filepath.Join(home, ".config", "zed", "themes", "*.json")) - for _, path := range m { + for _, path := range globAll(zedThemeGlobs()) { b, err := os.ReadFile(path) if err != nil { continue } - var family struct { - Themes []struct { - Appearance string `json:"appearance"` - } `json:"themes"` - } - if json.Unmarshal(b, &family) != nil { + fam, err := parseZedFamily(b) + if err != nil { continue } - for _, t := range family.Themes { + for _, t := range fam { if t.Appearance == appearance { return path } @@ -61,6 +62,31 @@ func loadTheme(path, name, appearance string) *Theme { if err != nil { fatal("%v", err) } + fam, err := parseZedFamily(b) + if err != nil { + fatal("bad zed theme %s: %v", path, err) + } + t := fam[0] + if name != "" { + for _, c := range fam { + if c.Name == name { + t = c + } + } + } else { + for _, c := range fam { + if c.Appearance == appearance { + t = c + break + } + } + } + return t +} + +// parseZedFamily reads a zed theme file: one file is a family, and every theme +// in it is one the picker can offer. +func parseZedFamily(b []byte) ([]*Theme, error) { var family struct { Name string `json:"name"` Themes []struct { @@ -69,76 +95,168 @@ func loadTheme(path, name, appearance string) *Theme { Style map[string]json.RawMessage `json:"style"` } `json:"themes"` } - if err := json.Unmarshal(b, &family); err != nil || len(family.Themes) == 0 { - fatal("bad zed theme %s: %v", path, err) + if err := json.Unmarshal(b, &family); err != nil { + return nil, err } - th := family.Themes[0] - if name != "" { - for _, t := range family.Themes { - if t.Name == name { - th = t + if len(family.Themes) == 0 { + return nil, errors.New("no themes in it") + } + var out []*Theme + for _, th := range family.Themes { + get := func(keys ...string) string { + for _, k := range keys { + var s string + if raw, ok := th.Style[k]; ok && json.Unmarshal(raw, &s) == nil && okColor(s) != "" { + return s + } } + return "" } - } else { - for _, t := range family.Themes { - if t.Appearance == appearance { - th = t - break + t := &Theme{ID: "zed/" + slug(th.Name), Source: "zed", + Name: th.Name, Appearance: th.Appearance, + Bg: get("editor.background", "background"), + Fg: get("editor.foreground", "text"), + Panel: get("panel.background", "surface.background", "background"), + Border: get("border", "border.variant"), + LineNo: get("editor.line_number"), + Muted: get("text.muted", "text.disabled"), + Accent: get("text.accent", "info"), + Note: get("warning", "conflict", "modified"), + Hover: get("element.hover", "ghost_element.hover"), + } + json.Unmarshal(th.Style["syntax"], &t.Syntax) + for k, s := range t.Syntax { // a theme in the wild does drop the odd "#" + if okColor(s.Color) == "" { + delete(t.Syntax, k) } } + t.fill() + var players []struct { + Cursor string `json:"cursor"` + } + json.Unmarshal(th.Style["players"], &players) + var cursors []string + for _, p := range players { + cursors = append(cursors, p.Cursor) + } + t.setPlayers(cursors, t.syntaxColors()) + out = append(out, t) } - get := func(keys ...string) string { - for _, k := range keys { - var s string - if raw, ok := th.Style[k]; ok && json.Unmarshal(raw, &s) == nil && s != "" { - return s + return out, nil +} + +// setPlayers picks the per-agent colors out of the theme's own palette. A zed +// theme has one meant for exactly this — the collaborator cursors — and it is +// asked first; when it runs short, or the theme is a helix one with no such +// palette at all, the code's own colors stand in, since those are what the +// reader is already telling apart on the page. +// +// Within each tier, colors that vanish against fg/bg, collide with the note +// color, or sit on top of one already taken are dropped, and the rest ordered +// greedily for mutual distance, so the first few agents contrast most. +func (t *Theme) setPlayers(tiers ...[]string) { + t.Players = nil + taken := func(c string, of []string) bool { + for _, p := range of { + if colorDist(c, p) <= 60 { + return true } } - return "" - } - t := &Theme{Name: th.Name, Appearance: th.Appearance, - Bg: get("editor.background", "background"), - Fg: get("editor.foreground", "text"), - Panel: get("panel.background", "surface.background", "background"), - Border: get("border", "border.variant"), - LineNo: get("editor.line_number"), - Muted: get("text.muted", "text.disabled"), - Accent: get("text.accent", "info"), - Note: get("warning", "conflict", "modified"), - Hover: get("element.hover", "ghost_element.hover"), - } - json.Unmarshal(th.Style["syntax"], &t.Syntax) - var players []struct { - Cursor string `json:"cursor"` + return false } - json.Unmarshal(th.Style["players"], &players) - // agent colors: drop player colors that vanish against fg/bg or collide - // with the note color, then order greedily for mutual distance so the - // first few agents contrast most - var usable []string - for _, p := range players { - c := hex6(p.Cursor) - if c != "" && colorDist(c, hex6(t.Fg)) > 60 && colorDist(c, hex6(t.Bg)) > 60 && colorDist(c, hex6(t.Note)) > 60 { + for _, tier := range tiers { + var usable []string + for _, c := range tier { + c = hex6(c) + if len(c) != 7 || taken(c, usable) || taken(c, t.Players) || + colorDist(c, hex6(t.Fg)) <= 60 || colorDist(c, hex6(t.Bg)) <= 60 || + colorDist(c, hex6(t.Note)) <= 60 { + continue + } usable = append(usable, c) } - } - for len(usable) > 0 { - best, bestScore := 0, -1 - for i, c := range usable { - score := 1 << 24 // first pick: keep theme order - for _, p := range t.Players { - if d := colorDist(c, p); d < score { - score = d + for len(usable) > 0 && len(t.Players) < maxAgents { + best, bestScore := 0, -1 + for i, c := range usable { + score := 1 << 24 // first pick of the first tier: keep theme order + for _, p := range t.Players { + if d := colorDist(c, p); d < score { + score = d + } + } + if score > bestScore { + best, bestScore = i, score } } - if score > bestScore { - best, bestScore = i, score + t.Players = append(t.Players, usable[best]) + usable = append(usable[:best], usable[best+1:]...) + } + } +} + +// fill supplies whatever a theme does not say. Editors leave gaps a web page +// cannot — a zed theme need not name a muted text color, a helix theme has no +// notion of a panel — so every gap is closed with a shade mixed from the +// theme's own background and foreground, or with a color out of its syntax. +// A theme with three colors in it still dresses a whole page. +func (t *Theme) fill() { + bg, fg := hex6(t.Bg), t.Fg + // panel and hover are backgrounds the page writes on, so a theme that + // offers one the text would be lost in does not get to keep it + t.Panel = first(readable(t.Panel, fg, ""), blend(bg, fg, 0.05)) + t.Hover = first(readable(t.Hover, fg, ""), blend(bg, fg, 0.12)) + t.Border = first(t.Border, blend(bg, fg, 0.2)) + t.LineNo = first(t.LineNo, blend(bg, fg, 0.4)) + t.Muted = first(readable(t.Muted, t.Panel, ""), blend(bg, fg, 0.55)) + t.Accent = first(t.Accent, t.syn("function"), t.syn("keyword"), t.syn("type"), fg) + t.Note = first(t.Note, t.syn("warning"), t.syn("number"), t.Accent) +} + +// syn is the theme's color for a syntax key, resolved along its dots. +func (t *Theme) syn(key string) string { + if k := resolveKey(t, key); k != "" { + return t.Syntax[k].Color + } + return "" +} + +// syntaxColors are the theme's own code colors, in the order they stand out +// from one another: what a theme offers when its palette runs short. +func (t *Theme) syntaxColors() []string { + var out []string + for _, k := range []string{"keyword", "function", "string", "type", "constant", + "variable.parameter", "attribute", "constructor", "number", "tag", "property", + "label", "namespace", "operator", "boolean"} { + if c := t.syn(k); c != "" { + out = append(out, c) + } + } + return out +} + +// readable keeps a color only if text in `on` can be seen against it. +func readable(c, on, fallback string) string { + if c == "" || colorDist(hex6(c), hex6(on)) < 2000 { + return fallback + } + return c +} + +// syntaxKeys is every syntax key the site paints, and so every one a theme is +// asked to fill: the union of what the two built-with themes name. +func syntaxKeys(themes ...*Theme) []string { + var keys []string + seen := map[string]bool{} + for _, t := range themes { + for k := range t.Syntax { + if !seen[k] { + seen[k] = true + keys = append(keys, k) } } - t.Players = append(t.Players, usable[best]) - usable = append(usable[:best], usable[best+1:]...) } - return t + sort.Strings(keys) + return keys } func colorDist(a, b string) int { @@ -161,34 +279,63 @@ func (t *Theme) Player(i int) string { return fallbackPalette[i%len(fallbackPalette)] } +// okColor keeps a color only if it is one a browser will take: #rgb, #rrggbb +// or #rrggbbaa. Whatever else a theme file says is treated as unsaid, and +// fill() supplies it. +func okColor(c string) string { + if len(c) != 4 && len(c) != 7 && len(c) != 9 || c[0] != '#' { + return "" + } + for _, r := range c[1:] { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + return "" + } + } + return c +} + // hex6 strips an alpha channel from #rrggbbaa. func hex6(c string) string { if len(c) == 9 && c[0] == '#' { return c[:7] } + if len(c) == 4 && c[0] == '#' { // #rgb + return "#" + string([]byte{c[1], c[1], c[2], c[2], c[3], c[3]}) + } return c } -func mixColors(cols []string) string { - if len(cols) == 1 { - return cols[0] +func rgb(c string) (r, g, b int, ok bool) { + c = hex6(c) + if len(c) != 7 || c[0] != '#' { + return 0, 0, 0, false } - var r, g, b int - n := 0 - for _, c := range cols { - c = hex6(c) - if len(c) != 7 { - continue - } - var cr, cg, cb int - fmt.Sscanf(c[1:], "%02x%02x%02x", &cr, &cg, &cb) - r += cr - g += cg - b += cb - n++ + if n, _ := fmt.Sscanf(c[1:], "%02x%02x%02x", &r, &g, &b); n != 3 { + return 0, 0, 0, false + } + return r, g, b, true +} + +// lum is perceived brightness, 0..1 — enough to tell a light theme from a +// dark one when the theme does not say which it is. +func lum(c string) float64 { + r, g, b, ok := rgb(c) + if !ok { + return 0 } - if n == 0 { - return "#888888" + return (0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 255 +} + +// blend mixes frac of b into a — for the shades a theme leaves unsaid. +func blend(a, b string, frac float64) string { + ar, ag, ab, ok := rgb(a) + br, bg, bb, ok2 := rgb(b) + if !ok || !ok2 { + return a } - return fmt.Sprintf("#%02x%02x%02x", r/n, g/n, b/n) + mix := func(x, y int) int { return int(float64(x) + (float64(y)-float64(x))*frac) } + return fmt.Sprintf("#%02x%02x%02x", mix(ar, br), mix(ag, bg), mix(ab, bb)) } + +// cssKey is a syntax key as it appears in class and property names. +func cssKey(k string) string { return strings.ReplaceAll(k, ".", "-") } |
