summaryrefslogtreecommitdiff
path: root/vrsite/theme.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-24 11:39:36 -0300
committerGabriel Schneider <[email protected]>2026-07-30 15:18:55 -0300
commitcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch)
treec3e89426ff044ffe339dd3a77e7f3b7115cba636 /vrsite/theme.go
downloadnotevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz
notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip
Diffstat (limited to 'vrsite/theme.go')
-rw-r--r--vrsite/theme.go194
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)
+}