detached d75221d

feat(ansi): add constrained color profiles

commit
d75221d465a14e02f8d0b34fe8d6eed71a11549c
author
brice · · 2026-08-18 19:07 UTC
committer
brice · · 2026-08-18 19:24 UTC
parent
711511a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +630 -94
A ansi/color_profile.go +110 -0
@@ -0,0 +1,110 @@
1+package ansi
2+
3+// ColorProfile describes the color sequences an output target can represent.
4+// The zero value preserves the renderer's historical truecolor behavior.
5+type ColorProfile uint8
6+
7+const (
8+ // ColorProfileTrueColor emits direct RGB colors and preserves indexed colors.
9+ ColorProfileTrueColor ColorProfile = iota
10+ // ColorProfileANSI256 maps RGB colors to the fixed xterm 256-color palette.
11+ ColorProfileANSI256
12+ // ColorProfileANSI16 emits basic and bright 16-color SGR sequences.
13+ ColorProfileANSI16
14+ // ColorProfileMonochrome omits explicit colors while preserving attributes.
15+ ColorProfileMonochrome
16+)
17+
18+var ansi16Palette = [...]RGB{
19+ {R: 0x00, G: 0x00, B: 0x00},
20+ {R: 0x80, G: 0x00, B: 0x00},
21+ {R: 0x00, G: 0x80, B: 0x00},
22+ {R: 0x80, G: 0x80, B: 0x00},
23+ {R: 0x00, G: 0x00, B: 0x80},
24+ {R: 0x80, G: 0x00, B: 0x80},
25+ {R: 0x00, G: 0x80, B: 0x80},
26+ {R: 0xc0, G: 0xc0, B: 0xc0},
27+ {R: 0x80, G: 0x80, B: 0x80},
28+ {R: 0xff, G: 0x00, B: 0x00},
29+ {R: 0x00, G: 0xff, B: 0x00},
30+ {R: 0xff, G: 0xff, B: 0x00},
31+ {R: 0x00, G: 0x00, B: 0xff},
32+ {R: 0xff, G: 0x00, B: 0xff},
33+ {R: 0x00, G: 0xff, B: 0xff},
34+ {R: 0xff, G: 0xff, B: 0xff},
35+}
36+
37+var ansi256CubeLevels = [...]uint8{0, 95, 135, 175, 215, 255}
38+
39+func rgbToANSI16(rgb RGB) int {
40+ best, bestDistance := 0, colorDistance(rgb, ansi16Palette[0])
41+ for index := 1; index < len(ansi16Palette); index++ {
42+ if distance := colorDistance(rgb, ansi16Palette[index]); distance < bestDistance {
43+ best, bestDistance = index, distance
44+ }
45+ }
46+ return best
47+}
48+
49+func rgbToANSI256(rgb RGB) int {
50+ ri, r := nearestCubeLevel(rgb.R)
51+ gi, g := nearestCubeLevel(rgb.G)
52+ bi, b := nearestCubeLevel(rgb.B)
53+ cubeIndex := 16 + 36*ri + 6*gi + bi
54+ cubeDistance := colorDistance(rgb, RGB{R: r, G: g, B: b})
55+
56+ grayIndex, grayDistance := 232, colorDistance(rgb, RGB{R: 8, G: 8, B: 8})
57+ for offset := 1; offset < 24; offset++ {
58+ level := uint8(8 + 10*offset)
59+ if distance := colorDistance(rgb, RGB{R: level, G: level, B: level}); distance < grayDistance {
60+ grayIndex, grayDistance = 232+offset, distance
61+ }
62+ }
63+ if grayDistance < cubeDistance {
64+ return grayIndex
65+ }
66+ return cubeIndex
67+}
68+
69+func nearestCubeLevel(value uint8) (int, uint8) {
70+ best, bestDistance := 0, absInt(int(value)-int(ansi256CubeLevels[0]))
71+ for index := 1; index < len(ansi256CubeLevels); index++ {
72+ if distance := absInt(int(value) - int(ansi256CubeLevels[index])); distance < bestDistance {
73+ best, bestDistance = index, distance
74+ }
75+ }
76+ return best, ansi256CubeLevels[best]
77+}
78+
79+func ansi256ToRGB(index int) (RGB, bool) {
80+ if index < 0 || index > 255 {
81+ return RGB{}, false
82+ }
83+ if index < len(ansi16Palette) {
84+ return ansi16Palette[index], true
85+ }
86+ if index < 232 {
87+ index -= 16
88+ return RGB{
89+ R: ansi256CubeLevels[index/36],
90+ G: ansi256CubeLevels[(index/6)%6],
91+ B: ansi256CubeLevels[index%6],
92+ }, true
93+ }
94+ level := uint8(8 + (index-232)*10)
95+ return RGB{R: level, G: level, B: level}, true
96+}
97+
98+func colorDistance(a, b RGB) int {
99+ dr := int(a.R) - int(b.R)
100+ dg := int(a.G) - int(b.G)
101+ db := int(a.B) - int(b.B)
102+ return dr*dr + dg*dg + db*db
103+}
104+
105+func absInt(value int) int {
106+ if value < 0 {
107+ return -value
108+ }
109+ return value
110+}
@@ -0,0 +1,110 @@
1+package ansi
2+
3+// ColorProfile describes the color sequences an output target can represent.
4+// The zero value preserves the renderer's historical truecolor behavior.
5+type ColorProfile uint8
6+
7+const (
8+ // ColorProfileTrueColor emits direct RGB colors and preserves indexed colors.
9+ ColorProfileTrueColor ColorProfile = iota
10+ // ColorProfileANSI256 maps RGB colors to the fixed xterm 256-color palette.
11+ ColorProfileANSI256
12+ // ColorProfileANSI16 emits basic and bright 16-color SGR sequences.
13+ ColorProfileANSI16
14+ // ColorProfileMonochrome omits explicit colors while preserving attributes.
15+ ColorProfileMonochrome
16+)
17+
18+var ansi16Palette = [...]RGB{
19+ {R: 0x00, G: 0x00, B: 0x00},
20+ {R: 0x80, G: 0x00, B: 0x00},
21+ {R: 0x00, G: 0x80, B: 0x00},
22+ {R: 0x80, G: 0x80, B: 0x00},
23+ {R: 0x00, G: 0x00, B: 0x80},
24+ {R: 0x80, G: 0x00, B: 0x80},
25+ {R: 0x00, G: 0x80, B: 0x80},
26+ {R: 0xc0, G: 0xc0, B: 0xc0},
27+ {R: 0x80, G: 0x80, B: 0x80},
28+ {R: 0xff, G: 0x00, B: 0x00},
29+ {R: 0x00, G: 0xff, B: 0x00},
30+ {R: 0xff, G: 0xff, B: 0x00},
31+ {R: 0x00, G: 0x00, B: 0xff},
32+ {R: 0xff, G: 0x00, B: 0xff},
33+ {R: 0x00, G: 0xff, B: 0xff},
34+ {R: 0xff, G: 0xff, B: 0xff},
35+}
36+
37+var ansi256CubeLevels = [...]uint8{0, 95, 135, 175, 215, 255}
38+
39+func rgbToANSI16(rgb RGB) int {
40+ best, bestDistance := 0, colorDistance(rgb, ansi16Palette[0])
41+ for index := 1; index < len(ansi16Palette); index++ {
42+ if distance := colorDistance(rgb, ansi16Palette[index]); distance < bestDistance {
43+ best, bestDistance = index, distance
44+ }
45+ }
46+ return best
47+}
48+
49+func rgbToANSI256(rgb RGB) int {
50+ ri, r := nearestCubeLevel(rgb.R)
51+ gi, g := nearestCubeLevel(rgb.G)
52+ bi, b := nearestCubeLevel(rgb.B)
53+ cubeIndex := 16 + 36*ri + 6*gi + bi
54+ cubeDistance := colorDistance(rgb, RGB{R: r, G: g, B: b})
55+
56+ grayIndex, grayDistance := 232, colorDistance(rgb, RGB{R: 8, G: 8, B: 8})
57+ for offset := 1; offset < 24; offset++ {
58+ level := uint8(8 + 10*offset)
59+ if distance := colorDistance(rgb, RGB{R: level, G: level, B: level}); distance < grayDistance {
60+ grayIndex, grayDistance = 232+offset, distance
61+ }
62+ }
63+ if grayDistance < cubeDistance {
64+ return grayIndex
65+ }
66+ return cubeIndex
67+}
68+
69+func nearestCubeLevel(value uint8) (int, uint8) {
70+ best, bestDistance := 0, absInt(int(value)-int(ansi256CubeLevels[0]))
71+ for index := 1; index < len(ansi256CubeLevels); index++ {
72+ if distance := absInt(int(value) - int(ansi256CubeLevels[index])); distance < bestDistance {
73+ best, bestDistance = index, distance
74+ }
75+ }
76+ return best, ansi256CubeLevels[best]
77+}
78+
79+func ansi256ToRGB(index int) (RGB, bool) {
80+ if index < 0 || index > 255 {
81+ return RGB{}, false
82+ }
83+ if index < len(ansi16Palette) {
84+ return ansi16Palette[index], true
85+ }
86+ if index < 232 {
87+ index -= 16
88+ return RGB{
89+ R: ansi256CubeLevels[index/36],
90+ G: ansi256CubeLevels[(index/6)%6],
91+ B: ansi256CubeLevels[index%6],
92+ }, true
93+ }
94+ level := uint8(8 + (index-232)*10)
95+ return RGB{R: level, G: level, B: level}, true
96+}
97+
98+func colorDistance(a, b RGB) int {
99+ dr := int(a.R) - int(b.R)
100+ dg := int(a.G) - int(b.G)
101+ db := int(a.B) - int(b.B)
102+ return dr*dr + dg*dg + db*db
103+}
104+
105+func absInt(value int) int {
106+ if value < 0 {
107+ return -value
108+ }
109+ return value
110+}
A ansi/color_profile_test.go +278 -0
@@ -0,0 +1,278 @@
1+package ansi
2+
3+import "testing"
4+
5+func TestRendererANSI256ProjectsRGBColors(t *testing.T) {
6+ frame := NewFrame(1, 1)
7+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{
8+ Foreground: -1,
9+ Background: -1,
10+ HasForegroundRGB: true,
11+ ForegroundRGB: RGB{R: 255, G: 0, B: 0},
12+ HasBackgroundRGB: true,
13+ BackgroundRGB: RGB{R: 8, G: 8, B: 8},
14+ HasUnderlineColorRGB: true,
15+ UnderlineColorRGB: RGB{R: 128, G: 128, B: 128},
16+ }})
17+
18+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileANSI256).Draw(frame, []Damage{FullRedraw()})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ want := "\x1b[1;1H\x1b[0;38;5;196;48;5;232;58;5;244mX\x1b[0m"
23+ if got := string(out); got != want {
24+ t.Fatalf("output = %q, want %q", got, want)
25+ }
26+}
27+
28+func TestRendererANSI16UsesBasicColorsAndOmitsUnderlineColor(t *testing.T) {
29+ frame := NewFrame(1, 1)
30+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{
31+ Bold: true,
32+ Attrs: AttrUnderline,
33+ Foreground: -1,
34+ Background: 4,
35+ HasForegroundRGB: true,
36+ ForegroundRGB: RGB{R: 255, G: 0, B: 0},
37+ UnderlineStyle: UnderlineCurly,
38+ HasUnderlineColor: true,
39+ UnderlineColor: 9,
40+ HasUnderlineColorRGB: true,
41+ UnderlineColorRGB: RGB{R: 0, G: 255, B: 0},
42+ }})
43+
44+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileANSI16).Draw(frame, []Damage{FullRedraw()})
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ want := "\x1b[1;1H\x1b[0;1;4:3;91;44mX\x1b[0m"
49+ if got := string(out); got != want {
50+ t.Fatalf("output = %q, want %q", got, want)
51+ }
52+}
53+
54+func TestRendererANSI16DownsamplesExtendedIndexedColors(t *testing.T) {
55+ frame := NewFrame(1, 1)
56+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: 196, Background: 21}})
57+
58+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileANSI16).Draw(frame, []Damage{FullRedraw()})
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ want := "\x1b[1;1H\x1b[0;91;104mX\x1b[0m"
63+ if got := string(out); got != want {
64+ t.Fatalf("output = %q, want %q", got, want)
65+ }
66+}
67+
68+func TestRendererIndexedColorsFollowProfile(t *testing.T) {
69+ style := Style{
70+ Foreground: 9,
71+ Background: 12,
72+ HasUnderlineColor: true,
73+ UnderlineColor: 10,
74+ }
75+ tests := []struct {
76+ name string
77+ profile ColorProfile
78+ wantStyle string
79+ }{
80+ {name: "truecolor preserves indexed colors", profile: ColorProfileTrueColor, wantStyle: "\x1b[0;38;5;9;48;5;12;58;5;10m"},
81+ {name: "ANSI-256 preserves indexed colors", profile: ColorProfileANSI256, wantStyle: "\x1b[0;38;5;9;48;5;12;58;5;10m"},
82+ {name: "ANSI-16 preserves basic indexes", profile: ColorProfileANSI16, wantStyle: "\x1b[0;91;104m"},
83+ }
84+
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ frame := NewFrame(1, 1)
88+ frame.Set(0, 0, Cell{Rune: 'X', Style: style})
89+ out, err := NewWithColorProfile(Capabilities{}, tt.profile).Draw(frame, []Damage{FullRedraw()})
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ want := "\x1b[1;1H" + tt.wantStyle + "X\x1b[0m"
94+ if got := string(out); got != want {
95+ t.Fatalf("output = %q, want %q", got, want)
96+ }
97+ })
98+ }
99+}
100+
101+func TestRendererMonochromeOmitsColorsAndPreservesAttributes(t *testing.T) {
102+ frame := NewFrame(2, 1)
103+ style := Style{
104+ Bold: true,
105+ Italic: true,
106+ Inverse: true,
107+ Attrs: AttrUnderline | AttrStrikethrough,
108+ Foreground: -1,
109+ Background: 200,
110+ HasForegroundRGB: true,
111+ ForegroundRGB: RGB{R: 12, G: 34, B: 56},
112+ UnderlineStyle: UnderlineDouble,
113+ HasUnderlineColor: true,
114+ UnderlineColor: 9,
115+ HasUnderlineColorRGB: true,
116+ UnderlineColorRGB: RGB{R: 200, G: 100, B: 50},
117+ }
118+ frame.Set(0, 0, Cell{Rune: 'A', Style: style})
119+ style.ForegroundRGB = RGB{R: 255, G: 0, B: 0}
120+ frame.Set(1, 0, Cell{Rune: 'B', Style: style})
121+
122+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileMonochrome).Draw(frame, []Damage{FullRedraw()})
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ want := "\x1b[1;1H\x1b[0;1;3;21;7;9mAB\x1b[0m"
127+ if got := string(out); got != want {
128+ t.Fatalf("output = %q, want %q", got, want)
129+ }
130+}
131+
132+func TestRendererColorProfilesApplyToFullIncrementalAndResetDraws(t *testing.T) {
133+ style := Style{
134+ Attrs: AttrUnderline,
135+ Foreground: -1,
136+ Background: -1,
137+ HasForegroundRGB: true,
138+ ForegroundRGB: RGB{R: 255, G: 0, B: 0},
139+ HasBackgroundRGB: true,
140+ BackgroundRGB: RGB{R: 0, G: 0, B: 255},
141+ HasUnderlineColorRGB: true,
142+ UnderlineColorRGB: RGB{R: 0, G: 255, B: 0},
143+ }
144+ tests := []struct {
145+ name string
146+ profile ColorProfile
147+ sgr string
148+ }{
149+ {name: "truecolor", profile: ColorProfileTrueColor, sgr: "\x1b[0;4;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0m"},
150+ {name: "ANSI-256", profile: ColorProfileANSI256, sgr: "\x1b[0;4;38;5;196;48;5;21;58;5;46m"},
151+ {name: "ANSI-16", profile: ColorProfileANSI16, sgr: "\x1b[0;4;91;104m"},
152+ {name: "monochrome", profile: ColorProfileMonochrome, sgr: "\x1b[0;4m"},
153+ }
154+
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ renderer := NewWithColorProfile(Capabilities{}, tt.profile)
158+ frame := NewFrame(1, 1)
159+ frame.Set(0, 0, Cell{Rune: 'A', Style: style})
160+ wantFull := "\x1b[1;1H" + tt.sgr + "A\x1b[0m"
161+ out, err := renderer.Draw(frame, []Damage{FullRedraw()})
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ if got := string(out); got != wantFull {
166+ t.Fatalf("full output = %q, want %q", got, wantFull)
167+ }
168+
169+ frame.Set(0, 0, Cell{Rune: 'B', Style: style})
170+ wantIncremental := "\x1b[1;1H" + tt.sgr + "B\x1b[0m"
171+ out, err = renderer.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}})
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ if got := string(out); got != wantIncremental {
176+ t.Fatalf("incremental output = %q, want %q", got, wantIncremental)
177+ }
178+
179+ unchanged, err := renderer.Draw(frame, nil)
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ if len(unchanged) != 0 {
184+ t.Fatalf("unchanged draw = %q, want no output", unchanged)
185+ }
186+
187+ renderer.Reset()
188+ out, err = renderer.Draw(frame, nil)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if got := string(out); got != wantIncremental {
193+ t.Fatalf("post-reset output = %q, want %q", got, wantIncremental)
194+ }
195+ })
196+ }
197+}
198+
199+func TestNewPreservesUnkeyedCapabilitiesAndTrueColorDefault(t *testing.T) {
200+ renderer := New(Capabilities{true})
201+ frame := NewFrame(1, 1)
202+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{
203+ Foreground: -1,
204+ Background: -1,
205+ HasForegroundRGB: true,
206+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
207+ }})
208+ out, err := renderer.Draw(frame, []Damage{FullRedraw()})
209+ if err != nil {
210+ t.Fatal(err)
211+ }
212+ want := SyncStartCSI + "\x1b[1;1H\x1b[0;38;2;1;2;3mX\x1b[0m" + SyncEndCSI
213+ if got := string(out); got != want {
214+ t.Fatalf("output = %q, want %q", got, want)
215+ }
216+}
217+
218+func TestConstrainedProfilesOmitInvalidIndexedColors(t *testing.T) {
219+ style := Style{
220+ Foreground: 256,
221+ Background: 257,
222+ HasUnderlineColor: true,
223+ UnderlineColor: -1,
224+ }
225+ profiles := []struct {
226+ name string
227+ profile ColorProfile
228+ }{
229+ {name: "ANSI-256", profile: ColorProfileANSI256},
230+ {name: "ANSI-16", profile: ColorProfileANSI16},
231+ {name: "monochrome", profile: ColorProfileMonochrome},
232+ }
233+ for _, tt := range profiles {
234+ t.Run(tt.name, func(t *testing.T) {
235+ frame := NewFrame(1, 1)
236+ frame.Set(0, 0, Cell{Rune: 'X', Style: style})
237+ out, err := NewWithColorProfile(Capabilities{}, tt.profile).Draw(frame, []Damage{FullRedraw()})
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ want := "\x1b[1;1HX\x1b[0m"
242+ if got := string(out); got != want {
243+ t.Fatalf("output = %q, want %q", got, want)
244+ }
245+ })
246+ }
247+}
248+
249+func TestColorQuantizationContract(t *testing.T) {
250+ tests := []struct {
251+ name string
252+ rgb RGB
253+ ansi16 int
254+ ansi256 int
255+ }{
256+ {name: "black", rgb: RGB{}, ansi16: 0, ansi256: 16},
257+ {name: "bright red", rgb: RGB{R: 255}, ansi16: 9, ansi256: 196},
258+ {name: "exact cube color", rgb: RGB{R: 95, G: 135, B: 175}, ansi16: 8, ansi256: 67},
259+ {name: "first gray", rgb: RGB{R: 8, G: 8, B: 8}, ansi16: 0, ansi256: 232},
260+ {name: "middle gray", rgb: RGB{R: 128, G: 128, B: 128}, ansi16: 8, ansi256: 244},
261+ {name: "last gray", rgb: RGB{R: 238, G: 238, B: 238}, ansi16: 15, ansi256: 255},
262+ {name: "white", rgb: RGB{R: 255, G: 255, B: 255}, ansi16: 15, ansi256: 231},
263+ {name: "arbitrary RGB", rgb: RGB{R: 12, G: 34, B: 56}, ansi16: 0, ansi256: 235},
264+ {name: "cube midpoint chooses lower index", rgb: RGB{R: 115}, ansi16: 1, ansi256: 52},
265+ {name: "ANSI-16 tie chooses lower index", rgb: RGB{R: 64}, ansi16: 0, ansi256: 52},
266+ }
267+
268+ for _, tt := range tests {
269+ t.Run(tt.name, func(t *testing.T) {
270+ if got := rgbToANSI16(tt.rgb); got != tt.ansi16 {
271+ t.Errorf("rgbToANSI16(%+v) = %d, want %d", tt.rgb, got, tt.ansi16)
272+ }
273+ if got := rgbToANSI256(tt.rgb); got != tt.ansi256 {
274+ t.Errorf("rgbToANSI256(%+v) = %d, want %d", tt.rgb, got, tt.ansi256)
275+ }
276+ })
277+ }
278+}
@@ -0,0 +1,278 @@
1+package ansi
2+
3+import "testing"
4+
5+func TestRendererANSI256ProjectsRGBColors(t *testing.T) {
6+ frame := NewFrame(1, 1)
7+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{
8+ Foreground: -1,
9+ Background: -1,
10+ HasForegroundRGB: true,
11+ ForegroundRGB: RGB{R: 255, G: 0, B: 0},
12+ HasBackgroundRGB: true,
13+ BackgroundRGB: RGB{R: 8, G: 8, B: 8},
14+ HasUnderlineColorRGB: true,
15+ UnderlineColorRGB: RGB{R: 128, G: 128, B: 128},
16+ }})
17+
18+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileANSI256).Draw(frame, []Damage{FullRedraw()})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ want := "\x1b[1;1H\x1b[0;38;5;196;48;5;232;58;5;244mX\x1b[0m"
23+ if got := string(out); got != want {
24+ t.Fatalf("output = %q, want %q", got, want)
25+ }
26+}
27+
28+func TestRendererANSI16UsesBasicColorsAndOmitsUnderlineColor(t *testing.T) {
29+ frame := NewFrame(1, 1)
30+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{
31+ Bold: true,
32+ Attrs: AttrUnderline,
33+ Foreground: -1,
34+ Background: 4,
35+ HasForegroundRGB: true,
36+ ForegroundRGB: RGB{R: 255, G: 0, B: 0},
37+ UnderlineStyle: UnderlineCurly,
38+ HasUnderlineColor: true,
39+ UnderlineColor: 9,
40+ HasUnderlineColorRGB: true,
41+ UnderlineColorRGB: RGB{R: 0, G: 255, B: 0},
42+ }})
43+
44+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileANSI16).Draw(frame, []Damage{FullRedraw()})
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ want := "\x1b[1;1H\x1b[0;1;4:3;91;44mX\x1b[0m"
49+ if got := string(out); got != want {
50+ t.Fatalf("output = %q, want %q", got, want)
51+ }
52+}
53+
54+func TestRendererANSI16DownsamplesExtendedIndexedColors(t *testing.T) {
55+ frame := NewFrame(1, 1)
56+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: 196, Background: 21}})
57+
58+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileANSI16).Draw(frame, []Damage{FullRedraw()})
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ want := "\x1b[1;1H\x1b[0;91;104mX\x1b[0m"
63+ if got := string(out); got != want {
64+ t.Fatalf("output = %q, want %q", got, want)
65+ }
66+}
67+
68+func TestRendererIndexedColorsFollowProfile(t *testing.T) {
69+ style := Style{
70+ Foreground: 9,
71+ Background: 12,
72+ HasUnderlineColor: true,
73+ UnderlineColor: 10,
74+ }
75+ tests := []struct {
76+ name string
77+ profile ColorProfile
78+ wantStyle string
79+ }{
80+ {name: "truecolor preserves indexed colors", profile: ColorProfileTrueColor, wantStyle: "\x1b[0;38;5;9;48;5;12;58;5;10m"},
81+ {name: "ANSI-256 preserves indexed colors", profile: ColorProfileANSI256, wantStyle: "\x1b[0;38;5;9;48;5;12;58;5;10m"},
82+ {name: "ANSI-16 preserves basic indexes", profile: ColorProfileANSI16, wantStyle: "\x1b[0;91;104m"},
83+ }
84+
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ frame := NewFrame(1, 1)
88+ frame.Set(0, 0, Cell{Rune: 'X', Style: style})
89+ out, err := NewWithColorProfile(Capabilities{}, tt.profile).Draw(frame, []Damage{FullRedraw()})
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ want := "\x1b[1;1H" + tt.wantStyle + "X\x1b[0m"
94+ if got := string(out); got != want {
95+ t.Fatalf("output = %q, want %q", got, want)
96+ }
97+ })
98+ }
99+}
100+
101+func TestRendererMonochromeOmitsColorsAndPreservesAttributes(t *testing.T) {
102+ frame := NewFrame(2, 1)
103+ style := Style{
104+ Bold: true,
105+ Italic: true,
106+ Inverse: true,
107+ Attrs: AttrUnderline | AttrStrikethrough,
108+ Foreground: -1,
109+ Background: 200,
110+ HasForegroundRGB: true,
111+ ForegroundRGB: RGB{R: 12, G: 34, B: 56},
112+ UnderlineStyle: UnderlineDouble,
113+ HasUnderlineColor: true,
114+ UnderlineColor: 9,
115+ HasUnderlineColorRGB: true,
116+ UnderlineColorRGB: RGB{R: 200, G: 100, B: 50},
117+ }
118+ frame.Set(0, 0, Cell{Rune: 'A', Style: style})
119+ style.ForegroundRGB = RGB{R: 255, G: 0, B: 0}
120+ frame.Set(1, 0, Cell{Rune: 'B', Style: style})
121+
122+ out, err := NewWithColorProfile(Capabilities{}, ColorProfileMonochrome).Draw(frame, []Damage{FullRedraw()})
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ want := "\x1b[1;1H\x1b[0;1;3;21;7;9mAB\x1b[0m"
127+ if got := string(out); got != want {
128+ t.Fatalf("output = %q, want %q", got, want)
129+ }
130+}
131+
132+func TestRendererColorProfilesApplyToFullIncrementalAndResetDraws(t *testing.T) {
133+ style := Style{
134+ Attrs: AttrUnderline,
135+ Foreground: -1,
136+ Background: -1,
137+ HasForegroundRGB: true,
138+ ForegroundRGB: RGB{R: 255, G: 0, B: 0},
139+ HasBackgroundRGB: true,
140+ BackgroundRGB: RGB{R: 0, G: 0, B: 255},
141+ HasUnderlineColorRGB: true,
142+ UnderlineColorRGB: RGB{R: 0, G: 255, B: 0},
143+ }
144+ tests := []struct {
145+ name string
146+ profile ColorProfile
147+ sgr string
148+ }{
149+ {name: "truecolor", profile: ColorProfileTrueColor, sgr: "\x1b[0;4;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0m"},
150+ {name: "ANSI-256", profile: ColorProfileANSI256, sgr: "\x1b[0;4;38;5;196;48;5;21;58;5;46m"},
151+ {name: "ANSI-16", profile: ColorProfileANSI16, sgr: "\x1b[0;4;91;104m"},
152+ {name: "monochrome", profile: ColorProfileMonochrome, sgr: "\x1b[0;4m"},
153+ }
154+
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ renderer := NewWithColorProfile(Capabilities{}, tt.profile)
158+ frame := NewFrame(1, 1)
159+ frame.Set(0, 0, Cell{Rune: 'A', Style: style})
160+ wantFull := "\x1b[1;1H" + tt.sgr + "A\x1b[0m"
161+ out, err := renderer.Draw(frame, []Damage{FullRedraw()})
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ if got := string(out); got != wantFull {
166+ t.Fatalf("full output = %q, want %q", got, wantFull)
167+ }
168+
169+ frame.Set(0, 0, Cell{Rune: 'B', Style: style})
170+ wantIncremental := "\x1b[1;1H" + tt.sgr + "B\x1b[0m"
171+ out, err = renderer.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}})
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ if got := string(out); got != wantIncremental {
176+ t.Fatalf("incremental output = %q, want %q", got, wantIncremental)
177+ }
178+
179+ unchanged, err := renderer.Draw(frame, nil)
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ if len(unchanged) != 0 {
184+ t.Fatalf("unchanged draw = %q, want no output", unchanged)
185+ }
186+
187+ renderer.Reset()
188+ out, err = renderer.Draw(frame, nil)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if got := string(out); got != wantIncremental {
193+ t.Fatalf("post-reset output = %q, want %q", got, wantIncremental)
194+ }
195+ })
196+ }
197+}
198+
199+func TestNewPreservesUnkeyedCapabilitiesAndTrueColorDefault(t *testing.T) {
200+ renderer := New(Capabilities{true})
201+ frame := NewFrame(1, 1)
202+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{
203+ Foreground: -1,
204+ Background: -1,
205+ HasForegroundRGB: true,
206+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
207+ }})
208+ out, err := renderer.Draw(frame, []Damage{FullRedraw()})
209+ if err != nil {
210+ t.Fatal(err)
211+ }
212+ want := SyncStartCSI + "\x1b[1;1H\x1b[0;38;2;1;2;3mX\x1b[0m" + SyncEndCSI
213+ if got := string(out); got != want {
214+ t.Fatalf("output = %q, want %q", got, want)
215+ }
216+}
217+
218+func TestConstrainedProfilesOmitInvalidIndexedColors(t *testing.T) {
219+ style := Style{
220+ Foreground: 256,
221+ Background: 257,
222+ HasUnderlineColor: true,
223+ UnderlineColor: -1,
224+ }
225+ profiles := []struct {
226+ name string
227+ profile ColorProfile
228+ }{
229+ {name: "ANSI-256", profile: ColorProfileANSI256},
230+ {name: "ANSI-16", profile: ColorProfileANSI16},
231+ {name: "monochrome", profile: ColorProfileMonochrome},
232+ }
233+ for _, tt := range profiles {
234+ t.Run(tt.name, func(t *testing.T) {
235+ frame := NewFrame(1, 1)
236+ frame.Set(0, 0, Cell{Rune: 'X', Style: style})
237+ out, err := NewWithColorProfile(Capabilities{}, tt.profile).Draw(frame, []Damage{FullRedraw()})
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ want := "\x1b[1;1HX\x1b[0m"
242+ if got := string(out); got != want {
243+ t.Fatalf("output = %q, want %q", got, want)
244+ }
245+ })
246+ }
247+}
248+
249+func TestColorQuantizationContract(t *testing.T) {
250+ tests := []struct {
251+ name string
252+ rgb RGB
253+ ansi16 int
254+ ansi256 int
255+ }{
256+ {name: "black", rgb: RGB{}, ansi16: 0, ansi256: 16},
257+ {name: "bright red", rgb: RGB{R: 255}, ansi16: 9, ansi256: 196},
258+ {name: "exact cube color", rgb: RGB{R: 95, G: 135, B: 175}, ansi16: 8, ansi256: 67},
259+ {name: "first gray", rgb: RGB{R: 8, G: 8, B: 8}, ansi16: 0, ansi256: 232},
260+ {name: "middle gray", rgb: RGB{R: 128, G: 128, B: 128}, ansi16: 8, ansi256: 244},
261+ {name: "last gray", rgb: RGB{R: 238, G: 238, B: 238}, ansi16: 15, ansi256: 255},
262+ {name: "white", rgb: RGB{R: 255, G: 255, B: 255}, ansi16: 15, ansi256: 231},
263+ {name: "arbitrary RGB", rgb: RGB{R: 12, G: 34, B: 56}, ansi16: 0, ansi256: 235},
264+ {name: "cube midpoint chooses lower index", rgb: RGB{R: 115}, ansi16: 1, ansi256: 52},
265+ {name: "ANSI-16 tie chooses lower index", rgb: RGB{R: 64}, ansi16: 0, ansi256: 52},
266+ }
267+
268+ for _, tt := range tests {
269+ t.Run(tt.name, func(t *testing.T) {
270+ if got := rgbToANSI16(tt.rgb); got != tt.ansi16 {
271+ t.Errorf("rgbToANSI16(%+v) = %d, want %d", tt.rgb, got, tt.ansi16)
272+ }
273+ if got := rgbToANSI256(tt.rgb); got != tt.ansi256 {
274+ t.Errorf("rgbToANSI256(%+v) = %d, want %d", tt.rgb, got, tt.ansi256)
275+ }
276+ })
277+ }
278+}
M ansi/doc.go +21 -0
@@ -4,4 +4,25 @@
44 // The package owns output planning, ANSI encoding, and a transactional renderer
55 // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those
66 // values are aliases of the core package and are owned by core.
7+//
8+// [New] preserves the historical truecolor behavior. A renderer created by
9+// [NewWithColorProfile] projects every color-bearing style field to its chosen
10+// [ColorProfile]. [ColorProfileTrueColor] preserves RGB and indexed colors
11+// exactly as earlier releases did. [ColorProfileANSI256] converts RGB colors to
12+// the nearest fixed xterm color in indices 16 through 255 while preserving
13+// valid indexed colors. [ColorProfileANSI16] preserves indices
14+// 0 through 15, converts other foreground and background colors to the nearest
15+// canonical ANSI color, and omits explicit underline colors.
16+// [ColorProfileMonochrome] omits foreground, background, and underline colors
17+// while preserving non-color attributes.
18+//
19+// RGB conversion minimizes squared distance in sRGB space and chooses the lower
20+// palette index on ties. ANSI-256 conversion compares the 6x6x6 color cube with
21+// the 24-step gray ramp; it does not generate theme-dependent indices 0 through
22+// 15. ANSI-16 output uses basic 30-37, 40-47, 90-97, and 100-107 SGR codes.
23+//
24+// Constrained profiles omit indexed values outside 0 through 255. Color
25+// projection is identical for full and incremental draws. Renderer
26+// configuration is fixed for its lifetime; construct a new Renderer when the
27+// output target changes.
728 package ansi
@@ -4,4 +4,25 @@
4 // The package owns output planning, ANSI encoding, and a transactional renderer4 // The package owns output planning, ANSI encoding, and a transactional renderer
5 // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those5 // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those
6 // values are aliases of the core package and are owned by core.6 // values are aliases of the core package and are owned by core.
7+//
8+// [New] preserves the historical truecolor behavior. A renderer created by
9+// [NewWithColorProfile] projects every color-bearing style field to its chosen
10+// [ColorProfile]. [ColorProfileTrueColor] preserves RGB and indexed colors
11+// exactly as earlier releases did. [ColorProfileANSI256] converts RGB colors to
12+// the nearest fixed xterm color in indices 16 through 255 while preserving
13+// valid indexed colors. [ColorProfileANSI16] preserves indices
14+// 0 through 15, converts other foreground and background colors to the nearest
15+// canonical ANSI color, and omits explicit underline colors.
16+// [ColorProfileMonochrome] omits foreground, background, and underline colors
17+// while preserving non-color attributes.
18+//
19+// RGB conversion minimizes squared distance in sRGB space and chooses the lower
20+// palette index on ties. ANSI-256 conversion compares the 6x6x6 color cube with
21+// the 24-step gray ramp; it does not generate theme-dependent indices 0 through
22+// 15. ANSI-16 output uses basic 30-37, 40-47, 90-97, and 100-107 SGR codes.
23+//
24+// Constrained profiles omit indexed values outside 0 through 255. Color
25+// projection is identical for full and incremental draws. Renderer
26+// configuration is fixed for its lifetime; construct a new Renderer when the
27+// output target changes.
7 package ansi28 package ansi
M ansi/draw_state.go +31 -12
@@ -16,11 +16,20 @@ type drawState struct {
1616 curKnown bool
1717 curRow int
1818 curCol int
19- pen Style
19+
20+ sourcePen Style
21+ outputPen outputStyle
22+ projector styleProjector
2023 }
2124
22-func newDrawState() drawState {
23- return drawState{pen: DefaultStyle()}
25+func newDrawStateForProfile(profile ColorProfile) drawState {
26+ projector := styleProjector{profile: profile}
27+ defaultStyle := DefaultStyle()
28+ return drawState{
29+ sourcePen: defaultStyle,
30+ outputPen: projector.project(defaultStyle),
31+ projector: projector,
32+ }
2433 }
2534
2635 func (st *drawState) setCursor(row, col int) {
@@ -111,13 +120,19 @@ func (st *drawState) moveCost(row, col int) int {
111120 return cost
112121 }
113122
114-// setPen emits an SGR sequence if style differs from the current pen.
123+// setPen emits an SGR sequence when a source style changes the effective
124+// terminal pen. Multiple source colors may collapse to one constrained color.
115125 func (st *drawState) setPen(out *bytes.Buffer, style Style) {
116- if style.Equal(st.pen) {
126+ if style.Equal(st.sourcePen) {
127+ return
128+ }
129+ st.sourcePen = style
130+ effective := st.projector.project(style)
131+ if effective == st.outputPen {
117132 return
118133 }
119- writeStyle(out, style)
120- st.pen = style
134+ writeStyle(out, effective)
135+ st.outputPen = effective
121136 }
122137
123138 // isBlank reports whether a cell renders as a default-style space. This is
@@ -186,11 +201,15 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
186201 if cell.Continuation {
187202 continue
188203 }
189- // Pen check kept inline (not via setPen): this is the hottest loop in
190- // the renderer and the method call costs measurably on full redraws.
191- if !cell.Style.Equal(st.pen) {
192- writeStyle(out, cell.Style)
193- st.pen = cell.Style
204+ // Keep the transition inline in this hot loop. Routing every cell through
205+ // setPen regresses full-frame rendering by about 15% in package benchmarks.
206+ if !cell.Style.Equal(st.sourcePen) {
207+ st.sourcePen = cell.Style
208+ effective := st.projector.project(cell.Style)
209+ if effective != st.outputPen {
210+ writeStyle(out, effective)
211+ st.outputPen = effective
212+ }
194213 }
195214 if cell.Rune == 0 {
196215 out.WriteByte(' ')
@@ -16,11 +16,20 @@ type drawState struct {
16 curKnown bool16 curKnown bool
17 curRow int17 curRow int
18 curCol int18 curCol int
19- pen Style19+
20+ sourcePen Style
21+ outputPen outputStyle
22+ projector styleProjector
20 }23 }
21 24
22-func newDrawState() drawState {25+func newDrawStateForProfile(profile ColorProfile) drawState {
23- return drawState{pen: DefaultStyle()}26+ projector := styleProjector{profile: profile}
27+ defaultStyle := DefaultStyle()
28+ return drawState{
29+ sourcePen: defaultStyle,
30+ outputPen: projector.project(defaultStyle),
31+ projector: projector,
32+ }
24 }33 }
25 34
26 func (st *drawState) setCursor(row, col int) {35 func (st *drawState) setCursor(row, col int) {
@@ -111,13 +120,19 @@ func (st *drawState) moveCost(row, col int) int {
111 return cost120 return cost
112 }121 }
113 122
114-// setPen emits an SGR sequence if style differs from the current pen.123+// setPen emits an SGR sequence when a source style changes the effective
124+// terminal pen. Multiple source colors may collapse to one constrained color.
115 func (st *drawState) setPen(out *bytes.Buffer, style Style) {125 func (st *drawState) setPen(out *bytes.Buffer, style Style) {
116- if style.Equal(st.pen) {126+ if style.Equal(st.sourcePen) {
127+ return
128+ }
129+ st.sourcePen = style
130+ effective := st.projector.project(style)
131+ if effective == st.outputPen {
117 return132 return
118 }133 }
119- writeStyle(out, style)134+ writeStyle(out, effective)
120- st.pen = style135+ st.outputPen = effective
121 }136 }
122 137
123 // isBlank reports whether a cell renders as a default-style space. This is138 // isBlank reports whether a cell renders as a default-style space. This is
@@ -186,11 +201,15 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
186 if cell.Continuation {201 if cell.Continuation {
187 continue202 continue
188 }203 }
189- // Pen check kept inline (not via setPen): this is the hottest loop in204+ // Keep the transition inline in this hot loop. Routing every cell through
190- // the renderer and the method call costs measurably on full redraws.205+ // setPen regresses full-frame rendering by about 15% in package benchmarks.
191- if !cell.Style.Equal(st.pen) {206+ if !cell.Style.Equal(st.sourcePen) {
192- writeStyle(out, cell.Style)207+ st.sourcePen = cell.Style
193- st.pen = cell.Style208+ effective := st.projector.project(cell.Style)
209+ if effective != st.outputPen {
210+ writeStyle(out, effective)
211+ st.outputPen = effective
212+ }
194 }213 }
195 if cell.Rune == 0 {214 if cell.Rune == 0 {
196 out.WriteByte(' ')215 out.WriteByte(' ')
M ansi/output_min_test.go +1 -1
@@ -36,7 +36,7 @@ func TestMoveToChoosesCheapestSequence(t *testing.T) {
3636
3737 for _, tt := range tests {
3838 t.Run(tt.name, func(t *testing.T) {
39- st := newDrawState()
39+ st := newDrawStateForProfile(ColorProfileTrueColor)
4040 if tt.known {
4141 st.setCursor(tt.fromRow, tt.fromCol)
4242 }
@@ -36,7 +36,7 @@ func TestMoveToChoosesCheapestSequence(t *testing.T) {
36 36
37 for _, tt := range tests {37 for _, tt := range tests {
38 t.Run(tt.name, func(t *testing.T) {38 t.Run(tt.name, func(t *testing.T) {
39- st := newDrawState()39+ st := newDrawStateForProfile(ColorProfileTrueColor)
40 if tt.known {40 if tt.known {
41 st.setCursor(tt.fromRow, tt.fromCol)41 st.setCursor(tt.fromRow, tt.fromCol)
42 }42 }
A ansi/output_style.go +81 -0
@@ -0,0 +1,81 @@
1+package ansi
2+
3+type outputColorKind uint8
4+
5+const (
6+ outputColorDefault outputColorKind = iota
7+ outputColorANSI16
8+ outputColorANSI256
9+ outputColorRGB
10+)
11+
12+type outputColor struct {
13+ kind outputColorKind
14+ index int
15+ rgb RGB
16+}
17+
18+type outputStyle struct {
19+ bold bool
20+ italic bool
21+ inverse bool
22+ attrs StyleAttrs
23+ underlineStyle UnderlineStyle
24+ foreground outputColor
25+ background outputColor
26+ underlineColor outputColor
27+}
28+
29+type styleProjector struct {
30+ profile ColorProfile
31+}
32+
33+func (p styleProjector) project(style Style) outputStyle {
34+ return outputStyle{
35+ bold: style.Bold,
36+ italic: style.Italic,
37+ inverse: style.Inverse,
38+ attrs: style.Attrs,
39+ underlineStyle: style.UnderlineStyle,
40+ foreground: p.projectColor(style.Foreground, style.Foreground >= 0, style.ForegroundRGB, style.HasForegroundRGB, true),
41+ background: p.projectColor(style.Background, style.Background >= 0, style.BackgroundRGB, style.HasBackgroundRGB, true),
42+ underlineColor: p.projectColor(style.UnderlineColor, style.HasUnderlineColor, style.UnderlineColorRGB, style.HasUnderlineColorRGB, false),
43+ }
44+}
45+
46+func (p styleProjector) projectColor(index int, hasIndex bool, rgb RGB, hasRGB, basicColor bool) outputColor {
47+ switch p.profile {
48+ case ColorProfileANSI256:
49+ if hasRGB {
50+ return outputColor{kind: outputColorANSI256, index: rgbToANSI256(rgb)}
51+ }
52+ if hasIndex && index >= 0 && index <= 255 {
53+ return outputColor{kind: outputColorANSI256, index: index}
54+ }
55+ case ColorProfileANSI16:
56+ if !basicColor {
57+ return outputColor{}
58+ }
59+ if hasRGB {
60+ return outputColor{kind: outputColorANSI16, index: rgbToANSI16(rgb)}
61+ }
62+ if hasIndex {
63+ if index >= 0 && index < 16 {
64+ return outputColor{kind: outputColorANSI16, index: index}
65+ }
66+ if resolved, ok := ansi256ToRGB(index); ok {
67+ return outputColor{kind: outputColorANSI16, index: rgbToANSI16(resolved)}
68+ }
69+ }
70+ case ColorProfileMonochrome:
71+ return outputColor{}
72+ default:
73+ if hasRGB {
74+ return outputColor{kind: outputColorRGB, rgb: rgb}
75+ }
76+ if hasIndex {
77+ return outputColor{kind: outputColorANSI256, index: index}
78+ }
79+ }
80+ return outputColor{}
81+}
@@ -0,0 +1,81 @@
1+package ansi
2+
3+type outputColorKind uint8
4+
5+const (
6+ outputColorDefault outputColorKind = iota
7+ outputColorANSI16
8+ outputColorANSI256
9+ outputColorRGB
10+)
11+
12+type outputColor struct {
13+ kind outputColorKind
14+ index int
15+ rgb RGB
16+}
17+
18+type outputStyle struct {
19+ bold bool
20+ italic bool
21+ inverse bool
22+ attrs StyleAttrs
23+ underlineStyle UnderlineStyle
24+ foreground outputColor
25+ background outputColor
26+ underlineColor outputColor
27+}
28+
29+type styleProjector struct {
30+ profile ColorProfile
31+}
32+
33+func (p styleProjector) project(style Style) outputStyle {
34+ return outputStyle{
35+ bold: style.Bold,
36+ italic: style.Italic,
37+ inverse: style.Inverse,
38+ attrs: style.Attrs,
39+ underlineStyle: style.UnderlineStyle,
40+ foreground: p.projectColor(style.Foreground, style.Foreground >= 0, style.ForegroundRGB, style.HasForegroundRGB, true),
41+ background: p.projectColor(style.Background, style.Background >= 0, style.BackgroundRGB, style.HasBackgroundRGB, true),
42+ underlineColor: p.projectColor(style.UnderlineColor, style.HasUnderlineColor, style.UnderlineColorRGB, style.HasUnderlineColorRGB, false),
43+ }
44+}
45+
46+func (p styleProjector) projectColor(index int, hasIndex bool, rgb RGB, hasRGB, basicColor bool) outputColor {
47+ switch p.profile {
48+ case ColorProfileANSI256:
49+ if hasRGB {
50+ return outputColor{kind: outputColorANSI256, index: rgbToANSI256(rgb)}
51+ }
52+ if hasIndex && index >= 0 && index <= 255 {
53+ return outputColor{kind: outputColorANSI256, index: index}
54+ }
55+ case ColorProfileANSI16:
56+ if !basicColor {
57+ return outputColor{}
58+ }
59+ if hasRGB {
60+ return outputColor{kind: outputColorANSI16, index: rgbToANSI16(rgb)}
61+ }
62+ if hasIndex {
63+ if index >= 0 && index < 16 {
64+ return outputColor{kind: outputColorANSI16, index: index}
65+ }
66+ if resolved, ok := ansi256ToRGB(index); ok {
67+ return outputColor{kind: outputColorANSI16, index: rgbToANSI16(resolved)}
68+ }
69+ }
70+ case ColorProfileMonochrome:
71+ return outputColor{}
72+ default:
73+ if hasRGB {
74+ return outputColor{kind: outputColorRGB, rgb: rgb}
75+ }
76+ if hasIndex {
77+ return outputColor{kind: outputColorANSI256, index: index}
78+ }
79+ }
80+ return outputColor{}
81+}
M ansi/renderer.go +16 -81
@@ -12,16 +12,18 @@ var bufferPool = sync.Pool{
1212 New: func() any { return new(bytes.Buffer) },
1313 }
1414
15+// Capabilities describes the fixed output features of a Renderer target.
1516 type Capabilities struct {
1617 SynchronizedOutput bool
1718 }
1819
1920 type Renderer struct {
20- caps Capabilities
21- width int
22- height int
23- shadow []Cell
24- committed Frame
21+ caps Capabilities
22+ colorProfile ColorProfile
23+ width int
24+ height int
25+ shadow []Cell
26+ committed Frame
2527 }
2628
2729 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -33,7 +35,14 @@ type PreparedDraw struct {
3335 commitOnce *sync.Once
3436 }
3537
36-func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
38+func New(caps Capabilities) *Renderer {
39+ return NewWithColorProfile(caps, ColorProfileTrueColor)
40+}
41+
42+// NewWithColorProfile constructs a renderer for a target color profile.
43+func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
44+ return &Renderer{caps: caps, colorProfile: profile}
45+}
3746
3847 func (r *Renderer) Reset() {
3948 r.width = 0
@@ -95,7 +104,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
95104 buf.WriteString(SyncStartCSI)
96105 }
97106
98- st := newDrawState()
107+ st := newDrawStateForProfile(r.colorProfile)
99108 if plan.Snapshot {
100109 if len(plan.Spans) > 0 {
101110 r.emitDamageSpans(buf, frame, plan.Spans, &st)
@@ -234,80 +243,6 @@ func writeCursor(out *bytes.Buffer, y, x int) {
234243 out.WriteByte('H')
235244 }
236245
237-// writeStyle emits SGR style parameters without fmt.Fprintf allocations.
238-func writeStyle(out *bytes.Buffer, style Style) {
239- out.WriteString("\x1b[0")
240- if style.Bold {
241- out.WriteString(";1")
242- }
243- if style.Attrs&AttrDim != 0 {
244- out.WriteString(";2")
245- }
246- if style.Italic {
247- out.WriteString(";3")
248- }
249- if style.Attrs&AttrUnderline != 0 {
250- switch style.UnderlineStyle {
251- case UnderlineDouble:
252- out.WriteString(";21")
253- case UnderlineCurly:
254- out.WriteString(";4:3")
255- case UnderlineDotted:
256- out.WriteString(";4:4")
257- case UnderlineDashed:
258- out.WriteString(";4:5")
259- default:
260- out.WriteString(";4")
261- }
262- }
263- if style.Attrs&AttrBlink != 0 {
264- out.WriteString(";5")
265- }
266- if style.Inverse {
267- out.WriteString(";7")
268- }
269- if style.Attrs&AttrStrikethrough != 0 {
270- out.WriteString(";9")
271- }
272- var b [16]byte
273- if style.HasForegroundRGB {
274- out.WriteString(";38;2;")
275- writeRGB(out, &b, style.ForegroundRGB)
276- } else if style.Foreground >= 0 {
277- out.WriteString(";38;5;")
278- n := strconv.AppendInt(b[:0], int64(style.Foreground), 10)
279- out.Write(n)
280- }
281- if style.HasBackgroundRGB {
282- out.WriteString(";48;2;")
283- writeRGB(out, &b, style.BackgroundRGB)
284- } else if style.Background >= 0 {
285- out.WriteString(";48;5;")
286- n := strconv.AppendInt(b[:0], int64(style.Background), 10)
287- out.Write(n)
288- }
289- if style.HasUnderlineColorRGB {
290- out.WriteString(";58;2;")
291- writeRGB(out, &b, style.UnderlineColorRGB)
292- } else if style.HasUnderlineColor {
293- out.WriteString(";58;5;")
294- n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
295- out.Write(n)
296- }
297- out.WriteByte('m')
298-}
299-
300-func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) {
301- n := strconv.AppendInt(b[:0], int64(rgb.R), 10)
302- out.Write(n)
303- out.WriteByte(';')
304- n = strconv.AppendInt(b[:0], int64(rgb.G), 10)
305- out.Write(n)
306- out.WriteByte(';')
307- n = strconv.AppendInt(b[:0], int64(rgb.B), 10)
308- out.Write(n)
309-}
310-
311246 func sameDamage(a, b Damage) bool {
312247 return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count
313248 }
@@ -12,16 +12,18 @@ var bufferPool = sync.Pool{
12 New: func() any { return new(bytes.Buffer) },12 New: func() any { return new(bytes.Buffer) },
13 }13 }
14 14
15+// Capabilities describes the fixed output features of a Renderer target.
15 type Capabilities struct {16 type Capabilities struct {
16 SynchronizedOutput bool17 SynchronizedOutput bool
17 }18 }
18 19
19 type Renderer struct {20 type Renderer struct {
20- caps Capabilities21+ caps Capabilities
21- width int22+ colorProfile ColorProfile
22- height int23+ width int
23- shadow []Cell24+ height int
24- committed Frame25+ shadow []Cell
26+ committed Frame
25 }27 }
26 28
27 // PreparedDraw owns encoded output and its transactional delta until Commit.29 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -33,7 +35,14 @@ type PreparedDraw struct {
33 commitOnce *sync.Once35 commitOnce *sync.Once
34 }36 }
35 37
36-func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }38+func New(caps Capabilities) *Renderer {
39+ return NewWithColorProfile(caps, ColorProfileTrueColor)
40+}
41+
42+// NewWithColorProfile constructs a renderer for a target color profile.
43+func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
44+ return &Renderer{caps: caps, colorProfile: profile}
45+}
37 46
38 func (r *Renderer) Reset() {47 func (r *Renderer) Reset() {
39 r.width = 048 r.width = 0
@@ -95,7 +104,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
95 buf.WriteString(SyncStartCSI)104 buf.WriteString(SyncStartCSI)
96 }105 }
97 106
98- st := newDrawState()107+ st := newDrawStateForProfile(r.colorProfile)
99 if plan.Snapshot {108 if plan.Snapshot {
100 if len(plan.Spans) > 0 {109 if len(plan.Spans) > 0 {
101 r.emitDamageSpans(buf, frame, plan.Spans, &st)110 r.emitDamageSpans(buf, frame, plan.Spans, &st)
@@ -234,80 +243,6 @@ func writeCursor(out *bytes.Buffer, y, x int) {
234 out.WriteByte('H')243 out.WriteByte('H')
235 }244 }
236 245
237-// writeStyle emits SGR style parameters without fmt.Fprintf allocations.
238-func writeStyle(out *bytes.Buffer, style Style) {
239- out.WriteString("\x1b[0")
240- if style.Bold {
241- out.WriteString(";1")
242- }
243- if style.Attrs&AttrDim != 0 {
244- out.WriteString(";2")
245- }
246- if style.Italic {
247- out.WriteString(";3")
248- }
249- if style.Attrs&AttrUnderline != 0 {
250- switch style.UnderlineStyle {
251- case UnderlineDouble:
252- out.WriteString(";21")
253- case UnderlineCurly:
254- out.WriteString(";4:3")
255- case UnderlineDotted:
256- out.WriteString(";4:4")
257- case UnderlineDashed:
258- out.WriteString(";4:5")
259- default:
260- out.WriteString(";4")
261- }
262- }
263- if style.Attrs&AttrBlink != 0 {
264- out.WriteString(";5")
265- }
266- if style.Inverse {
267- out.WriteString(";7")
268- }
269- if style.Attrs&AttrStrikethrough != 0 {
270- out.WriteString(";9")
271- }
272- var b [16]byte
273- if style.HasForegroundRGB {
274- out.WriteString(";38;2;")
275- writeRGB(out, &b, style.ForegroundRGB)
276- } else if style.Foreground >= 0 {
277- out.WriteString(";38;5;")
278- n := strconv.AppendInt(b[:0], int64(style.Foreground), 10)
279- out.Write(n)
280- }
281- if style.HasBackgroundRGB {
282- out.WriteString(";48;2;")
283- writeRGB(out, &b, style.BackgroundRGB)
284- } else if style.Background >= 0 {
285- out.WriteString(";48;5;")
286- n := strconv.AppendInt(b[:0], int64(style.Background), 10)
287- out.Write(n)
288- }
289- if style.HasUnderlineColorRGB {
290- out.WriteString(";58;2;")
291- writeRGB(out, &b, style.UnderlineColorRGB)
292- } else if style.HasUnderlineColor {
293- out.WriteString(";58;5;")
294- n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
295- out.Write(n)
296- }
297- out.WriteByte('m')
298-}
299-
300-func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) {
301- n := strconv.AppendInt(b[:0], int64(rgb.R), 10)
302- out.Write(n)
303- out.WriteByte(';')
304- n = strconv.AppendInt(b[:0], int64(rgb.G), 10)
305- out.Write(n)
306- out.WriteByte(';')
307- n = strconv.AppendInt(b[:0], int64(rgb.B), 10)
308- out.Write(n)
309-}
310-
311 func sameDamage(a, b Damage) bool {246 func sameDamage(a, b Damage) bool {
312 return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count247 return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count
313 }248 }
A ansi/sgr.go +92 -0
@@ -0,0 +1,92 @@
1+package ansi
2+
3+import (
4+ "bytes"
5+ "strconv"
6+)
7+
8+// writeStyle emits a canonical output style without making capability
9+// decisions. Those belong to styleProjector.
10+func writeStyle(out *bytes.Buffer, style outputStyle) {
11+ out.WriteString("\x1b[0")
12+ if style.bold {
13+ out.WriteString(";1")
14+ }
15+ if style.attrs&AttrDim != 0 {
16+ out.WriteString(";2")
17+ }
18+ if style.italic {
19+ out.WriteString(";3")
20+ }
21+ if style.attrs&AttrUnderline != 0 {
22+ switch style.underlineStyle {
23+ case UnderlineDouble:
24+ out.WriteString(";21")
25+ case UnderlineCurly:
26+ out.WriteString(";4:3")
27+ case UnderlineDotted:
28+ out.WriteString(";4:4")
29+ case UnderlineDashed:
30+ out.WriteString(";4:5")
31+ default:
32+ out.WriteString(";4")
33+ }
34+ }
35+ if style.attrs&AttrBlink != 0 {
36+ out.WriteString(";5")
37+ }
38+ if style.inverse {
39+ out.WriteString(";7")
40+ }
41+ if style.attrs&AttrStrikethrough != 0 {
42+ out.WriteString(";9")
43+ }
44+ var scratch [16]byte
45+ writeOutputColor(out, &scratch, style.foreground, 38, 30, 90)
46+ writeOutputColor(out, &scratch, style.background, 48, 40, 100)
47+ writeUnderlineColor(out, &scratch, style.underlineColor)
48+ out.WriteByte('m')
49+}
50+
51+func writeOutputColor(out *bytes.Buffer, scratch *[16]byte, color outputColor, extended, normal, bright int) {
52+ switch color.kind {
53+ case outputColorANSI16:
54+ code := normal + color.index
55+ if color.index >= 8 {
56+ code = bright + color.index - 8
57+ }
58+ writeSGRParam(out, scratch, code)
59+ case outputColorANSI256:
60+ writeExtendedColor(out, scratch, extended, 5, color.index)
61+ case outputColorRGB:
62+ writeExtendedRGB(out, scratch, extended, color.rgb)
63+ }
64+}
65+
66+func writeUnderlineColor(out *bytes.Buffer, scratch *[16]byte, color outputColor) {
67+ switch color.kind {
68+ case outputColorANSI256:
69+ writeExtendedColor(out, scratch, 58, 5, color.index)
70+ case outputColorRGB:
71+ writeExtendedRGB(out, scratch, 58, color.rgb)
72+ }
73+}
74+
75+func writeSGRParam(out *bytes.Buffer, scratch *[16]byte, value int) {
76+ out.WriteByte(';')
77+ out.Write(strconv.AppendInt(scratch[:0], int64(value), 10))
78+}
79+
80+func writeExtendedColor(out *bytes.Buffer, scratch *[16]byte, selector, mode, value int) {
81+ writeSGRParam(out, scratch, selector)
82+ writeSGRParam(out, scratch, mode)
83+ writeSGRParam(out, scratch, value)
84+}
85+
86+func writeExtendedRGB(out *bytes.Buffer, scratch *[16]byte, selector int, rgb RGB) {
87+ writeSGRParam(out, scratch, selector)
88+ writeSGRParam(out, scratch, 2)
89+ writeSGRParam(out, scratch, int(rgb.R))
90+ writeSGRParam(out, scratch, int(rgb.G))
91+ writeSGRParam(out, scratch, int(rgb.B))
92+}
@@ -0,0 +1,92 @@
1+package ansi
2+
3+import (
4+ "bytes"
5+ "strconv"
6+)
7+
8+// writeStyle emits a canonical output style without making capability
9+// decisions. Those belong to styleProjector.
10+func writeStyle(out *bytes.Buffer, style outputStyle) {
11+ out.WriteString("\x1b[0")
12+ if style.bold {
13+ out.WriteString(";1")
14+ }
15+ if style.attrs&AttrDim != 0 {
16+ out.WriteString(";2")
17+ }
18+ if style.italic {
19+ out.WriteString(";3")
20+ }
21+ if style.attrs&AttrUnderline != 0 {
22+ switch style.underlineStyle {
23+ case UnderlineDouble:
24+ out.WriteString(";21")
25+ case UnderlineCurly:
26+ out.WriteString(";4:3")
27+ case UnderlineDotted:
28+ out.WriteString(";4:4")
29+ case UnderlineDashed:
30+ out.WriteString(";4:5")
31+ default:
32+ out.WriteString(";4")
33+ }
34+ }
35+ if style.attrs&AttrBlink != 0 {
36+ out.WriteString(";5")
37+ }
38+ if style.inverse {
39+ out.WriteString(";7")
40+ }
41+ if style.attrs&AttrStrikethrough != 0 {
42+ out.WriteString(";9")
43+ }
44+ var scratch [16]byte
45+ writeOutputColor(out, &scratch, style.foreground, 38, 30, 90)
46+ writeOutputColor(out, &scratch, style.background, 48, 40, 100)
47+ writeUnderlineColor(out, &scratch, style.underlineColor)
48+ out.WriteByte('m')
49+}
50+
51+func writeOutputColor(out *bytes.Buffer, scratch *[16]byte, color outputColor, extended, normal, bright int) {
52+ switch color.kind {
53+ case outputColorANSI16:
54+ code := normal + color.index
55+ if color.index >= 8 {
56+ code = bright + color.index - 8
57+ }
58+ writeSGRParam(out, scratch, code)
59+ case outputColorANSI256:
60+ writeExtendedColor(out, scratch, extended, 5, color.index)
61+ case outputColorRGB:
62+ writeExtendedRGB(out, scratch, extended, color.rgb)
63+ }
64+}
65+
66+func writeUnderlineColor(out *bytes.Buffer, scratch *[16]byte, color outputColor) {
67+ switch color.kind {
68+ case outputColorANSI256:
69+ writeExtendedColor(out, scratch, 58, 5, color.index)
70+ case outputColorRGB:
71+ writeExtendedRGB(out, scratch, 58, color.rgb)
72+ }
73+}
74+
75+func writeSGRParam(out *bytes.Buffer, scratch *[16]byte, value int) {
76+ out.WriteByte(';')
77+ out.Write(strconv.AppendInt(scratch[:0], int64(value), 10))
78+}
79+
80+func writeExtendedColor(out *bytes.Buffer, scratch *[16]byte, selector, mode, value int) {
81+ writeSGRParam(out, scratch, selector)
82+ writeSGRParam(out, scratch, mode)
83+ writeSGRParam(out, scratch, value)
84+}
85+
86+func writeExtendedRGB(out *bytes.Buffer, scratch *[16]byte, selector int, rgb RGB) {
87+ writeSGRParam(out, scratch, selector)
88+ writeSGRParam(out, scratch, 2)
89+ writeSGRParam(out, scratch, int(rgb.R))
90+ writeSGRParam(out, scratch, int(rgb.G))
91+ writeSGRParam(out, scratch, int(rgb.B))
92+}

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 2

Tags 13

↑↓ moveenter openesc close