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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 @@ | |||
| 4 | 4 | // The package owns output planning, ANSI encoding, and a transactional renderer | |
| 5 | 5 | // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those | |
| 6 | 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 | 28 | package ansi | |
| @@ -4,4 +4,25 @@ | |||||
| 4 | // The package owns output planning, ANSI encoding, and a transactional renderer | 4 | // The package owns output planning, ANSI encoding, and a transactional renderer | ||
| 5 | // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those | 5 | // 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 ansi | 28 | package ansi | ||
M ansi/draw_state.go +31 -12
| @@ -16,11 +16,20 @@ type drawState struct { | |||
| 16 | 16 | curKnown bool | |
| 17 | 17 | curRow int | |
| 18 | 18 | curCol int | |
| 19 | - | pen Style | |
| 19 | + | ||
| 20 | + | sourcePen Style | |
| 21 | + | outputPen outputStyle | |
| 22 | + | projector styleProjector | |
| 20 | 23 | } | |
| 21 | 24 | ||
| 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 | + | } | |
| 24 | 33 | } | |
| 25 | 34 | ||
| 26 | 35 | func (st *drawState) setCursor(row, col int) { | |
| @@ -111,13 +120,19 @@ func (st *drawState) moveCost(row, col int) int { | |||
| 111 | 120 | 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 | 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 | 132 | return | |
| 118 | 133 | } | |
| 119 | - | writeStyle(out, style) | |
| 120 | - | st.pen = style | |
| 134 | + | writeStyle(out, effective) | |
| 135 | + | st.outputPen = effective | |
| 121 | 136 | } | |
| 122 | 137 | ||
| 123 | 138 | // 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 | 201 | if cell.Continuation { | |
| 187 | 202 | continue | |
| 188 | 203 | } | |
| 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 | + | } | |
| 194 | 213 | } | |
| 195 | 214 | if cell.Rune == 0 { | |
| 196 | 215 | out.WriteByte(' ') | |
| @@ -16,11 +16,20 @@ type drawState struct { | |||||
| 16 | curKnown bool | 16 | curKnown bool | ||
| 17 | curRow int | 17 | curRow int | ||
| 18 | curCol int | 18 | curCol int | ||
| 19 | - | pen Style | 19 | + | |
| 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 cost | 120 | 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 | return | 132 | return | ||
| 118 | } | 133 | } | ||
| 119 | - | writeStyle(out, style) | 134 | + | writeStyle(out, effective) |
| 120 | - | st.pen = style | 135 | + | st.outputPen = effective |
| 121 | } | 136 | } | ||
| 122 | 137 | ||||
| 123 | // isBlank reports whether a cell renders as a default-style space. This is | 138 | // 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 | continue | 202 | continue | ||
| 188 | } | 203 | } | ||
| 189 | - | // Pen check kept inline (not via setPen): this is the hottest loop in | 204 | + | // 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.Style | 208 | + | 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) { | |||
| 36 | 36 | ||
| 37 | 37 | for _, tt := range tests { | |
| 38 | 38 | t.Run(tt.name, func(t *testing.T) { | |
| 39 | - | st := newDrawState() | |
| 39 | + | st := newDrawStateForProfile(ColorProfileTrueColor) | |
| 40 | 40 | if tt.known { | |
| 41 | 41 | st.setCursor(tt.fromRow, tt.fromCol) | |
| 42 | 42 | } | |
| @@ -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{ | |||
| 12 | 12 | New: func() any { return new(bytes.Buffer) }, | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | // Capabilities describes the fixed output features of a Renderer target. | |
| 15 | 16 | type Capabilities struct { | |
| 16 | 17 | SynchronizedOutput bool | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | 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 | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // PreparedDraw owns encoded output and its transactional delta until Commit. | |
| @@ -33,7 +35,14 @@ type PreparedDraw struct { | |||
| 33 | 35 | 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 | 47 | func (r *Renderer) Reset() { | |
| 39 | 48 | r.width = 0 | |
| @@ -95,7 +104,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||
| 95 | 104 | buf.WriteString(SyncStartCSI) | |
| 96 | 105 | } | |
| 97 | 106 | ||
| 98 | - | st := newDrawState() | |
| 107 | + | st := newDrawStateForProfile(r.colorProfile) | |
| 99 | 108 | if plan.Snapshot { | |
| 100 | 109 | if len(plan.Spans) > 0 { | |
| 101 | 110 | r.emitDamageSpans(buf, frame, plan.Spans, &st) | |
| @@ -234,80 +243,6 @@ func writeCursor(out *bytes.Buffer, y, x int) { | |||
| 234 | 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 | 246 | func sameDamage(a, b Damage) bool { | |
| 312 | 247 | 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 | } | |
| @@ -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 bool | 17 | SynchronizedOutput bool | ||
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
| 19 | type Renderer struct { | 20 | type Renderer struct { | ||
| 20 | - | caps Capabilities | 21 | + | caps Capabilities |
| 21 | - | width int | 22 | + | colorProfile ColorProfile |
| 22 | - | height int | 23 | + | width int |
| 23 | - | shadow []Cell | 24 | + | height int |
| 24 | - | committed Frame | 25 | + | 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.Once | 35 | 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 = 0 | 48 | 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.Count | 247 | 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 | + | } | |||