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]; + } }