Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Console] Add Ansi8 (256 color) support, improve true color (Ansi24) support detection #46944

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
Jul 22, 2022
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
6 changes: 6 additions & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
CHANGELOG
=========

6.2
---

* Improve truecolor terminal detection in some cases
* Add support for 256 color terminals (conversion from Ansi24 to Ansi8 if terminal is capable of it)

6.1
---

Expand Down
49 changes: 1 addition & 48 deletions src/Symfony/Component/Console/Color.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,7 @@ private function parseColor(string $color, bool $background = false): string
}

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 ($background ? '4' : '3').$this->convertHexColorToAnsi(hexdec($color));
return ($background ? '4' : '3').Terminal::getTermColorSupport()->convertFromHexToAnsiColorCode($color);
}

if (isset(self::COLORS[$color])) {
Expand All @@ -140,41 +130,4 @@ private function parseColor(string $color, bool $background = false): string

throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_merge(array_keys(self::COLORS), array_keys(self::BRIGHT_COLORS)))));
}

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;
}
}
124 changes: 124 additions & 0 deletions src/Symfony/Component/Console/Output/AnsiColorMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Output;

use Symfony\Component\Asset\Exception\InvalidArgumentException;

/**
* @author Fabien Potencier <[email protected]>
* @author Julien Boudry <[email protected]>
*/
enum AnsiColorMode
{
/**
* Classical 4-bit Ansi colors, including 8 classical colors and 8 bright color. Output syntax is "ESC[${foreGroundColorcode};${backGroundColorcode}m"
* Must be compatible with all terminals and it's the minimal version supported.
*/
case Ansi4;

/**
* 8-bit Ansi colors (240 differents colors + 16 duplicate color codes, ensuring backward compatibility).
* Output syntax is: "ESC[38;5;${foreGroundColorcode};48;5;${backGroundColorcode}m"
* Should be compatible with most terminals.
*/
case Ansi8;

/**
* 24-bit Ansi colors (RGB).
* Output syntax is: "ESC[38;2;${foreGroundColorcodeRed};${foreGroundColorcodeGreen};${foreGroundColorcodeBlue};48;2;${backGroundColorcodeRed};${backGroundColorcodeGreen};${backGroundColorcodeBlue}m"
* May be compatible with many modern terminals.
*/
case Ansi24;

/**
* Converts an RGB hexadecimal color to the corresponding Ansi code.
*/
public function convertFromHexToAnsiColorCode(string $hexColor): string
{
$hexColor = str_replace('#', '', $hexColor);

if (3 === \strlen($hexColor)) {
$hexColor = $hexColor[0].$hexColor[0].$hexColor[1].$hexColor[1].$hexColor[2].$hexColor[2];
}

if (6 !== \strlen($hexColor)) {
throw new InvalidArgumentException(sprintf('Invalid "#%s" color.', $hexColor));
}

$color = hexdec($hexColor);

$r = ($color >> 16) & 255;
$g = ($color >> 8) & 255;
$b = $color & 255;

return match ($this) {
self::Ansi4 => (string) $this->convertFromRGB($r, $g, $b),
self::Ansi8 => '8;5;'.((string) $this->convertFromRGB($r, $g, $b)),
self::Ansi24 => sprintf('8;2;%d;%d;%d', $r, $g, $b)
};
}

private function convertFromRGB(int $r, int $g, int $b): int
{
return match ($this) {
self::Ansi4 => $this->degradeHexColorToAnsi4($r, $g, $b),
self::Ansi8 => $this->degradeHexColorToAnsi8($r, $g, $b),
default => throw new InvalidArgumentException("RGB cannot be converted to {$this->name}.")
};
}

private function degradeHexColorToAnsi4(int $r, int $g, int $b): int
{
if (0 === round($this->getSaturation($r, $g, $b) / 50)) {
return 0;
}

return (int) ((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) ((int) $diff * 100 / $v);
}

/**
* Inspired from https://github.com/ajalt/colormath/blob/e464e0da1b014976736cf97250063248fc77b8e7/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/model/Ansi256.kt code (MIT license).
*/
private function degradeHexColorToAnsi8(int $r, int $g, int $b): int
{
if ($r === $g && $g === $b) {
if ($r < 8) {
return 16;
}

if ($r > 248) {
return 231;
}

return (int) round(($r - 8) / 247 * 24) + 232;
} else {
return 16 +
(36 * (int) round($r / 255 * 5)) +
(6 * (int) round($g / 255 * 5)) +
(int) round($b / 255 * 5);
}
}
}
37 changes: 37 additions & 0 deletions src/Symfony/Component/Console/Terminal.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,49 @@

namespace Symfony\Component\Console;

use Symfony\Component\Console\Output\AnsiColorMode;

class Terminal
{
private static ?int $width = null;
private static ?int $height = null;
private static ?bool $stty = null;

/**
* About Ansi color types: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
* For more information about true color support with terminals https://github.com/termstandard/colors/.
*/
public static function getTermColorSupport(): AnsiColorMode
{
// Try with $COLORTERM first
if (\is_string($colorterm = getenv('COLORTERM'))) {
$colorterm = strtolower($colorterm);

if (str_contains($colorterm, 'truecolor')) {
return AnsiColorMode::Ansi24;
}

if (str_contains($colorterm, '256color')) {
return AnsiColorMode::Ansi8;
}
}

// Try with $TERM
if (\is_string($term = getenv('TERM'))) {
$term = strtolower($term);

if (str_contains($term, 'truecolor')) {
return AnsiColorMode::Ansi24;
}

if (str_contains($term, '256color')) {
return AnsiColorMode::Ansi8;
}
}

return AnsiColorMode::Ansi4;
}

/**
* Gets the terminal width.
*/
Expand Down
47 changes: 37 additions & 10 deletions src/Symfony/Component/Console/Tests/ColorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class ColorTest extends TestCase
{
public function testAnsiColors()
public function testAnsi4Colors()
{
$color = new Color();
$this->assertSame(' ', $color->apply(' '));
Expand All @@ -33,21 +33,27 @@ public function testAnsiColors()

public function testTrueColors()
{
if ('truecolor' !== getenv('COLORTERM')) {
$this->markTestSkipped('True color not supported.');
}
$colorterm = getenv('COLORTERM');
putenv('COLORTERM=truecolor');

$color = new Color('#fff', '#000');
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
try {
$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(' '));
$color = new Color('#ffffff', '#000000');
$this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' '));
} finally {
(false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM');
}
}

public function testDegradedTrueColors()
public function testDegradedTrueColorsToAnsi4()
{
$colorterm = getenv('COLORTERM');
$term = getenv('TERM');

putenv('COLORTERM=');
putenv('TERM=');

try {
$color = new Color('#f00', '#ff0');
Expand All @@ -56,7 +62,28 @@ public function testDegradedTrueColors()
$color = new Color('#c0392b', '#f1c40f');
$this->assertSame("\033[31;43m \033[39;49m", $color->apply(' '));
} finally {
putenv('COLORTERM='.$colorterm);
(false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM');
(false !== $term) ? putenv('TERM='.$term) : putenv('TERM');
}
}

public function testDegradedTrueColorsToAnsi8()
{
$colorterm = getenv('COLORTERM');
$term = getenv('TERM');

putenv('COLORTERM=');
putenv('TERM=symfonyTest-256color');

try {
$color = new Color('#f57255', '#8993c0');
$this->assertSame("\033[38;5;210;48;5;146m \033[39;49m", $color->apply(' '));

$color = new Color('#000000', '#ffffff');
$this->assertSame("\033[38;5;16;48;5;231m \033[39;49m", $color->apply(' '));
} finally {
(false !== $colorterm) ? putenv('COLORTERM='.$colorterm) : putenv('COLORTERM');
(false !== $term) ? putenv('TERM='.$term) : putenv('TERM');
}
}
}
Loading