detached 6e468ca

fix(theme): make accent candidates immutable

commit
6e468caf18d4a81db95bb2af75ac0043d6f2f3bd
author
brice · · 2026-07-18 08:14 UTC
parent
ffd16e4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +37 -21
M internal/usecase/theme/accent_resolve.go +29 -20
@@ -22,9 +22,16 @@ type Accent struct {
2222 }
2323
2424 // The resolver only ever considers the chromatic ANSI slots in automatic
25-// mode. Keeping this as a fixed array makes resolution bounded and independent
26-// of terminal response order.
27-var accentCandidateSlots = [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14}
25+// mode. This immutable mapping keeps resolution bounded and independent of
26+// terminal response order without mutable package-level state.
27+const accentCandidateCount = 12
28+
29+func accentCandidateSlot(index int) uint8 {
30+ if index < 6 {
31+ return uint8(index + 1)
32+ }
33+ return uint8(index + 3)
34+}
2835
2936 type accentGroup struct {
3037 members uint16
@@ -64,10 +71,11 @@ func resolveAutoAccent(t Theme) Accent {
6471 return schemeIndexedBlue(t)
6572 }
6673
67- var colors [len(accentCandidateSlots)]oklab
74+ var colors [accentCandidateCount]oklab
6875 var eligible uint16
6976 background := rgbToOKLab(t.Background)
70- for i, slot := range accentCandidateSlots {
77+ for i := range accentCandidateCount {
78+ slot := accentCandidateSlot(i)
7179 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
7280 continue
7381 }
@@ -80,13 +88,13 @@ func resolveAutoAccent(t Theme) Accent {
8088 return fallbackAutoAccent(t, background)
8189 }
8290
83- var adjacent [len(accentCandidateSlots)]uint16
84- for i := range accentCandidateSlots {
91+ var adjacent [accentCandidateCount]uint16
92+ for i := range accentCandidateCount {
8593 if eligible&(uint16(1)<<i) == 0 {
8694 continue
8795 }
8896 adjacent[i] = uint16(1) << i
89- for j := i + 1; j < len(accentCandidateSlots); j++ {
97+ for j := i + 1; j < accentCandidateCount; j++ {
9098 if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) {
9199 continue
92100 }
@@ -95,15 +103,15 @@ func resolveAutoAccent(t Theme) Accent {
95103 }
96104 }
97105
98- var groups [len(accentCandidateSlots)]accentGroup
106+ var groups [accentCandidateCount]accentGroup
99107 var visited uint16
100108 groupCount := 0
101- for start := range accentCandidateSlots {
109+ for start := range accentCandidateCount {
102110 startBit := uint16(1) << start
103111 if eligible&startBit == 0 || visited&startBit != 0 {
104112 continue
105113 }
106- var queue [len(accentCandidateSlots)]uint8
114+ var queue [accentCandidateCount]uint8
107115 head, tail := 0, 1
108116 queue[0] = uint8(start)
109117 visited |= startBit
@@ -112,7 +120,7 @@ func resolveAutoAccent(t Theme) Accent {
112120 head++
113121 groups[groupCount].members |= uint16(1) << i
114122 neighbors := adjacent[i] &^ visited
115- for j := range accentCandidateSlots {
123+ for j := range accentCandidateCount {
116124 bit := uint16(1) << j
117125 if neighbors&bit == 0 {
118126 continue
@@ -141,7 +149,7 @@ func resolveAutoAccent(t Theme) Accent {
141149 runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs
142150 }
143151 if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) {
144- slot := accentCandidateSlots[winner.rep]
152+ slot := accentCandidateSlot(int(winner.rep))
145153 return resolvedAccent(t, t.Palette[slot], slot)
146154 }
147155
@@ -180,23 +188,24 @@ func indexedAccent(slot uint8) Accent {
180188 return Accent{Slot: slot, IndexedOnly: true}
181189 }
182190
183-func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) {
191+func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) {
184192 var representative uint8
185193 var representativeDistance float64
186194 first := true
187- for i, slot := range accentCandidateSlots {
195+ for i := range accentCandidateCount {
196+ slot := accentCandidateSlot(i)
188197 if group.members&(uint16(1)<<i) == 0 {
189198 continue
190199 }
191200 group.size++
192201 group.chromaSum += okLabChroma(colors[i])
193202 var totalDistance float64
194- for j := range accentCandidateSlots {
203+ for j := range accentCandidateCount {
195204 if group.members&(uint16(1)<<j) != 0 {
196205 totalDistance += okLabDistance(colors[i], colors[j])
197206 }
198207 }
199- if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlots[representative]) {
208+ if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) {
200209 first = false
201210 representative = uint8(i)
202211 representativeDistance = totalDistance
@@ -211,8 +220,8 @@ func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]o
211220 }
212221
213222 func accentGroupHasSlot(group accentGroup, slot uint8) bool {
214- for i, candidate := range accentCandidateSlots {
215- if candidate == slot {
223+ for i := range accentCandidateCount {
224+ if accentCandidateSlot(i) == slot {
216225 return group.members&(uint16(1)<<i) != 0
217226 }
218227 }
@@ -236,7 +245,7 @@ func betterAccentGroup(a, b accentGroup) bool {
236245 if aBlue != bBlue {
237246 return aBlue
238247 }
239- return accentCandidateSlots[a.rep] < accentCandidateSlots[b.rep]
248+ return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep))
240249 }
241250
242251 func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
@@ -22,9 +22,16 @@ type Accent struct {
22 }22 }
23 23
24 // The resolver only ever considers the chromatic ANSI slots in automatic24 // The resolver only ever considers the chromatic ANSI slots in automatic
25-// mode. Keeping this as a fixed array makes resolution bounded and independent25+// mode. This immutable mapping keeps resolution bounded and independent of
26-// of terminal response order.26+// terminal response order without mutable package-level state.
27-var accentCandidateSlots = [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14}27+const accentCandidateCount = 12
28+
29+func accentCandidateSlot(index int) uint8 {
30+ if index < 6 {
31+ return uint8(index + 1)
32+ }
33+ return uint8(index + 3)
34+}
28 35
29 type accentGroup struct {36 type accentGroup struct {
30 members uint1637 members uint16
@@ -64,10 +71,11 @@ func resolveAutoAccent(t Theme) Accent {
64 return schemeIndexedBlue(t)71 return schemeIndexedBlue(t)
65 }72 }
66 73
67- var colors [len(accentCandidateSlots)]oklab74+ var colors [accentCandidateCount]oklab
68 var eligible uint1675 var eligible uint16
69 background := rgbToOKLab(t.Background)76 background := rgbToOKLab(t.Background)
70- for i, slot := range accentCandidateSlots {77+ for i := range accentCandidateCount {
78+ slot := accentCandidateSlot(i)
71 if t.PaletteKnown&(uint16(1)<<slot) == 0 {79 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
72 continue80 continue
73 }81 }
@@ -80,13 +88,13 @@ func resolveAutoAccent(t Theme) Accent {
80 return fallbackAutoAccent(t, background)88 return fallbackAutoAccent(t, background)
81 }89 }
82 90
83- var adjacent [len(accentCandidateSlots)]uint1691+ var adjacent [accentCandidateCount]uint16
84- for i := range accentCandidateSlots {92+ for i := range accentCandidateCount {
85 if eligible&(uint16(1)<<i) == 0 {93 if eligible&(uint16(1)<<i) == 0 {
86 continue94 continue
87 }95 }
88 adjacent[i] = uint16(1) << i96 adjacent[i] = uint16(1) << i
89- for j := i + 1; j < len(accentCandidateSlots); j++ {97+ for j := i + 1; j < accentCandidateCount; j++ {
90 if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) {98 if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) {
91 continue99 continue
92 }100 }
@@ -95,15 +103,15 @@ func resolveAutoAccent(t Theme) Accent {
95 }103 }
96 }104 }
97 105
98- var groups [len(accentCandidateSlots)]accentGroup106+ var groups [accentCandidateCount]accentGroup
99 var visited uint16107 var visited uint16
100 groupCount := 0108 groupCount := 0
101- for start := range accentCandidateSlots {109+ for start := range accentCandidateCount {
102 startBit := uint16(1) << start110 startBit := uint16(1) << start
103 if eligible&startBit == 0 || visited&startBit != 0 {111 if eligible&startBit == 0 || visited&startBit != 0 {
104 continue112 continue
105 }113 }
106- var queue [len(accentCandidateSlots)]uint8114+ var queue [accentCandidateCount]uint8
107 head, tail := 0, 1115 head, tail := 0, 1
108 queue[0] = uint8(start)116 queue[0] = uint8(start)
109 visited |= startBit117 visited |= startBit
@@ -112,7 +120,7 @@ func resolveAutoAccent(t Theme) Accent {
112 head++120 head++
113 groups[groupCount].members |= uint16(1) << i121 groups[groupCount].members |= uint16(1) << i
114 neighbors := adjacent[i] &^ visited122 neighbors := adjacent[i] &^ visited
115- for j := range accentCandidateSlots {123+ for j := range accentCandidateCount {
116 bit := uint16(1) << j124 bit := uint16(1) << j
117 if neighbors&bit == 0 {125 if neighbors&bit == 0 {
118 continue126 continue
@@ -141,7 +149,7 @@ func resolveAutoAccent(t Theme) Accent {
141 runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs149 runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs
142 }150 }
143 if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) {151 if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) {
144- slot := accentCandidateSlots[winner.rep]152+ slot := accentCandidateSlot(int(winner.rep))
145 return resolvedAccent(t, t.Palette[slot], slot)153 return resolvedAccent(t, t.Palette[slot], slot)
146 }154 }
147 155
@@ -180,23 +188,24 @@ func indexedAccent(slot uint8) Accent {
180 return Accent{Slot: slot, IndexedOnly: true}188 return Accent{Slot: slot, IndexedOnly: true}
181 }189 }
182 190
183-func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) {191+func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) {
184 var representative uint8192 var representative uint8
185 var representativeDistance float64193 var representativeDistance float64
186 first := true194 first := true
187- for i, slot := range accentCandidateSlots {195+ for i := range accentCandidateCount {
196+ slot := accentCandidateSlot(i)
188 if group.members&(uint16(1)<<i) == 0 {197 if group.members&(uint16(1)<<i) == 0 {
189 continue198 continue
190 }199 }
191 group.size++200 group.size++
192 group.chromaSum += okLabChroma(colors[i])201 group.chromaSum += okLabChroma(colors[i])
193 var totalDistance float64202 var totalDistance float64
194- for j := range accentCandidateSlots {203+ for j := range accentCandidateCount {
195 if group.members&(uint16(1)<<j) != 0 {204 if group.members&(uint16(1)<<j) != 0 {
196 totalDistance += okLabDistance(colors[i], colors[j])205 totalDistance += okLabDistance(colors[i], colors[j])
197 }206 }
198 }207 }
199- if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlots[representative]) {208+ if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) {
200 first = false209 first = false
201 representative = uint8(i)210 representative = uint8(i)
202 representativeDistance = totalDistance211 representativeDistance = totalDistance
@@ -211,8 +220,8 @@ func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]o
211 }220 }
212 221
213 func accentGroupHasSlot(group accentGroup, slot uint8) bool {222 func accentGroupHasSlot(group accentGroup, slot uint8) bool {
214- for i, candidate := range accentCandidateSlots {223+ for i := range accentCandidateCount {
215- if candidate == slot {224+ if accentCandidateSlot(i) == slot {
216 return group.members&(uint16(1)<<i) != 0225 return group.members&(uint16(1)<<i) != 0
217 }226 }
218 }227 }
@@ -236,7 +245,7 @@ func betterAccentGroup(a, b accentGroup) bool {
236 if aBlue != bBlue {245 if aBlue != bBlue {
237 return aBlue246 return aBlue
238 }247 }
239- return accentCandidateSlots[a.rep] < accentCandidateSlots[b.rep]248+ return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep))
240 }249 }
241 250
242 func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {251 func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
M internal/usecase/theme/accent_resolve_test.go +8 -1
@@ -9,6 +9,13 @@ import (
99 "github.com/stretchr/testify/require"
1010 )
1111
12+func TestAccentCandidateSlotsAreFixedAndAscending(t *testing.T) {
13+ want := [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14}
14+ for i, slot := range want {
15+ require.Equalf(t, slot, accentCandidateSlot(i), "candidate index %d", i)
16+ }
17+}
18+
1219 func TestResolveAccentThresholds(t *testing.T) {
1320 tests := []struct {
1421 name string
@@ -193,7 +200,7 @@ func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.
193200
194201 func TestResolveAccentMedoidAndTieBreak(t *testing.T) {
195202 group := accentGroup{members: 1<<0 | 1<<1 | 1<<2}
196- colors := [len(accentCandidateSlots)]oklab{
203+ colors := [accentCandidateCount]oklab{
197204 {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10},
198205 }
199206 finalizeAccentGroup(&group, colors)
@@ -9,6 +9,13 @@ import (
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
12+func TestAccentCandidateSlotsAreFixedAndAscending(t *testing.T) {
13+ want := [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14}
14+ for i, slot := range want {
15+ require.Equalf(t, slot, accentCandidateSlot(i), "candidate index %d", i)
16+ }
17+}
18+
12 func TestResolveAccentThresholds(t *testing.T) {19 func TestResolveAccentThresholds(t *testing.T) {
13 tests := []struct {20 tests := []struct {
14 name string21 name string
@@ -193,7 +200,7 @@ func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.
193 200
194 func TestResolveAccentMedoidAndTieBreak(t *testing.T) {201 func TestResolveAccentMedoidAndTieBreak(t *testing.T) {
195 group := accentGroup{members: 1<<0 | 1<<1 | 1<<2}202 group := accentGroup{members: 1<<0 | 1<<1 | 1<<2}
196- colors := [len(accentCandidateSlots)]oklab{203+ colors := [accentCandidateCount]oklab{
197 {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10},204 {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10},
198 }205 }
199 finalizeAccentGroup(&group, colors)206 finalizeAccentGroup(&group, colors)

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