1package theme
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 "github.com/bnema/vev/internal/domain"
6)
7
8const (
9 accentMinChroma = 0.025
10 accentMinBackgroundDistance = 0.08
11 accentClusterDistance = 0.04
12)
13
14// Accent is the single terminal-owned color selected for chrome. IndexedOnly
15// means the slot may decorate foregrounds and borders, but cannot fill RGB
16// backgrounds because the terminal's semantic foreground or background is unknown.
17type Accent struct {
18 RGB renderer.RGB
19 Slot uint8
20 Known bool
21 IndexedOnly bool
22}
23
24// The resolver only ever considers the chromatic ANSI slots in automatic
25// mode. This immutable mapping keeps resolution bounded and independent of
26// terminal response order without mutable package-level state.
27const accentCandidateCount = 12
28
29func accentCandidateSlot(index int) uint8 {
30 if index < 6 {
31 return uint8(index + 1)
32 }
33 return uint8(index + 3)
34}
35
36type accentGroup struct {
37 members uint16
38 size uint8
39 pairs uint8
40 distanceSum float64
41 chromaSum float64
42 rep uint8
43}
44
45// ResolveAccent deterministically selects a terminal-owned accent. It uses
46// only fixed arrays and bitsets so this pure hot-reload operation makes no
47// per-call heap allocations.
48func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent {
49 if !t.UsePalette {
50 return Accent{}
51 }
52 if policy.Mode == domain.ThemeAccentSlot {
53 return resolveExplicitAccent(t, policy.Slot)
54 }
55 return resolveAutoAccent(t)
56}
57
58func resolveExplicitAccent(t Theme, slot uint8) Accent {
59 if slot >= uint8(len(t.Palette)) {
60 return Accent{}
61 }
62 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
63 // An explicit policy is a strict override. Even without OSC RGB for its
64 // slot, it may still safely decorate foregrounds and borders by index.
65 return indexedAccent(slot)
66 }
67 return resolvedAccent(t, t.Palette[slot], slot)
68}
69
70func resolveAutoAccent(t Theme) Accent {
71 // Background is necessary to reject colors that merely alias the terminal
72 // surface. Without it, retain the pre-accent scheme-aware ANSI blue
73 // foreground fallback rather than inventing an RGB background.
74 if !t.HasBG {
75 return schemeIndexedBlue(t)
76 }
77
78 var colors [accentCandidateCount]oklab
79 var eligible uint16
80 background := rgbToOKLab(t.Background)
81 for i := range accentCandidateCount {
82 slot := accentCandidateSlot(i)
83 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
84 continue
85 }
86 colors[i] = rgbToOKLab(t.Palette[slot])
87 if accentEligible(colors[i], background) {
88 eligible |= uint16(1) << i
89 }
90 }
91 if eligible == 0 {
92 return fallbackAutoAccent(t, background)
93 }
94
95 var adjacent [accentCandidateCount]uint16
96 for i := range accentCandidateCount {
97 if eligible&(uint16(1)<<i) == 0 {
98 continue
99 }
100 adjacent[i] = uint16(1) << i
101 for j := i + 1; j < accentCandidateCount; j++ {
102 if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) {
103 continue
104 }
105 adjacent[i] |= uint16(1) << j
106 adjacent[j] |= uint16(1) << i
107 }
108 }
109
110 var groups [accentCandidateCount]accentGroup
111 var visited uint16
112 groupCount := 0
113 for start := range accentCandidateCount {
114 startBit := uint16(1) << start
115 if eligible&startBit == 0 || visited&startBit != 0 {
116 continue
117 }
118 var queue [accentCandidateCount]uint8
119 head, tail := 0, 1
120 queue[0] = uint8(start)
121 visited |= startBit
122 for head < tail {
123 i := queue[head]
124 head++
125 groups[groupCount].members |= uint16(1) << i
126 neighbors := adjacent[i] &^ visited
127 for j := range accentCandidateCount {
128 bit := uint16(1) << j
129 if neighbors&bit == 0 {
130 continue
131 }
132 visited |= bit
133 queue[tail] = uint8(j)
134 tail++
135 }
136 }
137 finalizeAccentGroup(&groups[groupCount], colors)
138 groupCount++
139 }
140
141 best, runner := -1, -1
142 for i := 0; i < groupCount; i++ {
143 if best < 0 || betterAccentGroup(groups[i], groups[best]) {
144 runner = best
145 best = i
146 } else if runner < 0 || betterAccentGroup(groups[i], groups[runner]) {
147 runner = i
148 }
149 }
150 winner := groups[best]
151 runnerUp := accentGroup{}
152 if runner >= 0 {
153 runnerUp = groups[runner]
154 }
155 if winner.size >= 2 && (winner.size > runnerUp.size ||
156 winner.size == runnerUp.size && (winner.distanceSum < runnerUp.distanceSum ||
157 winner.distanceSum == runnerUp.distanceSum && winner.pairs > runnerUp.pairs)) {
158 slot := accentCandidateSlot(int(winner.rep))
159 return resolvedAccent(t, t.Palette[slot], slot)
160 }
161
162 return fallbackAutoAccent(t, background)
163}
164
165func fallbackAutoAccent(t Theme, background oklab) Accent {
166 for _, slot := range [...]uint8{4, 12} {
167 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
168 continue
169 }
170 color := rgbToOKLab(t.Palette[slot])
171 if accentEligible(color, background) {
172 return resolvedAccent(t, t.Palette[slot], slot)
173 }
174 }
175 if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 {
176 // A terminal-reported blue that fails eligibility deliberately does not
177 // become an unvalidated indexed fallback.
178 return Accent{}
179 }
180 return schemeIndexedBlue(t)
181}
182
183func schemeIndexedBlue(t Theme) Accent {
184 if !t.SchemeKnown {
185 return Accent{}
186 }
187 if t.Light {
188 return indexedAccent(4)
189 }
190 return indexedAccent(12)
191}
192
193func indexedAccent(slot uint8) Accent {
194 return Accent{Slot: slot, IndexedOnly: true}
195}
196
197func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) {
198 var representative uint8
199 var representativeDistance float64
200 first := true
201 for i := range accentCandidateCount {
202 slot := accentCandidateSlot(i)
203 if group.members&(uint16(1)<<i) == 0 {
204 continue
205 }
206 group.size++
207 group.chromaSum += okLabChroma(colors[i])
208 var totalDistance float64
209 for j := range accentCandidateCount {
210 if group.members&(uint16(1)<<j) == 0 {
211 continue
212 }
213 distance := okLabDistance(colors[i], colors[j])
214 totalDistance += distance
215 if j > i {
216 group.distanceSum += distance
217 }
218 }
219 if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) {
220 first = false
221 representative = uint8(i)
222 representativeDistance = totalDistance
223 }
224 }
225 group.rep = representative
226 for slot := uint8(1); slot <= 6; slot++ {
227 if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) {
228 group.pairs++
229 }
230 }
231}
232
233func accentGroupHasSlot(group accentGroup, slot uint8) bool {
234 for i := range accentCandidateCount {
235 if accentCandidateSlot(i) == slot {
236 return group.members&(uint16(1)<<i) != 0
237 }
238 }
239 return false
240}
241
242func betterAccentGroup(a, b accentGroup) bool {
243 if a.size != b.size {
244 return a.size > b.size
245 }
246 if a.distanceSum != b.distanceSum {
247 return a.distanceSum < b.distanceSum
248 }
249 if a.pairs != b.pairs {
250 return a.pairs > b.pairs
251 }
252 averageA := a.chromaSum / float64(a.size)
253 averageB := b.chromaSum / float64(b.size)
254 if averageA != averageB {
255 return averageA > averageB
256 }
257 aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12)
258 bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12)
259 if aBlue != bBlue {
260 return aBlue
261 }
262 return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep))
263}
264
265func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
266 return Accent{
267 RGB: color,
268 Slot: slot,
269 Known: true,
270 IndexedOnly: !t.HasFG || !t.HasBG,
271 }
272}
273
274func accentEligible(candidate, background oklab) bool {
275 return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance
276}
277
278func accentConnected(a, b oklab) bool {
279 return okLabDistance(a, b) <= accentClusterDistance
280}