summaryrefslogtreecommitdiff
path: root/web/theme.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/theme.go')
-rw-r--r--web/theme.go341
1 files changed, 341 insertions, 0 deletions
diff --git a/web/theme.go b/web/theme.go
new file mode 100644
index 0000000..135af43
--- /dev/null
+++ b/web/theme.go
@@ -0,0 +1,341 @@
+// 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 web
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+)
+
+type SyntaxStyle struct {
+ Color string `json:"color"`
+ FontStyle *string `json:"font_style"`
+ FontWeight *float64 `json:"font_weight"`
+}
+
+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
+ Syntax map[string]SyntaxStyle
+ Players []string
+}
+
+// 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 {
+ 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 {
+ if t.Appearance == appearance {
+ return path
+ }
+ }
+ }
+ return ""
+}
+
+func loadTheme(path, name, appearance string) *Theme {
+ var b []byte
+ var err error
+ if path == "" {
+ b, err = tfs.ReadFile("assets/one.json")
+ } else {
+ b, err = os.ReadFile(path)
+ }
+ 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 {
+ Name string `json:"name"`
+ Appearance string `json:"appearance"`
+ Style map[string]json.RawMessage `json:"style"`
+ } `json:"themes"`
+ }
+ if err := json.Unmarshal(b, &family); err != nil {
+ return nil, err
+ }
+ 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 ""
+ }
+ 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)
+ }
+ 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 false
+ }
+ 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 && 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
+ }
+ }
+ 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)
+ }
+ }
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+func colorDist(a, b string) int {
+ var ar, ag, ab, br, bg, bb int
+ if len(a) != 7 || len(b) != 7 {
+ return 1 << 24
+ }
+ fmt.Sscanf(a[1:], "%02x%02x%02x", &ar, &ag, &ab)
+ fmt.Sscanf(b[1:], "%02x%02x%02x", &br, &bg, &bb)
+ d := func(x, y int) int { return (x - y) * (x - y) }
+ return int(float64(d(ar, br)+d(ag, bg)+d(ab, bb)) / 3)
+}
+
+var fallbackPalette = []string{"#7aa2f7", "#e0af68", "#9ece6a", "#f7768e", "#bb9af7", "#2ac3de"}
+
+func (t *Theme) Player(i int) string {
+ if i < len(t.Players) {
+ return t.Players[i]
+ }
+ 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 rgb(c string) (r, g, b int, ok bool) {
+ c = hex6(c)
+ if len(c) != 7 || c[0] != '#' {
+ return 0, 0, 0, false
+ }
+ 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
+ }
+ 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
+ }
+ 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, ".", "-") }