detached 914adb7

feat(theme): add perceptual color interpolation

commit
914adb7bf44dc3252bd8a0503f68fad744a7e3d4
author
brice · · 2026-07-18 07:41 UTC
parent
445049f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +209 -0
A internal/usecase/theme/color_oklab.go +108 -0
@@ -0,0 +1,108 @@
1+package theme
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// oklab is the perceptually uniform OKLab representation of an sRGB color.
10+type oklab struct {
11+ L float64
12+ A float64
13+ B float64
14+}
15+
16+// rgbToOKLab converts an 8-bit sRGB color to OKLab via linear sRGB and LMS.
17+func rgbToOKLab(color renderer.RGB) oklab {
18+ r := srgbToLinear(color.R)
19+ g := srgbToLinear(color.G)
20+ b := srgbToLinear(color.B)
21+
22+ l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b)
23+ m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b)
24+ s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b)
25+
26+ return oklab{
27+ L: 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
28+ A: 1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
29+ B: 0.0259040371*l + 0.7827717662*m - 0.8086757660*s,
30+ }
31+}
32+
33+// okLabToRGB converts OKLab to sRGB. Linear channels are clamped before
34+// transfer encoding so out-of-gamut intermediate values are deterministic.
35+func okLabToRGB(color oklab) renderer.RGB {
36+ l := cube(color.L + 0.3963377774*color.A + 0.2158037573*color.B)
37+ m := cube(color.L - 0.1055613458*color.A - 0.0638541728*color.B)
38+ s := cube(color.L - 0.0894841775*color.A - 1.2914855480*color.B)
39+
40+ return renderer.RGB{
41+ R: linearToSRGB(4.0767416621*l - 3.3077115913*m + 0.2309699292*s),
42+ G: linearToSRGB(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s),
43+ B: linearToSRGB(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s),
44+ }
45+}
46+
47+func okLabDistance(a, b oklab) float64 {
48+ return math.Sqrt(square(a.L-b.L) + square(a.A-b.A) + square(a.B-b.B))
49+}
50+
51+func okLabChroma(color oklab) float64 {
52+ return math.Hypot(color.A, color.B)
53+}
54+
55+// okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
56+// round-trip quantization changing terminal-provided colors.
57+func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
58+ weight = clampUnit(weight)
59+ if weight == 0 {
60+ return a
61+ }
62+ if weight == 1 {
63+ return b
64+ }
65+ start := rgbToOKLab(a)
66+ end := rgbToOKLab(b)
67+ return okLabToRGB(oklab{
68+ L: start.L + (end.L-start.L)*weight,
69+ A: start.A + (end.A-start.A)*weight,
70+ B: start.B + (end.B-start.B)*weight,
71+ })
72+}
73+
74+func srgbToLinear(channel uint8) float64 {
75+ value := float64(channel) / 255
76+ if value <= 0.04045 {
77+ return value / 12.92
78+ }
79+ return math.Pow((value+0.055)/1.055, 2.4)
80+}
81+
82+func linearToSRGB(value float64) uint8 {
83+ value = clampUnit(value)
84+ if value <= 0.0031308 {
85+ value *= 12.92
86+ } else {
87+ value = 1.055*math.Pow(value, 1/2.4) - 0.055
88+ }
89+ return uint8(value*255 + 0.5)
90+}
91+
92+func clampUnit(value float64) float64 {
93+ if math.IsNaN(value) || value <= 0 {
94+ return 0
95+ }
96+ if value >= 1 {
97+ return 1
98+ }
99+ return value
100+}
101+
102+func square(value float64) float64 {
103+ return value * value
104+}
105+
106+func cube(value float64) float64 {
107+ return value * value * value
108+}
@@ -0,0 +1,108 @@
1+package theme
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// oklab is the perceptually uniform OKLab representation of an sRGB color.
10+type oklab struct {
11+ L float64
12+ A float64
13+ B float64
14+}
15+
16+// rgbToOKLab converts an 8-bit sRGB color to OKLab via linear sRGB and LMS.
17+func rgbToOKLab(color renderer.RGB) oklab {
18+ r := srgbToLinear(color.R)
19+ g := srgbToLinear(color.G)
20+ b := srgbToLinear(color.B)
21+
22+ l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b)
23+ m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b)
24+ s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b)
25+
26+ return oklab{
27+ L: 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
28+ A: 1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
29+ B: 0.0259040371*l + 0.7827717662*m - 0.8086757660*s,
30+ }
31+}
32+
33+// okLabToRGB converts OKLab to sRGB. Linear channels are clamped before
34+// transfer encoding so out-of-gamut intermediate values are deterministic.
35+func okLabToRGB(color oklab) renderer.RGB {
36+ l := cube(color.L + 0.3963377774*color.A + 0.2158037573*color.B)
37+ m := cube(color.L - 0.1055613458*color.A - 0.0638541728*color.B)
38+ s := cube(color.L - 0.0894841775*color.A - 1.2914855480*color.B)
39+
40+ return renderer.RGB{
41+ R: linearToSRGB(4.0767416621*l - 3.3077115913*m + 0.2309699292*s),
42+ G: linearToSRGB(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s),
43+ B: linearToSRGB(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s),
44+ }
45+}
46+
47+func okLabDistance(a, b oklab) float64 {
48+ return math.Sqrt(square(a.L-b.L) + square(a.A-b.A) + square(a.B-b.B))
49+}
50+
51+func okLabChroma(color oklab) float64 {
52+ return math.Hypot(color.A, color.B)
53+}
54+
55+// okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
56+// round-trip quantization changing terminal-provided colors.
57+func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
58+ weight = clampUnit(weight)
59+ if weight == 0 {
60+ return a
61+ }
62+ if weight == 1 {
63+ return b
64+ }
65+ start := rgbToOKLab(a)
66+ end := rgbToOKLab(b)
67+ return okLabToRGB(oklab{
68+ L: start.L + (end.L-start.L)*weight,
69+ A: start.A + (end.A-start.A)*weight,
70+ B: start.B + (end.B-start.B)*weight,
71+ })
72+}
73+
74+func srgbToLinear(channel uint8) float64 {
75+ value := float64(channel) / 255
76+ if value <= 0.04045 {
77+ return value / 12.92
78+ }
79+ return math.Pow((value+0.055)/1.055, 2.4)
80+}
81+
82+func linearToSRGB(value float64) uint8 {
83+ value = clampUnit(value)
84+ if value <= 0.0031308 {
85+ value *= 12.92
86+ } else {
87+ value = 1.055*math.Pow(value, 1/2.4) - 0.055
88+ }
89+ return uint8(value*255 + 0.5)
90+}
91+
92+func clampUnit(value float64) float64 {
93+ if math.IsNaN(value) || value <= 0 {
94+ return 0
95+ }
96+ if value >= 1 {
97+ return 1
98+ }
99+ return value
100+}
101+
102+func square(value float64) float64 {
103+ return value * value
104+}
105+
106+func cube(value float64) float64 {
107+ return value * value * value
108+}
A internal/usecase/theme/color_oklab_test.go +101 -0
@@ -0,0 +1,101 @@
1+package theme
2+
3+import (
4+ "math"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestRGBToOKLabKnownValues(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ rgb renderer.RGB
14+ want oklab
15+ }{
16+ {name: "black", rgb: renderer.RGB{}, want: oklab{}},
17+ {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}},
18+ {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ got := rgbToOKLab(tt.rgb)
24+ if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 {
25+ t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestOKLabRGBRoundTrip(t *testing.T) {
32+ colors := []renderer.RGB{
33+ {},
34+ {R: 255, G: 255, B: 255},
35+ {R: 255},
36+ {G: 255},
37+ {B: 255},
38+ {R: 0x7d, G: 0xb5, B: 0xb5},
39+ {R: 0x6c, G: 0x9b, B: 0xd9},
40+ }
41+ for _, color := range colors {
42+ t.Run("round_trip", func(t *testing.T) {
43+ got := okLabToRGB(rgbToOKLab(color))
44+ if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 {
45+ t.Fatalf("round trip %v = %v", color, got)
46+ }
47+ })
48+ }
49+}
50+
51+func TestOKLabDistance(t *testing.T) {
52+ a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5})
53+ b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9})
54+ if got := okLabDistance(a, a); got != 0 {
55+ t.Fatalf("self distance = %v, want 0", got)
56+ }
57+ if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 {
58+ t.Fatalf("distance symmetry: %v != %v", got, want)
59+ }
60+}
61+
62+func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) {
63+ background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}
64+ accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
65+
66+ if got := okLabLerp(background, accent, 0); got != background {
67+ t.Fatalf("weight 0 = %v, want %v", got, background)
68+ }
69+ if got := okLabLerp(background, accent, 1); got != accent {
70+ t.Fatalf("weight 1 = %v, want %v", got, accent)
71+ }
72+ if got := okLabLerp(background, accent, -1); got != background {
73+ t.Fatalf("clamped low weight = %v, want %v", got, background)
74+ }
75+ if got := okLabLerp(background, accent, 2); got != accent {
76+ t.Fatalf("clamped high weight = %v, want %v", got, accent)
77+ }
78+
79+ previous := background
80+ for _, weight := range []float64{0.25, 0.5, 0.75, 1} {
81+ got := okLabLerp(background, accent, weight)
82+ if got.R < previous.R || got.G < previous.G || got.B < previous.B {
83+ t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous)
84+ }
85+ previous = got
86+ }
87+}
88+
89+func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
90+ got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5})
91+ if got.R != 255 || got.G != 0 || got.B != 0 {
92+ t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got)
93+ }
94+}
95+
96+func channelDelta(a, b uint8) uint8 {
97+ if a > b {
98+ return a - b
99+ }
100+ return b - a
101+}
@@ -0,0 +1,101 @@
1+package theme
2+
3+import (
4+ "math"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestRGBToOKLabKnownValues(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ rgb renderer.RGB
14+ want oklab
15+ }{
16+ {name: "black", rgb: renderer.RGB{}, want: oklab{}},
17+ {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}},
18+ {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ got := rgbToOKLab(tt.rgb)
24+ if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 {
25+ t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestOKLabRGBRoundTrip(t *testing.T) {
32+ colors := []renderer.RGB{
33+ {},
34+ {R: 255, G: 255, B: 255},
35+ {R: 255},
36+ {G: 255},
37+ {B: 255},
38+ {R: 0x7d, G: 0xb5, B: 0xb5},
39+ {R: 0x6c, G: 0x9b, B: 0xd9},
40+ }
41+ for _, color := range colors {
42+ t.Run("round_trip", func(t *testing.T) {
43+ got := okLabToRGB(rgbToOKLab(color))
44+ if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 {
45+ t.Fatalf("round trip %v = %v", color, got)
46+ }
47+ })
48+ }
49+}
50+
51+func TestOKLabDistance(t *testing.T) {
52+ a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5})
53+ b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9})
54+ if got := okLabDistance(a, a); got != 0 {
55+ t.Fatalf("self distance = %v, want 0", got)
56+ }
57+ if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 {
58+ t.Fatalf("distance symmetry: %v != %v", got, want)
59+ }
60+}
61+
62+func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) {
63+ background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}
64+ accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
65+
66+ if got := okLabLerp(background, accent, 0); got != background {
67+ t.Fatalf("weight 0 = %v, want %v", got, background)
68+ }
69+ if got := okLabLerp(background, accent, 1); got != accent {
70+ t.Fatalf("weight 1 = %v, want %v", got, accent)
71+ }
72+ if got := okLabLerp(background, accent, -1); got != background {
73+ t.Fatalf("clamped low weight = %v, want %v", got, background)
74+ }
75+ if got := okLabLerp(background, accent, 2); got != accent {
76+ t.Fatalf("clamped high weight = %v, want %v", got, accent)
77+ }
78+
79+ previous := background
80+ for _, weight := range []float64{0.25, 0.5, 0.75, 1} {
81+ got := okLabLerp(background, accent, weight)
82+ if got.R < previous.R || got.G < previous.G || got.B < previous.B {
83+ t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous)
84+ }
85+ previous = got
86+ }
87+}
88+
89+func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
90+ got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5})
91+ if got.R != 255 || got.G != 0 || got.B != 0 {
92+ t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got)
93+ }
94+}
95+
96+func channelDelta(a, b uint8) uint8 {
97+ if a > b {
98+ return a - b
99+ }
100+ return b - a
101+}

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