From 3f90257fd8d8cbf779463d5dd0d7c8aa66f8abbc Mon Sep 17 00:00:00 2001 From: Julien Boudry Date: Thu, 21 Jul 2022 20:18:27 +0200 Subject: [PATCH] [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. --- src/Symfony/Component/Console/CHANGELOG.md | 6 + src/Symfony/Component/Console/Color.php | 49 +------ .../Console/Output/AnsiColorMode.php | 124 ++++++++++++++++++ src/Symfony/Component/Console/Terminal.php | 37 ++++++ .../Component/Console/Tests/ColorTest.php | 47 +++++-- .../Tests/Output/AnsiColorModeTest.php | 86 ++++++++++++ .../Component/Console/Tests/TerminalTest.php | 32 +++++ 7 files changed, 323 insertions(+), 58 deletions(-) create mode 100644 src/Symfony/Component/Console/Output/AnsiColorMode.php create mode 100644 src/Symfony/Component/Console/Tests/Output/AnsiColorModeTest.php diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 4444b26ef7eb6..85f203813941b 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +6.2 +--- + +* Improve truecolor terminal detection in some cases +* Add support for 256 color terminals (conversion from Ansi24 to Ansi8 if terminal is capable of it) + 6.1 --- diff --git a/src/Symfony/Component/Console/Color.php b/src/Symfony/Component/Console/Color.php index 7fcc507079485..153d001334b9d 100644 --- a/src/Symfony/Component/Console/Color.php +++ b/src/Symfony/Component/Console/Color.php @@ -117,17 +117,7 @@ private function parseColor(string $color, bool $background = false): string } if ('#' === $color[0]) { - $color = substr($color, 1); - - if (3 === \strlen($color)) { - $color = $color[0].$color[0].$color[1].$color[1].$color[2].$color[2]; - } - - if (6 !== \strlen($color)) { - throw new InvalidArgumentException(sprintf('Invalid "%s" color.', $color)); - } - - return ($background ? '4' : '3').$this->convertHexColorToAnsi(hexdec($color)); + return ($background ? '4' : '3').Terminal::getTermColorSupport()->convertFromHexToAnsiColorCode($color); } if (isset(self::COLORS[$color])) { @@ -140,41 +130,4 @@ private function parseColor(string $color, bool $background = false): string throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_merge(array_keys(self::COLORS), array_keys(self::BRIGHT_COLORS))))); } - - private function convertHexColorToAnsi(int $color): string - { - $r = ($color >> 16) & 255; - $g = ($color >> 8) & 255; - $b = $color & 255; - - // see https://github.com/termstandard/colors/ for more information about true color support - if ('truecolor' !== getenv('COLORTERM')) { - return (string) $this->degradeHexColorToAnsi($r, $g, $b); - } - - return sprintf('8;2;%d;%d;%d', $r, $g, $b); - } - - private function degradeHexColorToAnsi(int $r, int $g, int $b): int - { - if (0 === round($this->getSaturation($r, $g, $b) / 50)) { - return 0; - } - - return (round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255); - } - - private function getSaturation(int $r, int $g, int $b): int - { - $r = $r / 255; - $g = $g / 255; - $b = $b / 255; - $v = max($r, $g, $b); - - if (0 === $diff = $v - min($r, $g, $b)) { - return 0; - } - - return (int) $diff * 100 / $v; - } } diff --git a/src/Symfony/Component/Console/Output/AnsiColorMode.php b/src/Symfony/Component/Console/Output/AnsiColorMode.php new file mode 100644 index 0000000000000..4d3c1fcefeb41 --- /dev/null +++ b/src/Symfony/Component/Console/Output/AnsiColorMode.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Output; + +use Symfony\Component\Asset\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * @author Julien Boudry + */ +enum AnsiColorMode +{ + /** + * Classical 4-bit Ansi colors, including 8 classical colors and 8 bright color. Output syntax is "ESC[${foreGroundColorcode};${backGroundColorcode}m" + * Must be compatible with all terminals and it's the minimal version supported. + */ + case Ansi4; + + /** + * 8-bit Ansi colors (240 differents colors + 16 duplicate color codes, ensuring backward compatibility). + * Output syntax is: "ESC[38;5;${foreGroundColorcode};48;5;${backGroundColorcode}m" + * Should be compatible with most terminals. + */ + case Ansi8; + + /** + * 24-bit Ansi colors (RGB). + * Output syntax is: "ESC[38;2;${foreGroundColorcodeRed};${foreGroundColorcodeGreen};${foreGroundColorcodeBlue};48;2;${backGroundColorcodeRed};${backGroundColorcodeGreen};${backGroundColorcodeBlue}m" + * May be compatible with many modern terminals. + */ + case Ansi24; + + /** + * Converts an RGB hexadecimal color to the corresponding Ansi code. + */ + public function convertFromHexToAnsiColorCode(string $hexColor): string + { + $hexColor = str_replace('#', '', $hexColor); + + if (3 === \strlen($hexColor)) { + $hexColor = $hexColor[0].$hexColor[0].$hexColor[1].$hexColor[1].$hexColor[2].$hexColor[2]; + } + + if (6 !== \strlen($hexColor)) { + throw new InvalidArgumentException(sprintf('Invalid "#%s" color.', $hexColor)); + } + + $color = hexdec($hexColor); + + $r = ($color >> 16) & 255; + $g = ($color >> 8) & 255; + $b = $color & 255; + + return match ($this) { + self::Ansi4 => (string) $this->convertFromRGB($r, $g, $b), + self::Ansi8 => '8;5;'.((string) $this->convertFromRGB($r, $g, $b)), + self::Ansi24 => sprintf('8;2;%d;%d;%d', $r, $g, $b) + }; + } + + private function convertFromRGB(int $r, int $g, int $b): int + { + return match ($this) { + self::Ansi4 => $this->degradeHexColorToAnsi4($r, $g, $b), + self::Ansi8 => $this->degradeHexColorToAnsi8($r, $g, $b), + default => throw new InvalidArgumentException("RGB cannot be converted to {$this->name}.") + }; + } + + private function degradeHexColorToAnsi4(int $r, int $g, int $b): int + { + if (0 === round($this->getSaturation($r, $g, $b) / 50)) { + return 0; + } + + return (int) ((round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255)); + } + + private function getSaturation(int $r, int $g, int $b): int + { + $r = $r / 255; + $g = $g / 255; + $b = $b / 255; + $v = max($r, $g, $b); + + if (0 === $diff = $v - min($r, $g, $b)) { + return 0; + } + + return (int) ((int) $diff * 100 / $v); + } + + /** + * Inspired from https://github.com/ajalt/colormath/blob/e464e0da1b014976736cf97250063248fc77b8e7/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/model/Ansi256.kt code (MIT license). + */ + private function degradeHexColorToAnsi8(int $r, int $g, int $b): int + { + if ($r === $g && $g === $b) { + if ($r < 8) { + return 16; + } + + if ($r > 248) { + return 231; + } + + return (int) round(($r - 8) / 247 * 24) + 232; + } else { + return 16 + + (36 * (int) round($r / 255 * 5)) + + (6 * (int) round($g / 255 * 5)) + + (int) round($b / 255 * 5); + } + } +} diff --git a/src/Symfony/Component/Console/Terminal.php b/src/Symfony/Component/Console/Terminal.php index 80020c95daabf..721e504ac4b7a 100644 --- a/src/Symfony/Component/Console/Terminal.php +++ b/src/Symfony/Component/Console/Terminal.php @@ -11,12 +11,49 @@ namespace Symfony\Component\Console; +use Symfony\Component\Console\Output\AnsiColorMode; + class Terminal { private static ?int $width = null; private static ?int $height = null; private static ?bool $stty = null; + /** + * About Ansi color types: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + * For more information about true color support with terminals https://github.com/termstandard/colors/. + */ + public static function getTermColorSupport(): AnsiColorMode + { + // Try with $COLORTERM first + if (\is_string($colorterm = getenv('COLORTERM'))) { + $colorterm = strtolower($colorterm); + + if (str_contains($colorterm, 'truecolor')) { + return AnsiColorMode::Ansi24; + } + + if (str_contains($colorterm, '256color')) { + return AnsiColorMode::Ansi8; + } + } + + // Try with $TERM + if (\is_string($term = getenv('TERM'))) { + $term = strtolower($term); + + if (str_contains($term, 'truecolor')) { + return AnsiColorMode::Ansi24; + } + + if (str_contains($term, '256color')) { + return AnsiColorMode::Ansi8; + } + } + + return AnsiColorMode::Ansi4; + } + /** * Gets the terminal width. */ diff --git a/src/Symfony/Component/Console/Tests/ColorTest.php b/src/Symfony/Component/Console/Tests/ColorTest.php index c9615aa8d6133..1c088b768f190 100644 --- a/src/Symfony/Component/Console/Tests/ColorTest.php +++ b/src/Symfony/Component/Console/Tests/ColorTest.php @@ -16,7 +16,7 @@ class ColorTest extends TestCase { - public function testAnsiColors() + public function testAnsi4Colors() { $color = new Color(); $this->assertSame(' ', $color->apply(' ')); @@ -33,21 +33,27 @@ public function testAnsiColors() public function testTrueColors() { - if ('truecolor' !== getenv('COLORTERM')) { - $this->markTestSkipped('True color not supported.'); - } + $colorterm = getenv('COLORTERM'); + putenv('COLORTERM=truecolor'); - $color = new Color('#fff', '#000'); - $this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' ')); + try { + $color = new Color('#fff', '#000'); + $this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' ')); - $color = new Color('#ffffff', '#000000'); - $this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' ')); + $color = new Color('#ffffff', '#000000'); + $this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' ')); + } finally { + (false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM'); + } } - public function testDegradedTrueColors() + public function testDegradedTrueColorsToAnsi4() { $colorterm = getenv('COLORTERM'); + $term = getenv('TERM'); + putenv('COLORTERM='); + putenv('TERM='); try { $color = new Color('#f00', '#ff0'); @@ -56,7 +62,28 @@ public function testDegradedTrueColors() $color = new Color('#c0392b', '#f1c40f'); $this->assertSame("\033[31;43m \033[39;49m", $color->apply(' ')); } finally { - putenv('COLORTERM='.$colorterm); + (false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM'); + (false !== $term) ? putenv('TERM='.$term) : putenv('TERM'); + } + } + + public function testDegradedTrueColorsToAnsi8() + { + $colorterm = getenv('COLORTERM'); + $term = getenv('TERM'); + + putenv('COLORTERM='); + putenv('TERM=symfonyTest-256color'); + + try { + $color = new Color('#f57255', '#8993c0'); + $this->assertSame("\033[38;5;210;48;5;146m \033[39;49m", $color->apply(' ')); + + $color = new Color('#000000', '#ffffff'); + $this->assertSame("\033[38;5;16;48;5;231m \033[39;49m", $color->apply(' ')); + } finally { + (false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM'); + (false !== $term) ? putenv('TERM='.$term) : putenv('TERM'); } } } diff --git a/src/Symfony/Component/Console/Tests/Output/AnsiColorModeTest.php b/src/Symfony/Component/Console/Tests/Output/AnsiColorModeTest.php new file mode 100644 index 0000000000000..d408b1583b820 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/Output/AnsiColorModeTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Output; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Asset\Exception\InvalidArgumentException; +use Symfony\Component\Console\Output\AnsiColorMode; + +class AnsiColorModeTest extends TestCase +{ + /** + * @dataProvider provideColorsConversion + */ + public function testColorsConversionToAnsi4(string $corlorHex, array $expected) + { + $this->assertSame((string) $expected[AnsiColorMode::Ansi4->name], AnsiColorMode::Ansi4->convertFromHexToAnsiColorCode($corlorHex)); + } + + /** + * @dataProvider provideColorsConversion + */ + public function testColorsConversionToAnsi8(string $corlorHex, array $expected) + { + $this->assertSame('8;5;'.$expected[AnsiColorMode::Ansi8->name], AnsiColorMode::Ansi8->convertFromHexToAnsiColorCode($corlorHex)); + } + + public function provideColorsConversion(): \Generator + { + yield ['#606702', [ + AnsiColorMode::Ansi8->name => 100, + AnsiColorMode::Ansi4->name => 0, + ]]; + + yield ['#f40502', [ + AnsiColorMode::Ansi8->name => 196, + AnsiColorMode::Ansi4->name => 1, + ]]; + + yield ['#2a2a2a', [ + AnsiColorMode::Ansi8->name => 235, + AnsiColorMode::Ansi4->name => 0, + ]]; + + yield ['#57f70f', [ + AnsiColorMode::Ansi8->name => 118, + AnsiColorMode::Ansi4->name => 2, + ]]; + + yield ['#eec7fa', [ + AnsiColorMode::Ansi8->name => 225, + AnsiColorMode::Ansi4->name => 7, + ]]; + + yield ['#a8a8a8', [ + AnsiColorMode::Ansi8->name => 248, + AnsiColorMode::Ansi4->name => 7, + ]]; + } + + public function testColorsConversionWithoutSharp() + { + $this->assertSame('8;5;102', AnsiColorMode::Ansi8->convertFromHexToAnsiColorCode('547869')); + } + + public function testColorsConversionWithout3Characters() + { + $this->assertSame('8;5;241', AnsiColorMode::Ansi8->convertFromHexToAnsiColorCode('#666')); + } + + public function testInvalidHexCode() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "#6666" color.'); + + AnsiColorMode::Ansi8->convertFromHexToAnsiColorCode('#6666'); + } +} diff --git a/src/Symfony/Component/Console/Tests/TerminalTest.php b/src/Symfony/Component/Console/Tests/TerminalTest.php index 416319e39e74b..510ab021c0531 100644 --- a/src/Symfony/Component/Console/Tests/TerminalTest.php +++ b/src/Symfony/Component/Console/Tests/TerminalTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Console\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\AnsiColorMode; use Symfony\Component\Console\Terminal; class TerminalTest extends TestCase @@ -93,4 +94,35 @@ public function testSttyOnWindows() $terminal = new Terminal(); $this->assertSame((int) $matches[1], $terminal->getWidth()); } + + /** + * @dataProvider provideTerminalColorEnv + */ + public function testGetTermColorSupport(?string $testColorTerm, ?string $testTerm, AnsiColorMode $expected) + { + $oriColorTerm = getenv('COLORTERM'); + $oriTerm = getenv('TERM'); + + try { + putenv($testColorTerm ? "COLORTERM={$testColorTerm}" : 'COLORTERM'); + putenv($testTerm ? "TERM={$testTerm}" : 'TERM'); + + $this->assertSame($expected, Terminal::getTermColorSupport()); + } finally { + (false !== $oriColorTerm) ? putenv('COLORTERM='.$oriColorTerm) : putenv('COLORTERM'); + (false !== $oriTerm) ? putenv('TERM='.$oriTerm) : putenv('TERM'); + } + } + + public function provideTerminalColorEnv(): \Generator + { + yield ['truecolor', null, AnsiColorMode::Ansi24]; + yield ['TRUECOLOR', null, AnsiColorMode::Ansi24]; + yield ['somethingLike256Color', null, AnsiColorMode::Ansi8]; + yield [null, 'xterm-truecolor', AnsiColorMode::Ansi24]; + yield [null, 'xterm-TRUECOLOR', AnsiColorMode::Ansi24]; + yield [null, 'xterm-256color', AnsiColorMode::Ansi8]; + yield [null, 'xterm-256COLOR', AnsiColorMode::Ansi8]; + yield [null, null, AnsiColorMode::Ansi4]; + } }