detached f223378

fix(terminal): quantize colors against full xterm palette

commit
f223378bc39565c61f7eb8a796ad4750b1530121
author
brice · · 2026-08-18 18:36 UTC
parent
385a8c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +178 -22
M internal/usecase/daemon/terminal_color.go +38 -4
@@ -28,9 +28,43 @@ func adaptFrameColors(frame renderer.Frame, trueColor bool) renderer.Frame {
2828 return adapted
2929 }
3030
31+var (
32+ xterm256ColorLevels = [...]uint8{0, 95, 135, 175, 215, 255}
33+ xterm256GrayLevels = [...]uint8{8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188, 198, 208, 218, 228, 238}
34+)
35+
3136 func xterm256Color(rgb renderer.RGB) int {
32- red := (int(rgb.R)*5 + 127) / 255
33- green := (int(rgb.G)*5 + 127) / 255
34- blue := (int(rgb.B)*5 + 127) / 255
35- return 16 + 36*red + 6*green + blue
37+ bestIndex := 16
38+ bestDistance := 3*255*255 + 1
39+
40+ for red, redLevel := range xterm256ColorLevels {
41+ for green, greenLevel := range xterm256ColorLevels {
42+ for blue, blueLevel := range xterm256ColorLevels {
43+ candidate := renderer.RGB{R: redLevel, G: greenLevel, B: blueLevel}
44+ distance := rgbDistance(rgb, candidate)
45+ if distance < bestDistance {
46+ bestIndex = 16 + 36*red + 6*green + blue
47+ bestDistance = distance
48+ }
49+ }
50+ }
51+ }
52+
53+ for i, level := range xterm256GrayLevels {
54+ candidate := renderer.RGB{R: level, G: level, B: level}
55+ distance := rgbDistance(rgb, candidate)
56+ if distance < bestDistance {
57+ bestIndex = 232 + i
58+ bestDistance = distance
59+ }
60+ }
61+
62+ return bestIndex
63+}
64+
65+func rgbDistance(a, b renderer.RGB) int {
66+ red := int(a.R) - int(b.R)
67+ green := int(a.G) - int(b.G)
68+ blue := int(a.B) - int(b.B)
69+ return red*red + green*green + blue*blue
3670 }
@@ -28,9 +28,43 @@ func adaptFrameColors(frame renderer.Frame, trueColor bool) renderer.Frame {
28 return adapted28 return adapted
29 }29 }
30 30
31+var (
32+ xterm256ColorLevels = [...]uint8{0, 95, 135, 175, 215, 255}
33+ xterm256GrayLevels = [...]uint8{8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188, 198, 208, 218, 228, 238}
34+)
35+
31 func xterm256Color(rgb renderer.RGB) int {36 func xterm256Color(rgb renderer.RGB) int {
32- red := (int(rgb.R)*5 + 127) / 25537+ bestIndex := 16
33- green := (int(rgb.G)*5 + 127) / 25538+ bestDistance := 3*255*255 + 1
34- blue := (int(rgb.B)*5 + 127) / 25539+
35- return 16 + 36*red + 6*green + blue40+ for red, redLevel := range xterm256ColorLevels {
41+ for green, greenLevel := range xterm256ColorLevels {
42+ for blue, blueLevel := range xterm256ColorLevels {
43+ candidate := renderer.RGB{R: redLevel, G: greenLevel, B: blueLevel}
44+ distance := rgbDistance(rgb, candidate)
45+ if distance < bestDistance {
46+ bestIndex = 16 + 36*red + 6*green + blue
47+ bestDistance = distance
48+ }
49+ }
50+ }
51+ }
52+
53+ for i, level := range xterm256GrayLevels {
54+ candidate := renderer.RGB{R: level, G: level, B: level}
55+ distance := rgbDistance(rgb, candidate)
56+ if distance < bestDistance {
57+ bestIndex = 232 + i
58+ bestDistance = distance
59+ }
60+ }
61+
62+ return bestIndex
63+}
64+
65+func rgbDistance(a, b renderer.RGB) int {
66+ red := int(a.R) - int(b.R)
67+ green := int(a.G) - int(b.G)
68+ blue := int(a.B) - int(b.B)
69+ return red*red + green*green + blue*blue
36 }70 }
M internal/usecase/daemon/terminal_color_test.go +140 -18
@@ -8,22 +8,144 @@ import (
88 )
99
1010 func TestAdaptFrameColors(t *testing.T) {
11- frame := renderer.NewFrame(1, 1)
12- frame.Cells[0].Style.HasForegroundRGB = true
13- frame.Cells[0].Style.ForegroundRGB = renderer.RGB{R: 255, G: 0, B: 0}
14- frame.Cells[0].Style.HasBackgroundRGB = true
15- frame.Cells[0].Style.BackgroundRGB = renderer.RGB{R: 0, G: 255, B: 0}
16- frame.Cells[0].Style.HasUnderlineColor = true
17- frame.Cells[0].Style.HasUnderlineColorRGB = true
18- frame.Cells[0].Style.UnderlineColorRGB = renderer.RGB{R: 0, G: 0, B: 255}
19-
20- got := adaptFrameColors(frame, false)
21-
22- require.False(t, got.Cells[0].Style.HasForegroundRGB)
23- require.Equal(t, 196, got.Cells[0].Style.Foreground)
24- require.False(t, got.Cells[0].Style.HasBackgroundRGB)
25- require.Equal(t, 46, got.Cells[0].Style.Background)
26- require.False(t, got.Cells[0].Style.HasUnderlineColorRGB)
27- require.Equal(t, 21, got.Cells[0].Style.UnderlineColor)
28- require.True(t, frame.Cells[0].Style.HasForegroundRGB, "adaptation must not mutate composed state")
11+ cases := []struct {
12+ name string
13+ trueColor bool
14+ style renderer.Style
15+ want renderer.Style
16+ }{
17+ {
18+ name: "truecolor passthrough",
19+ trueColor: true,
20+ style: renderer.Style{
21+ Foreground: 17,
22+ Background: 18,
23+ HasForegroundRGB: true,
24+ ForegroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
25+ HasBackgroundRGB: true,
26+ BackgroundRGB: renderer.RGB{R: 78, G: 90, B: 123},
27+ UnderlineStyle: renderer.UnderlineCurly,
28+ HasUnderlineColor: true,
29+ UnderlineColor: 19,
30+ HasUnderlineColorRGB: true,
31+ UnderlineColorRGB: renderer.RGB{R: 145, G: 167, B: 189},
32+ },
33+ want: renderer.Style{
34+ Foreground: 17,
35+ Background: 18,
36+ HasForegroundRGB: true,
37+ ForegroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
38+ HasBackgroundRGB: true,
39+ BackgroundRGB: renderer.RGB{R: 78, G: 90, B: 123},
40+ UnderlineStyle: renderer.UnderlineCurly,
41+ HasUnderlineColor: true,
42+ UnderlineColor: 19,
43+ HasUnderlineColorRGB: true,
44+ UnderlineColorRGB: renderer.RGB{R: 145, G: 167, B: 189},
45+ },
46+ },
47+ {
48+ name: "indexed colors convert foreground background and underline",
49+ trueColor: false,
50+ style: renderer.Style{
51+ Foreground: 1,
52+ Background: 2,
53+ HasForegroundRGB: true,
54+ ForegroundRGB: renderer.RGB{R: 255, G: 0, B: 0},
55+ HasBackgroundRGB: true,
56+ BackgroundRGB: renderer.RGB{R: 0, G: 255, B: 0},
57+ UnderlineStyle: renderer.UnderlineSingle,
58+ HasUnderlineColor: true,
59+ UnderlineColor: 3,
60+ HasUnderlineColorRGB: true,
61+ UnderlineColorRGB: renderer.RGB{R: 0, G: 0, B: 255},
62+ },
63+ want: renderer.Style{
64+ Foreground: 196,
65+ Background: 46,
66+ HasForegroundRGB: false,
67+ ForegroundRGB: renderer.RGB{R: 255, G: 0, B: 0},
68+ HasBackgroundRGB: false,
69+ BackgroundRGB: renderer.RGB{R: 0, G: 255, B: 0},
70+ UnderlineStyle: renderer.UnderlineSingle,
71+ HasUnderlineColor: true,
72+ UnderlineColor: 21,
73+ HasUnderlineColorRGB: false,
74+ UnderlineColorRGB: renderer.RGB{R: 0, G: 0, B: 255},
75+ },
76+ },
77+ {
78+ name: "neutral grays use the grayscale ramp",
79+ trueColor: false,
80+ style: renderer.Style{
81+ Foreground: 1,
82+ Background: 2,
83+ HasForegroundRGB: true,
84+ ForegroundRGB: renderer.RGB{R: 128, G: 128, B: 128},
85+ HasBackgroundRGB: true,
86+ BackgroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
87+ UnderlineStyle: renderer.UnderlineDashed,
88+ HasUnderlineColor: true,
89+ UnderlineColor: 3,
90+ HasUnderlineColorRGB: true,
91+ UnderlineColorRGB: renderer.RGB{R: 50, G: 50, B: 50},
92+ },
93+ want: renderer.Style{
94+ Foreground: 244,
95+ Background: 241,
96+ HasForegroundRGB: false,
97+ ForegroundRGB: renderer.RGB{R: 128, G: 128, B: 128},
98+ HasBackgroundRGB: false,
99+ BackgroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
100+ UnderlineStyle: renderer.UnderlineDashed,
101+ HasUnderlineColor: true,
102+ UnderlineColor: 236,
103+ HasUnderlineColorRGB: false,
104+ UnderlineColorRGB: renderer.RGB{R: 50, G: 50, B: 50},
105+ },
106+ },
107+ }
108+
109+ for _, tc := range cases {
110+ t.Run(tc.name, func(t *testing.T) {
111+ frame := renderer.NewFrame(1, 1)
112+ frame.Cells[0].Style = tc.style
113+ composed := frame.Clone()
114+
115+ got := adaptFrameColors(frame, tc.trueColor)
116+
117+ require.Equal(t, tc.want, got.Cells[0].Style)
118+ require.Equal(t, composed, frame, "adaptation must not mutate composed frame state")
119+ })
120+ }
121+}
122+
123+func TestXterm256Color(t *testing.T) {
124+ cases := []struct {
125+ name string
126+ rgb renderer.RGB
127+ want int
128+ }{
129+ {
130+ name: "exact neutral gray",
131+ rgb: renderer.RGB{R: 128, G: 128, B: 128},
132+ want: 244,
133+ },
134+ {
135+ name: "nearest neutral gray",
136+ rgb: renderer.RGB{R: 100, G: 100, B: 100},
137+ want: 241,
138+ },
139+ {
140+ name: "color between cube levels",
141+ rgb: renderer.RGB{R: 100, G: 100, B: 200},
142+ want: 62,
143+ },
144+ }
145+
146+ for _, tc := range cases {
147+ t.Run(tc.name, func(t *testing.T) {
148+ require.Equal(t, tc.want, xterm256Color(tc.rgb))
149+ })
150+ }
29151 }
@@ -8,22 +8,144 @@ import (
8 )8 )
9 9
10 func TestAdaptFrameColors(t *testing.T) {10 func TestAdaptFrameColors(t *testing.T) {
11- frame := renderer.NewFrame(1, 1)11+ cases := []struct {
12- frame.Cells[0].Style.HasForegroundRGB = true12+ name string
13- frame.Cells[0].Style.ForegroundRGB = renderer.RGB{R: 255, G: 0, B: 0}13+ trueColor bool
14- frame.Cells[0].Style.HasBackgroundRGB = true14+ style renderer.Style
15- frame.Cells[0].Style.BackgroundRGB = renderer.RGB{R: 0, G: 255, B: 0}15+ want renderer.Style
16- frame.Cells[0].Style.HasUnderlineColor = true16+ }{
17- frame.Cells[0].Style.HasUnderlineColorRGB = true17+ {
18- frame.Cells[0].Style.UnderlineColorRGB = renderer.RGB{R: 0, G: 0, B: 255}18+ name: "truecolor passthrough",
19-19+ trueColor: true,
20- got := adaptFrameColors(frame, false)20+ style: renderer.Style{
21-21+ Foreground: 17,
22- require.False(t, got.Cells[0].Style.HasForegroundRGB)22+ Background: 18,
23- require.Equal(t, 196, got.Cells[0].Style.Foreground)23+ HasForegroundRGB: true,
24- require.False(t, got.Cells[0].Style.HasBackgroundRGB)24+ ForegroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
25- require.Equal(t, 46, got.Cells[0].Style.Background)25+ HasBackgroundRGB: true,
26- require.False(t, got.Cells[0].Style.HasUnderlineColorRGB)26+ BackgroundRGB: renderer.RGB{R: 78, G: 90, B: 123},
27- require.Equal(t, 21, got.Cells[0].Style.UnderlineColor)27+ UnderlineStyle: renderer.UnderlineCurly,
28- require.True(t, frame.Cells[0].Style.HasForegroundRGB, "adaptation must not mutate composed state")28+ HasUnderlineColor: true,
29+ UnderlineColor: 19,
30+ HasUnderlineColorRGB: true,
31+ UnderlineColorRGB: renderer.RGB{R: 145, G: 167, B: 189},
32+ },
33+ want: renderer.Style{
34+ Foreground: 17,
35+ Background: 18,
36+ HasForegroundRGB: true,
37+ ForegroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
38+ HasBackgroundRGB: true,
39+ BackgroundRGB: renderer.RGB{R: 78, G: 90, B: 123},
40+ UnderlineStyle: renderer.UnderlineCurly,
41+ HasUnderlineColor: true,
42+ UnderlineColor: 19,
43+ HasUnderlineColorRGB: true,
44+ UnderlineColorRGB: renderer.RGB{R: 145, G: 167, B: 189},
45+ },
46+ },
47+ {
48+ name: "indexed colors convert foreground background and underline",
49+ trueColor: false,
50+ style: renderer.Style{
51+ Foreground: 1,
52+ Background: 2,
53+ HasForegroundRGB: true,
54+ ForegroundRGB: renderer.RGB{R: 255, G: 0, B: 0},
55+ HasBackgroundRGB: true,
56+ BackgroundRGB: renderer.RGB{R: 0, G: 255, B: 0},
57+ UnderlineStyle: renderer.UnderlineSingle,
58+ HasUnderlineColor: true,
59+ UnderlineColor: 3,
60+ HasUnderlineColorRGB: true,
61+ UnderlineColorRGB: renderer.RGB{R: 0, G: 0, B: 255},
62+ },
63+ want: renderer.Style{
64+ Foreground: 196,
65+ Background: 46,
66+ HasForegroundRGB: false,
67+ ForegroundRGB: renderer.RGB{R: 255, G: 0, B: 0},
68+ HasBackgroundRGB: false,
69+ BackgroundRGB: renderer.RGB{R: 0, G: 255, B: 0},
70+ UnderlineStyle: renderer.UnderlineSingle,
71+ HasUnderlineColor: true,
72+ UnderlineColor: 21,
73+ HasUnderlineColorRGB: false,
74+ UnderlineColorRGB: renderer.RGB{R: 0, G: 0, B: 255},
75+ },
76+ },
77+ {
78+ name: "neutral grays use the grayscale ramp",
79+ trueColor: false,
80+ style: renderer.Style{
81+ Foreground: 1,
82+ Background: 2,
83+ HasForegroundRGB: true,
84+ ForegroundRGB: renderer.RGB{R: 128, G: 128, B: 128},
85+ HasBackgroundRGB: true,
86+ BackgroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
87+ UnderlineStyle: renderer.UnderlineDashed,
88+ HasUnderlineColor: true,
89+ UnderlineColor: 3,
90+ HasUnderlineColorRGB: true,
91+ UnderlineColorRGB: renderer.RGB{R: 50, G: 50, B: 50},
92+ },
93+ want: renderer.Style{
94+ Foreground: 244,
95+ Background: 241,
96+ HasForegroundRGB: false,
97+ ForegroundRGB: renderer.RGB{R: 128, G: 128, B: 128},
98+ HasBackgroundRGB: false,
99+ BackgroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
100+ UnderlineStyle: renderer.UnderlineDashed,
101+ HasUnderlineColor: true,
102+ UnderlineColor: 236,
103+ HasUnderlineColorRGB: false,
104+ UnderlineColorRGB: renderer.RGB{R: 50, G: 50, B: 50},
105+ },
106+ },
107+ }
108+
109+ for _, tc := range cases {
110+ t.Run(tc.name, func(t *testing.T) {
111+ frame := renderer.NewFrame(1, 1)
112+ frame.Cells[0].Style = tc.style
113+ composed := frame.Clone()
114+
115+ got := adaptFrameColors(frame, tc.trueColor)
116+
117+ require.Equal(t, tc.want, got.Cells[0].Style)
118+ require.Equal(t, composed, frame, "adaptation must not mutate composed frame state")
119+ })
120+ }
121+}
122+
123+func TestXterm256Color(t *testing.T) {
124+ cases := []struct {
125+ name string
126+ rgb renderer.RGB
127+ want int
128+ }{
129+ {
130+ name: "exact neutral gray",
131+ rgb: renderer.RGB{R: 128, G: 128, B: 128},
132+ want: 244,
133+ },
134+ {
135+ name: "nearest neutral gray",
136+ rgb: renderer.RGB{R: 100, G: 100, B: 100},
137+ want: 241,
138+ },
139+ {
140+ name: "color between cube levels",
141+ rgb: renderer.RGB{R: 100, G: 100, B: 200},
142+ want: 62,
143+ },
144+ }
145+
146+ for _, tc := range cases {
147+ t.Run(tc.name, func(t *testing.T) {
148+ require.Equal(t, tc.want, xterm256Color(tc.rgb))
149+ })
150+ }
29 }151 }

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