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 terminal defaults or truecolor support are unavailable.
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 chromaSum float64
41 rep uint8
42}
43
44// ResolveAccent deterministically selects a terminal-owned accent. It uses
45// only fixed arrays and bitsets so this pure hot-reload operation makes no
46// per-call heap allocations.
47func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent {
48 if !t.UsePalette {
49 return Accent{}
50 }
51 if policy.Mode == domain.ThemeAccentSlot {
52 return resolveExplicitAccent(t, policy.Slot)
53 }
54 return resolveAutoAccent(t)
55}
56
57func resolveExplicitAccent(t Theme, slot uint8) Accent {
58 if slot >= uint8(len(t.Palette)) {
59 return Accent{}
60 }
61 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
62 // An explicit policy is a strict override. Even without OSC RGB for its
63 // slot, it may still safely decorate foregrounds and borders by index.
64 return indexedAccent(slot)
65 }
66 return resolvedAccent(t, t.Palette[slot], slot)
67}
68
69func resolveAutoAccent(t Theme) Accent {
70 // Background is necessary to reject colors that merely alias the terminal
71 // surface. Without it, retain the pre-accent scheme-aware ANSI blue
72 // foreground fallback rather than inventing an RGB background.
73 if !t.HasBG {
74 return schemeIndexedBlue(t)
75 }
76
77 var colors [accentCandidateCount]oklab
78 var eligible uint16
79 background := rgbToOKLab(t.Background)
80 for i := range accentCandidateCount {
81 slot := accentCandidateSlot(i)
82 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
83 continue
84 }
85 colors[i] = rgbToOKLab(t.Palette[slot])
86 if accentEligible(colors[i], background) {
87 eligible |= uint16(1) << i
88 }
89 }
90 if eligible == 0 {
91 return fallbackAutoAccent(t, background)
92 }
93
94 var adjacent [accentCandidateCount]uint16
95 for i := range accentCandidateCount {
96 if eligible&(uint16(1)<<i) == 0 {
97 continue
98 }
99 adjacent[i] = uint16(1) << i
100 for j := i + 1; j < accentCandidateCount; j++ {
101 if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) {
102 continue
103 }
104 adjacent[i] |= uint16(1) << j
105 adjacent[j] |= uint16(1) << i
106 }
107 }
108
109 var groups [accentCandidateCount]accentGroup
110 var visited uint16
111 groupCount := 0
112 for start := range accentCandidateCount {
113 startBit := uint16(1) << start
114 if eligible&startBit == 0 || visited&startBit != 0 {
115 continue
116 }
117 var queue [accentCandidateCount]uint8
118 head, tail := 0, 1
119 queue[0] = uint8(start)
120 visited |= startBit
121 for head < tail {
122 i := queue[head]
123 head++
124 groups[groupCount].members |= uint16(1) << i
125 neighbors := adjacent[i] &^ visited
126 for j := range accentCandidateCount {
127 bit := uint16(1) << j
128 if neighbors&bit == 0 {
129 continue
130 }
131 visited |= bit
132 queue[tail] = uint8(j)
133 tail++
134 }
135 }
136 finalizeAccentGroup(&groups[groupCount], colors)
137 groupCount++
138 }
139
140 best, runner := -1, -1
141 for i := 0; i < groupCount; i++ {
142 if best < 0 || betterAccentGroup(groups[i], groups[best]) {
143 runner = best
144 best = i
145 } else if runner < 0 || betterAccentGroup(groups[i], groups[runner]) {
146 runner = i
147 }
148 }
149 winner := groups[best]
150 runnerSize, runnerPairs := uint8(0), uint8(0)
151 if runner >= 0 {
152 runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs
153 }
154 if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) {
155 slot := accentCandidateSlot(int(winner.rep))
156 return resolvedAccent(t, t.Palette[slot], slot)
157 }
158
159 return fallbackAutoAccent(t, background)
160}
161
162func fallbackAutoAccent(t Theme, background oklab) Accent {
163 for _, slot := range [...]uint8{4, 12} {
164 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
165 continue
166 }
167 color := rgbToOKLab(t.Palette[slot])
168 if accentEligible(color, background) {
169 return resolvedAccent(t, t.Palette[slot], slot)
170 }
171 }
172 if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 {
173 // A terminal-reported blue that fails eligibility deliberately does not
174 // become an unvalidated indexed fallback.
175 return Accent{}
176 }
177 return schemeIndexedBlue(t)
178}
179
180func schemeIndexedBlue(t Theme) Accent {
181 if !t.SchemeKnown {
182 return Accent{}
183 }
184 if t.Light {
185 return indexedAccent(4)
186 }
187 return indexedAccent(12)
188}
189
190func indexedAccent(slot uint8) Accent {
191 return Accent{Slot: slot, IndexedOnly: true}
192}
193
194func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) {
195 var representative uint8
196 var representativeDistance float64
197 first := true
198 for i := range accentCandidateCount {
199 slot := accentCandidateSlot(i)
200 if group.members&(uint16(1)<<i) == 0 {
201 continue
202 }
203 group.size++
204 group.chromaSum += okLabChroma(colors[i])
205 var totalDistance float64
206 for j := range accentCandidateCount {
207 if group.members&(uint16(1)<<j) != 0 {
208 totalDistance += okLabDistance(colors[i], colors[j])
209 }
210 }
211 if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) {
212 first = false
213 representative = uint8(i)
214 representativeDistance = totalDistance
215 }
216 }
217 group.rep = representative
218 for slot := uint8(1); slot <= 6; slot++ {
219 if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) {
220 group.pairs++
221 }
222 }
223}
224
225func accentGroupHasSlot(group accentGroup, slot uint8) bool {
226 for i := range accentCandidateCount {
227 if accentCandidateSlot(i) == slot {
228 return group.members&(uint16(1)<<i) != 0
229 }
230 }
231 return false
232}
233
234func betterAccentGroup(a, b accentGroup) bool {
235 if a.size != b.size {
236 return a.size > b.size
237 }
238 if a.pairs != b.pairs {
239 return a.pairs > b.pairs
240 }
241 averageA := a.chromaSum / float64(a.size)
242 averageB := b.chromaSum / float64(b.size)
243 if averageA != averageB {
244 return averageA > averageB
245 }
246 aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12)
247 bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12)
248 if aBlue != bBlue {
249 return aBlue
250 }
251 return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep))
252}
253
254func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
255 return Accent{
256 RGB: color,
257 Slot: slot,
258 Known: true,
259 IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG,
260 }
261}
262
263func accentEligible(candidate, background oklab) bool {
264 return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance
265}
266
267func accentConnected(a, b oklab) bool {
268 return okLabDistance(a, b) <= accentClusterDistance
269}