feat(theme): infer terminal-owned accent
- commit
- 4c09134a333ed61b7119c2a16399d96b10927830
- author
- brice · · 2026-07-18 07:47 UTC
- parent
- 914adb7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +461 -0
A internal/usecase/theme/accent_resolve.go +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const ( | |
| 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. | |
| 17 | + | type 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. 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} | |
| 28 | + | ||
| 29 | + | type accentGroup struct { | |
| 30 | + | members uint16 | |
| 31 | + | size uint8 | |
| 32 | + | pairs uint8 | |
| 33 | + | chromaSum float64 | |
| 34 | + | rep uint8 | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ResolveAccent deterministically selects a terminal-owned accent. It uses | |
| 38 | + | // only fixed arrays and bitsets so this pure hot-reload operation makes no | |
| 39 | + | // per-call heap allocations. | |
| 40 | + | func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent { | |
| 41 | + | if !t.UsePalette { | |
| 42 | + | return Accent{} | |
| 43 | + | } | |
| 44 | + | if policy.Mode == domain.ThemeAccentSlot { | |
| 45 | + | return resolveExplicitAccent(t, policy.Slot) | |
| 46 | + | } | |
| 47 | + | return resolveAutoAccent(t) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func resolveExplicitAccent(t Theme, slot uint8) Accent { | |
| 51 | + | if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 52 | + | return Accent{} | |
| 53 | + | } | |
| 54 | + | return resolvedAccent(t, t.Palette[slot], slot) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func resolveAutoAccent(t Theme) Accent { | |
| 58 | + | // Background is necessary to reject colors that merely alias the terminal | |
| 59 | + | // surface. Foreground and truecolor are deliberately not required here: | |
| 60 | + | // the winning slot remains useful as an indexed foreground-only fallback. | |
| 61 | + | if !t.HasBG { | |
| 62 | + | return Accent{} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | var colors [len(accentCandidateSlots)]oklab | |
| 66 | + | var eligible uint16 | |
| 67 | + | background := rgbToOKLab(t.Background) | |
| 68 | + | for i, slot := range accentCandidateSlots { | |
| 69 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | colors[i] = rgbToOKLab(t.Palette[slot]) | |
| 73 | + | if accentEligible(colors[i], background) { | |
| 74 | + | eligible |= uint16(1) << i | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if eligible == 0 { | |
| 78 | + | return Accent{} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | var adjacent [len(accentCandidateSlots)]uint16 | |
| 82 | + | for i := range accentCandidateSlots { | |
| 83 | + | if eligible&(uint16(1)<<i) == 0 { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | adjacent[i] = uint16(1) << i | |
| 87 | + | for j := i + 1; j < len(accentCandidateSlots); j++ { | |
| 88 | + | if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) { | |
| 89 | + | continue | |
| 90 | + | } | |
| 91 | + | adjacent[i] |= uint16(1) << j | |
| 92 | + | adjacent[j] |= uint16(1) << i | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | var groups [len(accentCandidateSlots)]accentGroup | |
| 97 | + | var visited uint16 | |
| 98 | + | groupCount := 0 | |
| 99 | + | for start := range accentCandidateSlots { | |
| 100 | + | startBit := uint16(1) << start | |
| 101 | + | if eligible&startBit == 0 || visited&startBit != 0 { | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | var queue [len(accentCandidateSlots)]uint8 | |
| 105 | + | head, tail := 0, 1 | |
| 106 | + | queue[0] = uint8(start) | |
| 107 | + | visited |= startBit | |
| 108 | + | for head < tail { | |
| 109 | + | i := queue[head] | |
| 110 | + | head++ | |
| 111 | + | groups[groupCount].members |= uint16(1) << i | |
| 112 | + | neighbors := adjacent[i] &^ visited | |
| 113 | + | for j := range accentCandidateSlots { | |
| 114 | + | bit := uint16(1) << j | |
| 115 | + | if neighbors&bit == 0 { | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | visited |= bit | |
| 119 | + | queue[tail] = uint8(j) | |
| 120 | + | tail++ | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | finalizeAccentGroup(&groups[groupCount], colors) | |
| 124 | + | groupCount++ | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | best, runner := -1, -1 | |
| 128 | + | for i := 0; i < groupCount; i++ { | |
| 129 | + | if best < 0 || betterAccentGroup(groups[i], groups[best]) { | |
| 130 | + | runner = best | |
| 131 | + | best = i | |
| 132 | + | } else if runner < 0 || betterAccentGroup(groups[i], groups[runner]) { | |
| 133 | + | runner = i | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | winner := groups[best] | |
| 137 | + | runnerSize, runnerPairs := uint8(0), uint8(0) | |
| 138 | + | if runner >= 0 { | |
| 139 | + | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | |
| 140 | + | } | |
| 141 | + | if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { | |
| 142 | + | slot := accentCandidateSlots[winner.rep] | |
| 143 | + | return resolvedAccent(t, t.Palette[slot], slot) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | for _, slot := range [...]uint8{4, 12} { | |
| 147 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | color := rgbToOKLab(t.Palette[slot]) | |
| 151 | + | if accentEligible(color, background) { | |
| 152 | + | return resolvedAccent(t, t.Palette[slot], slot) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return Accent{} | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) { | |
| 159 | + | var representative uint8 | |
| 160 | + | var representativeDistance float64 | |
| 161 | + | first := true | |
| 162 | + | for i, slot := range accentCandidateSlots { | |
| 163 | + | if group.members&(uint16(1)<<i) == 0 { | |
| 164 | + | continue | |
| 165 | + | } | |
| 166 | + | group.size++ | |
| 167 | + | group.chromaSum += okLabChroma(colors[i]) | |
| 168 | + | var totalDistance float64 | |
| 169 | + | for j := range accentCandidateSlots { | |
| 170 | + | if group.members&(uint16(1)<<j) != 0 { | |
| 171 | + | totalDistance += okLabDistance(colors[i], colors[j]) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlots[representative]) { | |
| 175 | + | first = false | |
| 176 | + | representative = uint8(i) | |
| 177 | + | representativeDistance = totalDistance | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | group.rep = representative | |
| 181 | + | for slot := uint8(1); slot <= 6; slot++ { | |
| 182 | + | if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) { | |
| 183 | + | group.pairs++ | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func accentGroupHasSlot(group accentGroup, slot uint8) bool { | |
| 189 | + | for i, candidate := range accentCandidateSlots { | |
| 190 | + | if candidate == slot { | |
| 191 | + | return group.members&(uint16(1)<<i) != 0 | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | return false | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func betterAccentGroup(a, b accentGroup) bool { | |
| 198 | + | if a.size != b.size { | |
| 199 | + | return a.size > b.size | |
| 200 | + | } | |
| 201 | + | if a.pairs != b.pairs { | |
| 202 | + | return a.pairs > b.pairs | |
| 203 | + | } | |
| 204 | + | averageA := a.chromaSum / float64(a.size) | |
| 205 | + | averageB := b.chromaSum / float64(b.size) | |
| 206 | + | if averageA != averageB { | |
| 207 | + | return averageA > averageB | |
| 208 | + | } | |
| 209 | + | aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12) | |
| 210 | + | bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12) | |
| 211 | + | if aBlue != bBlue { | |
| 212 | + | return aBlue | |
| 213 | + | } | |
| 214 | + | return accentCandidateSlots[a.rep] < accentCandidateSlots[b.rep] | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent { | |
| 218 | + | return Accent{ | |
| 219 | + | RGB: color, | |
| 220 | + | Slot: slot, | |
| 221 | + | Known: true, | |
| 222 | + | IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG, | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func accentEligible(candidate, background oklab) bool { | |
| 227 | + | return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func accentConnected(a, b oklab) bool { | |
| 231 | + | return okLabDistance(a, b) <= accentClusterDistance | |
| 232 | + | } | |
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 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. | |||
| 17 | + | type 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. 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} | |||
| 28 | + | ||||
| 29 | + | type accentGroup struct { | |||
| 30 | + | members uint16 | |||
| 31 | + | size uint8 | |||
| 32 | + | pairs uint8 | |||
| 33 | + | chromaSum float64 | |||
| 34 | + | rep uint8 | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // ResolveAccent deterministically selects a terminal-owned accent. It uses | |||
| 38 | + | // only fixed arrays and bitsets so this pure hot-reload operation makes no | |||
| 39 | + | // per-call heap allocations. | |||
| 40 | + | func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent { | |||
| 41 | + | if !t.UsePalette { | |||
| 42 | + | return Accent{} | |||
| 43 | + | } | |||
| 44 | + | if policy.Mode == domain.ThemeAccentSlot { | |||
| 45 | + | return resolveExplicitAccent(t, policy.Slot) | |||
| 46 | + | } | |||
| 47 | + | return resolveAutoAccent(t) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func resolveExplicitAccent(t Theme, slot uint8) Accent { | |||
| 51 | + | if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | |||
| 52 | + | return Accent{} | |||
| 53 | + | } | |||
| 54 | + | return resolvedAccent(t, t.Palette[slot], slot) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func resolveAutoAccent(t Theme) Accent { | |||
| 58 | + | // Background is necessary to reject colors that merely alias the terminal | |||
| 59 | + | // surface. Foreground and truecolor are deliberately not required here: | |||
| 60 | + | // the winning slot remains useful as an indexed foreground-only fallback. | |||
| 61 | + | if !t.HasBG { | |||
| 62 | + | return Accent{} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | var colors [len(accentCandidateSlots)]oklab | |||
| 66 | + | var eligible uint16 | |||
| 67 | + | background := rgbToOKLab(t.Background) | |||
| 68 | + | for i, slot := range accentCandidateSlots { | |||
| 69 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | colors[i] = rgbToOKLab(t.Palette[slot]) | |||
| 73 | + | if accentEligible(colors[i], background) { | |||
| 74 | + | eligible |= uint16(1) << i | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | if eligible == 0 { | |||
| 78 | + | return Accent{} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | var adjacent [len(accentCandidateSlots)]uint16 | |||
| 82 | + | for i := range accentCandidateSlots { | |||
| 83 | + | if eligible&(uint16(1)<<i) == 0 { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | adjacent[i] = uint16(1) << i | |||
| 87 | + | for j := i + 1; j < len(accentCandidateSlots); j++ { | |||
| 88 | + | if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) { | |||
| 89 | + | continue | |||
| 90 | + | } | |||
| 91 | + | adjacent[i] |= uint16(1) << j | |||
| 92 | + | adjacent[j] |= uint16(1) << i | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | var groups [len(accentCandidateSlots)]accentGroup | |||
| 97 | + | var visited uint16 | |||
| 98 | + | groupCount := 0 | |||
| 99 | + | for start := range accentCandidateSlots { | |||
| 100 | + | startBit := uint16(1) << start | |||
| 101 | + | if eligible&startBit == 0 || visited&startBit != 0 { | |||
| 102 | + | continue | |||
| 103 | + | } | |||
| 104 | + | var queue [len(accentCandidateSlots)]uint8 | |||
| 105 | + | head, tail := 0, 1 | |||
| 106 | + | queue[0] = uint8(start) | |||
| 107 | + | visited |= startBit | |||
| 108 | + | for head < tail { | |||
| 109 | + | i := queue[head] | |||
| 110 | + | head++ | |||
| 111 | + | groups[groupCount].members |= uint16(1) << i | |||
| 112 | + | neighbors := adjacent[i] &^ visited | |||
| 113 | + | for j := range accentCandidateSlots { | |||
| 114 | + | bit := uint16(1) << j | |||
| 115 | + | if neighbors&bit == 0 { | |||
| 116 | + | continue | |||
| 117 | + | } | |||
| 118 | + | visited |= bit | |||
| 119 | + | queue[tail] = uint8(j) | |||
| 120 | + | tail++ | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | finalizeAccentGroup(&groups[groupCount], colors) | |||
| 124 | + | groupCount++ | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | best, runner := -1, -1 | |||
| 128 | + | for i := 0; i < groupCount; i++ { | |||
| 129 | + | if best < 0 || betterAccentGroup(groups[i], groups[best]) { | |||
| 130 | + | runner = best | |||
| 131 | + | best = i | |||
| 132 | + | } else if runner < 0 || betterAccentGroup(groups[i], groups[runner]) { | |||
| 133 | + | runner = i | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | winner := groups[best] | |||
| 137 | + | runnerSize, runnerPairs := uint8(0), uint8(0) | |||
| 138 | + | if runner >= 0 { | |||
| 139 | + | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | |||
| 140 | + | } | |||
| 141 | + | if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { | |||
| 142 | + | slot := accentCandidateSlots[winner.rep] | |||
| 143 | + | return resolvedAccent(t, t.Palette[slot], slot) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | for _, slot := range [...]uint8{4, 12} { | |||
| 147 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | color := rgbToOKLab(t.Palette[slot]) | |||
| 151 | + | if accentEligible(color, background) { | |||
| 152 | + | return resolvedAccent(t, t.Palette[slot], slot) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | return Accent{} | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) { | |||
| 159 | + | var representative uint8 | |||
| 160 | + | var representativeDistance float64 | |||
| 161 | + | first := true | |||
| 162 | + | for i, slot := range accentCandidateSlots { | |||
| 163 | + | if group.members&(uint16(1)<<i) == 0 { | |||
| 164 | + | continue | |||
| 165 | + | } | |||
| 166 | + | group.size++ | |||
| 167 | + | group.chromaSum += okLabChroma(colors[i]) | |||
| 168 | + | var totalDistance float64 | |||
| 169 | + | for j := range accentCandidateSlots { | |||
| 170 | + | if group.members&(uint16(1)<<j) != 0 { | |||
| 171 | + | totalDistance += okLabDistance(colors[i], colors[j]) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlots[representative]) { | |||
| 175 | + | first = false | |||
| 176 | + | representative = uint8(i) | |||
| 177 | + | representativeDistance = totalDistance | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | group.rep = representative | |||
| 181 | + | for slot := uint8(1); slot <= 6; slot++ { | |||
| 182 | + | if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) { | |||
| 183 | + | group.pairs++ | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func accentGroupHasSlot(group accentGroup, slot uint8) bool { | |||
| 189 | + | for i, candidate := range accentCandidateSlots { | |||
| 190 | + | if candidate == slot { | |||
| 191 | + | return group.members&(uint16(1)<<i) != 0 | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | return false | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func betterAccentGroup(a, b accentGroup) bool { | |||
| 198 | + | if a.size != b.size { | |||
| 199 | + | return a.size > b.size | |||
| 200 | + | } | |||
| 201 | + | if a.pairs != b.pairs { | |||
| 202 | + | return a.pairs > b.pairs | |||
| 203 | + | } | |||
| 204 | + | averageA := a.chromaSum / float64(a.size) | |||
| 205 | + | averageB := b.chromaSum / float64(b.size) | |||
| 206 | + | if averageA != averageB { | |||
| 207 | + | return averageA > averageB | |||
| 208 | + | } | |||
| 209 | + | aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12) | |||
| 210 | + | bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12) | |||
| 211 | + | if aBlue != bBlue { | |||
| 212 | + | return aBlue | |||
| 213 | + | } | |||
| 214 | + | return accentCandidateSlots[a.rep] < accentCandidateSlots[b.rep] | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent { | |||
| 218 | + | return Accent{ | |||
| 219 | + | RGB: color, | |||
| 220 | + | Slot: slot, | |||
| 221 | + | Known: true, | |||
| 222 | + | IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG, | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func accentEligible(candidate, background oklab) bool { | |||
| 227 | + | return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func accentConnected(a, b oklab) bool { | |||
| 231 | + | return okLabDistance(a, b) <= accentClusterDistance | |||
| 232 | + | } | |||
A internal/usecase/theme/accent_resolve_test.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math/rand" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestResolveAccentThresholds(t *testing.T) { | |
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | color oklab | |
| 16 | + | background oklab | |
| 17 | + | want bool | |
| 18 | + | }{ | |
| 19 | + | {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}}, | |
| 20 | + | {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true}, | |
| 21 | + | {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}}, | |
| 22 | + | {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |
| 23 | + | } | |
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | require.Equal(t, tt.want, accentEligible(tt.color, tt.background)) | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | a := oklab{} | |
| 31 | + | require.True(t, accentConnected(a, oklab{L: accentClusterDistance})) | |
| 32 | + | require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001})) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestResolveAccentAuto(t *testing.T) { | |
| 36 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 37 | + | blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9} | |
| 38 | + | tests := []struct { | |
| 39 | + | name string | |
| 40 | + | theme Theme | |
| 41 | + | want Accent | |
| 42 | + | }{ | |
| 43 | + | { | |
| 44 | + | name: "repeated teal beats repeated blue", | |
| 45 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 14: teal, 4: blue, 12: blue}), | |
| 46 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "singleton falls back to slot four", | |
| 50 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 4: blue}), | |
| 51 | + | want: Accent{RGB: blue, Slot: 4, Known: true}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "singleton falls back to slot twelve", | |
| 55 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 12: blue}), | |
| 56 | + | want: Accent{RGB: blue, Slot: 12, Known: true}, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "tied repeated groups fall back to blue", | |
| 60 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}), | |
| 61 | + | want: Accent{RGB: blue, Slot: 4, Known: true}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "regular bright pair beats equal size runner up", | |
| 65 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}), | |
| 66 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "neutral slots excluded from auto", | |
| 70 | + | theme: paletteTheme(map[int]renderer.RGB{0: teal, 7: teal, 8: teal, 15: teal}), | |
| 71 | + | want: Accent{}, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "low chroma is rejected", | |
| 75 | + | theme: paletteTheme(map[int]renderer.RGB{ | |
| 76 | + | 1: {R: 60, G: 61, B: 60}, 9: {R: 60, G: 61, B: 60}, | |
| 77 | + | }), | |
| 78 | + | want: Accent{}, | |
| 79 | + | }, | |
| 80 | + | { | |
| 81 | + | name: "near background is rejected", | |
| 82 | + | theme: paletteTheme(map[int]renderer.RGB{ | |
| 83 | + | 1: {R: 9, G: 8, B: 12}, 9: {R: 9, G: 8, B: 12}, | |
| 84 | + | }), | |
| 85 | + | want: Accent{}, | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | name: "missing foreground yields indexed fallback only", | |
| 89 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasFG = false; return t }(), | |
| 90 | + | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |
| 91 | + | }, | |
| 92 | + | { | |
| 93 | + | name: "non truecolor yields indexed fallback only", | |
| 94 | + | theme: func() Theme { | |
| 95 | + | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |
| 96 | + | t.TrueColor = false | |
| 97 | + | return t | |
| 98 | + | }(), | |
| 99 | + | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | name: "missing background cannot infer", | |
| 103 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasBG = false; return t }(), | |
| 104 | + | want: Accent{}, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "palette disabled ignores accent policy", | |
| 108 | + | theme: func() Theme { | |
| 109 | + | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |
| 110 | + | t.UsePalette = false | |
| 111 | + | return t | |
| 112 | + | }(), | |
| 113 | + | want: Accent{}, | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | for _, tt := range tests { | |
| 118 | + | t.Run(tt.name, func(t *testing.T) { | |
| 119 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})) | |
| 120 | + | }) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestResolveAccentExplicitSlot(t *testing.T) { | |
| 125 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 126 | + | blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9} | |
| 127 | + | tests := []struct { | |
| 128 | + | name string | |
| 129 | + | theme Theme | |
| 130 | + | policy domain.ThemeAccent | |
| 131 | + | want Accent | |
| 132 | + | }{ | |
| 133 | + | { | |
| 134 | + | name: "known explicit neutral slot bypasses auto exclusions", | |
| 135 | + | theme: paletteTheme(map[int]renderer.RGB{0: teal, 2: blue, 10: blue}), | |
| 136 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0}, | |
| 137 | + | want: Accent{RGB: teal, Slot: 0, Known: true}, | |
| 138 | + | }, | |
| 139 | + | { | |
| 140 | + | name: "known explicit slot bypasses eligibility", | |
| 141 | + | theme: paletteTheme(map[int]renderer.RGB{3: {R: 9, G: 8, B: 12}, 2: teal, 10: teal}), | |
| 142 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 143 | + | want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true}, | |
| 144 | + | }, | |
| 145 | + | { | |
| 146 | + | name: "unknown explicit slot never substitutes fallback blue", | |
| 147 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |
| 148 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}, | |
| 149 | + | want: Accent{}, | |
| 150 | + | }, | |
| 151 | + | { | |
| 152 | + | name: "explicit known slot is indexed only without truecolor prerequisites", | |
| 153 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | |
| 154 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 155 | + | want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true}, | |
| 156 | + | }, | |
| 157 | + | { | |
| 158 | + | name: "palette off ignores explicit slot", | |
| 159 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.UsePalette = false; return t }(), | |
| 160 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 161 | + | want: Accent{}, | |
| 162 | + | }, | |
| 163 | + | } | |
| 164 | + | for _, tt := range tests { | |
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |
| 166 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy)) | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.T) { | |
| 172 | + | entries := []struct { | |
| 173 | + | slot int | |
| 174 | + | color renderer.RGB | |
| 175 | + | }{ | |
| 176 | + | {2, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {10, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {14, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, | |
| 177 | + | {4, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, {12, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, | |
| 178 | + | } | |
| 179 | + | want := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true} | |
| 180 | + | rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test shuffle. | |
| 181 | + | for i := 0; i < 50; i++ { | |
| 182 | + | rng.Shuffle(len(entries), func(a, b int) { entries[a], entries[b] = entries[b], entries[a] }) | |
| 183 | + | palette := make(map[int]renderer.RGB, len(entries)) | |
| 184 | + | for _, entry := range entries { | |
| 185 | + | palette[entry.slot] = entry.color | |
| 186 | + | } | |
| 187 | + | require.Equalf(t, want, ResolveAccent(paletteTheme(palette), domain.ThemeAccent{}), "shuffle %d", i) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestResolveAccentMedoidAndTieBreak(t *testing.T) { | |
| 192 | + | group := accentGroup{members: 1<<0 | 1<<1 | 1<<2} | |
| 193 | + | colors := [len(accentCandidateSlots)]oklab{ | |
| 194 | + | {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10}, | |
| 195 | + | } | |
| 196 | + | finalizeAccentGroup(&group, colors) | |
| 197 | + | require.Equal(t, uint8(1), group.rep, "central member is the medoid") | |
| 198 | + | ||
| 199 | + | color := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 200 | + | got := ResolveAccent(paletteTheme(map[int]renderer.RGB{3: color, 11: color}), domain.ThemeAccent{}) | |
| 201 | + | require.Equal(t, Accent{RGB: color, Slot: 3, Known: true}, got) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestResolveAccentAllocs(t *testing.T) { | |
| 205 | + | theme := paletteTheme(map[int]renderer.RGB{ | |
| 206 | + | 2: {R: 0x7d, G: 0xb5, B: 0xb5}, 10: {R: 0x7d, G: 0xb5, B: 0xb5}, 14: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 207 | + | 4: {R: 0x6c, G: 0x9b, B: 0xd9}, 12: {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 208 | + | }) | |
| 209 | + | require.Zero(t, testing.AllocsPerRun(1000, func() { | |
| 210 | + | _ = ResolveAccent(theme, domain.ThemeAccent{}) | |
| 211 | + | })) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func paletteTheme(colors map[int]renderer.RGB) Theme { | |
| 215 | + | theme := Theme{ | |
| 216 | + | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |
| 217 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 218 | + | HasFG: true, | |
| 219 | + | HasBG: true, | |
| 220 | + | TrueColor: true, | |
| 221 | + | Known: true, | |
| 222 | + | UsePalette: true, | |
| 223 | + | } | |
| 224 | + | for slot, color := range colors { | |
| 225 | + | theme.Palette[slot] = color | |
| 226 | + | theme.PaletteKnown |= 1 << slot | |
| 227 | + | } | |
| 228 | + | return theme | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math/rand" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestResolveAccentThresholds(t *testing.T) { | |||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | color oklab | |||
| 16 | + | background oklab | |||
| 17 | + | want bool | |||
| 18 | + | }{ | |||
| 19 | + | {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}}, | |||
| 20 | + | {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true}, | |||
| 21 | + | {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}}, | |||
| 22 | + | {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |||
| 23 | + | } | |||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | require.Equal(t, tt.want, accentEligible(tt.color, tt.background)) | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | a := oklab{} | |||
| 31 | + | require.True(t, accentConnected(a, oklab{L: accentClusterDistance})) | |||
| 32 | + | require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001})) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestResolveAccentAuto(t *testing.T) { | |||
| 36 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 37 | + | blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9} | |||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | theme Theme | |||
| 41 | + | want Accent | |||
| 42 | + | }{ | |||
| 43 | + | { | |||
| 44 | + | name: "repeated teal beats repeated blue", | |||
| 45 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 14: teal, 4: blue, 12: blue}), | |||
| 46 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | name: "singleton falls back to slot four", | |||
| 50 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 4: blue}), | |||
| 51 | + | want: Accent{RGB: blue, Slot: 4, Known: true}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "singleton falls back to slot twelve", | |||
| 55 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 12: blue}), | |||
| 56 | + | want: Accent{RGB: blue, Slot: 12, Known: true}, | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "tied repeated groups fall back to blue", | |||
| 60 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}), | |||
| 61 | + | want: Accent{RGB: blue, Slot: 4, Known: true}, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "regular bright pair beats equal size runner up", | |||
| 65 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}), | |||
| 66 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "neutral slots excluded from auto", | |||
| 70 | + | theme: paletteTheme(map[int]renderer.RGB{0: teal, 7: teal, 8: teal, 15: teal}), | |||
| 71 | + | want: Accent{}, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "low chroma is rejected", | |||
| 75 | + | theme: paletteTheme(map[int]renderer.RGB{ | |||
| 76 | + | 1: {R: 60, G: 61, B: 60}, 9: {R: 60, G: 61, B: 60}, | |||
| 77 | + | }), | |||
| 78 | + | want: Accent{}, | |||
| 79 | + | }, | |||
| 80 | + | { | |||
| 81 | + | name: "near background is rejected", | |||
| 82 | + | theme: paletteTheme(map[int]renderer.RGB{ | |||
| 83 | + | 1: {R: 9, G: 8, B: 12}, 9: {R: 9, G: 8, B: 12}, | |||
| 84 | + | }), | |||
| 85 | + | want: Accent{}, | |||
| 86 | + | }, | |||
| 87 | + | { | |||
| 88 | + | name: "missing foreground yields indexed fallback only", | |||
| 89 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasFG = false; return t }(), | |||
| 90 | + | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |||
| 91 | + | }, | |||
| 92 | + | { | |||
| 93 | + | name: "non truecolor yields indexed fallback only", | |||
| 94 | + | theme: func() Theme { | |||
| 95 | + | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |||
| 96 | + | t.TrueColor = false | |||
| 97 | + | return t | |||
| 98 | + | }(), | |||
| 99 | + | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | name: "missing background cannot infer", | |||
| 103 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasBG = false; return t }(), | |||
| 104 | + | want: Accent{}, | |||
| 105 | + | }, | |||
| 106 | + | { | |||
| 107 | + | name: "palette disabled ignores accent policy", | |||
| 108 | + | theme: func() Theme { | |||
| 109 | + | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |||
| 110 | + | t.UsePalette = false | |||
| 111 | + | return t | |||
| 112 | + | }(), | |||
| 113 | + | want: Accent{}, | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | for _, tt := range tests { | |||
| 118 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 119 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})) | |||
| 120 | + | }) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestResolveAccentExplicitSlot(t *testing.T) { | |||
| 125 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 126 | + | blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9} | |||
| 127 | + | tests := []struct { | |||
| 128 | + | name string | |||
| 129 | + | theme Theme | |||
| 130 | + | policy domain.ThemeAccent | |||
| 131 | + | want Accent | |||
| 132 | + | }{ | |||
| 133 | + | { | |||
| 134 | + | name: "known explicit neutral slot bypasses auto exclusions", | |||
| 135 | + | theme: paletteTheme(map[int]renderer.RGB{0: teal, 2: blue, 10: blue}), | |||
| 136 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0}, | |||
| 137 | + | want: Accent{RGB: teal, Slot: 0, Known: true}, | |||
| 138 | + | }, | |||
| 139 | + | { | |||
| 140 | + | name: "known explicit slot bypasses eligibility", | |||
| 141 | + | theme: paletteTheme(map[int]renderer.RGB{3: {R: 9, G: 8, B: 12}, 2: teal, 10: teal}), | |||
| 142 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |||
| 143 | + | want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true}, | |||
| 144 | + | }, | |||
| 145 | + | { | |||
| 146 | + | name: "unknown explicit slot never substitutes fallback blue", | |||
| 147 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |||
| 148 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}, | |||
| 149 | + | want: Accent{}, | |||
| 150 | + | }, | |||
| 151 | + | { | |||
| 152 | + | name: "explicit known slot is indexed only without truecolor prerequisites", | |||
| 153 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | |||
| 154 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |||
| 155 | + | want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true}, | |||
| 156 | + | }, | |||
| 157 | + | { | |||
| 158 | + | name: "palette off ignores explicit slot", | |||
| 159 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.UsePalette = false; return t }(), | |||
| 160 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |||
| 161 | + | want: Accent{}, | |||
| 162 | + | }, | |||
| 163 | + | } | |||
| 164 | + | for _, tt := range tests { | |||
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 166 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy)) | |||
| 167 | + | }) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.T) { | |||
| 172 | + | entries := []struct { | |||
| 173 | + | slot int | |||
| 174 | + | color renderer.RGB | |||
| 175 | + | }{ | |||
| 176 | + | {2, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {10, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {14, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, | |||
| 177 | + | {4, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, {12, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, | |||
| 178 | + | } | |||
| 179 | + | want := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true} | |||
| 180 | + | rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test shuffle. | |||
| 181 | + | for i := 0; i < 50; i++ { | |||
| 182 | + | rng.Shuffle(len(entries), func(a, b int) { entries[a], entries[b] = entries[b], entries[a] }) | |||
| 183 | + | palette := make(map[int]renderer.RGB, len(entries)) | |||
| 184 | + | for _, entry := range entries { | |||
| 185 | + | palette[entry.slot] = entry.color | |||
| 186 | + | } | |||
| 187 | + | require.Equalf(t, want, ResolveAccent(paletteTheme(palette), domain.ThemeAccent{}), "shuffle %d", i) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestResolveAccentMedoidAndTieBreak(t *testing.T) { | |||
| 192 | + | group := accentGroup{members: 1<<0 | 1<<1 | 1<<2} | |||
| 193 | + | colors := [len(accentCandidateSlots)]oklab{ | |||
| 194 | + | {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10}, | |||
| 195 | + | } | |||
| 196 | + | finalizeAccentGroup(&group, colors) | |||
| 197 | + | require.Equal(t, uint8(1), group.rep, "central member is the medoid") | |||
| 198 | + | ||||
| 199 | + | color := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 200 | + | got := ResolveAccent(paletteTheme(map[int]renderer.RGB{3: color, 11: color}), domain.ThemeAccent{}) | |||
| 201 | + | require.Equal(t, Accent{RGB: color, Slot: 3, Known: true}, got) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestResolveAccentAllocs(t *testing.T) { | |||
| 205 | + | theme := paletteTheme(map[int]renderer.RGB{ | |||
| 206 | + | 2: {R: 0x7d, G: 0xb5, B: 0xb5}, 10: {R: 0x7d, G: 0xb5, B: 0xb5}, 14: {R: 0x7d, G: 0xb5, B: 0xb5}, | |||
| 207 | + | 4: {R: 0x6c, G: 0x9b, B: 0xd9}, 12: {R: 0x6c, G: 0x9b, B: 0xd9}, | |||
| 208 | + | }) | |||
| 209 | + | require.Zero(t, testing.AllocsPerRun(1000, func() { | |||
| 210 | + | _ = ResolveAccent(theme, domain.ThemeAccent{}) | |||
| 211 | + | })) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func paletteTheme(colors map[int]renderer.RGB) Theme { | |||
| 215 | + | theme := Theme{ | |||
| 216 | + | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |||
| 217 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |||
| 218 | + | HasFG: true, | |||
| 219 | + | HasBG: true, | |||
| 220 | + | TrueColor: true, | |||
| 221 | + | Known: true, | |||
| 222 | + | UsePalette: true, | |||
| 223 | + | } | |||
| 224 | + | for slot, color := range colors { | |||
| 225 | + | theme.Palette[slot] = color | |||
| 226 | + | theme.PaletteKnown |= 1 << slot | |||
| 227 | + | } | |||
| 228 | + | return theme | |||
| 229 | + | } | |||