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 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}

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