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