Skip to content
Closed
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
},
"autoload": {
"psr-4": {
"Patchlevel\\EventSourcing\\": "src/"
"Patchlevel\\EventSourcing\\": "src/",
" Symfony\\Component\\": "extern/"
}
},
"autoload-dev": {
Expand Down
319 changes: 319 additions & 0 deletions extern/Tui/Ansi/AnsiCodeTracker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
<?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\Tui\Ansi;

/**
* Tracks active ANSI SGR codes to preserve styling across line breaks.
*
* @experimental
*
* @internal
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class AnsiCodeTracker
{
private bool $bold = false;
private bool $dim = false;
private bool $italic = false;
private bool $underline = false;
private bool $doubleUnderline = false;
private bool $blink = false;
private bool $inverse = false;
private bool $hidden = false;
private bool $strikethrough = false;
private ?string $fgColor = null;
private ?string $bgColor = null;

/**
* Process an ANSI escape code and update tracking state.
*/
public function process(string $ansiCode): void
{
if (!str_ends_with($ansiCode, 'm')) {
return;
}

// Fast direct parsing: skip regex, extract params between \x1b[ and m
$len = \strlen($ansiCode);
if ($len < 3 || "\x1b" !== $ansiCode[0] || '[' !== $ansiCode[1]) {
return;
}

$params = substr($ansiCode, 2, $len - 3);
if ('' === $params || '0' === $params) {
$this->reset();

return;
}

$parts = explode(';', $params);
$i = 0;
$count = \count($parts);

while ($i < $count) {
$code = (int) $parts[$i];

// Handle 256-color and RGB codes which consume multiple parameters
if (38 === $code || 48 === $code) {
if (isset($parts[$i + 1]) && '5' === $parts[$i + 1]) {
if (isset($parts[$i + 2])) {
// 256 color: 38;5;N or 48;5;N
$colorCode = $parts[$i].';'.$parts[$i + 1].';'.$parts[$i + 2];
if (38 === $code) {
$this->fgColor = $colorCode;
} else {
$this->bgColor = $colorCode;
}
$i += 3;
} else {
// Malformed: 38;5 or 48;5 without color number, skip both
$i += 2;
}
continue;
} elseif (isset($parts[$i + 1]) && '2' === $parts[$i + 1]) {
if (isset($parts[$i + 4])) {
// RGB color: 38;2;R;G;B or 48;2;R;G;B
$colorCode = $parts[$i].';'.$parts[$i + 1].';'.$parts[$i + 2].';'.$parts[$i + 3].';'.$parts[$i + 4];
if (38 === $code) {
$this->fgColor = $colorCode;
} else {
$this->bgColor = $colorCode;
}
$i += 5;
} else {
// Malformed: 38;2 or 48;2 without enough RGB components, skip all remaining parts
$i = $count;
}
continue;
}
// 38/48 not followed by 5 or 2, ignore and move on
++$i;
continue;
}

// Standard SGR codes, including color ranges inline to avoid handleColorCode call
match ($code) {
0 => $this->reset(),
1 => $this->bold = true,
2 => $this->dim = true,
3 => $this->italic = true,
4 => $this->underline = true,
5 => $this->blink = true,
7 => $this->inverse = true,
8 => $this->hidden = true,
9 => $this->strikethrough = true,
21 => $this->doubleUnderline = true,
22 => $this->bold = $this->dim = false,
23 => $this->italic = false,
24 => $this->underline = $this->doubleUnderline = false,
25 => $this->blink = false,
27 => $this->inverse = false,
28 => $this->hidden = false,
29 => $this->strikethrough = false,
30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97 => $this->fgColor = (string) $code,
39 => $this->fgColor = null,
40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107 => $this->bgColor = (string) $code,
49 => $this->bgColor = null,
default => null,
};

++$i;
}
}

/**
* Reset all tracking state.
*/
public function reset(): void
{
$this->bold = false;
$this->dim = false;
$this->italic = false;
$this->underline = false;
$this->doubleUnderline = false;
$this->blink = false;
$this->inverse = false;
$this->hidden = false;
$this->strikethrough = false;
$this->fgColor = null;
$this->bgColor = null;
}

/**
* Get ANSI escape sequence to restore current active codes.
*/
public function getActiveCodes(): string
{
$codes = [];

if ($this->bold) {
$codes[] = '1';
}
if ($this->dim) {
$codes[] = '2';
}
if ($this->italic) {
$codes[] = '3';
}
if ($this->underline) {
$codes[] = '4';
}
if ($this->doubleUnderline) {
$codes[] = '21';
}
if ($this->blink) {
$codes[] = '5';
}
if ($this->inverse) {
$codes[] = '7';
}
if ($this->hidden) {
$codes[] = '8';
}
if ($this->strikethrough) {
$codes[] = '9';
}
if (null !== $this->fgColor) {
$codes[] = $this->fgColor;
}
if (null !== $this->bgColor) {
$codes[] = $this->bgColor;
}

if ([] === $codes) {
return '';
}

return "\x1b[".implode(';', $codes).'m';
}

/**
* Check if any codes are currently active.
*/
public function hasActiveCodes(): bool
{
return $this->bold
|| $this->dim
|| $this->italic
|| $this->underline
|| $this->doubleUnderline
|| $this->blink
|| $this->inverse
|| $this->hidden
|| $this->strikethrough
|| null !== $this->fgColor
|| null !== $this->bgColor;
}

/**
* Get reset codes for attributes that need to be turned off at line end.
* Specifically underline which bleeds into padding.
*/
public function getLineEndReset(): string
{
if ($this->underline || $this->doubleUnderline) {
return "\x1b[24m";
}

return '';
}

/**
* Update tracker state from all ANSI codes in a text string.
*/
public function processText(string $text): void
{
// Fast path: no escape sequences at all
if (!str_contains($text, "\x1b")) {
return;
}

// Use preg_match_all to find all SGR sequences at once (C-level scan)
if (preg_match_all('/\x1b\[([\d;]*)m/', $text, $matches)) {
foreach ($matches[1] as $params) {
if ('' === $params || '0' === $params) {
$this->reset();
continue;
}

$parts = explode(';', $params);
$pi = 0;
$pc = \count($parts);

while ($pi < $pc) {
$code = (int) $parts[$pi];

if (38 === $code || 48 === $code) {
if (isset($parts[$pi + 1]) && '5' === $parts[$pi + 1]) {
if (isset($parts[$pi + 2])) {
$colorCode = $parts[$pi].';'.$parts[$pi + 1].';'.$parts[$pi + 2];
if (38 === $code) {
$this->fgColor = $colorCode;
} else {
$this->bgColor = $colorCode;
}
$pi += 3;
} else {
$pi += 2;
}
continue;
}
if (isset($parts[$pi + 1]) && '2' === $parts[$pi + 1]) {
if (isset($parts[$pi + 4])) {
$colorCode = $parts[$pi].';'.$parts[$pi + 1].';'.$parts[$pi + 2].';'.$parts[$pi + 3].';'.$parts[$pi + 4];
if (38 === $code) {
$this->fgColor = $colorCode;
} else {
$this->bgColor = $colorCode;
}
$pi += 5;
} else {
$pi = $pc;
}
continue;
}
++$pi;
continue;
}

match ($code) {
0 => $this->reset(),
1 => $this->bold = true,
2 => $this->dim = true,
3 => $this->italic = true,
4 => $this->underline = true,
5 => $this->blink = true,
7 => $this->inverse = true,
8 => $this->hidden = true,
9 => $this->strikethrough = true,
21 => $this->doubleUnderline = true,
22 => $this->bold = $this->dim = false,
23 => $this->italic = false,
24 => $this->underline = $this->doubleUnderline = false,
25 => $this->blink = false,
27 => $this->inverse = false,
28 => $this->hidden = false,
29 => $this->strikethrough = false,
30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97 => $this->fgColor = (string) $code,
39 => $this->fgColor = null,
40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107 => $this->bgColor = (string) $code,
49 => $this->bgColor = null,
default => null,
};

++$pi;
}
}
}
}
}
Loading
Loading