Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0e4bf0e
[Tui] Add the component
fabpot Mar 25, 2026
a037eb8
Replace .root class selector with :root pseudo-class for root widget
fabpot Mar 26, 2026
fe529f5
Rename :focused pseudo-class to :focus to match CSS conventions
fabpot Mar 26, 2026
7dd3a11
Fix CS
fabpot Mar 26, 2026
632d862
Fix CS
fabpot Mar 27, 2026
ec93ef2
Add @internal annotation to Tui component implementation classes
fabpot Mar 27, 2026
374b5bc
Remove onDebug entirely, users can use onInput or onTick instead
fabpot Mar 27, 2026
73f9104
[Tui] Remove quitOn() in favor of onInput()
fabpot Mar 28, 2026
e60a83c
[Tui] Replace onInput() callback with InputEvent
fabpot Mar 28, 2026
599a2bb
Remove comment
fabpot Mar 28, 2026
8218f5c
[Tui] Fix Escape key and multi-byte quit key handling in StdinBuffer …
fabpot Mar 28, 2026
bc99ad5
[Tui] Remove unused VirtualTerminal methods and property
fabpot Mar 28, 2026
91b5fcb
[Tui] Remove unused composer dependencies
fabpot Mar 28, 2026
b871c1d
[Tui] Add class_exists checks for optional dependencies in MarkdownWi…
fabpot Mar 28, 2026
b463c94
[Tui] Use underscore keys for all internal arrays
fabpot Mar 29, 2026
0141876
[Tui] Use match expression in StdinBuffer::getUtf8CharLength()
fabpot Mar 29, 2026
2b294d5
[Tui] Remove unnecessary extractApcSequence() method
fabpot Mar 29, 2026
6a15829
[Tui] Remove empty constructor from StdinBuffer
fabpot Mar 29, 2026
0863f1b
[Tui] Remove empty constructor from ContainerWidget
fabpot Mar 29, 2026
9633e69
[Tui] Use component exception classes instead of global ones
fabpot Mar 29, 2026
a5a770c
[Tui] Use static return type instead of self for fluent interfaces
fabpot Mar 29, 2026
0e2af46
[Tui] Force full re-render on terminal resize
ruttydm Mar 30, 2026
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: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"symfony/stopwatch": "self.version",
"symfony/string": "self.version",
"symfony/translation": "self.version",
"symfony/tui": "self.version",
"symfony/twig-bridge": "self.version",
"symfony/twig-bundle": "self.version",
"symfony/type-info": "self.version",
Expand Down Expand Up @@ -141,6 +142,7 @@
"guzzlehttp/guzzle": "^7.10",
"jolicode/jolinotif": "^2.7.2|^3.0",
"jsonpath-standard/jsonpath-compliance-test-suite": "*",
"league/commonmark": "^2.9",
"nst/json-test-suite": "*",
"league/html-to-markdown": "^5.0",
"league/uri": "^6.5|^7.0",
Expand All @@ -162,6 +164,7 @@
"symfony/runtime": "self.version",
"symfony/security-acl": "^2.8|^3.0",
"symfony/webpack-encore-bundle": "^1.0|^2.0",
"tempest/highlight": "^2.16",
"twig/cssinliner-extra": "^3",
"twig/inky-extra": "^3",
"twig/markdown-extra": "^3",
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/Tui/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.github export-ignore
5 changes: 5 additions & 0 deletions src/Symfony/Component/Tui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.php-cs-fixer.cache
.phpunit.result.cache
composer.lock
phpunit.xml
vendor/
319 changes: 319 additions & 0 deletions src/Symfony/Component/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