v0.3.0 99dc266

accent_resolve.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close