Skip to content

Commit fa9f895

Browse files
committed
[Console] Add support for true colors
1 parent 1de42a5 commit fa9f895

File tree

5 files changed

+220
-111
lines changed

5 files changed

+220
-111
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console;
13+
14+
use Symfony\Component\Console\Exception\InvalidArgumentException;
15+
16+
/**
17+
* @author Fabien Potencier <fabien@symfony.com>
18+
*/
19+
final class Color
20+
{
21+
private static $colors = [
22+
'black' => 0,
23+
'red' => 1,
24+
'green' => 2,
25+
'yellow' => 3,
26+
'blue' => 4,
27+
'magenta' => 5,
28+
'cyan' => 6,
29+
'white' => 7,
30+
'default' => 9,
31+
];
32+
33+
private static $availableOptions = [
34+
'bold' => ['set' => 1, 'unset' => 22],
35+
'underscore' => ['set' => 4, 'unset' => 24],
36+
'blink' => ['set' => 5, 'unset' => 25],
37+
'reverse' => ['set' => 7, 'unset' => 27],
38+
'conceal' => ['set' => 8, 'unset' => 28],
39+
];
40+
41+
private $foreground;
42+
private $background;
43+
private $options = [];
44+
45+
public function __construct(string $foreground = '', string $background = '', array $options = [])
46+
{
47+
$this->foreground = $this->parseColor($foreground);
48+
$this->background = $this->parseColor($background);
49+
50+
foreach ($options as $option) {
51+
if (!isset(self::$availableOptions[$option])) {
52+
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::$availableOptions))));
53+
}
54+
55+
$this->options[$option] = self::$availableOptions[$option];
56+
}
57+
}
58+
59+
public function apply(string $text): string
60+
{
61+
return $this->set().$text.$this->unset();
62+
}
63+
64+
public function set(): string
65+
{
66+
$setCodes = [];
67+
if ('' !== $this->foreground) {
68+
$setCodes[] = '3'.$this->foreground;
69+
}
70+
if ('' !== $this->background) {
71+
$setCodes[] = '4'.$this->background;
72+
}
73+
foreach ($this->options as $option) {
74+
$setCodes[] = $option['set'];
75+
}
76+
if (0 === \count($setCodes)) {
77+
return '';
78+
}
79+
80+
return sprintf("\033[%sm", implode(';', $setCodes));
81+
}
82+
83+
public function unset(): string
84+
{
85+
$unsetCodes = [];
86+
if ('' !== $this->foreground) {
87+
$unsetCodes[] = 39;
88+
}
89+
if ('' !== $this->background) {
90+
$unsetCodes[] = 49;
91+
}
92+
foreach ($this->options as $option) {
93+
$unsetCodes[] = $option['unset'];
94+
}
95+
if (0 === \count($unsetCodes)) {
96+
return '';
97+
}
98+
99+
return sprintf("\033[%sm", implode(';', $unsetCodes));
100+
}
101+
102+
private function parseColor(string $color): string
103+
{
104+
if ('' === $color) {
105+
return '';
106+
}
107+
108+
if ('#' === $color[0]) {
109+
$color = substr($color, 1);
110+
111+
if (3 === \strlen($color)) {
112+
$color = $color[0].$color[0].$color[1].$color[1].$color[2].$color[2];
113+
}
114+
115+
if (6 !== \strlen($color)) {
116+
throw new InvalidArgumentException(sprintf('Invalid "%s" color.', $color));
117+
}
118+
119+
return $this->convertHexColorToAnsi(hexdec($color));
120+
}
121+
122+
if (!isset(self::$colors[$color])) {
123+
throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_keys(self::$colors))));
124+
}
125+
126+
return (string) self::$colors[$color];
127+
}
128+
129+
private function convertHexColorToAnsi(int $color): string
130+
{
131+
$r = ($color >> 16) & 255;
132+
$g = ($color >> 8) & 255;
133+
$b = $color & 255;
134+
135+
// see https://github.com/termstandard/colors/ for more information about true color support
136+
if ('truecolor' !== getenv('COLORTERM')) {
137+
return (string) $this->degradeHexColorToAnsi($r, $g, $b);
138+
}
139+
140+
return sprintf('8;2;%d;%d;%d', $r, $g, $b);
141+
}
142+
143+
private function degradeHexColorToAnsi(int $r, int $g, int $b): int
144+
{
145+
if (0 === round($this->getSaturation($r, $g, $b) / 50)) {
146+
return 0;
147+
}
148+
149+
return (round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255);
150+
}
151+
152+
private function getSaturation(int $r, int $g, int $b): int
153+
{
154+
$r = $r / 255;
155+
$g = $g / 255;
156+
$b = $b / 255;
157+
$v = max($r, $g, $b);
158+
159+
if (0 === $diff = $v - min($r, $g, $b)) {
160+
return 0;
161+
}
162+
163+
return (int) $diff * 100 / $v;
164+
}
165+
}

src/Symfony/Component/Console/Formatter/OutputFormatterStyle.php

Lines changed: 13 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace Symfony\Component\Console\Formatter;
1313

14-
use Symfony\Component\Console\Exception\InvalidArgumentException;
14+
use Symfony\Component\Console\Color;
1515

1616
/**
1717
* Formatter style class for defining styles.
@@ -20,40 +20,11 @@
2020
*/
2121
class OutputFormatterStyle implements OutputFormatterStyleInterface
2222
{
23-
private static $availableForegroundColors = [
24-
'black' => ['set' => 30, 'unset' => 39],
25-
'red' => ['set' => 31, 'unset' => 39],
26-
'green' => ['set' => 32, 'unset' => 39],
27-
'yellow' => ['set' => 33, 'unset' => 39],
28-
'blue' => ['set' => 34, 'unset' => 39],
29-
'magenta' => ['set' => 35, 'unset' => 39],
30-
'cyan' => ['set' => 36, 'unset' => 39],
31-
'white' => ['set' => 37, 'unset' => 39],
32-
'default' => ['set' => 39, 'unset' => 39],
33-
];
34-
private static $availableBackgroundColors = [
35-
'black' => ['set' => 40, 'unset' => 49],
36-
'red' => ['set' => 41, 'unset' => 49],
37-
'green' => ['set' => 42, 'unset' => 49],
38-
'yellow' => ['set' => 43, 'unset' => 49],
39-
'blue' => ['set' => 44, 'unset' => 49],
40-
'magenta' => ['set' => 45, 'unset' => 49],
41-
'cyan' => ['set' => 46, 'unset' => 49],
42-
'white' => ['set' => 47, 'unset' => 49],
43-
'default' => ['set' => 49, 'unset' => 49],
44-
];
45-
private static $availableOptions = [
46-
'bold' => ['set' => 1, 'unset' => 22],
47-
'underscore' => ['set' => 4, 'unset' => 24],
48-
'blink' => ['set' => 5, 'unset' => 25],
49-
'reverse' => ['set' => 7, 'unset' => 27],
50-
'conceal' => ['set' => 8, 'unset' => 28],
51-
];
52-
23+
private $color;
5324
private $foreground;
5425
private $background;
26+
private $options;
5527
private $href;
56-
private $options = [];
5728
private $handlesHrefGracefully;
5829

5930
/**
@@ -64,51 +35,23 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
6435
*/
6536
public function __construct(string $foreground = null, string $background = null, array $options = [])
6637
{
67-
if (null !== $foreground) {
68-
$this->setForeground($foreground);
69-
}
70-
if (null !== $background) {
71-
$this->setBackground($background);
72-
}
73-
if (\count($options)) {
74-
$this->setOptions($options);
75-
}
38+
$this->color = new Color($this->foreground = $foreground ?: '', $this->background = $background ?: '', $this->options = $options);
7639
}
7740

7841
/**
7942
* {@inheritdoc}
8043
*/
8144
public function setForeground(string $color = null)
8245
{
83-
if (null === $color) {
84-
$this->foreground = null;
85-
86-
return;
87-
}
88-
89-
if (!isset(static::$availableForegroundColors[$color])) {
90-
throw new InvalidArgumentException(sprintf('Invalid foreground color specified: "%s". Expected one of (%s).', $color, implode(', ', array_keys(static::$availableForegroundColors))));
91-
}
92-
93-
$this->foreground = static::$availableForegroundColors[$color];
46+
$this->color = new Color($this->foreground = $color ?: '', $this->background, $this->options);
9447
}
9548

9649
/**
9750
* {@inheritdoc}
9851
*/
9952
public function setBackground(string $color = null)
10053
{
101-
if (null === $color) {
102-
$this->background = null;
103-
104-
return;
105-
}
106-
107-
if (!isset(static::$availableBackgroundColors[$color])) {
108-
throw new InvalidArgumentException(sprintf('Invalid background color specified: "%s". Expected one of (%s).', $color, implode(', ', array_keys(static::$availableBackgroundColors))));
109-
}
110-
111-
$this->background = static::$availableBackgroundColors[$color];
54+
$this->color = new Color($this->foreground, $this->background = $color ?: '', $this->options);
11255
}
11356

11457
public function setHref(string $url): void
@@ -121,76 +64,44 @@ public function setHref(string $url): void
12164
*/
12265
public function setOption(string $option)
12366
{
124-
if (!isset(static::$availableOptions[$option])) {
125-
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions))));
126-
}
127-
128-
if (!\in_array(static::$availableOptions[$option], $this->options)) {
129-
$this->options[] = static::$availableOptions[$option];
130-
}
67+
$this->options[] = $option;
68+
$this->color = new Color($this->foreground, $this->background, $this->options);
13169
}
13270

13371
/**
13472
* {@inheritdoc}
13573
*/
13674
public function unsetOption(string $option)
13775
{
138-
if (!isset(static::$availableOptions[$option])) {
139-
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions))));
140-
}
141-
142-
$pos = array_search(static::$availableOptions[$option], $this->options);
76+
$pos = array_search($option, $this->options);
14377
if (false !== $pos) {
14478
unset($this->options[$pos]);
14579
}
80+
81+
$this->color = new Color($this->foreground, $this->background, $this->options);
14682
}
14783

14884
/**
14985
* {@inheritdoc}
15086
*/
15187
public function setOptions(array $options)
15288
{
153-
$this->options = [];
154-
155-
foreach ($options as $option) {
156-
$this->setOption($option);
157-
}
89+
$this->color = new Color($this->foreground, $this->background, $this->options = $options);
15890
}
15991

16092
/**
16193
* {@inheritdoc}
16294
*/
16395
public function apply(string $text)
16496
{
165-
$setCodes = [];
166-
$unsetCodes = [];
167-
16897
if (null === $this->handlesHrefGracefully) {
16998
$this->handlesHrefGracefully = 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR') && !getenv('KONSOLE_VERSION');
17099
}
171100

172-
if (null !== $this->foreground) {
173-
$setCodes[] = $this->foreground['set'];
174-
$unsetCodes[] = $this->foreground['unset'];
175-
}
176-
if (null !== $this->background) {
177-
$setCodes[] = $this->background['set'];
178-
$unsetCodes[] = $this->background['unset'];
179-
}
180-
181-
foreach ($this->options as $option) {
182-
$setCodes[] = $option['set'];
183-
$unsetCodes[] = $option['unset'];
184-
}
185-
186101
if (null !== $this->href && $this->handlesHrefGracefully) {
187102
$text = "\033]8;;$this->href\033\\$text\033]8;;\033\\";
188103
}
189104

190-
if (0 === \count($setCodes)) {
191-
return $text;
192-
}
193-
194-
return sprintf("\033[%sm%s\033[%sm", implode(';', $setCodes), $text, implode(';', $unsetCodes));
105+
return $this->color->apply($text);
195106
}
196107
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Console\Color;
16+
17+
class ColorTest extends TestCase
18+
{
19+
public function testAnsiColors()
20+
{
21+
$color = new Color();
22+
$this->assertSame(' ', $color->apply(' '));
23+
24+
$color = new Color('red', 'yellow');
25+
$this->assertSame("\033[31;43m \033[39;49m", $color->apply(' '));
26+
27+
$color = new Color('red', 'yellow', ['underscore']);
28+
$this->assertSame("\033[31;43;4m \033[39;49;24m", $color->apply(' '));
29+
30+
$color = new Color('#fff', '#000');
31+
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
32+
33+
$color = new Color('#ffffff', '#000000');
34+
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
35+
}
36+
}

0 commit comments

Comments
 (0)