detached 4c09134

feat(theme): infer terminal-owned accent

commit
4c09134a333ed61b7119c2a16399d96b10927830
author
brice · · 2026-07-18 07:47 UTC
parent
914adb7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+}

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