[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;
|
||||
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Color;
|
||||
|
||||
/**
|
||||
* Formatter style class for defining styles.
|
||||
@ -20,40 +20,11 @@ use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
*/
|
||||
class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
{
|
||||
private static $availableForegroundColors = [
|
||||
'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 $color;
|
||||
private $foreground;
|
||||
private $background;
|
||||
private $options;
|
||||
private $href;
|
||||
private $options = [];
|
||||
private $handlesHrefGracefully;
|
||||
|
||||
/**
|
||||
@ -64,15 +35,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
*/
|
||||
public function __construct(string $foreground = null, string $background = null, array $options = [])
|
||||
{
|
||||
if (null !== $foreground) {
|
||||
$this->setForeground($foreground);
|
||||
}
|
||||
if (null !== $background) {
|
||||
$this->setBackground($background);
|
||||
}
|
||||
if (\count($options)) {
|
||||
$this->setOptions($options);
|
||||
}
|
||||
$this->color = new Color($this->foreground = $foreground ?: '', $this->background = $background ?: '', $this->options = $options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -80,17 +43,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
*/
|
||||
public function setForeground(string $color = null)
|
||||
{
|
||||
if (null === $color) {
|
||||
$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];
|
||||
$this->color = new Color($this->foreground = $color ?: '', $this->background, $this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,17 +51,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
*/
|
||||
public function setBackground(string $color = null)
|
||||
{
|
||||
if (null === $color) {
|
||||
$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];
|
||||
$this->color = new Color($this->foreground, $this->background = $color ?: '', $this->options);
|
||||
}
|
||||
|
||||
public function setHref(string $url): void
|
||||
@ -121,13 +64,8 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
*/
|
||||
public function setOption(string $option)
|
||||
{
|
||||
if (!isset(static::$availableOptions[$option])) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions))));
|
||||
}
|
||||
|
||||
if (!\in_array(static::$availableOptions[$option], $this->options)) {
|
||||
$this->options[] = static::$availableOptions[$option];
|
||||
}
|
||||
$this->options[] = $option;
|
||||
$this->color = new Color($this->foreground, $this->background, $this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -135,14 +73,12 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
*/
|
||||
public function unsetOption(string $option)
|
||||
{
|
||||
if (!isset(static::$availableOptions[$option])) {
|
||||
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);
|
||||
$pos = array_search($option, $this->options);
|
||||
if (false !== $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)
|
||||
{
|
||||
$this->options = [];
|
||||
|
||||
foreach ($options as $option) {
|
||||
$this->setOption($option);
|
||||
}
|
||||
$this->color = new Color($this->foreground, $this->background, $this->options = $options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,35 +94,14 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
*/
|
||||
public function apply(string $text)
|
||||
{
|
||||
$setCodes = [];
|
||||
$unsetCodes = [];
|
||||
|
||||
if (null === $this->handlesHrefGracefully) {
|
||||
$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) {
|
||||
$text = "\033]8;;$this->href\033\\$text\033]8;;\033\\";
|
||||
}
|
||||
|
||||
if (0 === \count($setCodes)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return sprintf("\033[%sm%s\033[%sm", implode(';', $setCodes), $text, implode(';', $unsetCodes));
|
||||
return $this->color->apply($text);
|
||||
}
|
||||
}
|
||||
|
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->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()
|
||||
|
@ -159,8 +159,12 @@ class OutputFormatterTest extends TestCase
|
||||
/**
|
||||
* @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);
|
||||
$formatter = new OutputFormatter(true);
|
||||
$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=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=#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