summaryrefslogtreecommitdiff
path: root/vrsite/theme.go
blob: fc63de12dc1ad90475143a861e1d9053a594cad2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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)
}