diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-24 11:39:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-07-30 15:18:55 -0300 |
| commit | cb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch) | |
| tree | c3e89426ff044ffe339dd3a77e7f3b7115cba636 /vrsite/theme.go | |
| download | notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip | |
Diffstat (limited to 'vrsite/theme.go')
| -rw-r--r-- | vrsite/theme.go | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/vrsite/theme.go b/vrsite/theme.go new file mode 100644 index 0000000..fc63de1 --- /dev/null +++ b/vrsite/theme.go @@ -0,0 +1,194 @@ +// theme.go — Zed theme JSON in, CSS custom properties out. Per-agent colors +// come from the theme's player (collaborator) palette. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type SyntaxStyle struct { + Color string `json:"color"` + FontStyle *string `json:"font_style"` + FontWeight *float64 `json:"font_weight"` +} + +type Theme struct { + 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 { + home, _ := os.UserHomeDir() + m, _ := filepath.Glob(filepath.Join(home, ".config", "zed", "themes", "*.json")) + for _, path := range m { + 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 { + continue + } + for _, t := range family.Themes { + 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) + } + 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 || len(family.Themes) == 0 { + fatal("bad zed theme %s: %v", path, err) + } + th := family.Themes[0] + if name != "" { + for _, t := range family.Themes { + if t.Name == name { + th = t + } + } + } else { + for _, t := range family.Themes { + if t.Appearance == appearance { + th = t + break + } + } + } + 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 "" + } + 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"` + } + 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 { + 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 + } + } + if score > bestScore { + best, bestScore = i, score + } + } + t.Players = append(t.Players, usable[best]) + usable = append(usable[:best], usable[best+1:]...) + } + return t +} + +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)] +} + +// hex6 strips an alpha channel from #rrggbbaa. +func hex6(c string) string { + if len(c) == 9 && c[0] == '#' { + return c[:7] + } + return c +} + +func mixColors(cols []string) string { + if len(cols) == 1 { + return cols[0] + } + 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 == 0 { + return "#888888" + } + return fmt.Sprintf("#%02x%02x%02x", r/n, g/n, b/n) +} |
