Skip to content

Commit cfaffa0

Browse files
committed
[Console] PR #46944 - Adds support for Ansi8 (256 colors) and improves terminal support detection (Ansi4, Ansi8, Ansi24)
* The detection of terminal capabilities is always conservatively estimated, without speculation that may result in display errors. But significantly improved, bringing in particular 256 colors compatibility with Apple Terminal (instead of 8). * Ansi8 is used by converting the RGB hexadecimal to the nearest color. * The whole corresponding code is refactored and many tests are added for both existing and new features.
1 parent 9ccf365 commit cfaffa0

File tree

7 files changed

+323
-58
lines changed

7 files changed

+323
-58
lines changed

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
CHANGELOG
22
=========
33

4+
6.2
5+
---
6+
7+
* Improve truecolor terminal detection in some cases
8+
* Add support for 256 color terminals (conversion from Ansi24 to Ansi8 if terminal is capable of it)
9+
10+
411
6.1
512
---
613

src/Symfony/Component/Console/Color.php

Lines changed: 1 addition & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,7 @@ private function parseColor(string $color, bool $background = false): string
117117
}
118118

119119
if ('#' === $color[0]) {
120-
$color = substr($color, 1);
121-
122-
if (3 === \strlen($color)) {
123-
$color = $color[0].$color[0].$color[1].$color[1].$color[2].$color[2];
124-
}
125-
126-
if (6 !== \strlen($color)) {
127-
throw new InvalidArgumentException(sprintf('Invalid "%s" color.', $color));
128-
}
129-
130-
return ($background ? '4' : '3').$this->convertHexColorToAnsi(hexdec($color));
120+
return ($background ? '4' : '3').Terminal::getTermColorSupport()->convertFromHexToAnsiColorCode($color);
131121
}
132122

133123
if (isset(self::COLORS[$color])) {
@@ -140,41 +130,4 @@ private function parseColor(string $color, bool $background = false): string
140130

141131
throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_merge(array_keys(self::COLORS), array_keys(self::BRIGHT_COLORS)))));
142132
}
143-
144-
private function convertHexColorToAnsi(int $color): string
145-
{
146-
$r = ($color >> 16) & 255;
147-
$g = ($color >> 8) & 255;
148-
$b = $color & 255;
149-
150-
// see https://github.com/termstandard/colors/ for more information about true color support
151-
if ('truecolor' !== getenv('COLORTERM')) {
152-
return (string) $this->degradeHexColorToAnsi($r, $g, $b);
153-
}
154-
155-
return sprintf('8;2;%d;%d;%d', $r, $g, $b);
156-
}
157-
158-
private function degradeHexColorToAnsi(int $r, int $g, int $b): int
159-
{
160-
if (0 === round($this->getSaturation($r, $g, $b) / 50)) {
161-
return 0;
162-
}
163-
164-
return (round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255);
165-
}
166-
167-
private function getSaturation(int $r, int $g, int $b): int
168-
{
169-
$r = $r / 255;
170-
$g = $g / 255;
171-
$b = $b / 255;
172-
$v = max($r, $g, $b);
173-
174-
if (0 === $diff = $v - min($r, $g, $b)) {
175-
return 0;
176-
}
177-
178-
return (int) $diff * 100 / $v;
179-
}
180133
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Output;
13+
14+
use Symfony\Component\Asset\Exception\InvalidArgumentException;
15+
16+
/**
17+
* @author Julien Boudry <julien@condorcet.vote>
18+
*/
19+
enum AnsiColorMode
20+
{
21+
/**
22+
* Classical 4-bit Ansi colors, including 8 classical colors and 8 bright color. Output syntax is "ESC[${foreGroundColorcode};${backGroundColorcode}m"
23+
* Must be compatible with all terminals and it's the minimal version supported.
24+
*/
25+
case Ansi4;
26+
27+
/**
28+
* 8-bit Ansi colors (240 differents colors + 16 duplicate color codes, ensuring backward compatibility).
29+
* Output syntax is: "ESC[38;5;${foreGroundColorcode};48;5;${backGroundColorcode}m"
30+
* Should be compatible with most terminals.
31+
*/
32+
case Ansi8;
33+
34+
/**
35+
* 24-bit Ansi colors (RGB).
36+
* Output syntax is: "ESC[38;2;${foreGroundColorcodeRed};${foreGroundColorcodeGreen};${foreGroundColorcodeBlue};48;2;${backGroundColorcodeRed};${backGroundColorcodeGreen};${backGroundColorcodeBlue}m"
37+
* May be compatible with many modern terminals.
38+
*/
39+
case Ansi24;
40+
41+
/**
42+
* Converts an RGB hexadecimal color to the corresponding Ansi code.
43+
*/
44+
public function convertFromHexToAnsiColorCode(string $hexColor): string
45+
{
46+
$hexColor = str_replace('#', '', $hexColor);
47+
48+
if (3 === \strlen($hexColor)) {
49+
$hexColor = $hexColor[0].$hexColor[0].$hexColor[1].$hexColor[1].$hexColor[2].$hexColor[2];
50+
}
51+
52+
if (6 !== \strlen($hexColor)) {
53+
throw new InvalidArgumentException(sprintf('Invalid "#%s" color.', $hexColor));
54+
}
55+
56+
$color = hexdec($hexColor);
57+
58+
$r = ($color >> 16) & 255;
59+
$g = ($color >> 8) & 255;
60+
$b = $color & 255;
61+
62+
return match ($this) {
63+
self::Ansi4 => (string) $this->convertFromRGB($r, $g, $b),
64+
self::Ansi8 => '8;5;'.((string) $this->convertFromRGB($r, $g, $b)),
65+
self::Ansi24 => sprintf('8;2;%d;%d;%d', $r, $g, $b)
66+
};
67+
}
68+
69+
private function convertFromRGB(int $r, int $g, int $b): int
70+
{
71+
return match ($this) {
72+
self::Ansi4 => $this->degradeHexColorToAnsi4($r, $g, $b),
73+
self::Ansi8 => $this->degradeHexColorToAnsi8($r, $g, $b),
74+
default => throw new InvalidArgumentException("RGB cannot be converted to {$this->name}.")
75+
};
76+
}
77+
78+
private function degradeHexColorToAnsi4(int $r, int $g, int $b): int
79+
{
80+
if (0 === round($this->getSaturation($r, $g, $b) / 50)) {
81+
return 0;
82+
}
83+
84+
return (int) ((round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255));
85+
}
86+
87+
private function getSaturation(int $r, int $g, int $b): int
88+
{
89+
$r = $r / 255;
90+
$g = $g / 255;
91+
$b = $b / 255;
92+
$v = max($r, $g, $b);
93+
94+
if (0 === $diff = $v - min($r, $g, $b)) {
95+
return 0;
96+
}
97+
98+
return (int) ((int) $diff * 100 / $v);
99+
}
100+
101+
/**
102+
* Inspired from https://github.com/ajalt/colormath/blob/e464e0da1b014976736cf97250063248fc77b8e7/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/model/Ansi256.kt code (MIT license).
103+
*/
104+
private function degradeHexColorToAnsi8(int $r, int $g, int $b): int
105+
{
106+
if ($r === $g && $g === $b) {
107+
if ($r < 8) {
108+
return 16;
109+
}
110+
111+
if ($r > 248) {
112+
return 231;
113+
}
114+
115+
return (int) round(($r - 8) / 247 * 24) + 232;
116+
} else {
117+
return 16 +
118+
(36 * (int) round($r / 255 * 5)) +
119+
(6 * (int) round($g / 255 * 5)) +
120+
(int) round($b / 255 * 5);
121+
}
122+
}
123+
}

src/Symfony/Component/Console/Terminal.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,49 @@
1111

1212
namespace Symfony\Component\Console;
1313

14+
use Symfony\Component\Console\Output\AnsiColorMode;
15+
1416
class Terminal
1517
{
1618
private static ?int $width = null;
1719
private static ?int $height = null;
1820
private static ?bool $stty = null;
1921

22+
/**
23+
* About Ansi color types: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
24+
* For more information about true color support with terminals https://github.com/termstandard/colors/.
25+
*/
26+
public static function getTermColorSupport(): AnsiColorMode
27+
{
28+
// Try with $COLORTERM first
29+
if (\is_string($colorterm = getenv('COLORTERM'))) {
30+
$colorterm = strtolower($colorterm);
31+
32+
if (str_contains($colorterm, 'truecolor')) {
33+
return AnsiColorMode::Ansi24;
34+
}
35+
36+
if (str_contains($colorterm, '256color')) {
37+
return AnsiColorMode::Ansi8;
38+
}
39+
}
40+
41+
// Try with $TERM
42+
if (\is_string($term = getenv('TERM'))) {
43+
$term = strtolower($term);
44+
45+
if (str_contains($term, 'truecolor')) {
46+
return AnsiColorMode::Ansi24;
47+
}
48+
49+
if (str_contains($term, '256color')) {
50+
return AnsiColorMode::Ansi8;
51+
}
52+
}
53+
54+
return AnsiColorMode::Ansi4;
55+
}
56+
2057
/**
2158
* Gets the terminal width.
2259
*/

src/Symfony/Component/Console/Tests/ColorTest.php

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
class ColorTest extends TestCase
1818
{
19-
public function testAnsiColors()
19+
public function testAnsi4Colors()
2020
{
2121
$color = new Color();
2222
$this->assertSame(' ', $color->apply(' '));
@@ -33,21 +33,27 @@ public function testAnsiColors()
3333

3434
public function testTrueColors()
3535
{
36-
if ('truecolor' !== getenv('COLORTERM')) {
37-
$this->markTestSkipped('True color not supported.');
38-
}
36+
$colorterm = getenv('COLORTERM');
37+
putenv('COLORTERM=truecolor');
3938

40-
$color = new Color('#fff', '#000');
41-
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
39+
try {
40+
$color = new Color('#fff', '#000');
41+
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
4242

43-
$color = new Color('#ffffff', '#000000');
44-
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
43+
$color = new Color('#ffffff', '#000000');
44+
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
45+
} finally {
46+
(false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM');
47+
}
4548
}
4649

47-
public function testDegradedTrueColors()
50+
public function testDegradedTrueColorsToAnsi4()
4851
{
4952
$colorterm = getenv('COLORTERM');
53+
$term = getenv('TERM');
54+
5055
putenv('COLORTERM=');
56+
putenv('TERM=');
5157

5258
try {
5359
$color = new Color('#f00', '#ff0');
@@ -56,7 +62,28 @@ public function testDegradedTrueColors()
5662
$color = new Color('#c0392b', '#f1c40f');
5763
$this->assertSame("\033[31;43m \033[39;49m", $color->apply(' '));
5864
} finally {
59-
putenv('COLORTERM='.$colorterm);
65+
(false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM');
66+
(false !== $term) ? putenv('TERM='.$term) : putenv('TERM');
67+
}
68+
}
69+
70+
public function testDegradedTrueColorsToAnsi8()
71+
{
72+
$colorterm = getenv('COLORTERM');
73+
$term = getenv('TERM');
74+
75+
putenv('COLORTERM=');
76+
putenv('TERM=symfonyTest-256color');
77+
78+
try {
79+
$color = new Color('#f57255', '#8993c0');
80+
$this->assertSame("\033[38;5;210;48;5;146m \033[39;49m", $color->apply(' '));
81+
82+
$color = new Color('#000000', '#ffffff');
83+
$this->assertSame("\033[38;5;16;48;5;231m \033[39;49m", $color->apply(' '));
84+
} finally {
85+
(false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM');
86+
(false !== $term) ? putenv('TERM='.$term) : putenv('TERM');
6087
}
6188
}
6289
}

0 commit comments

Comments
 (0)