fix(theme): prefer cohesive accent groups (#247)
- commit
- da3b056a1bff18409b0f95cc65f6c4c7728e31cb
- author
- bnema · · 2026-09-03 11:32 UTC
- committer
- GitHub · · 2026-09-03 11:32 UTC
- parent
- ed18477
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
3 files · +50 -11
M docs/configuration.md +1 -1
| @@ -97,7 +97,7 @@ Catalogue failure is fail-closed: vev does not publish an empty replacement daem | |||
| 97 | 97 | ||
| 98 | 98 | ## Theme | |
| 99 | 99 | ||
| 100 | - | With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; repeated terminal colors are preferred, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`. | |
| 100 | + | With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; larger and more cohesive repeated-color groups are preferred, then conventional normal/bright pairs, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`. | |
| 101 | 101 | ||
| 102 | 102 | When the terminal provides truecolor defaults and a usable RGB palette slot, vev uses the resolved accent for active chrome and derives softer bar, inactive, recent-session, and border colors from it. It preserves readability by reducing a surface intensity when needed. Terminal application/pane colors are not recolored. Without truecolor default colors or a usable RGB resolved accent, chrome backgrounds remain neutral; an available selected ANSI slot can still decorate foregrounds or borders. An explicit unavailable slot is never replaced with another slot. | |
| 103 | 103 | ||
| @@ -97,7 +97,7 @@ Catalogue failure is fail-closed: vev does not publish an empty replacement daem | |||||
| 97 | 97 | ||||
| 98 | ## Theme | 98 | ## Theme | ||
| 99 | 99 | ||||
| 100 | - | With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; repeated terminal colors are preferred, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`. | 100 | + | With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; larger and more cohesive repeated-color groups are preferred, then conventional normal/bright pairs, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`. |
| 101 | 101 | ||||
| 102 | When the terminal provides truecolor defaults and a usable RGB palette slot, vev uses the resolved accent for active chrome and derives softer bar, inactive, recent-session, and border colors from it. It preserves readability by reducing a surface intensity when needed. Terminal application/pane colors are not recolored. Without truecolor default colors or a usable RGB resolved accent, chrome backgrounds remain neutral; an available selected ANSI slot can still decorate foregrounds or borders. An explicit unavailable slot is never replaced with another slot. | 102 | When the terminal provides truecolor defaults and a usable RGB palette slot, vev uses the resolved accent for active chrome and derives softer bar, inactive, recent-session, and border colors from it. It preserves readability by reducing a surface intensity when needed. Terminal application/pane colors are not recolored. Without truecolor default colors or a usable RGB resolved accent, chrome backgrounds remain neutral; an available selected ANSI slot can still decorate foregrounds or borders. An explicit unavailable slot is never replaced with another slot. | ||
| 103 | 103 | ||||
M internal/usecase/theme/accent_resolve.go +21 -10
| @@ -34,11 +34,12 @@ func accentCandidateSlot(index int) uint8 { | |||
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | type accentGroup struct { | |
| 37 | - | members uint16 | |
| 38 | - | size uint8 | |
| 39 | - | pairs uint8 | |
| 40 | - | chromaSum float64 | |
| 41 | - | rep uint8 | |
| 37 | + | members uint16 | |
| 38 | + | size uint8 | |
| 39 | + | pairs uint8 | |
| 40 | + | distanceSum float64 | |
| 41 | + | chromaSum float64 | |
| 42 | + | rep uint8 | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | // ResolveAccent deterministically selects a terminal-owned accent. It uses | |
| @@ -147,11 +148,13 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 147 | 148 | } | |
| 148 | 149 | } | |
| 149 | 150 | winner := groups[best] | |
| 150 | - | runnerSize, runnerPairs := uint8(0), uint8(0) | |
| 151 | + | runnerUp := accentGroup{} | |
| 151 | 152 | if runner >= 0 { | |
| 152 | - | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | |
| 153 | + | runnerUp = groups[runner] | |
| 153 | 154 | } | |
| 154 | - | if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { | |
| 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)) { | |
| 155 | 158 | slot := accentCandidateSlot(int(winner.rep)) | |
| 156 | 159 | return resolvedAccent(t, t.Palette[slot], slot) | |
| 157 | 160 | } | |
| @@ -204,8 +207,13 @@ func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) | |||
| 204 | 207 | group.chromaSum += okLabChroma(colors[i]) | |
| 205 | 208 | var totalDistance float64 | |
| 206 | 209 | for j := range accentCandidateCount { | |
| 207 | - | if group.members&(uint16(1)<<j) != 0 { | |
| 208 | - | totalDistance += okLabDistance(colors[i], colors[j]) | |
| 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 | |
| 209 | 217 | } | |
| 210 | 218 | } | |
| 211 | 219 | if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) { | |
| @@ -235,6 +243,9 @@ func betterAccentGroup(a, b accentGroup) bool { | |||
| 235 | 243 | if a.size != b.size { | |
| 236 | 244 | return a.size > b.size | |
| 237 | 245 | } | |
| 246 | + | if a.distanceSum != b.distanceSum { | |
| 247 | + | return a.distanceSum < b.distanceSum | |
| 248 | + | } | |
| 238 | 249 | if a.pairs != b.pairs { | |
| 239 | 250 | return a.pairs > b.pairs | |
| 240 | 251 | } | |
| @@ -34,11 +34,12 @@ func accentCandidateSlot(index int) uint8 { | |||||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | type accentGroup struct { | 36 | type accentGroup struct { | ||
| 37 | - | members uint16 | 37 | + | members uint16 |
| 38 | - | size uint8 | 38 | + | size uint8 |
| 39 | - | pairs uint8 | 39 | + | pairs uint8 |
| 40 | - | chromaSum float64 | 40 | + | distanceSum float64 |
| 41 | - | rep uint8 | 41 | + | chromaSum float64 |
| 42 | + | rep uint8 | |||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 44 | // ResolveAccent deterministically selects a terminal-owned accent. It uses | 45 | // ResolveAccent deterministically selects a terminal-owned accent. It uses | ||
| @@ -147,11 +148,13 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 147 | } | 148 | } | ||
| 148 | } | 149 | } | ||
| 149 | winner := groups[best] | 150 | winner := groups[best] | ||
| 150 | - | runnerSize, runnerPairs := uint8(0), uint8(0) | 151 | + | runnerUp := accentGroup{} |
| 151 | if runner >= 0 { | 152 | if runner >= 0 { | ||
| 152 | - | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | 153 | + | runnerUp = groups[runner] |
| 153 | } | 154 | } | ||
| 154 | - | if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { | 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)) { | |||
| 155 | slot := accentCandidateSlot(int(winner.rep)) | 158 | slot := accentCandidateSlot(int(winner.rep)) | ||
| 156 | return resolvedAccent(t, t.Palette[slot], slot) | 159 | return resolvedAccent(t, t.Palette[slot], slot) | ||
| 157 | } | 160 | } | ||
| @@ -204,8 +207,13 @@ func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) | |||||
| 204 | group.chromaSum += okLabChroma(colors[i]) | 207 | group.chromaSum += okLabChroma(colors[i]) | ||
| 205 | var totalDistance float64 | 208 | var totalDistance float64 | ||
| 206 | for j := range accentCandidateCount { | 209 | for j := range accentCandidateCount { | ||
| 207 | - | if group.members&(uint16(1)<<j) != 0 { | 210 | + | if group.members&(uint16(1)<<j) == 0 { |
| 208 | - | totalDistance += okLabDistance(colors[i], colors[j]) | 211 | + | continue |
| 212 | + | } | |||
| 213 | + | distance := okLabDistance(colors[i], colors[j]) | |||
| 214 | + | totalDistance += distance | |||
| 215 | + | if j > i { | |||
| 216 | + | group.distanceSum += distance | |||
| 209 | } | 217 | } | ||
| 210 | } | 218 | } | ||
| 211 | if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) { | 219 | if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) { | ||
| @@ -235,6 +243,9 @@ func betterAccentGroup(a, b accentGroup) bool { | |||||
| 235 | if a.size != b.size { | 243 | if a.size != b.size { | ||
| 236 | return a.size > b.size | 244 | return a.size > b.size | ||
| 237 | } | 245 | } | ||
| 246 | + | if a.distanceSum != b.distanceSum { | |||
| 247 | + | return a.distanceSum < b.distanceSum | |||
| 248 | + | } | |||
| 238 | if a.pairs != b.pairs { | 249 | if a.pairs != b.pairs { | ||
| 239 | return a.pairs > b.pairs | 250 | return a.pairs > b.pairs | ||
| 240 | } | 251 | } | ||
M internal/usecase/theme/accent_resolve_test.go +28 -0
| @@ -70,6 +70,34 @@ func TestResolveAccentAuto(t *testing.T) { | |||
| 70 | 70 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}), | |
| 71 | 71 | want: Accent{RGB: blue, Slot: 4, Known: true}, | |
| 72 | 72 | }, | |
| 73 | + | { | |
| 74 | + | name: "exact theme accent beats a looser regular bright pair", | |
| 75 | + | theme: func() Theme { | |
| 76 | + | t := paletteTheme(map[int]renderer.RGB{ | |
| 77 | + | 0: {R: 0xf4, G: 0xf7, B: 0xf5}, | |
| 78 | + | 1: {R: 0xb6, G: 0x4d, B: 0x58}, | |
| 79 | + | 2: {R: 0x3d, G: 0x66, B: 0x66}, | |
| 80 | + | 3: {R: 0x8f, G: 0x62, B: 0x2b}, | |
| 81 | + | 4: {R: 0x42, G: 0x6f, B: 0xa8}, | |
| 82 | + | 5: {R: 0x74, G: 0x6f, B: 0x76}, | |
| 83 | + | 6: {R: 0x5a, G: 0x85, B: 0x85}, | |
| 84 | + | 7: {R: 0x1d, G: 0x22, B: 0x24}, | |
| 85 | + | 8: {R: 0x75, G: 0x79, B: 0x82}, | |
| 86 | + | 9: {R: 0xa9, G: 0x47, B: 0x51}, | |
| 87 | + | 10: {R: 0x2a, G: 0x4a, B: 0x4a}, | |
| 88 | + | 11: {R: 0x7d, G: 0x55, B: 0x25}, | |
| 89 | + | 12: {R: 0x34, G: 0x5f, B: 0x95}, | |
| 90 | + | 13: {R: 0x62, G: 0x5d, B: 0x64}, | |
| 91 | + | 14: {R: 0x3d, G: 0x66, B: 0x66}, | |
| 92 | + | 15: {R: 0x08, G: 0x09, B: 0x0a}, | |
| 93 | + | }) | |
| 94 | + | t.Foreground = renderer.RGB{R: 0x1d, G: 0x22, B: 0x24} | |
| 95 | + | t.Background = renderer.RGB{R: 0xf4, G: 0xf7, B: 0xf5} | |
| 96 | + | t.Light = true | |
| 97 | + | return t | |
| 98 | + | }(), | |
| 99 | + | want: Accent{RGB: renderer.RGB{R: 0x3d, G: 0x66, B: 0x66}, Slot: 2, Known: true}, | |
| 100 | + | }, | |
| 73 | 101 | { | |
| 74 | 102 | name: "regular bright pair beats equal size runner up", | |
| 75 | 103 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}), | |
| @@ -70,6 +70,34 @@ func TestResolveAccentAuto(t *testing.T) { | |||||
| 70 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}), | 70 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}), | ||
| 71 | want: Accent{RGB: blue, Slot: 4, Known: true}, | 71 | want: Accent{RGB: blue, Slot: 4, Known: true}, | ||
| 72 | }, | 72 | }, | ||
| 73 | + | { | |||
| 74 | + | name: "exact theme accent beats a looser regular bright pair", | |||
| 75 | + | theme: func() Theme { | |||
| 76 | + | t := paletteTheme(map[int]renderer.RGB{ | |||
| 77 | + | 0: {R: 0xf4, G: 0xf7, B: 0xf5}, | |||
| 78 | + | 1: {R: 0xb6, G: 0x4d, B: 0x58}, | |||
| 79 | + | 2: {R: 0x3d, G: 0x66, B: 0x66}, | |||
| 80 | + | 3: {R: 0x8f, G: 0x62, B: 0x2b}, | |||
| 81 | + | 4: {R: 0x42, G: 0x6f, B: 0xa8}, | |||
| 82 | + | 5: {R: 0x74, G: 0x6f, B: 0x76}, | |||
| 83 | + | 6: {R: 0x5a, G: 0x85, B: 0x85}, | |||
| 84 | + | 7: {R: 0x1d, G: 0x22, B: 0x24}, | |||
| 85 | + | 8: {R: 0x75, G: 0x79, B: 0x82}, | |||
| 86 | + | 9: {R: 0xa9, G: 0x47, B: 0x51}, | |||
| 87 | + | 10: {R: 0x2a, G: 0x4a, B: 0x4a}, | |||
| 88 | + | 11: {R: 0x7d, G: 0x55, B: 0x25}, | |||
| 89 | + | 12: {R: 0x34, G: 0x5f, B: 0x95}, | |||
| 90 | + | 13: {R: 0x62, G: 0x5d, B: 0x64}, | |||
| 91 | + | 14: {R: 0x3d, G: 0x66, B: 0x66}, | |||
| 92 | + | 15: {R: 0x08, G: 0x09, B: 0x0a}, | |||
| 93 | + | }) | |||
| 94 | + | t.Foreground = renderer.RGB{R: 0x1d, G: 0x22, B: 0x24} | |||
| 95 | + | t.Background = renderer.RGB{R: 0xf4, G: 0xf7, B: 0xf5} | |||
| 96 | + | t.Light = true | |||
| 97 | + | return t | |||
| 98 | + | }(), | |||
| 99 | + | want: Accent{RGB: renderer.RGB{R: 0x3d, G: 0x66, B: 0x66}, Slot: 2, Known: true}, | |||
| 100 | + | }, | |||
| 73 | { | 101 | { | ||
| 74 | name: "regular bright pair beats equal size runner up", | 102 | name: "regular bright pair beats equal size runner up", | ||
| 75 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}), | 103 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}), | ||