Skip to content

[Console] Add support for true colors #36802

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/Symfony/Component/Console/Color.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
115 changes: 13 additions & 102 deletions src/Symfony/Component/Console/Formatter/OutputFormatterStyle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,40 +20,11 @@
*/
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;

/**
Expand All @@ -64,51 +35,23 @@ 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);
}

/**
* {@inheritdoc}
*/
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);
}

/**
* {@inheritdoc}
*/
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
Expand All @@ -121,76 +64,44 @@ public function setHref(string $url): void
*/
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);
}

/**
* {@inheritdoc}
*/
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);
}

/**
* {@inheritdoc}
*/
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);
}

/**
* {@inheritdoc}
*/
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);
}
}
Loading