[Console] Add support for true colors
This commit is contained in:
parent
bb70cc8c47
commit
d066514cf1
165
src/Symfony/Component/Console/Color.php
Normal file
165
src/Symfony/Component/Console/Color.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
43
src/Symfony/Component/Console/Tests/ColorTest.php
Normal file
43
src/Symfony/Component/Console/Tests/ColorTest.php
Normal 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(' '));
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
@ -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],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user