detached da3b056

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 UnverifiedB5690EEEBB952194 The 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
9797
9898 ## Theme
9999
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`.
101101
102102 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.
103103
@@ -97,7 +97,7 @@ Catalogue failure is fail-closed: vev does not publish an empty replacement daem
97 97
98 ## Theme98 ## 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 {
3434 }
3535
3636 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
4243 }
4344
4445 // ResolveAccent deterministically selects a terminal-owned accent. It uses
@@ -147,11 +148,13 @@ func resolveAutoAccent(t Theme) Accent {
147148 }
148149 }
149150 winner := groups[best]
150- runnerSize, runnerPairs := uint8(0), uint8(0)
151+ runnerUp := accentGroup{}
151152 if runner >= 0 {
152- runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs
153+ runnerUp = groups[runner]
153154 }
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)) {
155158 slot := accentCandidateSlot(int(winner.rep))
156159 return resolvedAccent(t, t.Palette[slot], slot)
157160 }
@@ -204,8 +207,13 @@ func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab)
204207 group.chromaSum += okLabChroma(colors[i])
205208 var totalDistance float64
206209 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
209217 }
210218 }
211219 if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) {
@@ -235,6 +243,9 @@ func betterAccentGroup(a, b accentGroup) bool {
235243 if a.size != b.size {
236244 return a.size > b.size
237245 }
246+ if a.distanceSum != b.distanceSum {
247+ return a.distanceSum < b.distanceSum
248+ }
238249 if a.pairs != b.pairs {
239250 return a.pairs > b.pairs
240251 }
@@ -34,11 +34,12 @@ func accentCandidateSlot(index int) uint8 {
34 }34 }
35 35
36 type accentGroup struct {36 type accentGroup struct {
37- members uint1637+ members uint16
38- size uint838+ size uint8
39- pairs uint839+ pairs uint8
40- chromaSum float6440+ distanceSum float64
41- rep uint841+ chromaSum float64
42+ rep uint8
42 }43 }
43 44
44 // ResolveAccent deterministically selects a terminal-owned accent. It uses45 // 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].pairs153+ 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 float64208 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.size244 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.pairs250 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) {
7070 theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}),
7171 want: Accent{RGB: blue, Slot: 4, Known: true},
7272 },
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+ },
73101 {
74102 name: "regular bright pair beats equal size runner up",
75103 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}),

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