summaryrefslogtreecommitdiff
path: root/vrsite/theme.go
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite/theme.go')
-rw-r--r--vrsite/theme.go315
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, ".", "-") }