fix(theme): make accent candidates immutable
- commit
- 6e468caf18d4a81db95bb2af75ac0043d6f2f3bd
- author
- brice · · 2026-07-18 08:14 UTC
- parent
- ffd16e4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | // 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 | + | } | |
| 28 | 35 | ||
| 29 | 36 | type accentGroup struct { | |
| 30 | 37 | members uint16 | |
| @@ -64,10 +71,11 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 64 | 71 | return schemeIndexedBlue(t) | |
| 65 | 72 | } | |
| 66 | 73 | ||
| 67 | - | var colors [len(accentCandidateSlots)]oklab | |
| 74 | + | var colors [accentCandidateCount]oklab | |
| 68 | 75 | var eligible uint16 | |
| 69 | 76 | background := rgbToOKLab(t.Background) | |
| 70 | - | for i, slot := range accentCandidateSlots { | |
| 77 | + | for i := range accentCandidateCount { | |
| 78 | + | slot := accentCandidateSlot(i) | |
| 71 | 79 | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 72 | 80 | continue | |
| 73 | 81 | } | |
| @@ -80,13 +88,13 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 80 | 88 | return fallbackAutoAccent(t, background) | |
| 81 | 89 | } | |
| 82 | 90 | ||
| 83 | - | var adjacent [len(accentCandidateSlots)]uint16 | |
| 84 | - | for i := range accentCandidateSlots { | |
| 91 | + | var adjacent [accentCandidateCount]uint16 | |
| 92 | + | for i := range accentCandidateCount { | |
| 85 | 93 | if eligible&(uint16(1)<<i) == 0 { | |
| 86 | 94 | continue | |
| 87 | 95 | } | |
| 88 | 96 | adjacent[i] = uint16(1) << i | |
| 89 | - | for j := i + 1; j < len(accentCandidateSlots); j++ { | |
| 97 | + | for j := i + 1; j < accentCandidateCount; j++ { | |
| 90 | 98 | if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) { | |
| 91 | 99 | continue | |
| 92 | 100 | } | |
| @@ -95,15 +103,15 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 95 | 103 | } | |
| 96 | 104 | } | |
| 97 | 105 | ||
| 98 | - | var groups [len(accentCandidateSlots)]accentGroup | |
| 106 | + | var groups [accentCandidateCount]accentGroup | |
| 99 | 107 | var visited uint16 | |
| 100 | 108 | groupCount := 0 | |
| 101 | - | for start := range accentCandidateSlots { | |
| 109 | + | for start := range accentCandidateCount { | |
| 102 | 110 | startBit := uint16(1) << start | |
| 103 | 111 | if eligible&startBit == 0 || visited&startBit != 0 { | |
| 104 | 112 | continue | |
| 105 | 113 | } | |
| 106 | - | var queue [len(accentCandidateSlots)]uint8 | |
| 114 | + | var queue [accentCandidateCount]uint8 | |
| 107 | 115 | head, tail := 0, 1 | |
| 108 | 116 | queue[0] = uint8(start) | |
| 109 | 117 | visited |= startBit | |
| @@ -112,7 +120,7 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 112 | 120 | head++ | |
| 113 | 121 | groups[groupCount].members |= uint16(1) << i | |
| 114 | 122 | neighbors := adjacent[i] &^ visited | |
| 115 | - | for j := range accentCandidateSlots { | |
| 123 | + | for j := range accentCandidateCount { | |
| 116 | 124 | bit := uint16(1) << j | |
| 117 | 125 | if neighbors&bit == 0 { | |
| 118 | 126 | continue | |
| @@ -141,7 +149,7 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 141 | 149 | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | |
| 142 | 150 | } | |
| 143 | 151 | if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { | |
| 144 | - | slot := accentCandidateSlots[winner.rep] | |
| 152 | + | slot := accentCandidateSlot(int(winner.rep)) | |
| 145 | 153 | return resolvedAccent(t, t.Palette[slot], slot) | |
| 146 | 154 | } | |
| 147 | 155 | ||
| @@ -180,23 +188,24 @@ func indexedAccent(slot uint8) Accent { | |||
| 180 | 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 | 192 | var representative uint8 | |
| 185 | 193 | var representativeDistance float64 | |
| 186 | 194 | first := true | |
| 187 | - | for i, slot := range accentCandidateSlots { | |
| 195 | + | for i := range accentCandidateCount { | |
| 196 | + | slot := accentCandidateSlot(i) | |
| 188 | 197 | if group.members&(uint16(1)<<i) == 0 { | |
| 189 | 198 | continue | |
| 190 | 199 | } | |
| 191 | 200 | group.size++ | |
| 192 | 201 | group.chromaSum += okLabChroma(colors[i]) | |
| 193 | 202 | var totalDistance float64 | |
| 194 | - | for j := range accentCandidateSlots { | |
| 203 | + | for j := range accentCandidateCount { | |
| 195 | 204 | if group.members&(uint16(1)<<j) != 0 { | |
| 196 | 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 | 209 | first = false | |
| 201 | 210 | representative = uint8(i) | |
| 202 | 211 | representativeDistance = totalDistance | |
| @@ -211,8 +220,8 @@ func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]o | |||
| 211 | 220 | } | |
| 212 | 221 | ||
| 213 | 222 | 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 { | |
| 216 | 225 | return group.members&(uint16(1)<<i) != 0 | |
| 217 | 226 | } | |
| 218 | 227 | } | |
| @@ -236,7 +245,7 @@ func betterAccentGroup(a, b accentGroup) bool { | |||
| 236 | 245 | if aBlue != bBlue { | |
| 237 | 246 | 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 | 251 | 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 automatic | 24 | // The resolver only ever considers the chromatic ANSI slots in automatic | ||
| 25 | - | // mode. Keeping this as a fixed array makes resolution bounded and independent | 25 | + | // 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 uint16 | 37 | 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)]oklab | 74 | + | var colors [accentCandidateCount]oklab |
| 68 | var eligible uint16 | 75 | 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 | continue | 80 | 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)]uint16 | 91 | + | 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 | continue | 94 | continue | ||
| 87 | } | 95 | } | ||
| 88 | adjacent[i] = uint16(1) << i | 96 | 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 | continue | 99 | continue | ||
| 92 | } | 100 | } | ||
| @@ -95,15 +103,15 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 95 | } | 103 | } | ||
| 96 | } | 104 | } | ||
| 97 | 105 | ||||
| 98 | - | var groups [len(accentCandidateSlots)]accentGroup | 106 | + | var groups [accentCandidateCount]accentGroup |
| 99 | var visited uint16 | 107 | var visited uint16 | ||
| 100 | groupCount := 0 | 108 | groupCount := 0 | ||
| 101 | - | for start := range accentCandidateSlots { | 109 | + | for start := range accentCandidateCount { |
| 102 | startBit := uint16(1) << start | 110 | startBit := uint16(1) << start | ||
| 103 | if eligible&startBit == 0 || visited&startBit != 0 { | 111 | if eligible&startBit == 0 || visited&startBit != 0 { | ||
| 104 | continue | 112 | continue | ||
| 105 | } | 113 | } | ||
| 106 | - | var queue [len(accentCandidateSlots)]uint8 | 114 | + | var queue [accentCandidateCount]uint8 |
| 107 | head, tail := 0, 1 | 115 | head, tail := 0, 1 | ||
| 108 | queue[0] = uint8(start) | 116 | queue[0] = uint8(start) | ||
| 109 | visited |= startBit | 117 | visited |= startBit | ||
| @@ -112,7 +120,7 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 112 | head++ | 120 | head++ | ||
| 113 | groups[groupCount].members |= uint16(1) << i | 121 | groups[groupCount].members |= uint16(1) << i | ||
| 114 | neighbors := adjacent[i] &^ visited | 122 | neighbors := adjacent[i] &^ visited | ||
| 115 | - | for j := range accentCandidateSlots { | 123 | + | for j := range accentCandidateCount { |
| 116 | bit := uint16(1) << j | 124 | bit := uint16(1) << j | ||
| 117 | if neighbors&bit == 0 { | 125 | if neighbors&bit == 0 { | ||
| 118 | continue | 126 | continue | ||
| @@ -141,7 +149,7 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 141 | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | 149 | 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 uint8 | 192 | var representative uint8 | ||
| 185 | var representativeDistance float64 | 193 | var representativeDistance float64 | ||
| 186 | first := true | 194 | 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 | continue | 198 | continue | ||
| 190 | } | 199 | } | ||
| 191 | group.size++ | 200 | group.size++ | ||
| 192 | group.chromaSum += okLabChroma(colors[i]) | 201 | group.chromaSum += okLabChroma(colors[i]) | ||
| 193 | var totalDistance float64 | 202 | 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 = false | 209 | first = false | ||
| 201 | representative = uint8(i) | 210 | representative = uint8(i) | ||
| 202 | representativeDistance = totalDistance | 211 | 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) != 0 | 225 | 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 aBlue | 246 | 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 ( | |||
| 9 | 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 | 19 | func TestResolveAccentThresholds(t *testing.T) { | |
| 13 | 20 | tests := []struct { | |
| 14 | 21 | name string | |
| @@ -193,7 +200,7 @@ func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing. | |||
| 193 | 200 | ||
| 194 | 201 | func TestResolveAccentMedoidAndTieBreak(t *testing.T) { | |
| 195 | 202 | group := accentGroup{members: 1<<0 | 1<<1 | 1<<2} | |
| 196 | - | colors := [len(accentCandidateSlots)]oklab{ | |
| 203 | + | colors := [accentCandidateCount]oklab{ | |
| 197 | 204 | {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10}, | |
| 198 | 205 | } | |
| 199 | 206 | 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 string | 21 | 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) | ||