[Console] Add support for true colors

This commit is contained in:
Fabien Potencier 2020-05-13 11:16:20 +02:00
parent bb70cc8c47
commit d066514cf1
5 changed files with 227 additions and 111 deletions

View File

@ -0,0 +1,165 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console;
use Symfony\Component\Console\Exception\InvalidArgumentException;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class Color
{
private const COLORS = [
'black' => 0,
'red' => 1,
'green' => 2,
'yellow' => 3,
'blue' => 4,
'magenta' => 5,
'cyan' => 6,
'white' => 7,
'default' => 9,
];
private const AVAILABLE_OPTIONS = [
'bold' => ['set' => 1, 'unset' => 22],
'underscore' => ['set' => 4, 'unset' => 24],
'blink' => ['set' => 5, 'unset' => 25],
'reverse' => ['set' => 7, 'unset' => 27],
'conceal' => ['set' => 8, 'unset' => 28],
];
private $foreground;
private $background;
private $options = [];
public function __construct(string $foreground = '', string $background = '', array $options = [])
{
$this->foreground = $this->parseColor($foreground);
$this->background = $this->parseColor($background);
foreach ($options as $option) {
if (!isset(self::AVAILABLE_OPTIONS[$option])) {
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::AVAILABLE_OPTIONS))));
}
$this->options[$option] = self::AVAILABLE_OPTIONS[$option];
}
}
public function apply(string $text): string
{
return $this->set().$text.$this->unset();
}
public function set(): string
{
$setCodes = [];
if ('' !== $this->foreground) {
$setCodes[] = '3'.$this->foreground;
}
if ('' !== $this->background) {
$setCodes[] = '4'.$this->background;
}
foreach ($this->options as $option) {
$setCodes[] = $option['set'];
}
if (0 === \count($setCodes)) {
return '';
}
return sprintf("\033[%sm", implode(';', $setCodes));
}
public function unset(): string
{
$unsetCodes = [];
if ('' !== $this->foreground) {
$unsetCodes[] = 39;
}
if ('' !== $this->background) {
$unsetCodes[] = 49;
}
foreach ($this->options as $option) {
$unsetCodes[] = $option['unset'];
}
if (0 === \count($unsetCodes)) {
return '';
}
return sprintf("\033[%sm", implode(';', $unsetCodes));
}
private function parseColor(string $color): string
{
if ('' === $color) {
return '';
}
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 $this->convertHexColorToAnsi(hexdec($color));
}
if (!isset(self::COLORS[$color])) {
throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_keys(self::COLORS))));
}
return (string) self::COLORS[$color];
}
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;
}
}

View File

@ -11,7 +11,7 @@
namespace Symfony\Component\Console\Formatter; namespace Symfony\Component\Console\Formatter;
use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Color;
/** /**
* Formatter style class for defining styles. * Formatter style class for defining styles.
@ -20,40 +20,11 @@ use Symfony\Component\Console\Exception\InvalidArgumentException;
*/ */
class OutputFormatterStyle implements OutputFormatterStyleInterface class OutputFormatterStyle implements OutputFormatterStyleInterface
{ {
private static $availableForegroundColors = [ private $color;
'black' => ['set' => 30, 'unset' => 39],
'red' => ['set' => 31, 'unset' => 39],
'green' => ['set' => 32, 'unset' => 39],
'yellow' => ['set' => 33, 'unset' => 39],
'blue' => ['set' => 34, 'unset' => 39],
'magenta' => ['set' => 35, 'unset' => 39],
'cyan' => ['set' => 36, 'unset' => 39],
'white' => ['set' => 37, 'unset' => 39],
'default' => ['set' => 39, 'unset' => 39],
];
private static $availableBackgroundColors = [
'black' => ['set' => 40, 'unset' => 49],
'red' => ['set' => 41, 'unset' => 49],
'green' => ['set' => 42, 'unset' => 49],
'yellow' => ['set' => 43, 'unset' => 49],
'blue' => ['set' => 44, 'unset' => 49],
'magenta' => ['set' => 45, 'unset' => 49],
'cyan' => ['set' => 46, 'unset' => 49],
'white' => ['set' => 47, 'unset' => 49],
'default' => ['set' => 49, 'unset' => 49],
];
private static $availableOptions = [
'bold' => ['set' => 1, 'unset' => 22],
'underscore' => ['set' => 4, 'unset' => 24],
'blink' => ['set' => 5, 'unset' => 25],
'reverse' => ['set' => 7, 'unset' => 27],
'conceal' => ['set' => 8, 'unset' => 28],
];
private $foreground; private $foreground;
private $background; private $background;
private $options;
private $href; private $href;
private $options = [];
private $handlesHrefGracefully; private $handlesHrefGracefully;
/** /**
@ -64,15 +35,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function __construct(string $foreground = null, string $background = null, array $options = []) public function __construct(string $foreground = null, string $background = null, array $options = [])
{ {
if (null !== $foreground) { $this->color = new Color($this->foreground = $foreground ?: '', $this->background = $background ?: '', $this->options = $options);
$this->setForeground($foreground);
}
if (null !== $background) {
$this->setBackground($background);
}
if (\count($options)) {
$this->setOptions($options);
}
} }
/** /**
@ -80,17 +43,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function setForeground(string $color = null) public function setForeground(string $color = null)
{ {
if (null === $color) { $this->color = new Color($this->foreground = $color ?: '', $this->background, $this->options);
$this->foreground = null;
return;
}
if (!isset(static::$availableForegroundColors[$color])) {
throw new InvalidArgumentException(sprintf('Invalid foreground color specified: "%s". Expected one of (%s).', $color, implode(', ', array_keys(static::$availableForegroundColors))));
}
$this->foreground = static::$availableForegroundColors[$color];
} }
/** /**
@ -98,17 +51,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function setBackground(string $color = null) public function setBackground(string $color = null)
{ {
if (null === $color) { $this->color = new Color($this->foreground, $this->background = $color ?: '', $this->options);
$this->background = null;
return;
}
if (!isset(static::$availableBackgroundColors[$color])) {
throw new InvalidArgumentException(sprintf('Invalid background color specified: "%s". Expected one of (%s).', $color, implode(', ', array_keys(static::$availableBackgroundColors))));
}
$this->background = static::$availableBackgroundColors[$color];
} }
public function setHref(string $url): void public function setHref(string $url): void
@ -121,13 +64,8 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function setOption(string $option) public function setOption(string $option)
{ {
if (!isset(static::$availableOptions[$option])) { $this->options[] = $option;
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions)))); $this->color = new Color($this->foreground, $this->background, $this->options);
}
if (!\in_array(static::$availableOptions[$option], $this->options)) {
$this->options[] = static::$availableOptions[$option];
}
} }
/** /**
@ -135,14 +73,12 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function unsetOption(string $option) public function unsetOption(string $option)
{ {
if (!isset(static::$availableOptions[$option])) { $pos = array_search($option, $this->options);
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions))));
}
$pos = array_search(static::$availableOptions[$option], $this->options);
if (false !== $pos) { if (false !== $pos) {
unset($this->options[$pos]); unset($this->options[$pos]);
} }
$this->color = new Color($this->foreground, $this->background, $this->options);
} }
/** /**
@ -150,11 +86,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function setOptions(array $options) public function setOptions(array $options)
{ {
$this->options = []; $this->color = new Color($this->foreground, $this->background, $this->options = $options);
foreach ($options as $option) {
$this->setOption($option);
}
} }
/** /**
@ -162,35 +94,14 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
*/ */
public function apply(string $text) public function apply(string $text)
{ {
$setCodes = [];
$unsetCodes = [];
if (null === $this->handlesHrefGracefully) { if (null === $this->handlesHrefGracefully) {
$this->handlesHrefGracefully = 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR') && !getenv('KONSOLE_VERSION'); $this->handlesHrefGracefully = 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR') && !getenv('KONSOLE_VERSION');
} }
if (null !== $this->foreground) {
$setCodes[] = $this->foreground['set'];
$unsetCodes[] = $this->foreground['unset'];
}
if (null !== $this->background) {
$setCodes[] = $this->background['set'];
$unsetCodes[] = $this->background['unset'];
}
foreach ($this->options as $option) {
$setCodes[] = $option['set'];
$unsetCodes[] = $option['unset'];
}
if (null !== $this->href && $this->handlesHrefGracefully) { if (null !== $this->href && $this->handlesHrefGracefully) {
$text = "\033]8;;$this->href\033\\$text\033]8;;\033\\"; $text = "\033]8;;$this->href\033\\$text\033]8;;\033\\";
} }
if (0 === \count($setCodes)) { return $this->color->apply($text);
return $text;
}
return sprintf("\033[%sm%s\033[%sm", implode(';', $setCodes), $text, implode(';', $unsetCodes));
} }
} }

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Color;
class ColorTest extends TestCase
{
public function testAnsiColors()
{
$color = new Color();
$this->assertSame(' ', $color->apply(' '));
$color = new Color('red', 'yellow');
$this->assertSame("\033[31;43m \033[39;49m", $color->apply(' '));
$color = new Color('red', 'yellow', ['underscore']);
$this->assertSame("\033[31;43;4m \033[39;49;24m", $color->apply(' '));
}
public function testTrueColors()
{
if ('truecolor' !== getenv('COLORTERM')) {
$this->markTestSkipped('True color not supported.');
}
$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(' '));
}
}

View File

@ -88,14 +88,6 @@ class OutputFormatterStyleTest extends TestCase
$this->assertInstanceOf('\InvalidArgumentException', $e, '->setOption() throws an \InvalidArgumentException when the option does not exist in the available options'); $this->assertInstanceOf('\InvalidArgumentException', $e, '->setOption() throws an \InvalidArgumentException when the option does not exist in the available options');
$this->assertStringContainsString('Invalid option specified: "foo"', $e->getMessage(), '->setOption() throws an \InvalidArgumentException when the option does not exist in the available options'); $this->assertStringContainsString('Invalid option specified: "foo"', $e->getMessage(), '->setOption() throws an \InvalidArgumentException when the option does not exist in the available options');
} }
try {
$style->unsetOption('foo');
$this->fail('->unsetOption() throws an \InvalidArgumentException when the option does not exist in the available options');
} catch (\Exception $e) {
$this->assertInstanceOf('\InvalidArgumentException', $e, '->unsetOption() throws an \InvalidArgumentException when the option does not exist in the available options');
$this->assertStringContainsString('Invalid option specified: "foo"', $e->getMessage(), '->unsetOption() throws an \InvalidArgumentException when the option does not exist in the available options');
}
} }
public function testHref() public function testHref()

View File

@ -159,8 +159,12 @@ class OutputFormatterTest extends TestCase
/** /**
* @dataProvider provideInlineStyleOptionsCases * @dataProvider provideInlineStyleOptionsCases
*/ */
public function testInlineStyleOptions(string $tag, string $expected = null, string $input = null) public function testInlineStyleOptions(string $tag, string $expected = null, string $input = null, bool $truecolor = false)
{ {
if ($truecolor && 'truecolor' !== getenv('COLORTERM')) {
$this->markTestSkipped('The terminal does not support true colors.');
}
$styleString = substr($tag, 1, -1); $styleString = substr($tag, 1, -1);
$formatter = new OutputFormatter(true); $formatter = new OutputFormatter(true);
$method = new \ReflectionMethod($formatter, 'createStyleFromString'); $method = new \ReflectionMethod($formatter, 'createStyleFromString');
@ -189,6 +193,7 @@ class OutputFormatterTest extends TestCase
['<fg=green;options=reverse;>', "\033[32;7m<a>\033[39;27m", '<a>'], ['<fg=green;options=reverse;>', "\033[32;7m<a>\033[39;27m", '<a>'],
['<fg=green;options=bold,underscore>', "\033[32;1;4mz\033[39;22;24m", 'z'], ['<fg=green;options=bold,underscore>', "\033[32;1;4mz\033[39;22;24m", 'z'],
['<fg=green;options=bold,underscore,reverse;>', "\033[32;1;4;7md\033[39;22;24;27m", 'd'], ['<fg=green;options=bold,underscore,reverse;>', "\033[32;1;4;7md\033[39;22;24;27m", 'd'],
['<fg=#00ff00;bg=#00f>', "\033[38;2;0;255;0;48;2;0;0;255m[test]\033[39;49m", '[test]', true],
]; ];
} }