From 5ec27e0a0ddbb022bd6ff36074d528b278c9d712 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 16 Apr 2026 14:15:11 +0200 Subject: [PATCH] tui test --- composer.json | 3 +- extern/Tui/Ansi/AnsiCodeTracker.php | 319 +++ extern/Tui/Ansi/AnsiUtils.php | 629 +++++ extern/Tui/Ansi/ScreenBufferHtmlRenderer.php | 261 ++ extern/Tui/Ansi/TextWrapper.php | 461 ++++ extern/Tui/CHANGELOG | 7 + extern/Tui/Event/AbstractEvent.php | 39 + extern/Tui/Event/CancelEvent.php | 23 + extern/Tui/Event/ChangeEvent.php | 47 + extern/Tui/Event/FocusEvent.php | 40 + extern/Tui/Event/InputEvent.php | 41 + extern/Tui/Event/QuitEvent.php | 23 + extern/Tui/Event/SelectEvent.php | 68 + extern/Tui/Event/SelectionChangeEvent.php | 71 + extern/Tui/Event/SettingChangeEvent.php | 71 + extern/Tui/Event/SubmitEvent.php | 47 + extern/Tui/Event/TickEvent.php | 57 + extern/Tui/Exception/ExceptionInterface.php | 23 + .../Exception/InvalidArgumentException.php | 21 + extern/Tui/Exception/LogicException.php | 21 + extern/Tui/Exception/RenderException.php | 48 + extern/Tui/Exception/RuntimeException.php | 21 + extern/Tui/Focus/FocusManager.php | 236 ++ extern/Tui/Input/Key.php | 92 + extern/Tui/Input/KeyParser.php | 968 +++++++ extern/Tui/Input/Keybindings.php | 82 + extern/Tui/Input/StdinBuffer.php | 368 +++ extern/Tui/LICENSE | 19 + extern/Tui/Loop/AdaptativeTicker.php | 117 + extern/Tui/Loop/FixedStepAccumulator.php | 75 + extern/Tui/Loop/LoopClock.php | 61 + extern/Tui/Loop/PeriodicStepper.php | 86 + extern/Tui/Loop/TickRuntimeInterface.php | 28 + extern/Tui/Loop/TickScheduler.php | 105 + extern/Tui/README.md | 19 + extern/Tui/Render/CellBuffer.php | 571 +++++ extern/Tui/Render/ChromeApplier.php | 227 ++ extern/Tui/Render/Compositor.php | 72 + extern/Tui/Render/Layer.php | 79 + extern/Tui/Render/LayoutEngine.php | 478 ++++ extern/Tui/Render/PositionTracker.php | 173 ++ extern/Tui/Render/RenderContext.php | 102 + .../Tui/Render/RenderRequestorInterface.php | 45 + extern/Tui/Render/Renderer.php | 339 +++ extern/Tui/Render/ScreenWriter.php | 548 ++++ extern/Tui/Render/WidgetRect.php | 81 + extern/Tui/Render/WidgetRendererInterface.php | 50 + extern/Tui/Style/Align.php | 29 + extern/Tui/Style/Border.php | 238 ++ extern/Tui/Style/BorderPattern.php | 447 ++++ extern/Tui/Style/Color.php | 387 +++ extern/Tui/Style/ColorType.php | 33 + extern/Tui/Style/CursorShape.php | 32 + extern/Tui/Style/DefaultStyleSheet.php | 99 + extern/Tui/Style/Direction.php | 25 + extern/Tui/Style/Padding.php | 123 + extern/Tui/Style/Style.php | 823 ++++++ extern/Tui/Style/StyleSheet.php | 474 ++++ extern/Tui/Style/TailwindStylesheet.php | 469 ++++ extern/Tui/Style/TextAlign.php | 26 + extern/Tui/Style/VerticalAlign.php | 29 + extern/Tui/Terminal/ScreenBuffer.php | 840 +++++++ extern/Tui/Terminal/TeeTerminal.php | 133 + extern/Tui/Terminal/Terminal.php | 348 +++ extern/Tui/Terminal/TerminalInterface.php | 116 + extern/Tui/Terminal/VirtualTerminal.php | 202 ++ extern/Tui/Tui.php | 516 ++++ extern/Tui/Widget/AbstractWidget.php | 459 ++++ extern/Tui/Widget/BracketedPasteTrait.php | 78 + extern/Tui/Widget/CancellableLoaderWidget.php | 90 + extern/Tui/Widget/ContainerInterface.php | 42 + extern/Tui/Widget/ContainerWidget.php | 164 ++ extern/Tui/Widget/DirtyWidgetTrait.php | 36 + extern/Tui/Widget/Editor/EditorDocument.php | 732 ++++++ extern/Tui/Widget/Editor/EditorRenderer.php | 222 ++ extern/Tui/Widget/Editor/EditorViewport.php | 161 ++ extern/Tui/Widget/EditorWidget.php | 599 +++++ extern/Tui/Widget/Figlet/FigletFont.php | 207 ++ extern/Tui/Widget/Figlet/FigletRenderer.php | 88 + extern/Tui/Widget/Figlet/FontRegistry.php | 104 + extern/Tui/Widget/Figlet/fonts/big.flf | 2204 ++++++++++++++++ extern/Tui/Widget/Figlet/fonts/mini.flf | 899 +++++++ extern/Tui/Widget/Figlet/fonts/slant.flf | 1295 ++++++++++ extern/Tui/Widget/Figlet/fonts/small.flf | 1097 ++++++++ extern/Tui/Widget/Figlet/fonts/standard.flf | 2238 +++++++++++++++++ extern/Tui/Widget/FocusableInterface.php | 82 + extern/Tui/Widget/FocusableTrait.php | 45 + extern/Tui/Widget/InputWidget.php | 450 ++++ extern/Tui/Widget/KeybindingsTrait.php | 90 + extern/Tui/Widget/LoaderWidget.php | 261 ++ .../Tui/Widget/Markdown/DarkTerminalTheme.php | 59 + extern/Tui/Widget/MarkdownWidget.php | 572 +++++ extern/Tui/Widget/ParentInterface.php | 32 + extern/Tui/Widget/ProgressBarWidget.php | 598 +++++ extern/Tui/Widget/QuitableTrait.php | 64 + extern/Tui/Widget/ScheduledTickTrait.php | 82 + extern/Tui/Widget/SelectListWidget.php | 355 +++ extern/Tui/Widget/SettingItem.php | 100 + extern/Tui/Widget/SettingsListWidget.php | 417 +++ extern/Tui/Widget/TextWidget.php | 140 ++ extern/Tui/Widget/Util/KillRing.php | 143 ++ extern/Tui/Widget/Util/Line.php | 291 +++ extern/Tui/Widget/Util/StringUtils.php | 58 + extern/Tui/Widget/Util/WordNavigator.php | 128 + .../Widget/VerticallyExpandableInterface.php | 39 + extern/Tui/Widget/WidgetContext.php | 137 + extern/Tui/Widget/WidgetTree.php | 96 + extern/Tui/composer.json | 37 + extern/Tui/phpstan.neon.dist | 4 + 109 files changed, 27745 insertions(+), 1 deletion(-) create mode 100644 extern/Tui/Ansi/AnsiCodeTracker.php create mode 100644 extern/Tui/Ansi/AnsiUtils.php create mode 100644 extern/Tui/Ansi/ScreenBufferHtmlRenderer.php create mode 100644 extern/Tui/Ansi/TextWrapper.php create mode 100644 extern/Tui/CHANGELOG create mode 100644 extern/Tui/Event/AbstractEvent.php create mode 100644 extern/Tui/Event/CancelEvent.php create mode 100644 extern/Tui/Event/ChangeEvent.php create mode 100644 extern/Tui/Event/FocusEvent.php create mode 100644 extern/Tui/Event/InputEvent.php create mode 100644 extern/Tui/Event/QuitEvent.php create mode 100644 extern/Tui/Event/SelectEvent.php create mode 100644 extern/Tui/Event/SelectionChangeEvent.php create mode 100644 extern/Tui/Event/SettingChangeEvent.php create mode 100644 extern/Tui/Event/SubmitEvent.php create mode 100644 extern/Tui/Event/TickEvent.php create mode 100644 extern/Tui/Exception/ExceptionInterface.php create mode 100644 extern/Tui/Exception/InvalidArgumentException.php create mode 100644 extern/Tui/Exception/LogicException.php create mode 100644 extern/Tui/Exception/RenderException.php create mode 100644 extern/Tui/Exception/RuntimeException.php create mode 100644 extern/Tui/Focus/FocusManager.php create mode 100644 extern/Tui/Input/Key.php create mode 100644 extern/Tui/Input/KeyParser.php create mode 100644 extern/Tui/Input/Keybindings.php create mode 100644 extern/Tui/Input/StdinBuffer.php create mode 100644 extern/Tui/LICENSE create mode 100644 extern/Tui/Loop/AdaptativeTicker.php create mode 100644 extern/Tui/Loop/FixedStepAccumulator.php create mode 100644 extern/Tui/Loop/LoopClock.php create mode 100644 extern/Tui/Loop/PeriodicStepper.php create mode 100644 extern/Tui/Loop/TickRuntimeInterface.php create mode 100644 extern/Tui/Loop/TickScheduler.php create mode 100644 extern/Tui/README.md create mode 100644 extern/Tui/Render/CellBuffer.php create mode 100644 extern/Tui/Render/ChromeApplier.php create mode 100644 extern/Tui/Render/Compositor.php create mode 100644 extern/Tui/Render/Layer.php create mode 100644 extern/Tui/Render/LayoutEngine.php create mode 100644 extern/Tui/Render/PositionTracker.php create mode 100644 extern/Tui/Render/RenderContext.php create mode 100644 extern/Tui/Render/RenderRequestorInterface.php create mode 100644 extern/Tui/Render/Renderer.php create mode 100644 extern/Tui/Render/ScreenWriter.php create mode 100644 extern/Tui/Render/WidgetRect.php create mode 100644 extern/Tui/Render/WidgetRendererInterface.php create mode 100644 extern/Tui/Style/Align.php create mode 100644 extern/Tui/Style/Border.php create mode 100644 extern/Tui/Style/BorderPattern.php create mode 100644 extern/Tui/Style/Color.php create mode 100644 extern/Tui/Style/ColorType.php create mode 100644 extern/Tui/Style/CursorShape.php create mode 100644 extern/Tui/Style/DefaultStyleSheet.php create mode 100644 extern/Tui/Style/Direction.php create mode 100644 extern/Tui/Style/Padding.php create mode 100644 extern/Tui/Style/Style.php create mode 100644 extern/Tui/Style/StyleSheet.php create mode 100644 extern/Tui/Style/TailwindStylesheet.php create mode 100644 extern/Tui/Style/TextAlign.php create mode 100644 extern/Tui/Style/VerticalAlign.php create mode 100644 extern/Tui/Terminal/ScreenBuffer.php create mode 100644 extern/Tui/Terminal/TeeTerminal.php create mode 100644 extern/Tui/Terminal/Terminal.php create mode 100644 extern/Tui/Terminal/TerminalInterface.php create mode 100644 extern/Tui/Terminal/VirtualTerminal.php create mode 100644 extern/Tui/Tui.php create mode 100644 extern/Tui/Widget/AbstractWidget.php create mode 100644 extern/Tui/Widget/BracketedPasteTrait.php create mode 100644 extern/Tui/Widget/CancellableLoaderWidget.php create mode 100644 extern/Tui/Widget/ContainerInterface.php create mode 100644 extern/Tui/Widget/ContainerWidget.php create mode 100644 extern/Tui/Widget/DirtyWidgetTrait.php create mode 100644 extern/Tui/Widget/Editor/EditorDocument.php create mode 100644 extern/Tui/Widget/Editor/EditorRenderer.php create mode 100644 extern/Tui/Widget/Editor/EditorViewport.php create mode 100644 extern/Tui/Widget/EditorWidget.php create mode 100644 extern/Tui/Widget/Figlet/FigletFont.php create mode 100644 extern/Tui/Widget/Figlet/FigletRenderer.php create mode 100644 extern/Tui/Widget/Figlet/FontRegistry.php create mode 100644 extern/Tui/Widget/Figlet/fonts/big.flf create mode 100644 extern/Tui/Widget/Figlet/fonts/mini.flf create mode 100644 extern/Tui/Widget/Figlet/fonts/slant.flf create mode 100644 extern/Tui/Widget/Figlet/fonts/small.flf create mode 100644 extern/Tui/Widget/Figlet/fonts/standard.flf create mode 100644 extern/Tui/Widget/FocusableInterface.php create mode 100644 extern/Tui/Widget/FocusableTrait.php create mode 100644 extern/Tui/Widget/InputWidget.php create mode 100644 extern/Tui/Widget/KeybindingsTrait.php create mode 100644 extern/Tui/Widget/LoaderWidget.php create mode 100644 extern/Tui/Widget/Markdown/DarkTerminalTheme.php create mode 100644 extern/Tui/Widget/MarkdownWidget.php create mode 100644 extern/Tui/Widget/ParentInterface.php create mode 100644 extern/Tui/Widget/ProgressBarWidget.php create mode 100644 extern/Tui/Widget/QuitableTrait.php create mode 100644 extern/Tui/Widget/ScheduledTickTrait.php create mode 100644 extern/Tui/Widget/SelectListWidget.php create mode 100644 extern/Tui/Widget/SettingItem.php create mode 100644 extern/Tui/Widget/SettingsListWidget.php create mode 100644 extern/Tui/Widget/TextWidget.php create mode 100644 extern/Tui/Widget/Util/KillRing.php create mode 100644 extern/Tui/Widget/Util/Line.php create mode 100644 extern/Tui/Widget/Util/StringUtils.php create mode 100644 extern/Tui/Widget/Util/WordNavigator.php create mode 100644 extern/Tui/Widget/VerticallyExpandableInterface.php create mode 100644 extern/Tui/Widget/WidgetContext.php create mode 100644 extern/Tui/Widget/WidgetTree.php create mode 100644 extern/Tui/composer.json create mode 100644 extern/Tui/phpstan.neon.dist diff --git a/composer.json b/composer.json index 2aeb6883..26b66dda 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,8 @@ }, "autoload": { "psr-4": { - "Patchlevel\\EventSourcing\\": "src/" + "Patchlevel\\EventSourcing\\": "src/", + " Symfony\\Component\\": "extern/" } }, "autoload-dev": { diff --git a/extern/Tui/Ansi/AnsiCodeTracker.php b/extern/Tui/Ansi/AnsiCodeTracker.php new file mode 100644 index 00000000..02e3ba66 --- /dev/null +++ b/extern/Tui/Ansi/AnsiCodeTracker.php @@ -0,0 +1,319 @@ + + * + * 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 + */ +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; + } + } + } + } +} diff --git a/extern/Tui/Ansi/AnsiUtils.php b/extern/Tui/Ansi/AnsiUtils.php new file mode 100644 index 00000000..c15d2b11 --- /dev/null +++ b/extern/Tui/Ansi/AnsiUtils.php @@ -0,0 +1,629 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +use Symfony\Component\String\UnicodeString; +use Symfony\Component\Tui\Style\CursorShape; + +/** + * ANSI escape code utilities for terminal rendering. + * + * @experimental + * + * @author Fabien Potencier + */ +final class AnsiUtils +{ + /** + * Cursor position marker prefix. + * + * Widgets emit an APC marker at the cursor position when focused. + * The full marker format is `ESC _ pi:c ; N BEL` where N is the + * DECSCUSR parameter (cursor shape). The ScreenWriter finds this + * marker, extracts the shape, positions the hardware cursor, and + * sets the cursor style via `ESC [ N SP q`. + * + * @see cursorMarker() + */ + public const CURSOR_MARKER_PREFIX = "\x1b_pi:c;"; + + /** + * Full SGR reset and OSC 8 reset sequence. + */ + public const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + + /** + * Combined pattern matching all ECMA-48 escape sequences in a single regex. + * Alternation order: CSI (most common), string sequences, nF, two-byte. + */ + private const ALL_ESC_PATTERN = '/\x1b(?:\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|[P\]_\^X][^\x07\x1b]*(?:\x07|\x1b\\\\)|[\x20-\x2F]+[\x30-\x7E]|[\x30-\x7E])/'; + + /** + * Character set for CSI parameter bytes (0x30-0x3F). + */ + private const CSI_PARAM_CHARS = '0123456789:;<=>?'; + + /** + * Character set for CSI intermediate bytes (0x20-0x2F). + */ + private const CSI_INTERMEDIATE_CHARS = " !\"#\$%&'()*+,-./"; + + /** + * Create a cursor marker embedding the given shape. + * + * The returned APC sequence is zero-width. The ScreenWriter strips + * it, positions the hardware cursor, and sets the cursor style. + */ + public static function cursorMarker(CursorShape $shape = CursorShape::Block): string + { + return self::CURSOR_MARKER_PREFIX.$shape->value."\x07"; + } + + /** + * Calculate the visible width of a string in terminal columns. + * ANSI escape codes are stripped before calculating width. + */ + public static function visibleWidth(string $str): int + { + if ('' === $str) { + return 0; + } + + $len = \strlen($str); + + // Ultra-fast path: pure printable ASCII (0x20-0x7E) with no ESC, no tabs, no non-ASCII + if (!str_contains($str, "\x1b") && !str_contains($str, "\t") && 1 === preg_match('/^[\x20-\x7E]*$/', $str)) { + return $len; + } + + // Fast path for ASCII + ANSI: jump between ESC sequences using strpos + // instead of scanning byte-by-byte with ord() + $fastWidth = 0; + $fastPath = true; + $i = 0; + + while ($i < $len) { + // Find next ESC byte + $escPos = strpos($str, "\x1b", $i); + $segEnd = false === $escPos ? $len : $escPos; + + // Process the text segment before the ESC (or end of string) + if ($segEnd > $i) { + $segLen = $segEnd - $i; + $segment = substr($str, $i, $segLen); + if (1 === preg_match('/^[\x20-\x7E]*$/', $segment)) { + // Pure printable ASCII (no tabs, no non-ASCII) + $fastWidth += $segLen; + } elseif (str_contains($segment, "\t")) { + // Has tabs + $tabCount = substr_count($segment, "\t"); + $fastWidth += $segLen - $tabCount + ($tabCount * 3); + $withoutTabs = str_replace("\t", '', $segment); + if ('' !== $withoutTabs && 1 !== preg_match('/^[\x20-\x7E]*$/', $withoutTabs)) { + $fastPath = false; + break; + } + } else { + $fastPath = false; + break; + } + } + + if (false === $escPos) { + break; + } + + // Skip the ANSI escape sequence, inline CSI fast path + if ($escPos + 1 < $len && '[' === $str[$escPos + 1]) { + $j = $escPos + 2 + strspn($str, self::CSI_PARAM_CHARS, $escPos + 2); + if ($j < $len && \ord($str[$j]) >= 0x40 && \ord($str[$j]) <= 0x7E) { + $i = $j + 1; + continue; + } + } + $ansi = self::extractAnsiCode($str, $escPos); + if (null === $ansi) { + $fastPath = false; + break; + } + $i = $escPos + $ansi['length']; + } + + if ($fastPath) { + return $fastWidth; + } + + $clean = $str; + + if (str_contains($clean, "\t")) { + $clean = str_replace("\t", ' ', $clean); + } + + if (str_contains($clean, "\x1b")) { + $clean = preg_replace(self::ALL_ESC_PATTERN, '', $clean) ?? $clean; + } + + if ('' === $clean) { + return 0; + } + + if (false === preg_match('//u', $clean)) { + $clean = @iconv('UTF-8', 'UTF-8//IGNORE', $clean) ?: ''; + } + + if ('' === $clean) { + return 0; + } + + return mb_strwidth($clean, 'UTF-8'); + } + + /** + * Strip all ANSI escape codes from a string. + */ + public static function stripAnsiCodes(string $str): string + { + if (!str_contains($str, "\x1b")) { + return $str; + } + + // Strip all ECMA-48 escape sequences using a single combined regex + return preg_replace(self::ALL_ESC_PATTERN, '', $str) ?? $str; + } + + /** + * Extract ANSI escape sequence at the given position. + * + * Handles all ECMA-48 sequence types: + * - CSI: ESC [ params intermediates final + * - String sequences: OSC (ESC ]), DCS (ESC P), APC (ESC _), PM (ESC ^), SOS (ESC X) + * - nF announced: ESC intermediates(0x20-0x2F)+ final(0x30-0x7E) + * - Fe/Fp/Fs two-byte: ESC + byte in 0x30-0x7E + * + * @return array{code: string, length: int}|null + */ + public static function extractAnsiCode(string $str, int $pos): ?array + { + $len = \strlen($str); + if ($pos >= $len || "\x1b" !== $str[$pos]) { + return null; + } + + if ($pos + 1 >= $len) { + return null; + } + + $next = $str[$pos + 1]; + + // CSI sequence: ESC [ * * + if ('[' === $next) { + // Use strspn for C-level scanning of parameter bytes + $j = $pos + 2 + strspn($str, self::CSI_PARAM_CHARS, $pos + 2); + // Check final byte, skip intermediate scan if already in final range (common case) + if ($j < $len && \ord($str[$j]) >= 0x40 && \ord($str[$j]) <= 0x7E) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + // Rare: scan intermediate bytes (0x20-0x2F) then check final byte + if ($j < $len && \ord($str[$j]) >= 0x20 && \ord($str[$j]) <= 0x2F) { + $j += strspn($str, self::CSI_INTERMEDIATE_CHARS, $j); + if ($j < $len && \ord($str[$j]) >= 0x40 && \ord($str[$j]) <= 0x7E) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + } + + return null; + } + + // String sequences: OSC (ESC ]), DCS (ESC P), APC (ESC _), PM (ESC ^), SOS (ESC X) + // All terminated by BEL (0x07) or ST (ESC \) + if (']' === $next || 'P' === $next || '_' === $next || '^' === $next || 'X' === $next) { + $j = $pos + 2; + while ($j < $len) { + // Skip ahead to next BEL or ESC using strcspn (C-level scan) + $j += strcspn($str, "\x07\x1b", $j); + if ($j >= $len) { + break; + } + if ("\x07" === $str[$j]) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + if ("\x1b" === $str[$j] && isset($str[$j + 1]) && '\\' === $str[$j + 1]) { + return ['code' => substr($str, $pos, $j + 2 - $pos), 'length' => $j + 2 - $pos]; + } + ++$j; + } + + return null; + } + + $nextOrd = \ord($next); + + // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) + // e.g., ESC ( B = G0 charset designation + if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { + $j = $pos + 2 + strspn($str, self::CSI_INTERMEDIATE_CHARS, $pos + 2); + // Final byte must be in 0x30-0x7E + if ($j < $len && \ord($str[$j]) >= 0x30 && \ord($str[$j]) <= 0x7E) { + return ['code' => substr($str, $pos, $j + 1 - $pos), 'length' => $j + 1 - $pos]; + } + + return null; + } + + // Fe (0x40-0x5F), Fp (0x30-0x3F), Fs (0x60-0x7E) two-byte sequences + // e.g., ESC D = IND, ESC M = RI, ESC 7 = DECSC, ESC 8 = DECRC, ESC c = RIS + if ($nextOrd >= 0x30 && $nextOrd <= 0x7E) { + return ['code' => substr($str, $pos, 2), 'length' => 2]; + } + + return null; + } + + /** + * Truncate text to fit within a maximum visible width, adding ellipsis if needed. + * + * @param string $text Text to truncate (may contain ANSI codes) + * @param int $maxWidth Maximum visible width + * @param string $ellipsis Ellipsis string to append when truncating + * @param bool $pad If true, pad result with spaces to exactly maxWidth + */ + public static function truncateToWidth(string $text, int $maxWidth, string $ellipsis = '...', bool $pad = false): string + { + $textVisibleWidth = self::visibleWidth($text); + + if ($textVisibleWidth <= $maxWidth) { + return $pad ? $text.str_repeat(' ', $maxWidth - $textVisibleWidth) : $text; + } + + // Fast path: pure ASCII ellipsis width = strlen (avoids visibleWidth overhead) + $ellipsisWidth = '' !== $ellipsis && !str_contains($ellipsis, "\x1b") && 1 === preg_match('/^[\x20-\x7E]*$/', $ellipsis) + ? \strlen($ellipsis) + : self::visibleWidth($ellipsis); + $targetWidth = $maxWidth - $ellipsisWidth; + + if ($targetWidth <= 0) { + return substr($ellipsis, 0, $maxWidth); + } + + // Fast path: pure printable ASCII, direct substr avoids sliceByColumn overhead + if ($textVisibleWidth === \strlen($text) && 1 === preg_match('/^[\x20-\x7E]*$/', $text)) { + $truncated = substr($text, 0, $targetWidth).$ellipsis; + + if ($pad) { + return $truncated.str_repeat(' ', max(0, $maxWidth - $targetWidth - $ellipsisWidth)); + } + + return $truncated; + } + + $result = self::sliceByColumn($text, 0, $targetWidth); + + // Add reset code before ellipsis to prevent styling leaking into it + $truncated = $result."\x1b[0m".$ellipsis; + + if ($pad) { + $truncatedWidth = self::visibleWidth($truncated); + + return $truncated.str_repeat(' ', max(0, $maxWidth - $truncatedWidth)); + } + + return $truncated; + } + + /** + * Extract a range of visible columns from a line. + * Handles ANSI codes and wide characters. + * + * @param bool $strict If true, exclude wide chars at boundary that would extend past the range + */ + public static function sliceByColumn(string $line, int $startCol, int $length, bool $strict = false): string + { + // Optimized path for startCol=0 (prefix extraction), skip pendingAnsi tracking + if (0 === $startCol && !$strict) { + return self::slicePrefix($line, $length); + } + + return self::sliceWithWidth($line, $startCol, $length, $strict)['text']; + } + + /** + * Extract a range of visible columns from a line, also returning actual width. + * + * @return array{text: string, width: int} + */ + public static function sliceWithWidth(string $line, int $startCol, int $length, bool $strict = false): array + { + if ($length <= 0) { + return ['text' => '', 'width' => 0]; + } + + $endCol = $startCol + $length; + $result = ''; + $resultWidth = 0; + $currentCol = 0; + $i = 0; + $pendingAnsi = ''; + $lineLen = \strlen($line); + + while ($i < $lineLen) { + // Handle ANSI escape sequences + if ("\x1b" === $line[$i]) { + // Inline CSI fast path to avoid extractAnsiCode call overhead + if ($i + 1 < $lineLen && '[' === $line[$i + 1]) { + $j = $i + 2 + strspn($line, self::CSI_PARAM_CHARS, $i + 2); + if ($j < $lineLen && \ord($line[$j]) >= 0x40 && \ord($line[$j]) <= 0x7E) { + $code = substr($line, $i, $j + 1 - $i); + if ($currentCol >= $startCol && $currentCol < $endCol) { + $result .= $code; + } elseif ($currentCol < $startCol) { + $pendingAnsi .= $code; + } + $i = $j + 1; + continue; + } + } + $ansi = self::extractAnsiCode($line, $i); + if (null !== $ansi) { + if ($currentCol >= $startCol && $currentCol < $endCol) { + $result .= $ansi['code']; + } elseif ($currentCol < $startCol) { + $pendingAnsi .= $ansi['code']; + } + $i += $ansi['length']; + continue; + } + } + + // Find the next ESC byte or end of string + $textEnd = strpos($line, "\x1b", $i + 1); + if (false === $textEnd) { + $textEnd = $lineLen; + } + + // Process text segment between ANSI codes + // Fast path: check if segment is pure printable ASCII (0x20-0x7E) + $segLen = $textEnd - $i; + $segment = substr($line, $i, $segLen); + + if ('' === $segment || 1 === preg_match('/^[\x20-\x7E]*$/', $segment)) { + // ASCII fast path: each byte is exactly 1 column wide + // Use substr for bulk extraction when possible + $segEndCol = $currentCol + $segLen; + + if ($segEndCol <= $startCol) { + // Entire segment is before range, skip it + $currentCol = $segEndCol; + } elseif ($currentCol >= $startCol && $segEndCol <= $endCol) { + // Entire segment is within range, take it all + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= $segment; + $resultWidth += $segLen; + $currentCol = $segEndCol; + } else { + // Segment partially overlaps, extract the overlap + $skipChars = (int) max(0, $startCol - $currentCol); + $takeChars = (int) min($segLen - $skipChars, $endCol - max($currentCol, $startCol)); + + if ($takeChars > 0) { + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= substr($segment, $skipChars, $takeChars); + $resultWidth += $takeChars; + } + $currentCol = $segEndCol; + } + } else { + // Unicode path + $textPortion = substr($line, $i, $segLen); + + // Fast check: if the entire segment fits within range, use mb_strwidth + // to skip expensive grapheme_str_split + per-grapheme iteration. + // mb_strwidth may overcount for ZWJ sequences; conservative check. + $segWidth = mb_strwidth($textPortion, 'UTF-8'); + if ($currentCol >= $startCol && $currentCol + $segWidth <= $endCol) { + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= $textPortion; + $resultWidth += $segWidth; + $currentCol += $segWidth; + } else { + // Per-grapheme path for boundary-spanning segments + $graphemes = grapheme_str_split($textPortion) ?: []; + + foreach ($graphemes as $grapheme) { + $w = self::graphemeWidth($grapheme); + $inRange = $currentCol >= $startCol && $currentCol < $endCol; + $fits = !$strict || ($currentCol + $w <= $endCol); + + if ($inRange && $fits) { + if ('' !== $pendingAnsi) { + $result .= $pendingAnsi; + $pendingAnsi = ''; + } + $result .= $grapheme; + $resultWidth += $w; + } + $currentCol += $w; + + if ($currentCol >= $endCol) { + break; + } + } + } + } + + $i = $textEnd; + + if ($currentCol >= $endCol) { + break; + } + } + + /* @var int $resultWidth */ + return ['text' => $result, 'width' => $resultWidth]; + } + + /** + * Calculate the display width of a single grapheme in terminal columns. + * + * Uses mb_strwidth() for single-codepoint graphemes (fast C-level call), + * falling back to UnicodeString::width() for multi-codepoint graphemes + * (ZWJ emoji sequences, skin tone modifiers, decomposed combining chars) + * where mb_strwidth() overcounts by summing component widths. + */ + public static function graphemeWidth(string $grapheme): int + { + if (1 === mb_strlen($grapheme, 'UTF-8')) { + return mb_strwidth($grapheme, 'UTF-8'); + } + + return new UnicodeString($grapheme)->width(false); + } + + /** + * Check if a character is whitespace. + */ + public static function isWhitespace(string $char): bool + { + return 1 === preg_match('/\s/', $char); + } + + /** + * Check if a character is punctuation. + */ + public static function isPunctuation(string $char): bool + { + return 1 === preg_match('/[(){}[\]<>.,;:\'"!?+\-=*\/\\\\|&%^$#@~`]/', $char); + } + + /** + * Reapply a background SGR code after reset sequences. + */ + public static function reapplyBackgroundAfterResets(string $text, string $backgroundCode): string + { + // Fast path: no escape sequences at all + if (!str_contains($text, "\x1b")) { + return $text; + } + + return preg_replace_callback('/\x1b\[([\d;]*)m/', static function (array $m) use ($backgroundCode): string { + $params = $m[1]; + + // Fast path: common reset sequences + if ('' === $params || '0' === $params) { + return $m[0].$backgroundCode; + } + + // Check for '49' (background reset) or '0' in compound sequences + if (str_contains($params, '49') || str_contains($params, '0')) { + $parts = explode(';', $params); + if (\in_array('0', $parts, true) || \in_array('49', $parts, true)) { + return $m[0].$backgroundCode; + } + } + + return $m[0]; + }, $text) ?? $text; + } + + /** + * Check if a line contains image escape sequences. + */ + public static function containsImage(string $line): bool + { + return str_contains($line, "\x1b_G") || str_contains($line, "\x1b]1337;File="); + } + + /** + * Extract a prefix of visible columns from a line (startCol=0 specialization). + * Skips pendingAnsi tracking since all ANSI codes are in range from the start. + */ + private static function slicePrefix(string $line, int $length): string + { + if ($length <= 0) { + return ''; + } + + $result = ''; + $currentCol = 0; + $i = 0; + $lineLen = \strlen($line); + + while ($i < $lineLen && $currentCol < $length) { + if ("\x1b" === $line[$i]) { + // Inline CSI fast path + if ($i + 1 < $lineLen && '[' === $line[$i + 1]) { + $j = $i + 2 + strspn($line, self::CSI_PARAM_CHARS, $i + 2); + if ($j < $lineLen && \ord($line[$j]) >= 0x40 && \ord($line[$j]) <= 0x7E) { + $result .= substr($line, $i, $j + 1 - $i); + $i = $j + 1; + continue; + } + } + $ansi = self::extractAnsiCode($line, $i); + if (null !== $ansi) { + $result .= $ansi['code']; + $i += $ansi['length']; + continue; + } + } + + // Find next ESC or end of string + $textEnd = strpos($line, "\x1b", $i + 1); + if (false === $textEnd) { + $textEnd = $lineLen; + } + + $segLen = $textEnd - $i; + $segment = substr($line, $i, $segLen); + + if ('' === $segment || 1 === preg_match('/^[\x20-\x7E]*$/', $segment)) { + // ASCII: take up to remaining columns + $take = min($segLen, $length - $currentCol); + if ($take === $segLen) { + $result .= $segment; + } else { + $result .= substr($segment, 0, $take); + } + $currentCol += $take; + } else { + // Unicode path + $segWidth = mb_strwidth($segment, 'UTF-8'); + if ($currentCol + $segWidth <= $length) { + $result .= $segment; + $currentCol += $segWidth; + } else { + $graphemes = grapheme_str_split($segment) ?: []; + foreach ($graphemes as $grapheme) { + $w = self::graphemeWidth($grapheme); + if ($currentCol + $w > $length) { + break; + } + $result .= $grapheme; + $currentCol += $w; + } + } + } + + $i = $textEnd; + } + + return $result; + } +} diff --git a/extern/Tui/Ansi/ScreenBufferHtmlRenderer.php b/extern/Tui/Ansi/ScreenBufferHtmlRenderer.php new file mode 100644 index 00000000..22cbeaac --- /dev/null +++ b/extern/Tui/Ansi/ScreenBufferHtmlRenderer.php @@ -0,0 +1,261 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +use Symfony\Component\Tui\Style\Color; +use Symfony\Component\Tui\Terminal\ScreenBuffer; + +/** + * Renders a ScreenBuffer to HTML with inline CSS styles. + * + * @experimental + * + * @author Fabien Potencier + */ +final class ScreenBufferHtmlRenderer +{ + private Color $defaultForeground; + private Color $defaultBackground; + + public function __construct( + ?Color $defaultForeground = null, + ?Color $defaultBackground = null, + ) { + $this->defaultForeground = $defaultForeground ?? Color::hex('#d4d4d4'); + $this->defaultBackground = $defaultBackground ?? Color::hex('#1e1e1e'); + } + + /** + * Convert a ScreenBuffer to HTML with inline styles. + */ + public function convert(ScreenBuffer $screen): string + { + $cells = $screen->getCells(); + $height = $screen->getHeight(); + $result = []; + $lastNonEmpty = -1; + + for ($row = 0; $row < $height; ++$row) { + $line = $this->convertLine($cells[$row] ?? []); + $textOnly = $this->getLineText($cells[$row] ?? []); + if ('' !== rtrim($textOnly)) { + $lastNonEmpty = $row; + } + $result[] = $line; + } + + // Only include lines up to the last non-empty line + if ($lastNonEmpty >= 0) { + $result = \array_slice($result, 0, $lastNonEmpty + 1); + } else { + $result = []; + } + + return implode("\n", $result); + } + + /** + * Convert a single line of cells to HTML. + * + * @param array $cells + */ + private function convertLine(array $cells): string + { + if ([] === $cells) { + return ''; + } + + $maxCol = max(array_keys($cells)); + + $html = ''; + $lastStyle = ''; + $inSpan = false; + + for ($col = 0; $col <= $maxCol; ++$col) { + $cell = $cells[$col] ?? ['char' => ' ', 'style' => '']; + $char = $cell['char']; + + // Skip wide character continuation cells (empty string placeholders) + if ('' === $char) { + continue; + } + + $style = $cell['style']; + + if ($style !== $lastStyle) { + if ($inSpan) { + $html .= ''; + $inSpan = false; + } + if ('' !== $style) { + $css = $this->ansiToCss($style); + if ('' !== $css) { + $html .= ''; + $inSpan = true; + } + } + $lastStyle = $style; + } + + $html .= htmlspecialchars($char, \ENT_QUOTES | \ENT_HTML5); + } + + if ($inSpan) { + $html .= ''; + } + + return $html; + } + + /** + * Get plain text from a line of cells. + * + * @param array $cells + */ + private function getLineText(array $cells): string + { + if ([] === $cells) { + return ''; + } + + $line = ''; + $maxCol = max(array_keys($cells)); + + for ($col = 0; $col <= $maxCol; ++$col) { + $char = $cells[$col]['char'] ?? ' '; + // Skip wide character continuation cells (empty string placeholders) + if ('' === $char) { + continue; + } + $line .= $char; + } + + return $line; + } + + /** + * Convert ANSI escape sequence to CSS style string. + */ + private function ansiToCss(string $ansi): string + { + // Parse SGR parameters from the style string + // Style is stored as the full escape sequence, e.g., "\x1b[1;32m" + if (!preg_match('/\x1b\[([0-9;]*)m/', $ansi, $matches)) { + return ''; + } + + $params = '' !== $matches[1] ? array_map('intval', explode(';', $matches[1])) : [0]; + $css = []; + + $i = 0; + $paramCount = \count($params); + while ($i < $paramCount) { + $code = $params[$i]; + + switch ($code) { + case 0: // Reset + $css = []; + break; + case 1: // Bold + $css['font-weight'] = 'bold'; + break; + case 2: // Dim + $css['opacity'] = '0.7'; + break; + case 3: // Italic + $css['font-style'] = 'italic'; + break; + case 4: // Underline + $css['--underline'] = true; + break; + case 7: // Reverse video - mark for fg/bg swap + $css['--reverse'] = true; + break; + case 27: // Reverse off + unset($css['--reverse']); + break; + case 9: // Strikethrough + $css['--strikethrough'] = true; + break; + + default: + // Foreground colors (30-37, 90-97) + if ($color = Color::fromSgrForeground($code)) { + $css['color'] = $color->toHex(); + break; + } + + // Background colors (40-47, 100-107) + if ($color = Color::fromSgrBackground($code)) { + $css['background-color'] = $color->toHex(); + break; + } + + // 256-color mode (38;5;N / 48;5;N) and RGB truecolor (38;2;R;G;B / 48;2;R;G;B) + if (38 === $code || 48 === $code || 58 === $code) { + $cssProp = match ($code) { + 38 => 'color', + 48 => 'background-color', + 58 => 'text-decoration-color', + }; + if (isset($params[$i + 1]) && 5 === $params[$i + 1] && isset($params[$i + 2])) { + $css[$cssProp] = Color::palette($params[$i + 2])->toHex(); + $i += 2; + } elseif (isset($params[$i + 1]) && 2 === $params[$i + 1] && isset($params[$i + 4])) { + $css[$cssProp] = Color::rgb($params[$i + 2], $params[$i + 3], $params[$i + 4])->toHex(); + $i += 4; + } + break; + } + + // Default underline color + if (59 === $code) { + unset($css['text-decoration-color']); + } + + break; + } + + ++$i; + } + + // Combine text-decoration from underline and strikethrough markers + $decorations = []; + if (isset($css['--underline'])) { + $decorations[] = 'underline'; + unset($css['--underline']); + } + if (isset($css['--strikethrough'])) { + $decorations[] = 'line-through'; + unset($css['--strikethrough']); + } + if ($decorations) { + $css['text-decoration'] = implode(' ', $decorations); + } + + // Handle reverse video: swap foreground and background colors + if (isset($css['--reverse'])) { + unset($css['--reverse']); + $fg = $css['color'] ?? $this->defaultForeground->toHex(); + $bg = $css['background-color'] ?? $this->defaultBackground->toHex(); + $css['color'] = $bg; + $css['background-color'] = $fg; + } + + $cssStr = ''; + foreach ($css as $prop => $value) { + $cssStr .= $prop.': '.$value.'; '; + } + + return rtrim($cssStr); + } +} diff --git a/extern/Tui/Ansi/TextWrapper.php b/extern/Tui/Ansi/TextWrapper.php new file mode 100644 index 00000000..03012cb6 --- /dev/null +++ b/extern/Tui/Ansi/TextWrapper.php @@ -0,0 +1,461 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Ansi; + +/** + * Text wrapping with ANSI code preservation. + * + * @experimental + * + * @author Fabien Potencier + */ +final class TextWrapper +{ + /** + * Wrap a single line into chunks with position tracking. + * + * Unlike wrapTextWithAnsi(), each chunk carries its start/end position + * in the original line, allowing callers to map cursor positions + * accurately through word-wrap boundaries. + * + * The chunk text may include trailing whitespace; callers that need + * trimmed display text can rtrim() themselves. + * + * @param string $line A single line of text (no newlines) + * @param int $width Maximum visible width per chunk + * + * @return list + */ + public static function wrapLineIntoChunks(string $line, int $width): array + { + if ('' === $line) { + return [['text' => '', 'start_index' => 0, 'end_index' => 0]]; + } + + if ($width <= 0) { + return [['text' => $line, 'start_index' => 0, 'end_index' => \strlen($line)]]; + } + + $lineWidth = AnsiUtils::visibleWidth($line); + if ($lineWidth <= $width) { + return [['text' => $line, 'start_index' => 0, 'end_index' => \strlen($line)]]; + } + + $chunks = []; + $graphemes = grapheme_str_split($line); + if (false === $graphemes) { + return [['text' => $line, 'start_index' => 0, 'end_index' => \strlen($line)]]; + } + + $currentWidth = 0; + $chunkStart = 0; + + // Wrap opportunity: the byte position after the last whitespace + // before a non-whitespace grapheme (where a line break is allowed). + $wrapOppIndex = -1; + $wrapOppWidth = 0; + + $byteOffset = 0; + $count = \count($graphemes); + + for ($i = 0; $i < $count; ++$i) { + $grapheme = $graphemes[$i]; + $graphemeBytes = \strlen($grapheme); + $gWidth = AnsiUtils::visibleWidth($grapheme); + $isWs = ' ' === $grapheme || "\t" === $grapheme; + + // Overflow: current grapheme would exceed the width limit. + if ($currentWidth + $gWidth > $width) { + if ($wrapOppIndex >= 0) { + // Backtrack to last wrap opportunity (word boundary). + $chunks[] = [ + 'text' => substr($line, $chunkStart, $wrapOppIndex - $chunkStart), + 'start_index' => $chunkStart, + 'end_index' => $wrapOppIndex, + ]; + $chunkStart = $wrapOppIndex; + $currentWidth -= $wrapOppWidth; + } elseif ($chunkStart < $byteOffset) { + // No word boundary available: force-break at current position. + $chunks[] = [ + 'text' => substr($line, $chunkStart, $byteOffset - $chunkStart), + 'start_index' => $chunkStart, + 'end_index' => $byteOffset, + ]; + $chunkStart = $byteOffset; + $currentWidth = 0; + } + $wrapOppIndex = -1; + } + + // Advance past this grapheme. + $currentWidth += $gWidth; + $byteOffset += $graphemeBytes; + + // Record wrap opportunity: whitespace followed by non-whitespace. + // Multiple consecutive spaces group together; the break point is + // after the last space, at the start of the next word. + if ($isWs && $i + 1 < $count && ' ' !== $graphemes[$i + 1] && "\t" !== $graphemes[$i + 1]) { + $wrapOppIndex = $byteOffset; // byte position of the next grapheme + $wrapOppWidth = $currentWidth; + } + } + + // Push the final chunk. + $chunks[] = [ + 'text' => substr($line, $chunkStart), + 'start_index' => $chunkStart, + 'end_index' => \strlen($line), + ]; + + return $chunks; + } + + /** + * Wrap text with ANSI codes preserved. + * + * Only does word wrapping - no padding, no background colors. + * Returns lines where each line is <= width visible chars. + * Active ANSI codes are preserved across line breaks. + * + * @param string $text Text to wrap (may contain ANSI codes and newlines) + * @param int $width Maximum visible width per line + * + * @return string[] Array of wrapped lines (not padded to width) + */ + public static function wrapTextWithAnsi(string $text, int $width): array + { + if ('' === $text) { + return ['']; + } + + // Guard against invalid width - return text as-is split by newlines + if ($width <= 0) { + return explode("\n", $text); + } + + // Fast path: single line (no newlines), skip explode/tracker overhead + if (!str_contains($text, "\n")) { + return self::wrapSingleLine($text, $width); + } + + // Handle newlines by processing each line separately + // Track ANSI state across lines so styles carry over after literal newlines + $inputLines = explode("\n", $text); + $result = []; + $tracker = new AnsiCodeTracker(); + + foreach ($inputLines as $inputLine) { + // Prepend active ANSI codes from previous lines (except for first line) + $prefix = [] !== $result ? $tracker->getActiveCodes() : ''; + $wrapped = self::wrapSingleLine($prefix.$inputLine, $width); + array_push($result, ...$wrapped); + + // Update tracker with codes from this line for next iteration + // (skip the scan entirely when no escape sequences are present). + if (str_contains($inputLine, "\x1b")) { + $tracker->processText($inputLine); + } + } + + return [] !== $result ? $result : ['']; + } + + /** + * Wrap a single line of text. + * + * @return string[] + */ + private static function wrapSingleLine(string $line, int $width): array + { + if ('' === $line) { + return ['']; + } + + $visibleLength = AnsiUtils::visibleWidth($line); + if ($visibleLength <= $width) { + return [$line]; + } + + $wrapped = []; + $tracker = new AnsiCodeTracker(); + $tokens = self::splitIntoTokensWithAnsi($line); + + $currentLine = ''; + $currentVisibleLength = 0; + + foreach ($tokens as $token) { + $tokenText = $token['text']; + $tokenVisibleLength = $token['width']; + $isWhitespace = $token['is_whitespace']; + + // Token itself is too long - break it character by character + if ($tokenVisibleLength > $width && !$isWhitespace) { + if ('' !== $currentLine) { + $lineEndReset = $tracker->getLineEndReset(); + if ('' !== $lineEndReset) { + $currentLine .= $lineEndReset; + } + $wrapped[] = $currentLine; + $currentLine = ''; + $currentVisibleLength = 0; + } + + // Break long token + $broken = self::breakLongWord($tokenText, $width, $tracker); + $brokenLines = $broken['lines']; + $lastIndex = \count($brokenLines) - 1; + for ($i = 0; $i < $lastIndex; ++$i) { + $wrapped[] = $brokenLines[$i]; + } + $currentLine = $brokenLines[$lastIndex] ?? ''; + $currentVisibleLength = $broken['last_width']; + continue; + } + + // Check if adding this token would exceed width + $totalNeeded = $currentVisibleLength + $tokenVisibleLength; + + if ($totalNeeded > $width && $currentVisibleLength > 0) { + // Trim trailing whitespace, then add underline reset + $lineToWrap = rtrim($currentLine); + $lineEndReset = $tracker->getLineEndReset(); + if ('' !== $lineEndReset) { + $lineToWrap .= $lineEndReset; + } + $wrapped[] = $lineToWrap; + + if ($isWhitespace) { + // Don't start new line with whitespace + $currentLine = $tracker->getActiveCodes(); + $currentVisibleLength = 0; + } else { + $currentLine = $tracker->getActiveCodes().$tokenText; + $currentVisibleLength = $tokenVisibleLength; + } + } else { + // Add to current line + $currentLine .= $tokenText; + $currentVisibleLength += $tokenVisibleLength; + } + + if ($token['has_ansi']) { + $tracker->processText($tokenText); + } + } + + if ('' !== $currentLine) { + $wrapped[] = $currentLine; + } + + // Trailing whitespace can cause lines to exceed the requested width + return [] !== $wrapped ? array_map('rtrim', $wrapped) : ['']; + } + + /** + * Split text into tokens (words and whitespace runs) while keeping ANSI codes attached. + * + * @return array + */ + private static function splitIntoTokensWithAnsi(string $text): array + { + $tokens = []; + $current = ''; + $pendingAnsi = ''; + $inWhitespace = false; + $currentWidth = 0; + $needsUnicodeWidth = false; + $currentHasAnsi = false; + $i = 0; + $len = \strlen($text); + + while ($i < $len) { + $char = $text[$i]; + + // Only check for ANSI codes when we see an ESC byte + if ("\x1b" === $char) { + // Inline CSI fast path with strspn + if ($i + 1 < $len && '[' === $text[$i + 1]) { + $j = $i + 2 + strspn($text, '0123456789:;<=>?', $i + 2); + if ($j < $len && \ord($text[$j]) >= 0x40 && \ord($text[$j]) <= 0x7E) { + $pendingAnsi .= substr($text, $i, $j + 1 - $i); + $i = $j + 1; + continue; + } + } + $ansi = AnsiUtils::extractAnsiCode($text, $i); + if (null !== $ansi) { + $pendingAnsi .= $ansi['code']; + $i += $ansi['length']; + continue; + } + } + + $charIsSpace = ' ' === $char || "\t" === $char; + + if ($charIsSpace !== $inWhitespace && '' !== $current) { + // Switching between whitespace and non-whitespace, push current token + /* @var int $currentWidth */ + $tokens[] = [ + 'text' => $current, + 'width' => $needsUnicodeWidth ? AnsiUtils::visibleWidth($current) : $currentWidth, + 'is_whitespace' => $inWhitespace, + 'has_ansi' => $currentHasAnsi, + ]; + $current = ''; + $currentWidth = 0; + $needsUnicodeWidth = false; + $currentHasAnsi = false; + } + + // Attach any pending ANSI codes to this visible character + if ('' !== $pendingAnsi) { + $current .= $pendingAnsi; + $pendingAnsi = ''; + $currentHasAnsi = true; + } + + $inWhitespace = $charIsSpace; + + // Bulk-consume consecutive printable ASCII non-space chars or consecutive spaces + if (!$needsUnicodeWidth && $char >= '!' && $char <= '~') { + // Non-whitespace printable ASCII: scan ahead for a run + $runStart = $i; + ++$i; + while ($i < $len && $text[$i] >= '!' && $text[$i] <= '~') { + ++$i; + } + $run = substr($text, $runStart, $i - $runStart); + $current .= $run; + $currentWidth += $i - $runStart; + continue; + } + + $current .= $char; + + if ("\t" === $char) { + $currentWidth += 3; + } elseif ($char >= ' ' && $char <= '~') { + ++$currentWidth; + } else { + $needsUnicodeWidth = true; + } + + ++$i; + } + + // Handle any remaining pending ANSI codes (attach to last token) + if ('' !== $pendingAnsi) { + $current .= $pendingAnsi; + $currentHasAnsi = true; + } + + if ('' !== $current) { + /* @var int $currentWidth */ + $tokens[] = [ + 'text' => $current, + 'width' => $needsUnicodeWidth ? AnsiUtils::visibleWidth($current) : $currentWidth, + 'is_whitespace' => $inWhitespace, + 'has_ansi' => $currentHasAnsi, + ]; + } + + return $tokens; + } + + /** + * Break a long word into multiple lines. + * + * @return array{lines: string[], last_width: int} + */ + private static function breakLongWord(string $word, int $width, AnsiCodeTracker $tracker): array + { + $lines = []; + $currentLine = $tracker->getActiveCodes(); + $currentWidth = 0; + + $i = 0; + $wordLen = \strlen($word); + $segments = []; + + // First, separate ANSI codes from visible content + while ($i < $wordLen) { + $byte = $word[$i]; + + // Only check for ANSI when we see an ESC byte + if ("\x1b" === $byte) { + $ansi = AnsiUtils::extractAnsiCode($word, $i); + if (null !== $ansi) { + $segments[] = ['type' => 'ansi', 'value' => $ansi['code']]; + $i += $ansi['length']; + continue; + } + } + + // Find the next ESC byte or end of string for the text portion + $end = strpos($word, "\x1b", $i + 1); + if (false === $end) { + $end = $wordLen; + } + + // Segment this non-ANSI portion into graphemes + $textPortion = substr($word, $i, $end - $i); + $graphemes = grapheme_str_split($textPortion); + if (false !== $graphemes) { + foreach ($graphemes as $grapheme) { + $segments[] = ['type' => 'grapheme', 'value' => $grapheme]; + } + } + $i = $end; + } + + // Process segments + foreach ($segments as $seg) { + if ('ansi' === $seg['type']) { + $currentLine .= $seg['value']; + $tracker->process($seg['value']); + continue; + } + + $grapheme = $seg['value']; + if ('' === $grapheme) { + continue; + } + + $graphemeWidth = AnsiUtils::graphemeWidth($grapheme); + + if ($currentWidth + $graphemeWidth > $width) { + // Add specific reset for underline only (preserves background) + $lineEndReset = $tracker->getLineEndReset(); + if ('' !== $lineEndReset) { + $currentLine .= $lineEndReset; + } + $lines[] = $currentLine; + $currentLine = $tracker->getActiveCodes(); + $currentWidth = 0; + } + + $currentLine .= $grapheme; + $currentWidth += $graphemeWidth; + } + + if ('' !== $currentLine) { + $lines[] = $currentLine; + } + + if ([] === $lines) { + return ['lines' => [''], 'last_width' => 0]; + } + + return ['lines' => $lines, 'last_width' => $currentWidth]; + } +} diff --git a/extern/Tui/CHANGELOG b/extern/Tui/CHANGELOG new file mode 100644 index 00000000..56cfb06a --- /dev/null +++ b/extern/Tui/CHANGELOG @@ -0,0 +1,7 @@ +CHANGELOG +========= + +8.1 +--- + + * Introduce the component as experimental diff --git a/extern/Tui/Event/AbstractEvent.php b/extern/Tui/Event/AbstractEvent.php new file mode 100644 index 00000000..82498186 --- /dev/null +++ b/extern/Tui/Event/AbstractEvent.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Contracts\EventDispatcher\Event as BaseEvent; + +/** + * Base class for all TUI widget events. + * + * Extends Symfony's Event so it can be dispatched through + * Symfony's EventDispatcher. Carries the target widget that + * originated the event. + * + * @experimental + * + * @author Fabien Potencier + */ +abstract class AbstractEvent extends BaseEvent +{ + public function __construct( + private readonly AbstractWidget $target, + ) { + } + + public function getTarget(): AbstractWidget + { + return $this->target; + } +} diff --git a/extern/Tui/Event/CancelEvent.php b/extern/Tui/Event/CancelEvent.php new file mode 100644 index 00000000..a2f4d9fa --- /dev/null +++ b/extern/Tui/Event/CancelEvent.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +/** + * Event dispatched when a widget action is cancelled (e.g., Escape pressed). + * + * @experimental + * + * @author Fabien Potencier + */ +class CancelEvent extends AbstractEvent +{ +} diff --git a/extern/Tui/Event/ChangeEvent.php b/extern/Tui/Event/ChangeEvent.php new file mode 100644 index 00000000..3b56b901 --- /dev/null +++ b/extern/Tui/Event/ChangeEvent.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Event dispatched when a widget's value changes. + * + * @experimental + * + * @author Fabien Potencier + */ +class ChangeEvent extends AbstractEvent +{ + public function __construct( + AbstractWidget $target, + private readonly string $value, + ) { + parent::__construct($target); + } + + /** + * Get the current value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Check if the current value is empty or contains only whitespace. + */ + public function isEmpty(): bool + { + return '' === trim($this->value); + } +} diff --git a/extern/Tui/Event/FocusEvent.php b/extern/Tui/Event/FocusEvent.php new file mode 100644 index 00000000..a69a691d --- /dev/null +++ b/extern/Tui/Event/FocusEvent.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\FocusableInterface; + +/** + * Event dispatched when focus changes to a new widget. + * + * @experimental + * + * @author Fabien Potencier + */ +class FocusEvent extends AbstractEvent +{ + public function __construct( + AbstractWidget&FocusableInterface $target, + private readonly ?FocusableInterface $previous, + ) { + parent::__construct($target); + } + + /** + * Get the previously focused widget, if any. + */ + public function getPrevious(): ?FocusableInterface + { + return $this->previous; + } +} diff --git a/extern/Tui/Event/InputEvent.php b/extern/Tui/Event/InputEvent.php new file mode 100644 index 00000000..f129a48b --- /dev/null +++ b/extern/Tui/Event/InputEvent.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Event dispatched when raw terminal input is received. + * + * Dispatched before focus navigation and before the focused widget + * receives input. Call {@see stopPropagation()} to consume the input + * and prevent further processing. + * + * @experimental + * + * @author Fabien Potencier + */ +class InputEvent extends Event +{ + public function __construct( + private readonly string $data, + ) { + } + + /** + * The raw input data from the terminal. + */ + public function getData(): string + { + return $this->data; + } +} diff --git a/extern/Tui/Event/QuitEvent.php b/extern/Tui/Event/QuitEvent.php new file mode 100644 index 00000000..7b87052a --- /dev/null +++ b/extern/Tui/Event/QuitEvent.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +/** + * Event dispatched when user requests to quit. + * + * @experimental + * + * @author Fabien Potencier + */ +class QuitEvent extends AbstractEvent +{ +} diff --git a/extern/Tui/Event/SelectEvent.php b/extern/Tui/Event/SelectEvent.php new file mode 100644 index 00000000..7efa66e4 --- /dev/null +++ b/extern/Tui/Event/SelectEvent.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\SelectListWidget; + +/** + * Event dispatched when an item is selected in a SelectList. + * + * @experimental + * + * @author Fabien Potencier + */ +class SelectEvent extends AbstractEvent +{ + /** + * @param array{value: string, label: string, description?: string} $item + */ + public function __construct( + SelectListWidget $target, + private readonly array $item, + ) { + parent::__construct($target); + } + + /** + * Get the full selected item array. + * + * @return array{value: string, label: string, description?: string} + */ + public function getItem(): array + { + return $this->item; + } + + /** + * Get the selected item's value. + */ + public function getValue(): string + { + return $this->item['value']; + } + + /** + * Get the selected item's label. + */ + public function getLabel(): string + { + return $this->item['label']; + } + + /** + * Get the selected item's description, if any. + */ + public function getDescription(): ?string + { + return $this->item['description'] ?? null; + } +} diff --git a/extern/Tui/Event/SelectionChangeEvent.php b/extern/Tui/Event/SelectionChangeEvent.php new file mode 100644 index 00000000..f179f7c7 --- /dev/null +++ b/extern/Tui/Event/SelectionChangeEvent.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\SelectListWidget; + +/** + * Event dispatched when the highlighted item changes in a SelectList. + * + * This fires when the user moves the cursor (arrow keys, scroll), not + * when they confirm a selection (that's {@see SelectEvent}). + * + * @experimental + * + * @author Fabien Potencier + */ +class SelectionChangeEvent extends AbstractEvent +{ + /** + * @param array{value: string, label: string, description?: string} $item + */ + public function __construct( + SelectListWidget $target, + private readonly array $item, + ) { + parent::__construct($target); + } + + /** + * Get the full highlighted item array. + * + * @return array{value: string, label: string, description?: string} + */ + public function getItem(): array + { + return $this->item; + } + + /** + * Get the highlighted item's value. + */ + public function getValue(): string + { + return $this->item['value']; + } + + /** + * Get the highlighted item's label. + */ + public function getLabel(): string + { + return $this->item['label']; + } + + /** + * Get the highlighted item's description, if any. + */ + public function getDescription(): ?string + { + return $this->item['description'] ?? null; + } +} diff --git a/extern/Tui/Event/SettingChangeEvent.php b/extern/Tui/Event/SettingChangeEvent.php new file mode 100644 index 00000000..d88e5dbf --- /dev/null +++ b/extern/Tui/Event/SettingChangeEvent.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\SettingsListWidget; + +/** + * Event dispatched when a setting value changes in SettingsList. + * + * @experimental + * + * @author Fabien Potencier + */ +class SettingChangeEvent extends AbstractEvent +{ + private const ENABLED_VALUES = ['on', 'true', 'yes', '1', 'enabled']; + private const DISABLED_VALUES = ['off', 'false', 'no', '0', 'disabled']; + + public function __construct( + SettingsListWidget $target, + private readonly string $id, + private readonly string $value, + ) { + parent::__construct($target); + } + + /** + * Get the setting identifier. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the new setting value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Check if the value represents an enabled/truthy state. + * + * Matches: on, true, yes, 1, enabled + */ + public function isEnabled(): bool + { + return \in_array(strtolower($this->value), self::ENABLED_VALUES, true); + } + + /** + * Check if the value represents a disabled/falsy state. + * + * Matches: off, false, no, 0, disabled + */ + public function isDisabled(): bool + { + return \in_array(strtolower($this->value), self::DISABLED_VALUES, true); + } +} diff --git a/extern/Tui/Event/SubmitEvent.php b/extern/Tui/Event/SubmitEvent.php new file mode 100644 index 00000000..64f184fa --- /dev/null +++ b/extern/Tui/Event/SubmitEvent.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Event dispatched when content is submitted (e.g., Enter pressed in Input/Editor). + * + * @experimental + * + * @author Fabien Potencier + */ +class SubmitEvent extends AbstractEvent +{ + public function __construct( + AbstractWidget $target, + private readonly string $value, + ) { + parent::__construct($target); + } + + /** + * Get the submitted value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Check if the submitted value is empty or contains only whitespace. + */ + public function isEmpty(): bool + { + return '' === trim($this->value); + } +} diff --git a/extern/Tui/Event/TickEvent.php b/extern/Tui/Event/TickEvent.php new file mode 100644 index 00000000..a9de8e26 --- /dev/null +++ b/extern/Tui/Event/TickEvent.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Event; + +/** + * Event dispatched on each tick of the main loop. + * + * Unlike widget events, tick is a global application event + * with no associated widget target. + * + * @experimental + * + * @author Fabien Potencier + */ +class TickEvent +{ + private bool $hasBusyHint = false; + private bool $busy = false; + + public function __construct( + private readonly float $deltaTime = 0.0, + ) { + } + + /** + * Time elapsed (in seconds) since the previous tick callback. + */ + public function getDeltaTime(): float + { + return $this->deltaTime; + } + + public function setBusy(bool $busy = true): void + { + $this->hasBusyHint = true; + $this->busy = $busy; + } + + public function hasBusyHint(): bool + { + return $this->hasBusyHint; + } + + public function isBusy(): bool + { + return $this->busy; + } +} diff --git a/extern/Tui/Exception/ExceptionInterface.php b/extern/Tui/Exception/ExceptionInterface.php new file mode 100644 index 00000000..0d2765ad --- /dev/null +++ b/extern/Tui/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * Exception interface for all exceptions thrown by the component. + * + * @experimental + * + * @author Fabien Potencier + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/extern/Tui/Exception/InvalidArgumentException.php b/extern/Tui/Exception/InvalidArgumentException.php new file mode 100644 index 00000000..57cd86db --- /dev/null +++ b/extern/Tui/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * @experimental + * + * @author Fabien Potencier + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/extern/Tui/Exception/LogicException.php b/extern/Tui/Exception/LogicException.php new file mode 100644 index 00000000..b34e5170 --- /dev/null +++ b/extern/Tui/Exception/LogicException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * @experimental + * + * @author Fabien Potencier + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/extern/Tui/Exception/RenderException.php b/extern/Tui/Exception/RenderException.php new file mode 100644 index 00000000..f23acf35 --- /dev/null +++ b/extern/Tui/Exception/RenderException.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * Exception thrown when rendering fails. + * + * Typically thrown when a component renders a line that exceeds the terminal width. + * + * @experimental + * + * @author Fabien Potencier + */ +class RenderException extends RuntimeException +{ + public function __construct( + string $message, + private readonly int $lineNumber = 0, + private readonly int $lineWidth = 0, + private readonly int $terminalWidth = 0, + ) { + parent::__construct($message); + } + + public function getLineNumber(): int + { + return $this->lineNumber; + } + + public function getLineWidth(): int + { + return $this->lineWidth; + } + + public function getTerminalWidth(): int + { + return $this->terminalWidth; + } +} diff --git a/extern/Tui/Exception/RuntimeException.php b/extern/Tui/Exception/RuntimeException.php new file mode 100644 index 00000000..e7263c13 --- /dev/null +++ b/extern/Tui/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Exception; + +/** + * @experimental + * + * @author Fabien Potencier + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/extern/Tui/Focus/FocusManager.php b/extern/Tui/Focus/FocusManager.php new file mode 100644 index 00000000..0ee9e2ba --- /dev/null +++ b/extern/Tui/Focus/FocusManager.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Focus; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Event\FocusEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Input\KeyParser; +use Symfony\Component\Tui\Render\RenderRequestorInterface; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\FocusableInterface; + +/** + * Owns the focused-widget state and handles focus navigation. + * + * Default bindings: F6 (next) and Shift+F6 (previous). + * + * @experimental + * + * @author Fabien Potencier + */ +class FocusManager +{ + private const DEFAULT_BINDINGS = [ + 'focus_next' => [Key::F6], + 'focus_previous' => ['shift+f6'], + ]; + + private ?AbstractWidget $focused = null; + + /** @var array */ + private array $focusables = []; + + private Keybindings $keybindings; + + public function __construct( + private readonly RenderRequestorInterface $renderRequestor, + ?Keybindings $keybindings = null, + ?KeyParser $parser = null, + private ?EventDispatcherInterface $eventDispatcher = null, + ) { + $this->keybindings = $keybindings ?? new Keybindings(self::DEFAULT_BINDINGS, $parser); + } + + /** + * Set the focused widget. + * + * Clears the focused flag on the previous widget, sets it on the + * new one, and fires the onFocusChanged callback. + */ + public function setFocus(?AbstractWidget $widget): void + { + if ($this->focused === $widget) { + return; + } + + $previous = $this->focused; + + if ($this->focused instanceof FocusableInterface) { + $this->focused->setFocused(false); + } + + $this->focused = $widget; + + if ($widget instanceof FocusableInterface) { + $widget->setFocused(true); + } + + $this->notifyFocusChanged($widget, $previous); + $this->renderRequestor->requestRender(); + } + + /** + * Get the currently focused widget. + */ + public function getFocus(): ?AbstractWidget + { + return $this->focused; + } + + /** + * @return $this + */ + public function add(FocusableInterface&AbstractWidget $widget): static + { + if (!\in_array($widget, $this->focusables, true)) { + $this->focusables[] = $widget; + + if (null === $this->focused) { + $this->setFocus($widget); + } + } + + return $this; + } + + /** + * @return $this + */ + public function remove(FocusableInterface&AbstractWidget $widget): static + { + $index = array_search($widget, $this->focusables, true); + if (false !== $index) { + array_splice($this->focusables, (int) $index, 1); + } + + if ($this->focused === $widget) { + $next = $this->focusables[0] ?? null; + $this->setFocus($next); + } + + return $this; + } + + /** + * @return $this + */ + public function clear(): static + { + $this->focusables = []; + + return $this; + } + + /** + * @return FocusableInterface[] + */ + public function all(): array + { + return $this->focusables; + } + + /** + * Register a listener for focus change events. + * + * @param callable(FocusEvent): void $callback + * + * @return $this + */ + public function onFocusChanged(callable $callback): static + { + $this->eventDispatcher?->addListener(FocusEvent::class, $callback); + + return $this; + } + + public function handleInput(string $data): bool + { + // Only handle focus navigation when there are multiple focusables + if (\count($this->focusables) <= 1) { + return false; + } + + if ($this->keybindings->matches($data, 'focus_next')) { + $this->focusNext(); + + return true; + } + + if ($this->keybindings->matches($data, 'focus_previous')) { + $this->focusPrevious(); + + return true; + } + + return false; + } + + public function focusNext(): ?FocusableInterface + { + $count = \count($this->focusables); + if (0 === $count) { + return null; + } + + $index = array_search($this->focused, $this->focusables, true); + if (false === $index) { + $index = -1; + } else { + $index = (int) $index; + } + + $nextIndex = ($index + 1) % $count; + $next = $this->focusables[$nextIndex]; + $this->setFocus($next); + + return $next; + } + + public function focusPrevious(): ?FocusableInterface + { + $count = \count($this->focusables); + if (0 === $count) { + return null; + } + + $index = array_search($this->focused, $this->focusables, true); + if (false === $index) { + $index = 0; + } else { + $index = (int) $index; + } + + $previousIndex = ($index - 1 + $count) % $count; + $previous = $this->focusables[$previousIndex]; + $this->setFocus($previous); + + return $previous; + } + + private function notifyFocusChanged(?AbstractWidget $focused, ?AbstractWidget $previous): void + { + if (null === $focused || $focused === $previous) { + return; + } + + if (!$focused instanceof FocusableInterface) { + return; + } + + $this->eventDispatcher?->dispatch(new FocusEvent( + $focused, + $previous instanceof FocusableInterface ? $previous : null, + )); + } +} diff --git a/extern/Tui/Input/Key.php b/extern/Tui/Input/Key.php new file mode 100644 index 00000000..873fe69c --- /dev/null +++ b/extern/Tui/Input/Key.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Helper class for creating key identifiers. + * + * Provides constants and factory methods for keyboard input matching. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Key +{ + // Special keys + public const ESCAPE = 'escape'; + public const ENTER = 'enter'; + public const TAB = 'tab'; + public const SPACE = 'space'; + public const BACKSPACE = 'backspace'; + public const DELETE = 'delete'; + public const INSERT = 'insert'; + public const HOME = 'home'; + public const END = 'end'; + public const PAGE_UP = 'page_up'; + public const PAGE_DOWN = 'page_down'; + + // Arrow keys + public const UP = 'up'; + public const DOWN = 'down'; + public const LEFT = 'left'; + public const RIGHT = 'right'; + + // Function keys + public const F1 = 'f1'; + public const F2 = 'f2'; + public const F3 = 'f3'; + public const F4 = 'f4'; + public const F5 = 'f5'; + public const F6 = 'f6'; + public const F7 = 'f7'; + public const F8 = 'f8'; + public const F9 = 'f9'; + public const F10 = 'f10'; + public const F11 = 'f11'; + public const F12 = 'f12'; + + public static function ctrl(string $key): string + { + return 'ctrl+'.strtolower($key); + } + + public static function shift(string $key): string + { + return 'shift+'.strtolower($key); + } + + public static function alt(string $key): string + { + return 'alt+'.strtolower($key); + } + + public static function ctrlShift(string $key): string + { + return 'ctrl+shift+'.strtolower($key); + } + + public static function ctrlAlt(string $key): string + { + return 'ctrl+alt+'.strtolower($key); + } + + public static function shiftAlt(string $key): string + { + return 'shift+alt+'.strtolower($key); + } + + public static function ctrlShiftAlt(string $key): string + { + return 'ctrl+shift+alt+'.strtolower($key); + } +} diff --git a/extern/Tui/Input/KeyParser.php b/extern/Tui/Input/KeyParser.php new file mode 100644 index 00000000..64fa6804 --- /dev/null +++ b/extern/Tui/Input/KeyParser.php @@ -0,0 +1,968 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Parses raw terminal input into key identifiers. + * + * Supports both legacy terminal sequences and Kitty keyboard protocol. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class KeyParser +{ + private const MOD_SHIFT = 1; + private const MOD_ALT = 2; + private const MOD_CTRL = 4; + private const LOCK_MASK = 192; // Caps Lock + Num Lock + + private const EVENT_PRESS = 1; + private const EVENT_REPEAT = 2; + private const EVENT_RELEASE = 3; + + private const CODEPOINTS = [ + 'escape' => 27, + 'tab' => 9, + 'enter' => 13, + 'space' => 32, + 'backspace' => 127, + 'kp_enter' => 57414, + ]; + + private const ARROW_CODEPOINTS = [ + 'up' => -1, + 'down' => -2, + 'right' => -3, + 'left' => -4, + ]; + + private const FUNCTIONAL_CODEPOINTS = [ + 'delete' => -10, + 'insert' => -11, + 'page_up' => -12, + 'page_down' => -13, + 'home' => -14, + 'end' => -15, + ]; + + private const LEGACY_KEY_SEQUENCES = [ + 'up' => ["\x1b[A", "\x1bOA"], + 'down' => ["\x1b[B", "\x1bOB"], + 'right' => ["\x1b[C", "\x1bOC"], + 'left' => ["\x1b[D", "\x1bOD"], + 'home' => ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], + 'end' => ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], + 'insert' => ["\x1b[2~"], + 'delete' => ["\x1b[3~"], + 'page_up' => ["\x1b[5~", "\x1b[[5~"], + 'page_down' => ["\x1b[6~", "\x1b[[6~"], + 'clear' => ["\x1b[E", "\x1bOE"], + 'f1' => ["\x1bOP", "\x1b[11~", "\x1b[[A"], + 'f2' => ["\x1bOQ", "\x1b[12~", "\x1b[[B"], + 'f3' => ["\x1bOR", "\x1b[13~", "\x1b[[C"], + 'f4' => ["\x1bOS", "\x1b[14~", "\x1b[[D"], + 'f5' => ["\x1b[15~", "\x1b[[E"], + 'f6' => ["\x1b[17~"], + 'f7' => ["\x1b[18~"], + 'f8' => ["\x1b[19~"], + 'f9' => ["\x1b[20~"], + 'f10' => ["\x1b[21~"], + 'f11' => ["\x1b[23~"], + 'f12' => ["\x1b[24~"], + ]; + + private const LEGACY_FUNCTION_KEY_CODES = [ + 'f1' => 11, + 'f2' => 12, + 'f3' => 13, + 'f4' => 14, + 'f5' => 15, + 'f6' => 17, + 'f7' => 18, + 'f8' => 19, + 'f9' => 20, + 'f10' => 21, + 'f11' => 23, + 'f12' => 24, + ]; + + private const LEGACY_FUNCTION_KEY_LETTERS = [ + 'f1' => 'P', + 'f2' => 'Q', + 'f3' => 'R', + 'f4' => 'S', + ]; + + private const LEGACY_SHIFT_SEQUENCES = [ + 'up' => ["\x1b[a"], + 'down' => ["\x1b[b"], + 'right' => ["\x1b[c"], + 'left' => ["\x1b[d"], + 'clear' => ["\x1b[e"], + 'insert' => ["\x1b[2$"], + 'delete' => ["\x1b[3$"], + 'page_up' => ["\x1b[5$"], + 'page_down' => ["\x1b[6$"], + 'home' => ["\x1b[7$"], + 'end' => ["\x1b[8$"], + ]; + + private const LEGACY_CTRL_SEQUENCES = [ + 'up' => ["\x1bOa"], + 'down' => ["\x1bOb"], + 'right' => ["\x1bOc"], + 'left' => ["\x1bOd"], + 'clear' => ["\x1bOe"], + 'insert' => ["\x1b[2^"], + 'delete' => ["\x1b[3^"], + 'page_up' => ["\x1b[5^"], + 'page_down' => ["\x1b[6^"], + 'home' => ["\x1b[7^"], + 'end' => ["\x1b[8^"], + ]; + + private const LEGACY_SEQUENCE_KEY_IDS = [ + "\x1bOA" => 'up', + "\x1bOB" => 'down', + "\x1bOC" => 'right', + "\x1bOD" => 'left', + "\x1bOH" => 'home', + "\x1bOF" => 'end', + "\x1b[E" => 'clear', + "\x1bOE" => 'clear', + "\x1bOe" => 'ctrl+clear', + "\x1b[e" => 'shift+clear', + "\x1b[2~" => 'insert', + "\x1b[2$" => 'shift+insert', + "\x1b[2^" => 'ctrl+insert', + "\x1b[3$" => 'shift+delete', + "\x1b[3^" => 'ctrl+delete', + "\x1b[[5~" => 'page_up', + "\x1b[[6~" => 'page_down', + "\x1b[a" => 'shift+up', + "\x1b[b" => 'shift+down', + "\x1b[c" => 'shift+right', + "\x1b[d" => 'shift+left', + "\x1bOa" => 'ctrl+up', + "\x1bOb" => 'ctrl+down', + "\x1bOc" => 'ctrl+right', + "\x1bOd" => 'ctrl+left', + "\x1b[5$" => 'shift+page_up', + "\x1b[6$" => 'shift+page_down', + "\x1b[7$" => 'shift+home', + "\x1b[8$" => 'shift+end', + "\x1b[5^" => 'ctrl+page_up', + "\x1b[6^" => 'ctrl+page_down', + "\x1b[7^" => 'ctrl+home', + "\x1b[8^" => 'ctrl+end', + "\x1bOP" => 'f1', + "\x1bOQ" => 'f2', + "\x1bOR" => 'f3', + "\x1bOS" => 'f4', + "\x1b[11~" => 'f1', + "\x1b[12~" => 'f2', + "\x1b[13~" => 'f3', + "\x1b[14~" => 'f4', + "\x1b[[A" => 'f1', + "\x1b[[B" => 'f2', + "\x1b[[C" => 'f3', + "\x1b[[D" => 'f4', + "\x1b[[E" => 'f5', + "\x1b[15~" => 'f5', + "\x1b[17~" => 'f6', + "\x1b[18~" => 'f7', + "\x1b[19~" => 'f8', + "\x1b[20~" => 'f9', + "\x1b[21~" => 'f10', + "\x1b[23~" => 'f11', + "\x1b[24~" => 'f12', + "\x1bb" => 'alt+left', + "\x1bf" => 'alt+right', + "\x1bp" => 'alt+up', + "\x1bn" => 'alt+down', + ]; + + private const SYMBOL_KEYS = [ + '`', '-', '=', '[', ']', '\\', ';', "'", ',', '.', '/', + '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', + '|', '~', '{', '}', ':', '<', '>', '?', + ]; + + private bool $kittyProtocolActive = false; + + public function setKittyProtocolActive(bool $active): void + { + $this->kittyProtocolActive = $active; + } + + public function isKittyProtocolActive(): bool + { + return $this->kittyProtocolActive; + } + + /** + * Parse raw input and return the key identifier. + * + * @return array{key: string, modifiers: array, event_type: int}|null + */ + public function parse(string $data): ?array + { + $parsed = $this->parseKey($data); + if (null === $parsed) { + return null; + } + + $key = $parsed['key']; + $modifiers = []; + + if (str_contains($key, '+')) { + $parts = explode('+', $key); + $keyPart = array_pop($parts); + $modifiers = $parts; + $key = $parts ? implode('+', $parts).'+'.$keyPart : $keyPart; + } + + return [ + 'key' => $key, + 'modifiers' => $modifiers, + 'event_type' => $parsed['event_type'], + ]; + } + + /** + * Check if input matches a key identifier. + */ + public function matches(string $data, string $keyId): bool + { + if ($this->isKeyRelease($data)) { + return false; + } + + return $this->matchesKey($data, $keyId); + } + + public function isKeyRelease(string $data): bool + { + if (str_contains($data, "\x1b[200~")) { + return false; + } + + return (bool) preg_match('/:3[u~ABCDHF]$/', $data); + } + + public function isKeyRepeat(string $data): bool + { + if (str_contains($data, "\x1b[200~")) { + return false; + } + + return (bool) preg_match('/:2[u~ABCDHF]$/', $data); + } + + /** + * Parse input data into a key identifier and event type. + * + * @return array{key: string, event_type: int}|null + */ + private function parseKey(string $data): ?array + { + if ('' === $data) { + return null; + } + + if ( + $this->kittyProtocolActive + || (str_starts_with($data, "\x1b[") && (str_ends_with($data, 'u') || str_contains($data, ':'))) + ) { + $kitty = $this->parseKittySequence($data); + if (null !== $kitty) { + $keyName = $this->keyNameFromCodepoint($kitty['codepoint']); + if (null !== $keyName) { + $mods = $this->modsFromFlags($kitty['modifier']); + $key = [] !== $mods ? implode('+', $mods).'+'.$keyName : $keyName; + + return ['key' => $key, 'event_type' => $kitty['event_type']]; + } + } + } + + if ($this->kittyProtocolActive) { + if ("\x1b\r" === $data || "\n" === $data) { + return ['key' => 'shift+enter', 'event_type' => self::EVENT_PRESS]; + } + } + + if (isset(self::LEGACY_SEQUENCE_KEY_IDS[$data])) { + return ['key' => self::LEGACY_SEQUENCE_KEY_IDS[$data], 'event_type' => self::EVENT_PRESS]; + } + + $press = static fn (string $key): array => ['key' => $key, 'event_type' => self::EVENT_PRESS]; + + $matched = match ($data) { + "\x1b" => $press('escape'), + "\x1c" => $press('ctrl+\\'), + "\x1d" => $press('ctrl+]'), + "\x1f" => $press('ctrl+-'), + "\x1b\x1b" => $press('ctrl+alt+['), + "\x1b\x1c" => $press('ctrl+alt+\\'), + "\x1b\x1d" => $press('ctrl+alt+]'), + "\x1b\x1f" => $press('ctrl+alt+-'), + "\t" => $press('tab'), + "\r", "\x1bOM" => $press('enter'), + "\x00" => $press('ctrl+space'), + ' ' => $press('space'), + "\x7f", "\x08" => $press('backspace'), + "\x1b[Z" => $press('shift+tab'), + "\x1b\x7f", "\x1b\x08" => $press('alt+backspace'), + "\x1b[A" => $press('up'), + "\x1b[B" => $press('down'), + "\x1b[C" => $press('right'), + "\x1b[D" => $press('left'), + "\x1b[H" => $press('home'), + "\x1b[F" => $press('end'), + "\x1b[3~" => $press('delete'), + "\x1b[5~" => $press('page_up'), + "\x1b[6~" => $press('page_down'), + default => null, + }; + if (null !== $matched) { + return $matched; + } + + if (!$this->kittyProtocolActive && "\n" === $data) { + return $press('enter'); + } + if (!$this->kittyProtocolActive) { + $matched = match ($data) { + "\x1b\r" => $press('alt+enter'), + "\x1b " => $press('alt+space'), + "\x1bB" => $press('alt+left'), + "\x1bF" => $press('alt+right'), + default => null, + }; + if (null !== $matched) { + return $matched; + } + + if (2 === \strlen($data) && "\x1b" === $data[0]) { + $code = \ord($data[1]); + if ($code >= 1 && $code <= 26) { + return $press('ctrl+alt+'.\chr($code + 96)); + } + if (($code >= 48 && $code <= 57) || ($code >= 97 && $code <= 122)) { + return $press('alt+'.\chr($code)); + } + } + } + + if (1 === \strlen($data)) { + $code = \ord($data); + if ($code >= 1 && $code <= 26) { + return $press('ctrl+'.\chr($code + 96)); + } + if ($code >= 32 && $code <= 126) { + return $press($data); + } + } + + if (\strlen($data) > 1 && !str_starts_with($data, "\x1b")) { + return $press($data); + } + + return null; + } + + /** + * @return array{codepoint: int, modifier: int, event_type: int}|null + */ + private function parseKittySequence(string $data): ?array + { + // Format: ESC [ codepoint[:shifted_key[:base_layout_key]] [;modifiers[:event_type]] u + // We parse the full syntax but only use the codepoint (logical key) + // for key resolution. The base_layout_key (US QWERTY physical position) + // is intentionally ignored; keybindings must follow the logical layout + // so that e.g. Ctrl+W means Ctrl+W on every keyboard layout. + if (preg_match('/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/', $data, $match)) { + $codepoint = (int) $match[1]; + $modifierValue = isset($match[4]) && '' !== $match[4] ? (int) $match[4] : 1; + $eventType = $this->parseEventType($match[5] ?? null); + + return [ + 'codepoint' => $codepoint, + 'modifier' => $modifierValue - 1, + 'event_type' => $eventType, + ]; + } + + if (preg_match('/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/', $data, $match)) { + $modifierValue = (int) $match[1]; + $eventType = $this->parseEventType('' !== $match[2] ? $match[2] : null); + $arrowCodes = [ + 'A' => self::ARROW_CODEPOINTS['up'], + 'B' => self::ARROW_CODEPOINTS['down'], + 'C' => self::ARROW_CODEPOINTS['right'], + 'D' => self::ARROW_CODEPOINTS['left'], + ]; + + return [ + 'codepoint' => $arrowCodes[$match[3]], + 'modifier' => $modifierValue - 1, + 'event_type' => $eventType, + ]; + } + + if (preg_match('/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/', $data, $match)) { + $keyNum = (int) $match[1]; + $modifierValue = isset($match[2]) && '' !== $match[2] ? (int) $match[2] : 1; + $eventType = $this->parseEventType($match[3] ?? null); + $funcCodes = [ + 2 => self::FUNCTIONAL_CODEPOINTS['insert'], + 3 => self::FUNCTIONAL_CODEPOINTS['delete'], + 5 => self::FUNCTIONAL_CODEPOINTS['page_up'], + 6 => self::FUNCTIONAL_CODEPOINTS['page_down'], + 7 => self::FUNCTIONAL_CODEPOINTS['home'], + 8 => self::FUNCTIONAL_CODEPOINTS['end'], + ]; + + if (isset($funcCodes[$keyNum])) { + return [ + 'codepoint' => $funcCodes[$keyNum], + 'modifier' => $modifierValue - 1, + 'event_type' => $eventType, + ]; + } + } + + if (preg_match('/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/', $data, $match)) { + $modifierValue = (int) $match[1]; + $eventType = $this->parseEventType('' !== $match[2] ? $match[2] : null); + $codepoint = 'H' === $match[3] + ? self::FUNCTIONAL_CODEPOINTS['home'] + : self::FUNCTIONAL_CODEPOINTS['end']; + + return [ + 'codepoint' => $codepoint, + 'modifier' => $modifierValue - 1, + 'event_type' => $eventType, + ]; + } + + return null; + } + + private function parseEventType(?string $eventTypeStr): int + { + if (null === $eventTypeStr || '' === $eventTypeStr) { + return self::EVENT_PRESS; + } + + return match ((int) $eventTypeStr) { + self::EVENT_REPEAT => self::EVENT_REPEAT, + self::EVENT_RELEASE => self::EVENT_RELEASE, + default => self::EVENT_PRESS, + }; + } + + private function keyNameFromCodepoint(int $codepoint): ?string + { + return match ($codepoint) { + self::CODEPOINTS['escape'] => 'escape', + self::CODEPOINTS['tab'] => 'tab', + self::CODEPOINTS['enter'], self::CODEPOINTS['kp_enter'] => 'enter', + self::CODEPOINTS['space'] => 'space', + self::CODEPOINTS['backspace'] => 'backspace', + self::FUNCTIONAL_CODEPOINTS['delete'] => 'delete', + self::FUNCTIONAL_CODEPOINTS['insert'] => 'insert', + self::FUNCTIONAL_CODEPOINTS['home'] => 'home', + self::FUNCTIONAL_CODEPOINTS['end'] => 'end', + self::FUNCTIONAL_CODEPOINTS['page_up'] => 'page_up', + self::FUNCTIONAL_CODEPOINTS['page_down'] => 'page_down', + self::ARROW_CODEPOINTS['up'] => 'up', + self::ARROW_CODEPOINTS['down'] => 'down', + self::ARROW_CODEPOINTS['left'] => 'left', + self::ARROW_CODEPOINTS['right'] => 'right', + default => $this->keyNameFromChar($codepoint), + }; + } + + private function keyNameFromChar(int $codepoint): ?string + { + if (($codepoint >= 48 && $codepoint <= 57) || ($codepoint >= 97 && $codepoint <= 122)) { + return \chr($codepoint); + } + + if ($codepoint < 0 || $codepoint > 255) { + return null; + } + + $char = \chr($codepoint); + + return \in_array($char, self::SYMBOL_KEYS, true) ? $char : null; + } + + /** + * @return string[] + */ + private function modsFromFlags(int $modifier): array + { + $mods = []; + $effective = $modifier & ~self::LOCK_MASK; + if ($effective & self::MOD_SHIFT) { + $mods[] = 'shift'; + } + if ($effective & self::MOD_CTRL) { + $mods[] = 'ctrl'; + } + if ($effective & self::MOD_ALT) { + $mods[] = 'alt'; + } + + return $mods; + } + + private function matchesKey(string $data, string $keyId): bool + { + $parsed = $this->parseKeyId($keyId); + if (null === $parsed) { + return false; + } + + $key = $parsed['key']; + $ctrl = $parsed['ctrl']; + $shift = $parsed['shift']; + $alt = $parsed['alt']; + + $modifier = 0; + if ($shift) { + $modifier |= self::MOD_SHIFT; + } + if ($alt) { + $modifier |= self::MOD_ALT; + } + if ($ctrl) { + $modifier |= self::MOD_CTRL; + } + + switch ($key) { + case 'escape': + case 'esc': + if (0 !== $modifier) { + return false; + } + + return "\x1b" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['escape'], 0); + + case 'space': + if (!$this->kittyProtocolActive) { + if ($ctrl && !$alt && !$shift && "\x00" === $data) { + return true; + } + if ($alt && !$ctrl && !$shift && "\x1b " === $data) { + return true; + } + } + if (0 === $modifier) { + return ' ' === $data || $this->matchesKittySequence($data, self::CODEPOINTS['space'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['space'], $modifier); + + case 'tab': + if ($shift && !$ctrl && !$alt) { + return "\x1b[Z" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['tab'], self::MOD_SHIFT); + } + if (0 === $modifier) { + return "\t" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['tab'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['tab'], $modifier); + + case 'enter': + case 'return': + if ($shift && !$ctrl && !$alt) { + if ( + $this->matchesKittySequence($data, self::CODEPOINTS['enter'], self::MOD_SHIFT) + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], self::MOD_SHIFT) + ) { + return true; + } + if ($this->matchesModifyOtherKeys($data, self::CODEPOINTS['enter'], self::MOD_SHIFT)) { + return true; + } + if ($this->kittyProtocolActive) { + return "\x1b\r" === $data || "\n" === $data; + } + + return false; + } + if ($alt && !$ctrl && !$shift) { + if ( + $this->matchesKittySequence($data, self::CODEPOINTS['enter'], self::MOD_ALT) + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], self::MOD_ALT) + ) { + return true; + } + if ($this->matchesModifyOtherKeys($data, self::CODEPOINTS['enter'], self::MOD_ALT)) { + return true; + } + if (!$this->kittyProtocolActive) { + return "\x1b\r" === $data; + } + + return false; + } + if (0 === $modifier) { + return "\r" === $data + || (!$this->kittyProtocolActive && "\n" === $data) + || "\x1bOM" === $data + || $this->matchesKittySequence($data, self::CODEPOINTS['enter'], 0) + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['enter'], $modifier) + || $this->matchesKittySequence($data, self::CODEPOINTS['kp_enter'], $modifier); + + case 'backspace': + if ($alt && !$ctrl && !$shift) { + if ("\x1b\x7f" === $data || "\x1b\x08" === $data) { + return true; + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['backspace'], self::MOD_ALT); + } + if (0 === $modifier) { + return "\x7f" === $data || "\x08" === $data || $this->matchesKittySequence($data, self::CODEPOINTS['backspace'], 0); + } + + return $this->matchesKittySequence($data, self::CODEPOINTS['backspace'], $modifier); + + case 'insert': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['insert']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['insert'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'insert', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['insert'], $modifier); + + case 'delete': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['delete']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['delete'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'delete', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['delete'], $modifier); + + case 'clear': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['clear']); + } + + return $this->matchesLegacyModifierSequence($data, 'clear', $modifier); + + case 'home': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['home']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['home'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'home', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['home'], $modifier); + + case 'end': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['end']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['end'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'end', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['end'], $modifier); + + case 'page_up': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['page_up']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_up'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'page_up', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_up'], $modifier); + + case 'page_down': + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['page_down']) + || $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_down'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'page_down', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::FUNCTIONAL_CODEPOINTS['page_down'], $modifier); + + case 'up': + if ($alt && !$ctrl && !$shift) { + return "\x1bp" === $data || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['up'], self::MOD_ALT); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['up']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['up'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'up', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['up'], $modifier); + + case 'down': + if ($alt && !$ctrl && !$shift) { + return "\x1bn" === $data || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['down'], self::MOD_ALT); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['down']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['down'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'down', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['down'], $modifier); + + case 'left': + if ($alt && !$ctrl && !$shift) { + return "\x1b[1;3D" === $data + || (!$this->kittyProtocolActive && "\x1bB" === $data) + || "\x1bb" === $data + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], self::MOD_ALT); + } + if ($ctrl && !$alt && !$shift) { + return "\x1b[1;5D" === $data + || $this->matchesLegacyModifierSequence($data, 'left', self::MOD_CTRL) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], self::MOD_CTRL); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['left']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'left', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['left'], $modifier); + + case 'right': + if ($alt && !$ctrl && !$shift) { + return "\x1b[1;3C" === $data + || (!$this->kittyProtocolActive && "\x1bF" === $data) + || "\x1bf" === $data + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], self::MOD_ALT); + } + if ($ctrl && !$alt && !$shift) { + return "\x1b[1;5C" === $data + || $this->matchesLegacyModifierSequence($data, 'right', self::MOD_CTRL) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], self::MOD_CTRL); + } + if (0 === $modifier) { + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES['right']) + || $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], 0); + } + if ($this->matchesLegacyModifierSequence($data, 'right', $modifier)) { + return true; + } + + return $this->matchesKittySequence($data, self::ARROW_CODEPOINTS['right'], $modifier); + + case 'f1': + case 'f2': + case 'f3': + case 'f4': + case 'f5': + case 'f6': + case 'f7': + case 'f8': + case 'f9': + case 'f10': + case 'f11': + case 'f12': + if (0 !== $modifier) { + return $this->matchesLegacyFunctionKeyModifierSequence($data, $key, $modifier); + } + + return $this->matchesLegacySequence($data, self::LEGACY_KEY_SEQUENCES[$key]); + } + + $isDigit = 1 === \strlen($key) && $key >= '0' && $key <= '9'; + if (1 === \strlen($key) && (($key >= 'a' && $key <= 'z') || $isDigit || \in_array($key, self::SYMBOL_KEYS, true))) { + $codepoint = \ord($key); + $rawCtrl = $this->rawCtrlChar($key); + + if ($ctrl && $alt && !$shift && !$this->kittyProtocolActive && null !== $rawCtrl) { + return "\x1b".$rawCtrl === $data; + } + + if ($alt && !$ctrl && !$shift && !$this->kittyProtocolActive && (($key >= 'a' && $key <= 'z') || $isDigit)) { + if ("\x1b".$key === $data) { + return true; + } + } + + if ($ctrl && !$shift && !$alt) { + if (null !== $rawCtrl && $rawCtrl === $data) { + return true; + } + + return $this->matchesKittySequence($data, $codepoint, self::MOD_CTRL); + } + + if ($ctrl && $shift && !$alt) { + return $this->matchesKittySequence($data, $codepoint, self::MOD_SHIFT + self::MOD_CTRL); + } + + if ($shift && !$ctrl && !$alt) { + if (strtoupper($key) === $data) { + return true; + } + + return $this->matchesKittySequence($data, $codepoint, self::MOD_SHIFT); + } + + if (0 !== $modifier) { + return $this->matchesKittySequence($data, $codepoint, $modifier); + } + + return $data === $key || $this->matchesKittySequence($data, $codepoint, 0); + } + + return false; + } + + /** + * @param string[] $sequences + */ + private function matchesLegacySequence(string $data, array $sequences): bool + { + return \in_array($data, $sequences, true); + } + + private function matchesLegacyModifierSequence(string $data, string $key, int $modifier): bool + { + return match ($modifier) { + self::MOD_SHIFT => $this->matchesLegacySequence($data, self::LEGACY_SHIFT_SEQUENCES[$key] ?? []), + self::MOD_CTRL => $this->matchesLegacySequence($data, self::LEGACY_CTRL_SEQUENCES[$key] ?? []), + default => false, + }; + } + + private function matchesLegacyFunctionKeyModifierSequence(string $data, string $key, int $modifier): bool + { + $modValue = $modifier + 1; + + if (isset(self::LEGACY_FUNCTION_KEY_LETTERS[$key])) { + $letter = self::LEGACY_FUNCTION_KEY_LETTERS[$key]; + if ("\x1b[1;{$modValue}{$letter}" === $data) { + return true; + } + } + + if (isset(self::LEGACY_FUNCTION_KEY_CODES[$key])) { + $code = self::LEGACY_FUNCTION_KEY_CODES[$key]; + if ("\x1b[{$code};{$modValue}~" === $data) { + return true; + } + } + + return false; + } + + private function matchesKittySequence(string $data, int $expectedCodepoint, int $expectedModifier): bool + { + $parsed = $this->parseKittySequence($data); + if (null === $parsed) { + return false; + } + + $actualMod = $parsed['modifier'] & ~self::LOCK_MASK; + $expectedMod = $expectedModifier & ~self::LOCK_MASK; + + if ($actualMod !== $expectedMod) { + return false; + } + + return $parsed['codepoint'] === $expectedCodepoint; + } + + private function matchesModifyOtherKeys(string $data, int $expectedKeycode, int $expectedModifier): bool + { + if (!preg_match('/^\x1b\[27;(\d+);(\d+)~$/', $data, $match)) { + return false; + } + + $modValue = (int) $match[1]; + $keycode = (int) $match[2]; + $actualMod = $modValue - 1; + + return $keycode === $expectedKeycode && $actualMod === $expectedModifier; + } + + private function rawCtrlChar(string $key): ?string + { + $char = strtolower($key); + $code = \ord($char); + + if (($code >= 97 && $code <= 122) || '[' === $char || '\\' === $char || ']' === $char || '_' === $char) { + return \chr($code & 0x1F); + } + + if ('-' === $char) { + return \chr(31); + } + + return null; + } + + /** + * @return array{key: string, ctrl: bool, shift: bool, alt: bool}|null + */ + private function parseKeyId(string $keyId): ?array + { + // Special case: the '+' key itself + if ('+' === $keyId) { + return ['key' => '+', 'ctrl' => false, 'shift' => false, 'alt' => false]; + } + + $parts = explode('+', strtolower($keyId)); + $key = $parts[\count($parts) - 1] ?? ''; + if ('' === $key) { + return null; + } + + return [ + 'key' => $key, + 'ctrl' => \in_array('ctrl', $parts, true), + 'shift' => \in_array('shift', $parts, true), + 'alt' => \in_array('alt', $parts, true), + ]; + } +} diff --git a/extern/Tui/Input/Keybindings.php b/extern/Tui/Input/Keybindings.php new file mode 100644 index 00000000..12318356 --- /dev/null +++ b/extern/Tui/Input/Keybindings.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Configurable keybindings manager. + * + * Maps action names to key identifiers, allowing customizable keybindings. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Keybindings +{ + /** @var array */ + private array $bindings; + + private KeyParser $parser; + + /** + * @param array $bindings + */ + public function __construct(array $bindings = [], ?KeyParser $parser = null) + { + $this->bindings = $bindings; + $this->parser = $parser ?? new KeyParser(); + } + + public function matches(string $data, string $action): bool + { + if (!isset($this->bindings[$action])) { + return false; + } + + foreach ($this->bindings[$action] as $keyId) { + if ($this->parser->matches($data, $keyId)) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + public function getBindings(string $action): array + { + return $this->bindings[$action] ?? []; + } + + /** + * @return array + */ + public function all(): array + { + return $this->bindings; + } + + public function setKittyProtocolActive(bool $active): void + { + $this->parser->setKittyProtocolActive($active); + } + + /** + * @internal + */ + public function getParser(): KeyParser + { + return $this->parser; + } +} diff --git a/extern/Tui/Input/StdinBuffer.php b/extern/Tui/Input/StdinBuffer.php new file mode 100644 index 00000000..4bc60332 --- /dev/null +++ b/extern/Tui/Input/StdinBuffer.php @@ -0,0 +1,368 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Input; + +/** + * Buffers and splits batched stdin input into individual sequences. + * + * This ensures components receive single key events, making key parsing work correctly. + * Also handles bracketed paste mode. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class StdinBuffer +{ + private string $buffer = ''; + + /** @var callable(string): void|null */ + private $onData; + + /** @var callable(string): void|null */ + private $onPaste; + + private bool $inPaste = false; + private string $pasteBuffer = ''; + + /** + * Set callback for individual key sequences. + * + * @param callable(string): void $callback + */ + public function onData(callable $callback): void + { + $this->onData = $callback; + } + + /** + * Set callback for paste content. + * + * @param callable(string): void $callback + */ + public function onPaste(callable $callback): void + { + $this->onPaste = $callback; + } + + /** + * Process incoming data and emit individual sequences. + */ + public function process(string $data): void + { + // Handle high-byte meta encoding: some terminals (e.g. macOS Terminal.app + // with "Use Option as Meta key") send Alt+key as a single byte with the + // high bit set (byte | 0x80) instead of the standard ESC + key sequence. + // Convert single high bytes to ESC + (byte & 0x7F) to normalize input. + // This matches the Pi reference implementation. + if (1 === \strlen($data) && \ord($data) > 127) { + $data = "\x1b".\chr(\ord($data) - 128); + } + + $this->buffer .= $data; + + while ('' !== $this->buffer) { + // Check for bracketed paste start + if (str_starts_with($this->buffer, "\x1b[200~")) { + $this->inPaste = true; + $this->pasteBuffer = ''; + $this->buffer = substr($this->buffer, 6); + continue; + } + + // If in paste mode, accumulate until end marker + if ($this->inPaste) { + $endPos = strpos($this->buffer, "\x1b[201~"); + if (false !== $endPos) { + $this->pasteBuffer .= substr($this->buffer, 0, $endPos); + $this->buffer = substr($this->buffer, $endPos + 6); + $this->inPaste = false; + + if (null !== $this->onPaste) { + ($this->onPaste)($this->pasteBuffer); + } + $this->pasteBuffer = ''; + } else { + // Still waiting for end marker + $this->pasteBuffer .= $this->buffer; + $this->buffer = ''; + } + continue; + } + + // Try to extract a complete sequence + $sequence = $this->extractSequence(); + + if (null === $sequence) { + // Buffer might contain incomplete sequence, wait for more data + break; + } + + if (null !== $this->onData) { + ($this->onData)($sequence); + } + } + } + + /** + * Get any remaining buffered data. + */ + public function getBuffer(): string + { + return $this->buffer; + } + + /** + * Clear the buffer. + */ + public function clear(): void + { + $this->buffer = ''; + $this->pasteBuffer = ''; + $this->inPaste = false; + } + + /** + * Flush any pending data in the buffer. + * + * This is used when no more input is expected (e.g., end of test input). + * A standalone ESC that was waiting for more characters will be emitted. + */ + public function flush(): void + { + // If we have a single ESC waiting, emit it as a standalone Escape key + if ("\x1b" === $this->buffer && null !== $this->onData) { + ($this->onData)("\x1b"); + $this->buffer = ''; + } + } + + /** + * Extract a complete sequence from the buffer. + */ + private function extractSequence(): ?string + { + if ('' === $this->buffer) { + return null; + } + + $first = $this->buffer[0]; + + // Regular printable ASCII character + if ("\x1b" !== $first) { + // Check for multi-byte UTF-8 + $ord = \ord($first); + if ($ord >= 0x80) { + $len = $this->getUtf8CharLength($ord); + if (\strlen($this->buffer) >= $len) { + $sequence = substr($this->buffer, 0, $len); + $this->buffer = substr($this->buffer, $len); + + return $sequence; + } + + // Incomplete UTF-8 sequence + return null; + } + + $this->buffer = substr($this->buffer, 1); + + return $first; + } + + // ESC sequence + if (\strlen($this->buffer) < 2) { + // Might be incomplete, or just ESC key + return null; + } + + $second = $this->buffer[1]; + + // ESC ESC (double escape) - need to look ahead to determine behavior + if ("\x1b" === $second) { + // Need at least 3 chars to decide + if (\strlen($this->buffer) < 3) { + return null; // Wait for more data + } + + $third = $this->buffer[2]; + + // If third char starts a CSI or SS3 sequence, emit first ESC and continue + // This handles: ESC ESC [ ... or ESC ESC O ... + if ('[' === $third || 'O' === $third) { + $this->buffer = substr($this->buffer, 1); + + return "\x1b"; + } + + // Otherwise it's a double-escape followed by something else + $this->buffer = substr($this->buffer, 2); + + return "\x1b\x1b"; + } + + // Sequence type dispatch based on second byte: + // CSI (ESC [), SS3 (ESC O), OSC (ESC ]), DCS (ESC P), APC (ESC _) + return match ($second) { + '[' => $this->extractCsiSequence(), + 'O' => $this->extractSs3Sequence(), + ']' => $this->extractOscSequence(), + 'P' => $this->extractDcsSequence(), + '_' => $this->extractOscSequence(), // APC uses the same terminator rules + default => $this->extractAltKey($second), + }; + } + + /** + * Extract Alt+key or Ctrl+Alt+key: ESC followed by any non-ESC byte that + * isn't a sequence initiator. This covers Alt+letter, + * Alt+Backspace (\x1b\x7f), Alt+Space (\x1b\x20), Alt+Enter + * (\x1b\r), Ctrl+Alt+] (\x1b\x1d), etc. + */ + private function extractAltKey(string $second): string + { + $this->buffer = substr($this->buffer, 2); + + return "\x1b".$second; + } + + /** + * Extract CSI sequence (ESC [ ... terminator). + */ + private function extractCsiSequence(): ?string + { + $len = \strlen($this->buffer); + + // Old-style mouse sequence: ESC [ M + 3 bytes + if ($len >= 3 && 'M' === $this->buffer[2]) { + if ($len < 6) { + return null; + } + + $sequence = substr($this->buffer, 0, 6); + $this->buffer = substr($this->buffer, 6); + + return $sequence; + } + + for ($i = 2; $i < $len; ++$i) { + $char = $this->buffer[$i]; + + // CSI terminators: @ through ~ + if ($char >= '@' && $char <= '~') { + $sequence = substr($this->buffer, 0, $i + 1); + $payload = substr($this->buffer, 2, $i - 1); + + // Special handling for SGR mouse sequences ESC[buffer = substr($this->buffer, $i + 1); + + return $sequence; + } + + // Invalid character in CSI sequence + if ($char < ' ' || $char > '?') { + // Malformed sequence, just return what we have + $this->buffer = substr($this->buffer, 1); + + return "\x1b"; + } + } + + // Incomplete sequence + return null; + } + + /** + * Extract SS3 sequence (ESC O letter). + */ + private function extractSs3Sequence(): ?string + { + if (\strlen($this->buffer) < 3) { + return null; + } + + $sequence = substr($this->buffer, 0, 3); + $this->buffer = substr($this->buffer, 3); + + return $sequence; + } + + /** + * Extract OSC sequence (ESC ] ... BEL or ESC ] ... ST). + */ + private function extractOscSequence(): ?string + { + $len = \strlen($this->buffer); + + for ($i = 2; $i < $len; ++$i) { + // BEL terminator + if ("\x07" === $this->buffer[$i]) { + $sequence = substr($this->buffer, 0, $i + 1); + $this->buffer = substr($this->buffer, $i + 1); + + return $sequence; + } + + // ST terminator (ESC \) + if ("\x1b" === $this->buffer[$i] && isset($this->buffer[$i + 1]) && '\\' === $this->buffer[$i + 1]) { + $sequence = substr($this->buffer, 0, $i + 2); + $this->buffer = substr($this->buffer, $i + 2); + + return $sequence; + } + } + + // Incomplete sequence + return null; + } + + /** + * Extract DCS sequence (ESC P ... ST). + */ + private function extractDcsSequence(): ?string + { + $len = \strlen($this->buffer); + + for ($i = 2; $i < $len; ++$i) { + if ("\x1b" === $this->buffer[$i] && isset($this->buffer[$i + 1]) && '\\' === $this->buffer[$i + 1]) { + $sequence = substr($this->buffer, 0, $i + 2); + $this->buffer = substr($this->buffer, $i + 2); + + return $sequence; + } + } + + return null; + } + + /** + * Get the expected length of a UTF-8 character from its first byte. + */ + private function getUtf8CharLength(int $ord): int + { + return match (true) { + $ord < 0xC0 => 1, // ASCII or invalid continuation byte + $ord < 0xE0 => 2, + $ord < 0xF0 => 3, + $ord < 0xF8 => 4, + default => 1, // Invalid, treat as single byte + }; + } +} diff --git a/extern/Tui/LICENSE b/extern/Tui/LICENSE new file mode 100644 index 00000000..36d6fdc3 --- /dev/null +++ b/extern/Tui/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/extern/Tui/Loop/AdaptativeTicker.php b/extern/Tui/Loop/AdaptativeTicker.php new file mode 100644 index 00000000..ff5e637b --- /dev/null +++ b/extern/Tui/Loop/AdaptativeTicker.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Revolt\EventLoop; + +/** + * Drives the main TUI tick interval using adaptive scheduling. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class AdaptativeTicker +{ + private const float MIN_INTERVAL = 0.001; + + private ?string $callbackId = null; + private ?float $interval = null; + + public function __construct( + private readonly TickRuntimeInterface $runtime, + private readonly float $activeTickInterval = 0.01, + private readonly float $idleTickInterval = 0.25, + ) { + } + + public function refresh(bool $running, bool $renderRequested, ?float $nextScheduledDelay, bool $hasTickCallback, ?bool $lastTickBusyHint): void + { + $this->setInterval($this->computeDesiredInterval($running, $renderRequested, $nextScheduledDelay, $hasTickCallback, $lastTickBusyHint)); + } + + public function stop(): void + { + $this->setInterval(null); + } + + private function computeDesiredInterval(bool $running, bool $renderRequested, ?float $nextScheduledDelay, bool $hasTickCallback, ?bool $lastTickBusyHint): ?float + { + if (!$running) { + return null; + } + + $intervals = []; + + if ($renderRequested) { + $intervals[] = $this->activeTickInterval; + } + + if (null !== $nextScheduledDelay) { + $intervals[] = $nextScheduledDelay; + } + + if ($hasTickCallback) { + if (true === $lastTickBusyHint) { + $intervals[] = $this->activeTickInterval; + } elseif (null === $lastTickBusyHint) { + $intervals[] = $this->idleTickInterval; + } + } + + if ([] === $intervals) { + return null; + } + + return max(self::MIN_INTERVAL, min($intervals)); + } + + private function setInterval(?float $interval): void + { + if (null === $interval) { + if (null !== $this->callbackId) { + EventLoop::cancel($this->callbackId); + $this->callbackId = null; + } + $this->interval = null; + + return; + } + + if (null !== $this->interval && abs($this->interval - $interval) < 0.0001) { + return; + } + + if (null !== $this->callbackId) { + EventLoop::cancel($this->callbackId); + $this->callbackId = null; + } + + $this->interval = $interval; + $this->callbackId = EventLoop::repeat($interval, function (string $callbackId): void { + if (!$this->runtime->isRunning()) { + EventLoop::cancel($callbackId); + + if ($this->callbackId === $callbackId) { + $this->callbackId = null; + $this->interval = null; + } + + return; + } + + $this->runtime->tick(); + }); + } +} diff --git a/extern/Tui/Loop/FixedStepAccumulator.php b/extern/Tui/Loop/FixedStepAccumulator.php new file mode 100644 index 00000000..5d4aaecb --- /dev/null +++ b/extern/Tui/Loop/FixedStepAccumulator.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Converts elapsed time into bounded fixed-step counts. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class FixedStepAccumulator +{ + private float $accumulator = 0.0; + + public function __construct( + private float $stepsPerSecond, + private int $maxStepsPerUpdate = 5, + ) { + if ($stepsPerSecond <= 0.0) { + throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %d.', $stepsPerSecond)); + } + + if ($maxStepsPerUpdate < 1) { + throw new InvalidArgumentException(\sprintf('Max steps per update must be greater than 0, got %d.', $maxStepsPerUpdate)); + } + } + + /** + * @return int Number of fixed logic steps to execute for this update + */ + public function computeSteps(?float $deltaTime): int + { + // Preserve legacy "one update call = one logic step" behavior. + if (null === $deltaTime) { + return 1; + } + + $this->accumulator += max(0.0, $deltaTime) * $this->stepsPerSecond; + $steps = min($this->maxStepsPerUpdate, (int) floor($this->accumulator)); + + if ($steps > 0) { + $this->accumulator -= $steps; + } + + return $steps; + } + + public function setStepsPerSecond(float $stepsPerSecond): void + { + if ($stepsPerSecond <= 0.0) { + throw new InvalidArgumentException(\sprintf('Steps per second must be greater than 0, got %d.', $stepsPerSecond)); + } + + $this->stepsPerSecond = $stepsPerSecond; + } + + public function reset(): void + { + $this->accumulator = 0.0; + } +} diff --git a/extern/Tui/Loop/LoopClock.php b/extern/Tui/Loop/LoopClock.php new file mode 100644 index 00000000..2e3ff1c5 --- /dev/null +++ b/extern/Tui/Loop/LoopClock.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +/** + * Small monotonic-ish clock abstraction for game and animation loops. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class LoopClock +{ + private float $time; + + public function __construct( + ?float $time = null, + ) { + $this->time = $time ?? microtime(true); + } + + /** + * Advance clock state and return elapsed seconds since previous advance. + */ + public function advance(?float $deltaTime = null): float + { + if (null === $deltaTime) { + $now = microtime(true); + $elapsed = max(0.0, $now - $this->time); + $this->time = $now; + + return $elapsed; + } + + $elapsed = max(0.0, $deltaTime); + $this->time += $elapsed; + + return $elapsed; + } + + public function now(): float + { + return $this->time; + } + + public function reset(?float $time = null): void + { + $this->time = $time ?? microtime(true); + } +} diff --git a/extern/Tui/Loop/PeriodicStepper.php b/extern/Tui/Loop/PeriodicStepper.php new file mode 100644 index 00000000..9ab557f4 --- /dev/null +++ b/extern/Tui/Loop/PeriodicStepper.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Converts elapsed time into periodic fixed-step counts. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class PeriodicStepper +{ + private FixedStepAccumulator $accumulator; + private LoopClock $clock; + + public function __construct( + private float $intervalSeconds, + int $maxStepsPerUpdate = 8, + ) { + if ($intervalSeconds <= 0.0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); + } + + $this->accumulator = new FixedStepAccumulator(1.0 / $intervalSeconds, $maxStepsPerUpdate); + $this->clock = new LoopClock(); + } + + public static function everyMs(int $intervalMs, int $maxStepsPerUpdate = 8): self + { + if ($intervalMs <= 0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalMs)); + } + + return new self($intervalMs / 1000, $maxStepsPerUpdate); + } + + public function advance(?float $deltaTime = null): int + { + return $this->accumulator->computeSteps($this->clock->advance($deltaTime)); + } + + public function reset(): void + { + $this->accumulator->reset(); + $this->clock->reset(); + } + + public function setIntervalSeconds(float $intervalSeconds): void + { + if ($intervalSeconds <= 0.0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); + } + + $this->intervalSeconds = $intervalSeconds; + $this->accumulator->setStepsPerSecond(1.0 / $intervalSeconds); + $this->reset(); + } + + public function setIntervalMs(int $intervalMs): void + { + if ($intervalMs <= 0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalMs)); + } + + $this->setIntervalSeconds($intervalMs / 1000); + } + + public function getIntervalSeconds(): float + { + return $this->intervalSeconds; + } +} diff --git a/extern/Tui/Loop/TickRuntimeInterface.php b/extern/Tui/Loop/TickRuntimeInterface.php new file mode 100644 index 00000000..06ac471b --- /dev/null +++ b/extern/Tui/Loop/TickRuntimeInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +/** + * Runtime contract used by the adaptive ticker driver. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +interface TickRuntimeInterface +{ + public function tick(): void; + + public function isRunning(): bool; +} diff --git a/extern/Tui/Loop/TickScheduler.php b/extern/Tui/Loop/TickScheduler.php new file mode 100644 index 00000000..c427ef45 --- /dev/null +++ b/extern/Tui/Loop/TickScheduler.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Loop; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Internal scheduler for repeat callbacks executed from the TUI tick. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class TickScheduler +{ + private int $counter = 0; + + /** + * @var array + */ + private array $intervals = []; + + /** + * @param callable(): void $callback + */ + public function schedule(callable $callback, float $intervalSeconds): string + { + if ($intervalSeconds <= 0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); + } + + $id = 'interval-'.(++$this->counter); + $this->intervals[$id] = [ + 'callback' => $callback, + 'interval' => $intervalSeconds, + 'next_run_at' => microtime(true) + $intervalSeconds, + ]; + + return $id; + } + + public function cancel(string $id): void + { + unset($this->intervals[$id]); + } + + public function clear(): void + { + $this->intervals = []; + } + + public function runDue(?float $now = null): void + { + if ([] === $this->intervals) { + return; + } + + $now ??= microtime(true); + $intervals = $this->intervals; + + foreach ($intervals as $id => $interval) { + if (!isset($this->intervals[$id])) { + continue; + } + + if ($interval['next_run_at'] > $now) { + continue; + } + + $this->intervals[$id]['next_run_at'] = $now + $interval['interval']; + ($interval['callback'])(); + } + } + + public function getNextDelay(?float $now = null): ?float + { + if ([] === $this->intervals) { + return null; + } + + $now ??= microtime(true); + $nextAt = null; + + foreach ($this->intervals as $interval) { + $nextAt = null === $nextAt ? $interval['next_run_at'] : min($nextAt, $interval['next_run_at']); + } + + return max(0.001, $nextAt - $now); + } +} diff --git a/extern/Tui/README.md b/extern/Tui/README.md new file mode 100644 index 00000000..b98e5b5f --- /dev/null +++ b/extern/Tui/README.md @@ -0,0 +1,19 @@ +TUI Component +============= + +The TUI component provides a terminal UI framework for building rich, +interactive CLI applications in PHP. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/tui.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/extern/Tui/Render/CellBuffer.php b/extern/Tui/Render/CellBuffer.php new file mode 100644 index 00000000..045d3daa --- /dev/null +++ b/extern/Tui/Render/CellBuffer.php @@ -0,0 +1,571 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * A 2D grid of terminal cells for efficient compositing and rendering. + * + * Uses flat parallel arrays (not objects) for memory efficiency. + * Each cell stores: character (grapheme), display width, foreground color, + * background color, and text attributes (bold, italic, etc.). + * + * ## Usage + * + * Create a buffer, write ANSI-styled lines into regions, then serialize + * back to ANSI strings. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class CellBuffer +{ + // Attribute bitmask constants + public const ATTR_BOLD = 1; + public const ATTR_DIM = 2; + public const ATTR_ITALIC = 4; + public const ATTR_UNDERLINE = 8; + public const ATTR_BLINK = 16; + public const ATTR_REVERSE = 32; + public const ATTR_STRIKETHROUGH = 64; + + /** + * Flat arrays indexed by (row * width + col). + * + * @var string[] Character/grapheme at each cell + */ + private array $chars; + + /** @var int[] Display width of each cell (1 for normal, 2 for CJK, 0 for continuation) */ + private array $widths; + + /** @var string[] Foreground color code (e.g., "38;2;255;0;0") or "" for default */ + private array $fg; + + /** @var string[] Background color code (e.g., "48;2;30;30;46") or "" for default */ + private array $bg; + + /** @var int[] Attribute bitmask (bold|dim|italic|underline|blink|reverse|strikethrough) */ + private array $attrs; + + /* Row of the cursor marker, or null if not found */ + private ?int $cursorRow = null; + + /* Column (cell index) of the cursor marker, or null if not found */ + private ?int $cursorCol = null; + + public function __construct( + private readonly int $width, + private readonly int $height, + ) { + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException(\sprintf('CellBuffer dimensions must be at least 1x1, got %dx%d', $width, $height)); + } + + $size = $width * $height; + $this->chars = array_fill(0, $size, ' '); + $this->widths = array_fill(0, $size, 1); + $this->fg = array_fill(0, $size, ''); + $this->bg = array_fill(0, $size, ''); + $this->attrs = array_fill(0, $size, 0); + } + + public function getWidth(): int + { + return $this->width; + } + + public function getHeight(): int + { + return $this->height; + } + + /** + * Write ANSI-formatted lines into the buffer at the given position. + * + * Lines are parsed: ANSI escape codes are interpreted and stored as + * cell attributes; visible characters are placed into the grid. + * + * @param string[] $lines ANSI-formatted lines + * @param int $startRow Row offset to start writing + * @param int $startCol Column offset to start writing + * @param bool $transparent When true, cells with no explicit background preserve + * the existing buffer background (transparency). Cells that + * are plain spaces with default fg/bg/attrs are fully transparent + * and leave the buffer cell entirely unchanged. + */ + public function writeAnsiLines(array $lines, int $startRow = 0, int $startCol = 0, bool $transparent = false): void + { + $width = $this->width; + $height = $this->height; + $startCol = max(0, $startCol); + + foreach ($lines as $lineIndex => $line) { + $row = $startRow + $lineIndex; + if ($row < 0 || $row >= $height) { + continue; + } + + // Reset SGR state at the start of each line. + // Widget render methods produce independent lines, each with their + // own SGR codes; state must not leak between lines. + $fgState = ''; + $bgState = ''; + $attrState = 0; + + $col = $startCol; + $i = 0; + $len = \strlen($line); + $rowOffset = $row * $width; + + while ($i < $len && $col < $width) { + $ord = \ord($line[$i]); + + // Fast path: ASCII printable (0x20-0x7E), most common case + if ($ord >= 0x20 && $ord <= 0x7E) { + // In transparent mode, skip fully unstyled spaces (fully transparent cell) + if ($transparent && ' ' === $line[$i] && '' === $fgState && '' === $bgState && 0 === $attrState) { + ++$col; + ++$i; + continue; + } + $idx = $rowOffset + $col; + $this->chars[$idx] = $line[$i]; + $this->widths[$idx] = 1; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + ++$col; + ++$i; + continue; + } + + // Escape sequence + if (0x1B === $ord) { + // Inline escape sequence parsing (avoids AnsiUtils::extractAnsiCode overhead) + $next = $line[$i + 1] ?? ''; + + if ('[' === $next) { + // CSI sequence: ESC [ * * + $j = $i + 2; + while ($j < $len && \ord($line[$j]) >= 0x30 && \ord($line[$j]) <= 0x3F) { + ++$j; + } + while ($j < $len && \ord($line[$j]) >= 0x20 && \ord($line[$j]) <= 0x2F) { + ++$j; + } + if ($j >= $len || \ord($line[$j]) < 0x40 || \ord($line[$j]) > 0x7E) { + // Malformed CSI, skip ESC and [ entirely + $i = $j; + continue; + } + $seqEnd = $j + 1; + // Only parse SGR (ends with 'm') + if ('m' === $line[$j]) { + $this->parseSgrInline($line, $i + 2, $j, $fgState, $bgState, $attrState); + } + $i = $seqEnd; + continue; + } + + if ('_' === $next) { + // APC sequence: ESC _ ... BEL or ESC _ ... ST + $j = $i + 2; + $apcEnd = null; + while ($j < $len) { + if ("\x07" === $line[$j]) { + $apcEnd = $j + 1; + break; + } + if ("\x1b" === $line[$j] && isset($line[$j + 1]) && '\\' === $line[$j + 1]) { + $apcEnd = $j + 2; + break; + } + ++$j; + } + if (null === $apcEnd) { + ++$i; + continue; + } + // Check for cursor marker: ESC _ p i : c + if ($i + 5 < $len && 'p' === $line[$i + 2] && 'i' === $line[$i + 3] && ':' === $line[$i + 4] && 'c' === $line[$i + 5]) { + $this->cursorRow = $row; + $this->cursorCol = $col; + } + $i = $apcEnd; + continue; + } + + // String sequences: OSC (ESC ]), DCS (ESC P), PM (ESC ^), SOS (ESC X) + if (']' === $next || 'P' === $next || '^' === $next || 'X' === $next) { + $j = $i + 2; + while ($j < $len) { + if ("\x07" === $line[$j]) { + $i = $j + 1; + break; + } + if ("\x1b" === $line[$j] && isset($line[$j + 1]) && '\\' === $line[$j + 1]) { + $i = $j + 2; + break; + } + ++$j; + } + if ($j >= $len) { + ++$i; + } + continue; + } + + if ('' === $next) { + ++$i; + continue; + } + + $nextOrd = \ord($next); + + // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) + if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { + $j = $i + 2; + while ($j < $len && \ord($line[$j]) >= 0x20 && \ord($line[$j]) <= 0x2F) { + ++$j; + } + if ($j < $len && \ord($line[$j]) >= 0x30 && \ord($line[$j]) <= 0x7E) { + $i = $j + 1; + } else { + ++$i; + } + continue; + } + + // Fe (0x40-0x5F), Fp (0x30-0x3F), Fs (0x60-0x7E) two-byte sequences + if ($nextOrd >= 0x30 && $nextOrd <= 0x7E) { + $i += 2; + continue; + } + + // Unknown escape, skip ESC byte + ++$i; + continue; + } + + // Tab + if (0x09 === $ord) { + $spaces = 3; // Match AnsiUtils tab width + for ($s = 0; $s < $spaces && $col < $width; ++$s) { + if ($transparent && '' === $fgState && '' === $bgState && 0 === $attrState) { + ++$col; + continue; + } + $idx = $rowOffset + $col; + $this->chars[$idx] = ' '; + $this->widths[$idx] = 1; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + ++$col; + } + ++$i; + continue; + } + + // Other control characters, skip + if ($ord < 0x20) { + ++$i; + continue; + } + + // Multi-byte / Unicode: use grapheme_extract for correctness + $grapheme = grapheme_extract($line, 1, \GRAPHEME_EXTR_COUNT, $i, $nextPos); + if (false === $grapheme || '' === $grapheme) { + ++$i; + continue; + } + + // Calculate display width + $charWidth = AnsiUtils::graphemeWidth($grapheme); + + // Check if it fits + if ($col + $charWidth > $width) { + while ($col < $width) { + $idx = $rowOffset + $col; + $this->chars[$idx] = ' '; + $this->widths[$idx] = 1; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + ++$col; + } + $i = $nextPos; + continue; + } + + // Place the character + $idx = $rowOffset + $col; + $this->chars[$idx] = $grapheme; + $this->widths[$idx] = $charWidth; + $this->fg[$idx] = $fgState; + $this->bg[$idx] = $transparent && '' === $bgState ? $this->bg[$idx] : $bgState; + $this->attrs[$idx] = $attrState; + + // For wide characters, mark continuation cell(s) + for ($w = 1; $w < $charWidth; ++$w) { + if ($col + $w < $width) { + $contIdx = $rowOffset + $col + $w; + $this->chars[$contIdx] = ''; + $this->widths[$contIdx] = 0; + $this->fg[$contIdx] = $fgState; + $this->bg[$contIdx] = $transparent && '' === $bgState ? $this->bg[$contIdx] : $bgState; + $this->attrs[$contIdx] = $attrState; + } + } + + $col += $charWidth; + $i = $nextPos; + } + } + } + + /** + * Get the cursor position found during parsing, if any. + * + * @return array{row: int, col: int}|null + */ + public function getCursorPosition(): ?array + { + if (null === $this->cursorRow || null === $this->cursorCol) { + return null; + } + + return ['row' => $this->cursorRow, 'col' => $this->cursorCol]; + } + + /** + * Clear the cursor position. + */ + public function clearCursorPosition(): void + { + $this->cursorRow = null; + $this->cursorCol = null; + } + + /** + * Serialize the buffer back to ANSI-formatted strings. + * + * Produces optimized output: only emits SGR changes when the style + * actually changes between cells. + * + * @return string[] + */ + public function toLines(): array + { + $lines = []; + $width = $this->width; + $chars = $this->chars; + $widths = $this->widths; + $fg = $this->fg; + $bg = $this->bg; + $attrs = $this->attrs; + + for ($row = 0; $row < $this->height; ++$row) { + $line = ''; + $currentFg = ''; + $currentBg = ''; + $currentAttrs = 0; + $rowOffset = $row * $width; + + for ($col = 0; $col < $width; ++$col) { + $idx = $rowOffset + $col; + + // Skip continuation cells (part of a wide character) + if (0 === $widths[$idx]) { + continue; + } + + $cellFg = $fg[$idx]; + $cellBg = $bg[$idx]; + $cellAttrs = $attrs[$idx]; + + // Emit SGR change if needed + if ($cellFg !== $currentFg || $cellBg !== $currentBg || $cellAttrs !== $currentAttrs) { + $line .= $this->buildSgr($cellFg, $cellBg, $cellAttrs); + $currentFg = $cellFg; + $currentBg = $cellBg; + $currentAttrs = $cellAttrs; + } + + $line .= $chars[$idx]; + } + + // Reset at end of line + if ('' !== $currentFg || '' !== $currentBg || 0 !== $currentAttrs) { + $line .= "\x1b[0m"; + } + + $lines[] = $line; + } + + return $lines; + } + + /** + * Build an SGR escape sequence from cell attributes. + * + * Always emits a full reset + set to avoid state accumulation issues. + */ + private function buildSgr(string $fg, string $bg, int $attrs): string + { + // Fast path: reset to default (no style) + if ('' === $fg && '' === $bg && 0 === $attrs) { + return "\x1b[0m"; + } + + $sgr = "\x1b[0"; + + if ($attrs & self::ATTR_BOLD) { + $sgr .= ';1'; + } + if ($attrs & self::ATTR_DIM) { + $sgr .= ';2'; + } + if ($attrs & self::ATTR_ITALIC) { + $sgr .= ';3'; + } + if ($attrs & self::ATTR_UNDERLINE) { + $sgr .= ';4'; + } + if ($attrs & self::ATTR_BLINK) { + $sgr .= ';5'; + } + if ($attrs & self::ATTR_REVERSE) { + $sgr .= ';7'; + } + if ($attrs & self::ATTR_STRIKETHROUGH) { + $sgr .= ';9'; + } + if ('' !== $fg) { + $sgr .= ';'.$fg; + } + if ('' !== $bg) { + $sgr .= ';'.$bg; + } + + return $sgr.'m'; + } + + /** + * Parse SGR parameters directly from the string (avoids regex, explode, array_map). + * + * @param string $line The full line string + * @param int $start Start of parameter chars (after "\x1b[") + * @param int $end Position of the 'm' terminator + */ + private function parseSgrInline(string $line, int $start, int $end, string &$fg, string &$bg, int &$attrs): void + { + // Fast path: \x1b[0m or \x1b[m, pure reset + if ($start === $end || (1 === $end - $start && '0' === $line[$start])) { + $fg = ''; + $bg = ''; + $attrs = 0; + + return; + } + + // Parse semicolon-delimited integers directly from the string + $num = 0; + $hasNum = false; + /** @var int[] $codes */ + $codes = []; + + for ($p = $start; $p <= $end; ++$p) { + $ch = $line[$p] ?? 'm'; + if ($ch >= '0' && $ch <= '9') { + $num = $num * 10 + \ord($ch) - 48; + $hasNum = true; + } elseif (';' === $ch || 'm' === $ch) { + $codes[] = $hasNum ? $num : 0; + $num = 0; + $hasNum = false; + } + } + + $i = 0; + $count = \count($codes); + + while ($i < $count) { + $c = $codes[$i]; + + if (0 === $c) { + $fg = ''; + $bg = ''; + $attrs = 0; + } elseif ($c >= 1 && $c <= 9) { + // Attributes + $attrs |= match ($c) { + 1 => self::ATTR_BOLD, + 2 => self::ATTR_DIM, + 3 => self::ATTR_ITALIC, + 4 => self::ATTR_UNDERLINE, + 5 => self::ATTR_BLINK, + 7 => self::ATTR_REVERSE, + 9 => self::ATTR_STRIKETHROUGH, + default => 0, + }; + } elseif ($c >= 22 && $c <= 29) { + // Attribute off + $attrs &= match ($c) { + 22 => ~(self::ATTR_BOLD | self::ATTR_DIM), + 23 => ~self::ATTR_ITALIC, + 24 => ~self::ATTR_UNDERLINE, + 25 => ~self::ATTR_BLINK, + 27 => ~self::ATTR_REVERSE, + 29 => ~self::ATTR_STRIKETHROUGH, + default => ~0, + }; + } elseif ($c >= 30 && $c <= 37) { + $fg = (string) $c; + } elseif (39 === $c) { + $fg = ''; + } elseif ($c >= 40 && $c <= 47) { + $bg = (string) $c; + } elseif (49 === $c) { + $bg = ''; + } elseif ($c >= 90 && $c <= 97) { + $fg = (string) $c; + } elseif ($c >= 100 && $c <= 107) { + $bg = (string) $c; + } elseif (38 === $c && $i + 1 < $count) { + if (5 === $codes[$i + 1] && $i + 2 < $count) { + $fg = '38;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && $i + 4 < $count) { + $fg = '38;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } elseif (48 === $c && $i + 1 < $count) { + if (5 === $codes[$i + 1] && $i + 2 < $count) { + $bg = '48;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && $i + 4 < $count) { + $bg = '48;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + + ++$i; + } + } +} diff --git a/extern/Tui/Render/ChromeApplier.php b/extern/Tui/Render/ChromeApplier.php new file mode 100644 index 00000000..eebdd0a2 --- /dev/null +++ b/extern/Tui/Render/ChromeApplier.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\TextAlign; +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Applies chrome (padding, border, background) around widget content. + * + * Chrome is the visual frame around a widget's rendered lines: + * padding adds space inside, borders draw a box, and background colors + * fill the area. The result is cached for performance. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class ChromeApplier +{ + private WidgetRendererInterface $widgetRenderer; + + public function setWidgetRenderer(WidgetRendererInterface $widgetRenderer): void + { + $this->widgetRenderer = $widgetRenderer; + } + + /** + * Apply chrome (padding, border, background) to rendered lines. + * + * @param string[] $lines + * + * @return string[] + */ + public function apply(array $lines, int $width, Style $style, AbstractWidget $widget): array + { + $border = $style->getBorder(); + $padding = $style->getPadding(); + + $borderLeft = null !== $border ? $border->getLeft() : 0; + $borderRight = null !== $border ? $border->getRight() : 0; + $borderTop = null !== $border ? $border->getTop() : 0; + $borderBottom = null !== $border ? $border->getBottom() : 0; + $paddingLeft = null !== $padding ? $padding->getLeft() : 0; + $paddingRight = null !== $padding ? $padding->getRight() : 0; + $paddingTop = null !== $padding ? $padding->getTop() : 0; + $paddingBottom = null !== $padding ? $padding->getBottom() : 0; + + $hasVerticalPadding = 0 !== $paddingTop || 0 !== $paddingBottom; + $hasHorizontalPadding = 0 !== $paddingLeft || 0 !== $paddingRight; + $hasBorder = 0 !== $borderTop || 0 !== $borderBottom || 0 !== $borderLeft || 0 !== $borderRight; + + if (!$hasBorder && !$hasHorizontalPadding && !$hasVerticalPadding && $style->isPlain() && null === $style->getTextAlign()) { + return $lines; + } + + if ([] === $lines && !$hasVerticalPadding && 0 === $borderTop && 0 === $borderBottom) { + return []; + } + + $outerStyle = $this->resolveOuterStyle($widget); + + $innerWidth = max(1, $width - $borderLeft - $borderRight); + + // Clamp padding so it fits within the inner width, preserving + // at least 1 column for content. Without this, excessive padding + // (e.g. padding=50 in a 10-column container) would overflow. + $maxHorizontalPadding = max(0, $innerWidth - 1); + if ($paddingLeft + $paddingRight > $maxHorizontalPadding) { + $paddingLeft = min($paddingLeft, $maxHorizontalPadding); + $paddingRight = min($paddingRight, max(0, $maxHorizontalPadding - $paddingLeft)); + } + + $contentWidth = max(1, $innerWidth - $paddingLeft - $paddingRight); + + $processedLines = []; + foreach ($lines as $line) { + $processedLines[] = AnsiUtils::truncateToWidth($line, $contentWidth); + } + + // If no content and no padding/border, return empty + if ([] === $processedLines && 0 === $paddingTop && 0 === $paddingBottom + && 0 === $borderTop && 0 === $borderBottom) { + return []; + } + + $styledEmptyLine = $style->apply(str_repeat(' ', $innerWidth)); + $topPadding = $paddingTop > 0 ? array_fill(0, $paddingTop, $styledEmptyLine) : []; + $bottomPadding = $paddingBottom > 0 ? array_fill(0, $paddingBottom, $styledEmptyLine) : []; + $textAlign = $style->getTextAlign() ?? TextAlign::Left; + + // For center/right alignment, compute offset from the widest line + // so all lines shift uniformly (preserving internal alignment of + // multi-line content like FIGlet). + $alignPadLeft = 0; + if (TextAlign::Left !== $textAlign) { + $maxContentWidth = 0; + foreach ($processedLines as $line) { + $maxContentWidth = max($maxContentWidth, AnsiUtils::visibleWidth($line)); + } + $availableSpace = max(0, $contentWidth - $maxContentWidth); + $alignPadLeft = match ($textAlign) { + TextAlign::Center => (int) floor($availableSpace / 2), + TextAlign::Right => $availableSpace, + }; + } + + $contentLines = []; + foreach ($processedLines as $line) { + $lineWithPad = str_repeat(' ', $paddingLeft + $alignPadLeft).$line; + $visibleWidth = AnsiUtils::visibleWidth($lineWithPad); + $rightPad = str_repeat(' ', max(0, $innerWidth - $visibleWidth)); + $contentLines[] = $style->apply($lineWithPad.$rightPad); + } + + $innerLines = [...$topPadding, ...$contentLines, ...$bottomPadding]; + + if (null !== $border) { + $innerLines = $border->wrapLines( + $innerLines, + $innerWidth, + $style, + $outerStyle, + ); + } + + return $innerLines; + } + + /** + * Compute inner dimensions (content area after border/padding). + * + * @return array{int, int} [innerColumns, innerRows] + */ + public function computeInnerDimensions(int $columns, int $rows, Style $style): array + { + $border = $style->getBorder(); + $padding = $style->getPadding(); + + $hChrome = (null !== $border ? $border->getLeft() + $border->getRight() : 0) + + (null !== $padding ? $padding->getLeft() + $padding->getRight() : 0); + $vChrome = (null !== $border ? $border->getTop() + $border->getBottom() : 0) + + (null !== $padding ? $padding->getTop() + $padding->getBottom() : 0); + + return [ + max(1, $columns - $hChrome), + max(1, $rows - $vChrome), + ]; + } + + /** + * Compute the top-left chrome offset (border + padding) for a style. + * + * @return array{int, int} [topOffset, leftOffset] + */ + public function computeChromeOffset(Style $style): array + { + $border = $style->getBorder(); + $padding = $style->getPadding(); + + $top = (null !== $border ? $border->getTop() : 0) + (null !== $padding ? $padding->getTop() : 0); + $left = (null !== $border ? $border->getLeft() : 0) + (null !== $padding ? $padding->getLeft() : 0); + + return [$top, $left]; + } + + /** + * Compute a RenderContext with inner dimensions (content area after border/padding). + * + * Widgets receive this context so they render into the content area without + * needing to account for their own chrome. + */ + public function computeInnerContext(RenderContext $context, Style $style): RenderContext + { + [$innerColumns, $innerRows] = $this->computeInnerDimensions($context->getColumns(), $context->getRows(), $style); + + // Strip layout properties from the style so leaf widgets only see + // visual formatting (color, bold, etc.). The Renderer owns layout + // (padding, border, gap, direction, hidden, cursorShape, textAlign, align, verticalAlign); widgets own content. + return new RenderContext($innerColumns, $innerRows, $context->getStyle()->withoutLayoutProperties(), $context->getFontRegistry()); + } + + /** + * Resolve the outer style for a widget by accumulating resolved + * ancestor styles from root to immediate parent. + * + * This ensures that visual properties (color, background) set on + * a grandparent propagate through intermediate containers that + * don't override them. + */ + private function resolveOuterStyle(AbstractWidget $widget): ?Style + { + // Collect ancestors from immediate parent to root + $ancestors = []; + $parent = $widget->getParent(); + while (null !== $parent) { + $ancestors[] = $parent; + $parent = $parent->getParent(); + } + + if ([] === $ancestors) { + return null; + } + + // Resolve each ancestor's style from root (last) to immediate + // parent (first) so closer ancestors override more distant ones + $resolvedStyles = []; + for ($i = \count($ancestors) - 1; $i >= 0; --$i) { + $resolvedStyles[] = $this->widgetRenderer->resolveStyle($ancestors[$i]); + } + + return Style::mergeAll($resolvedStyles); + } +} diff --git a/extern/Tui/Render/Compositor.php b/extern/Tui/Render/Compositor.php new file mode 100644 index 00000000..002bebb3 --- /dev/null +++ b/extern/Tui/Render/Compositor.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * Composites multiple layers into a single set of ANSI-formatted lines. + * + * Layers are applied in order: layer 0 is the base (typically opaque), + * subsequent layers are painted on top. Transparent layers let the + * content below show through where no explicit background is set. + * + * The canvas dimensions are derived from the first (base) layer: + * height is the number of lines, width is the visible width of the + * first line. + * + * Usage: + * + * $lines = Compositor::composite( + * new Layer($backgroundLines), + * new Layer($foregroundLines, transparent: true), + * ); + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class Compositor +{ + /** + * Composite multiple layers into ANSI-formatted output lines. + * + * The first layer defines the canvas dimensions. + * + * @return string[] + */ + public static function composite(Layer ...$layers): array + { + if ([] === $layers) { + return []; + } + + $base = $layers[0]; + $height = $base->getHeight() ?? \count($base->getLines()); + $width = $base->getWidth() ?? ([] === $base->getLines() ? 0 : AnsiUtils::visibleWidth($base->getLines()[0])); + + $buffer = new CellBuffer($width, $height); + + foreach ($layers as $layer) { + $buffer->writeAnsiLines( + $layer->getLines(), + $layer->getRow(), + $layer->getCol(), + $layer->isTransparent(), + ); + } + + return $buffer->toLines(); + } +} diff --git a/extern/Tui/Render/Layer.php b/extern/Tui/Render/Layer.php new file mode 100644 index 00000000..a17a7587 --- /dev/null +++ b/extern/Tui/Render/Layer.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +/** + * A compositing layer: content lines at a position, optionally transparent. + * + * When transparent, cells with no explicit background preserve the + * background from the layer below. Fully unstyled spaces are completely + * transparent (the entire cell below shows through). + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class Layer +{ + /** + * @param string[] $lines ANSI-formatted content lines + * @param int $row Vertical offset in the composite + * @param int $col Horizontal offset in the composite + * @param bool $transparent Whether cells with default background inherit from layers below + * @param int|null $width Explicit canvas width (used by the base layer to define the canvas size) + * @param int|null $height Explicit canvas height (used by the base layer to define the canvas size) + */ + public function __construct( + private readonly array $lines, + private readonly int $row = 0, + private readonly int $col = 0, + private readonly bool $transparent = false, + private readonly ?int $width = null, + private readonly ?int $height = null, + ) { + } + + /** + * @return string[] + */ + public function getLines(): array + { + return $this->lines; + } + + public function getRow(): int + { + return $this->row; + } + + public function getCol(): int + { + return $this->col; + } + + public function isTransparent(): bool + { + return $this->transparent; + } + + public function getWidth(): ?int + { + return $this->width; + } + + public function getHeight(): ?int + { + return $this->height; + } +} diff --git a/extern/Tui/Render/LayoutEngine.php b/extern/Tui/Render/LayoutEngine.php new file mode 100644 index 00000000..ce32ae80 --- /dev/null +++ b/extern/Tui/Render/LayoutEngine.php @@ -0,0 +1,478 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Style\Align; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\VerticalAlign; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\ParentInterface; +use Symfony\Component\Tui\Widget\VerticallyExpandableInterface; + +/** + * Lays out children vertically or horizontally with gap, fill, and alignment. + * + * The layout engine distributes available space among children, handles + * fill-expanding children, and applies horizontal/vertical alignment. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class LayoutEngine +{ + private WidgetRendererInterface $widgetRenderer; + + public function __construct( + private readonly PositionTracker $positionTracker, + private readonly FontRegistry $fontRegistry, + ) { + } + + public function setWidgetRenderer(WidgetRendererInterface $widgetRenderer): void + { + $this->widgetRenderer = $widgetRenderer; + } + + /** + * Layout children based on direction. + * + * @param AbstractWidget[] $children + * + * @return string[] + */ + public function layout( + array $children, + int $columns, + int $rows, + int $gap, + Direction $direction, + ?string $gapLine = null, + ): array { + if (Direction::Horizontal === $direction) { + return $this->layoutHorizontal($children, $columns, $rows, $gap); + } + + return $this->layoutVertical($children, $columns, $rows, $gap, $gapLine); + } + + /** + * Compute the horizontal offset needed to align content within the available width. + * + * @param string[] $lines + */ + public function computeAlignOffset(array $lines, int $columns, Align $align): int + { + if ([] === $lines) { + return 0; + } + + $maxWidth = 0; + foreach ($lines as $line) { + $maxWidth = max($maxWidth, AnsiUtils::visibleWidth($line)); + } + + $availableSpace = max(0, $columns - $maxWidth); + + return match ($align) { + Align::Center => (int) floor($availableSpace / 2), + Align::Right => $availableSpace, + Align::Left => 0, + }; + } + + /** + * Compute the vertical offset (number of top-padding rows) for alignment. + */ + public function computeVerticalAlignOffset(int $contentRows, int $availableRows, VerticalAlign $verticalAlign): int + { + $space = max(0, $availableRows - $contentRows); + + return match ($verticalAlign) { + VerticalAlign::Top => 0, + VerticalAlign::Center => (int) floor($space / 2), + VerticalAlign::Bottom => $space, + }; + } + + /** + * Shift all lines by prepending spaces. + * + * @param string[] $lines + * + * @return string[] + */ + public function shiftLines(array $lines, int $offset): array + { + $prefix = str_repeat(' ', $offset); + $result = []; + foreach ($lines as $line) { + $result[] = $prefix.$line; + } + + return $result; + } + + /** + * Layout children vertically with gap and fill support. + * + * @param AbstractWidget[] $children + * + * @return string[] + */ + private function layoutVertical(array $children, int $columns, int $rows, int $gap, ?string $gapLine = null): array + { + if ([] === $children) { + return []; + } + + $lines = []; + $gapLine ??= str_repeat(' ', max(1, $columns)); + $gapLines = $gap > 0 ? array_fill(0, $gap, $gapLine) : []; + $first = true; + + // Calculate total gap rows + $totalGapRows = $gap * max(0, \count($children) - 1); + $remainingRows = $rows - $totalGapRows; + + // First pass: identify fill children and measure non-fill children. + // During this pass, suppress position tracking for descendants since + // we don't yet know each child's final absolute row offset. + $fillChildren = []; + $nonFillRenders = []; + $nonFillNeedsDescendantTracking = []; + $savedStack = $this->positionTracker->suppressStack(); + + foreach ($children as $index => $child) { + if ($child instanceof VerticallyExpandableInterface && $child->isVerticallyExpanded()) { + $fillChildren[$index] = $child; + } else { + // Suppress descendant position tracking during measurement. + // Children that can have descendants must be re-rendered later + // with the final absolute offset to populate descendant rects. + $context = new RenderContext($columns, $rows, null, $this->fontRegistry); + $childLines = $this->widgetRenderer->renderWidget($child, $context); + $nonFillRenders[$index] = $childLines; + $nonFillNeedsDescendantTracking[$index] = $child instanceof ParentInterface; + + $remainingRows -= \count($childLines); + } + } + + $this->positionTracker->restoreStack($savedStack); + + // Calculate rows for fill children + $fillCount = \count($fillChildren); + $baseFillRows = $fillCount > 0 ? max(1, intdiv(max(0, $remainingRows), $fillCount)) : 0; + $extraRows = $fillCount > 0 ? max(0, $remainingRows) % $fillCount : 0; + + // Second pass: render all children in order with correct position tracking. + // At this point we know the accumulated line count for each child's offset. + $fillIndex = 0; + $hasPositionStack = $this->positionTracker->isActive(); + foreach ($children as $index => $child) { + if (isset($fillChildren[$index])) { + // Fill child gets calculated rows, distributing remainder to first children + $childFillRows = $baseFillRows + ($fillIndex < $extraRows ? 1 : 0); + ++$fillIndex; + + // Add gap before this child (so line count is correct for position) + if (!$first && $gapLines) { + array_push($lines, ...$gapLines); + } + + $context = new RenderContext($columns, $childFillRows, null, $this->fontRegistry); + + // Push correct absolute position so descendants get proper coordinates + if ($hasPositionStack) { + [$parentAbsRow, $parentAbsCol] = $this->positionTracker->currentOffset(); + $this->positionTracker->push($parentAbsRow + \count($lines), $parentAbsCol); + } + $childLines = $this->widgetRenderer->renderWidget($child, $context); + if ($hasPositionStack) { + $this->positionTracker->pop(); + } + + // Pad fill children to their allocated rows so they actually fill the space + while (\count($childLines) < $childFillRows) { + $childLines[] = ''; + } + } else { + $childLines = $nonFillRenders[$index] ?? $this->widgetRenderer->renderWidget($child, new RenderContext($columns, $rows, null, $this->fontRegistry)); + + // Skip gap for children that render nothing + if ([] === $childLines) { + continue; + } + + if (!$first && $gapLines) { + array_push($lines, ...$gapLines); + } + } + + // Track widget position + if ($hasPositionStack) { + [$parentAbsRow, $parentAbsCol] = $this->positionTracker->currentOffset(); + $childAbsRow = $parentAbsRow + \count($lines); + $childAbsCol = $parentAbsCol; + + $this->positionTracker->setWidgetRect($child, new WidgetRect( + $childAbsRow, + $childAbsCol, + $columns, + \count($childLines), + )); + + // For non-fill parent widgets rendered during measurement, + // re-render to track descendant positions with the correct + // absolute offset. Leaf widgets don't need this extra pass. + // Clear the render cache first so the re-render walks the + // subtree instead of returning the cached measurement output. + if (($nonFillNeedsDescendantTracking[$index] ?? false) && !isset($fillChildren[$index])) { + $child->clearRenderCache(); + $this->positionTracker->push($childAbsRow, $childAbsCol); + $this->widgetRenderer->renderWidget($child, new RenderContext($columns, $rows, null, $this->fontRegistry)); + $this->positionTracker->pop(); + } + } + + array_push($lines, ...$childLines); + $first = false; + } + + return $lines; + } + + /** + * Layout children horizontally with gap and flex-based column distribution. + * + * Flex modes: + * - No child has flex set: equal distribution (backward compatible) + * - flex: 0: intrinsic width (render to measure, then use actual width, capped by maxColumns) + * - flex: N (N > 0): proportional weight (remaining space after fixed children is distributed by weight) + * + * @param AbstractWidget[] $children + * + * @return string[] + */ + private function layoutHorizontal(array $children, int $columns, int $rows, int $gap): array + { + $count = \count($children); + if (0 === $count) { + return []; + } + + // When there are more children than available columns (accounting + // for gap), only the first N that fit are rendered. Each child + // needs at least 1 column, and each gap between children takes + // $gap columns: maxChildren = floor((columns + gap) / (1 + gap)). + $maxChildren = (int) floor(($columns + $gap) / (1 + $gap)); + if ($maxChildren < 1) { + $maxChildren = 1; + } + if ($count > $maxChildren) { + $children = \array_slice($children, 0, $maxChildren); + $count = $maxChildren; + } + + $gapColumns = $gap * max(0, $count - 1); + $availableColumns = max(1, $columns - $gapColumns); + + // Resolve flex values for each child + $flexValues = []; + $anyFlexSet = false; + foreach ($children as $index => $child) { + $childStyle = $this->widgetRenderer->resolveStyle($child); + $flexValues[$index] = $childStyle->getFlex(); + if (null !== $childStyle->getFlex()) { + $anyFlexSet = true; + } + } + + // Compute column widths based on flex values + $childColumnCounts = $this->computeFlexColumnWidths( + $children, + $flexValues, + $anyFlexSet, + $availableColumns, + $rows, + ); + + $childRenders = []; + $maxRows = 0; + $hasPositionStack = $this->positionTracker->isActive(); + + $colOffset = 0; + foreach ($children as $index => $child) { + $childColumns = $childColumnCounts[$index]; + + // Push correct absolute position for this horizontal child + // so descendants get proper coordinates during rendering + if ($hasPositionStack) { + [$absRow, $absCol] = $this->positionTracker->currentOffset(); + $this->positionTracker->push($absRow, $absCol + $colOffset); + } + + $context = new RenderContext($childColumns, $rows, null, $this->fontRegistry); + $childLines = $this->widgetRenderer->renderWidget($child, $context); + $childRenders[$index] = $childLines; + $maxRows = max($maxRows, \count($childLines)); + + if ($hasPositionStack) { + $this->positionTracker->pop(); + } + + $colOffset += $childColumns + $gap; + } + + if (0 === $maxRows) { + return []; + } + + // Track widget positions for horizontal children + if ($hasPositionStack) { + [$absRow, $absCol] = $this->positionTracker->currentOffset(); + $colOffset = 0; + foreach ($children as $index => $child) { + $this->positionTracker->setWidgetRect($child, new WidgetRect( + $absRow, + $absCol + $colOffset, + $childColumnCounts[$index], + \count($childRenders[$index]), + )); + $colOffset += $childColumnCounts[$index] + $gap; + } + } + + $gapSpaces = $gap > 0 ? str_repeat(' ', $gap) : ''; + $lines = []; + + for ($row = 0; $row < $maxRows; ++$row) { + $lineParts = []; + foreach ($children as $index => $child) { + $line = $childRenders[$index][$row] ?? ''; + $visibleLen = AnsiUtils::visibleWidth($line); + $cols = $childColumnCounts[$index]; + + if ($visibleLen > $cols) { + $line = AnsiUtils::truncateToWidth($line, $cols, ''); + } elseif ($visibleLen < $cols) { + $line .= str_repeat(' ', $cols - $visibleLen); + } + + $lineParts[] = $line; + } + + $lines[] = implode($gapSpaces, $lineParts); + } + + return $lines; + } + + /** + * Compute column widths for horizontal children based on flex values. + * + * When no child has flex set, falls back to equal distribution. + * flex: 0 children get their intrinsic width (measured by rendering). + * flex: N children share remaining space proportionally. + * + * @param AbstractWidget[] $children + * @param array $flexValues + * + * @return array + */ + private function computeFlexColumnWidths( + array $children, + array $flexValues, + bool $anyFlexSet, + int $availableColumns, + int $rows, + ): array { + $count = \count($children); + + // No flex set: equal distribution (backward compatible) + if (!$anyFlexSet) { + $baseColumns = intdiv($availableColumns, $count); + $extra = $availableColumns % $count; + $result = []; + foreach ($children as $index => $child) { + $result[$index] = max(1, $baseColumns + ($index < $extra ? 1 : 0)); + } + + return $result; + } + + // First pass: measure intrinsic-width children (flex: 0) and collect flex weights. + // Suppress position tracking during measurement (same pattern as vertical fill). + $intrinsicWidths = []; + $flexWeights = []; + $totalFlexWeight = 0; + $usedColumns = 0; + $savedStack = $this->positionTracker->suppressStack(); + + foreach ($children as $index => $child) { + $flex = $flexValues[$index]; + + if (0 === $flex) { + // Intrinsic width: measure the child's natural content width + // plus chrome (border/padding). This uses measureIntrinsicWidth() + // instead of renderWidget() because renderWidget() pads lines + // to the full allocated width via ChromeApplier. + $width = $this->widgetRenderer->measureIntrinsicWidth($child, $availableColumns, $rows); + $intrinsicWidths[$index] = $width; + $usedColumns += $width; + } elseif (null !== $flex && $flex > 0) { + $flexWeights[$index] = $flex; + $totalFlexWeight += $flex; + } else { + // null flex when other siblings have flex set: treat as flex: 1 + $flexWeights[$index] = 1; + ++$totalFlexWeight; + } + } + + $this->positionTracker->restoreStack($savedStack); + + // Second pass: distribute remaining space among flex children + $remainingColumns = max(0, $availableColumns - $usedColumns); + $result = []; + $flexAllocated = 0; + $flexColumnsUsed = 0; + $flexCount = \count($flexWeights); + + foreach ($children as $index => $child) { + if (isset($intrinsicWidths[$index])) { + $result[$index] = $intrinsicWidths[$index]; + } elseif (isset($flexWeights[$index])) { + if ($totalFlexWeight > 0 && $remainingColumns > 0) { + // Last flex child gets whatever is left to avoid rounding errors + ++$flexAllocated; + if ($flexAllocated === $flexCount) { + $allocated = $remainingColumns - $flexColumnsUsed; + } else { + $allocated = (int) floor($remainingColumns * $flexWeights[$index] / $totalFlexWeight); + } + } else { + $allocated = 0; + } + $result[$index] = max(1, $allocated); + $flexColumnsUsed += $result[$index]; + } + } + + return $result; + } +} diff --git a/extern/Tui/Render/PositionTracker.php b/extern/Tui/Render/PositionTracker.php new file mode 100644 index 00000000..d0e6f6ad --- /dev/null +++ b/extern/Tui/Render/PositionTracker.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Tracks absolute positions of rendered widgets on screen. + * + * Maintains a stack of absolute [row, col] offsets for the current rendering + * context and records each widget's final position as a WidgetRect. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class PositionTracker +{ + /** @var \WeakMap */ + private \WeakMap $widgetPositions; + + /** + * Stack of absolute [row, col] offsets for the current rendering context. + * + * @var list + */ + private array $positionStack = []; + + public function __construct() + { + $this->widgetPositions = new \WeakMap(); + } + + /** + * Reset the position stack for a new render pass. + * + * Previous widget positions are preserved so that cached subtrees + * (which skip re-rendering) keep their tracked rects. Any widget + * whose parent is re-rendered gets a fresh rect, replacing the old + * entry. Removed widgets are garbage-collected by the WeakMap. + */ + public function reset(): void + { + $this->positionStack = [[0, 0]]; + } + + /** + * Get the tracked position of a widget from the last render pass. + */ + public function getWidgetRect(AbstractWidget $widget): ?WidgetRect + { + return $this->widgetPositions[$widget] ?? null; + } + + /** + * Record a widget's absolute position. + */ + public function setWidgetRect(AbstractWidget $widget, WidgetRect $rect): void + { + $this->widgetPositions[$widget] = $rect; + } + + /** + * Whether position tracking is active (has a non-empty stack). + */ + public function isActive(): bool + { + return [] !== $this->positionStack; + } + + /** + * Get the current absolute [row, col] offset from the top of the stack. + * + * @return array{int, int} + */ + public function currentOffset(): array + { + return $this->positionStack[\count($this->positionStack) - 1]; + } + + /** + * Push a new absolute [row, col] offset onto the stack. + */ + public function push(int $row, int $col): void + { + $this->positionStack[] = [$row, $col]; + } + + /** + * Pop the top offset from the stack. + */ + public function pop(): void + { + if (\count($this->positionStack) > 1) { + array_pop($this->positionStack); + } + } + + /** + * Save the position stack and replace it with an empty one. + * + * Used to suppress position tracking during measurement passes. + * + * @return list + */ + public function suppressStack(): array + { + $saved = $this->positionStack; + $this->positionStack = []; + + return $saved; + } + + /** + * Restore a previously saved position stack. + * + * @param list $stack + */ + public function restoreStack(array $stack): void + { + $this->positionStack = $stack; + } + + /** + * Snapshot the set of widgets currently tracked. + * + * @return \SplObjectStorage + */ + public function snapshotKeys(): \SplObjectStorage + { + /** @var \SplObjectStorage $snapshot */ + $snapshot = new \SplObjectStorage(); + foreach ($this->widgetPositions as $widget => $_) { + $snapshot[$widget] = true; + } + + return $snapshot; + } + + /** + * Shift positions for all widgets added since the snapshot. + * + * @param \SplObjectStorage|null $before + */ + public function shiftDescendantPositions(?\SplObjectStorage $before, int $colOffset, int $rowOffset = 0): void + { + if (null === $before) { + return; + } + + foreach ($this->widgetPositions as $widget => $rect) { + if (!$before->offsetExists($widget)) { + $this->widgetPositions[$widget] = new WidgetRect( + $rect->getRow() + $rowOffset, + $rect->getCol() + $colOffset, + $rect->getColumns(), + $rect->getRows(), + ); + } + } + } +} diff --git a/extern/Tui/Render/RenderContext.php b/extern/Tui/Render/RenderContext.php new file mode 100644 index 00000000..a30b6026 --- /dev/null +++ b/extern/Tui/Render/RenderContext.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; + +/** + * Context passed to widgets during rendering. + * + * Contains the available dimensions for rendering in terminal character cells, + * the resolved style for the widget, and the font registry for FIGlet rendering. + * + * This is an immutable value object - use with*() methods to create modified copies. + * + * ## Terminology: columns and rows + * + * This class uses `columns` and `rows` to match terminal conventions: + * - Terminals measure size in character cells (columns × rows), not pixels + * - The TerminalInterface uses `getColumns()` and `getRows()` + * - Standard tools like `stty`, `tput`, and env vars `COLUMNS`/`LINES` use this terminology + * + * @experimental + * + * @author Fabien Potencier + */ +final class RenderContext +{ + private readonly Style $style; + private readonly FontRegistry $fontRegistry; + + public function __construct( + private readonly int $columns, + private readonly int $rows, + ?Style $style = null, + ?FontRegistry $fontRegistry = null, + ) { + $this->style = $style ?? new Style(); + $this->fontRegistry = $fontRegistry ?? new FontRegistry(); + } + + public function getColumns(): int + { + return $this->columns; + } + + public function getRows(): int + { + return $this->rows; + } + + public function getStyle(): Style + { + return $this->style; + } + + public function getFontRegistry(): FontRegistry + { + return $this->fontRegistry; + } + + /** + * Create a new context with a different column count. + */ + public function withColumns(int $columns): self + { + return new self($columns, $this->rows, $this->style, $this->fontRegistry); + } + + /** + * Create a new context with a different row count. + */ + public function withRows(int $rows): self + { + return new self($this->columns, $rows, $this->style, $this->fontRegistry); + } + + /** + * Create a new context with different dimensions. + */ + public function withSize(int $columns, int $rows): self + { + return new self($columns, $rows, $this->style, $this->fontRegistry); + } + + /** + * Create a new context with a resolved style. + */ + public function withStyle(Style $style): self + { + return new self($this->columns, $this->rows, $style, $this->fontRegistry); + } +} diff --git a/extern/Tui/Render/RenderRequestorInterface.php b/extern/Tui/Render/RenderRequestorInterface.php new file mode 100644 index 00000000..c5f82e18 --- /dev/null +++ b/extern/Tui/Render/RenderRequestorInterface.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +/** + * Capability interface for managing the render lifecycle. + * + * Used by internal collaborators (FocusManager, MouseCoordinator) + * that need to trigger or flush a render pass without depending + * on the full Tui API. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +interface RenderRequestorInterface +{ + /** + * Request a render on the next tick. + * + * @param bool $force If true, clear all cached state and do a full re-render + */ + public function requestRender(bool $force = false): void; + + /** + * Flush any pending render immediately. + * + * Unlike requestRender() which defers to the next tick, this + * synchronously renders the current frame. Used when up-to-date + * widget positions are needed before further processing (e.g. + * mouse hit-testing after a screen transition). + */ + public function processRender(): void; +} diff --git a/extern/Tui/Render/Renderer.php b/extern/Tui/Render/Renderer.php new file mode 100644 index 00000000..c995c84a --- /dev/null +++ b/extern/Tui/Render/Renderer.php @@ -0,0 +1,339 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\RenderException; +use Symfony\Component\Tui\Style\Align; +use Symfony\Component\Tui\Style\DefaultStyleSheet; +use Symfony\Component\Tui\Style\Direction; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\ParentInterface; + +/** + * Renders the widget tree with style resolution, layout, and chrome. + * + * The Renderer: + * 1. Resolves styles through cascade (* → FQCN → CSS class → state → instance) + * 2. Computes layout (vertical/horizontal with gap and fill children) + * 3. Calls widget->render() with enriched context + * 4. Applies chrome (padding, border, background) around widget content + * + * All widget types are rendered through the Renderer: containers via + * renderContainer(), and leaf widgets by delegating to widget->render(). + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class Renderer implements WidgetRendererInterface +{ + private StyleSheet $styleSheet; + private FontRegistry $fontRegistry; + private PositionTracker $positionTracker; + private LayoutEngine $layoutEngine; + private ChromeApplier $chromeApplier; + + /** Current terminal columns, set during render() for breakpoint resolution */ + private ?int $currentColumns = null; + + public function __construct(?StyleSheet $styleSheet = null, ?FontRegistry $fontRegistry = null) + { + $this->fontRegistry = $fontRegistry ?? new FontRegistry(); + $this->positionTracker = new PositionTracker(); + $this->layoutEngine = new LayoutEngine($this->positionTracker, $this->fontRegistry); + $this->layoutEngine->setWidgetRenderer($this); + $this->chromeApplier = new ChromeApplier(); + $this->chromeApplier->setWidgetRenderer($this); + + if (null !== $styleSheet) { + // Clone the user stylesheet to preserve its runtime type + // (e.g. TailwindStylesheet) so that its resolve() override + // is used. Merge the defaults underneath: default rules are + // added only for selectors the user hasn't already defined. + $this->styleSheet = clone $styleSheet; + $this->styleSheet->mergeDefaults(DefaultStyleSheet::create()); + } else { + $this->styleSheet = DefaultStyleSheet::create(); + } + } + + /** + * Add a stylesheet. + */ + public function addStyleSheet(StyleSheet $styleSheet): void + { + $this->styleSheet->merge($styleSheet); + } + + /** + * Get the stylesheet. + */ + public function getStyleSheet(): StyleSheet + { + return $this->styleSheet; + } + + /** + * Get the tracked position of a widget from the last render pass. + * + * Returns null if the widget was not rendered or is not being tracked. + */ + public function getWidgetRect(AbstractWidget $widget): ?WidgetRect + { + return $this->positionTracker->getWidgetRect($widget); + } + + /** + * Render the widget tree starting from root. + * + * @return string[] Array of rendered lines + */ + public function render(ContainerWidget $root, int $columns, int $rows): array + { + $context = new RenderContext($columns, $rows, null, $this->fontRegistry); + $this->currentColumns = $columns; + $this->positionTracker->reset(); + + $result = $this->renderWidget($root, $context); + + // Track root widget position + $this->positionTracker->setWidgetRect($root, new WidgetRect(0, 0, $columns, \count($result))); + + return $result; + } + + public function renderWidget(AbstractWidget $widget, RenderContext $context): array + { + // Allow widget to sync state before rendering + $widget->beforeRender(); + + // Check render cache: if the widget hasn't been invalidated and + // the available dimensions are unchanged, reuse the previous output. + // This skips style resolution, layout, chrome, and content rendering. + $cacheColumns = $context->getColumns(); + $cacheRows = $context->getRows(); + $cached = $widget->getRenderCache($cacheColumns, $cacheRows); + if (null !== $cached) { + return $cached; + } + + // 1. Resolve style by merging: global → FQCN → state → instance + $resolvedStyle = $this->resolveStyle($widget); + + // Hidden widgets produce no output and take no space + if (true === $resolvedStyle->getHidden()) { + $widget->setRenderCache([], $cacheColumns, $cacheRows); + + return []; + } + + // 2. Apply maxColumns constraint if set + $maxColumns = $resolvedStyle->getMaxColumns(); + if (null !== $maxColumns && $context->getColumns() > $maxColumns) { + $context = $context->withColumns($maxColumns); + } + + // 3. Create enriched context with resolved style + $styledContext = $context->withStyle($resolvedStyle); + + // 4. For ContainerWidget, use the layout engine + if ($widget instanceof ContainerWidget) { + $lines = $this->renderContainer($widget, $styledContext, $resolvedStyle); + } else { + // 5. For all other widgets (leaf widgets + ParentInterface), + // render content with inner dimensions, then apply chrome + $innerContext = $this->chromeApplier->computeInnerContext($styledContext, $resolvedStyle); + $lines = $widget->render($innerContext); + $lines = $this->chromeApplier->apply($lines, $context->getColumns(), $resolvedStyle, $widget); + } + + // Validate that no line exceeds the available width. + // This catches widget bugs early, at the source, rather than + // letting over-wide lines flow to ScreenWriter where the widget + // context is lost. Image lines (Kitty/iTerm2 protocol) are + // excluded because their visible width is not meaningful. + $availableColumns = $context->getColumns(); + foreach ($lines as $i => $line) { + if ('' === $line || AnsiUtils::containsImage($line)) { + continue; + } + + $lineWidth = AnsiUtils::visibleWidth($line); + if ($lineWidth > $availableColumns) { + throw new RenderException(\sprintf("Widget \"%s\" rendered line %d with width %d, exceeding the available %d columns.\nLine preview: %d.", $widget::class, $i, $lineWidth, $availableColumns, mb_substr(AnsiUtils::stripAnsiCodes($line), 0, 100)), $i, $lineWidth, $availableColumns); + } + } + + $widget->setRenderCache($lines, $cacheColumns, $cacheRows); + + return $lines; + } + + public function resolveStyle(AbstractWidget $widget): Style + { + return $this->styleSheet->resolve($widget, $this->currentColumns); + } + + public function measureIntrinsicWidth(AbstractWidget $widget, int $maxColumns, int $rows): int + { + $resolvedStyle = $this->resolveStyle($widget); + + // Apply maxColumns from the widget's own style + $styleMaxColumns = $resolvedStyle->getMaxColumns(); + if (null !== $styleMaxColumns) { + $maxColumns = min($maxColumns, $styleMaxColumns); + } + + // Compute chrome (border + padding) + [$innerColumns] = $this->chromeApplier->computeInnerDimensions($maxColumns, $rows, $resolvedStyle); + + if ($widget instanceof ContainerWidget) { + // For containers, render children within inner dimensions and measure + $children = array_values(array_filter( + $widget->all(), + fn (AbstractWidget $child) => true !== $this->resolveStyle($child)->getHidden(), + )); + + $direction = $resolvedStyle->getDirection() ?? Direction::Vertical; + $gap = $resolvedStyle->getGap() ?? 0; + + if (Direction::Horizontal === $direction) { + // Horizontal container: sum of children's intrinsic widths + gaps + $totalWidth = $gap * max(0, \count($children) - 1); + foreach ($children as $child) { + $totalWidth += $this->measureIntrinsicWidth($child, $innerColumns, $rows); + } + $contentWidth = $totalWidth; + } else { + // Vertical container: widest child + $contentWidth = 0; + foreach ($children as $child) { + $contentWidth = max($contentWidth, $this->measureIntrinsicWidth($child, $innerColumns, $rows)); + } + } + } else { + // For leaf widgets, render content at inner dimensions and measure widest line + $innerContext = $this->chromeApplier->computeInnerContext( + new RenderContext($maxColumns, $rows, null, $this->fontRegistry)->withStyle($resolvedStyle), + $resolvedStyle, + ); + $widget->beforeRender(); + $contentLines = $widget->render($innerContext); + $widget->clearRenderCache(); + + $contentWidth = 0; + foreach ($contentLines as $line) { + $contentWidth = max($contentWidth, AnsiUtils::visibleWidth($line)); + } + } + + $chromeWidth = $maxColumns - $innerColumns; + + return min(max(1, $contentWidth + $chromeWidth), $maxColumns); + } + + /** + * Render a container widget with its children. + * + * @return string[] + */ + private function renderContainer(ContainerWidget $widget, RenderContext $context, Style $resolvedStyle): array + { + // Filter out hidden children so they don't take up layout space + $children = array_values(array_filter( + $widget->all(), + fn (AbstractWidget $child) => true !== $this->resolveStyle($child)->getHidden(), + )); + + $columns = $context->getColumns(); + $rows = $context->getRows(); + + if ([] === $children) { + return $this->chromeApplier->apply([], $columns, $resolvedStyle, $widget); + } + + // Calculate inner dimensions (content area after border/padding) + [$innerColumns, $innerRows] = $this->chromeApplier->computeInnerDimensions($columns, $rows, $resolvedStyle); + + // Get direction and gap from resolved style + $direction = $resolvedStyle->getDirection() ?? Direction::Vertical; + $gap = $resolvedStyle->getGap() ?? 0; + + // Compute styled gap line matching what a child widget would render as blank + // This ensures gap lines inherit the container's resolved style (e.g. bold from * rule) + $gapLine = null; + if ($gap > 0) { + $gapContent = str_repeat(' ', max(1, $innerColumns)); + $gapLine = $resolvedStyle->isPlain() ? $gapContent : $resolvedStyle->apply($gapContent); + } + + // Push the content area's absolute offset onto the position stack + if ($this->positionTracker->isActive()) { + [$parentRow, $parentCol] = $this->positionTracker->currentOffset(); + [$chromeTop, $chromeLeft] = $this->chromeApplier->computeChromeOffset($resolvedStyle); + $this->positionTracker->push($parentRow + $chromeTop, $parentCol + $chromeLeft); + } + + // Snapshot positions before layout so we can adjust them if alignment shifts content + $align = $resolvedStyle->getAlign(); + $hasAlign = null !== $align && Align::Left !== $align; + $verticalAlign = $resolvedStyle->getVerticalAlign(); + $hasVerticalAlign = null !== $verticalAlign; + $positionsBeforeLayout = ($hasAlign || $hasVerticalAlign) ? $this->positionTracker->snapshotKeys() : null; + + // Render children using layout engine + $childLines = $this->layoutEngine->layout( + $children, + $innerColumns, + $innerRows, + $gap, + $direction, + $gapLine, + ); + + // Pop position stack + $this->positionTracker->pop(); + + // Apply vertical alignment for child widgets and adjust tracked positions + if ($hasVerticalAlign && \count($childLines) < $innerRows) { + $verticalOffset = $this->layoutEngine->computeVerticalAlignOffset(\count($childLines), $innerRows, $verticalAlign); + if ($verticalOffset > 0) { + $topPad = array_fill(0, $verticalOffset, ''); + array_unshift($childLines, ...$topPad); + $this->positionTracker->shiftDescendantPositions($positionsBeforeLayout, 0, $verticalOffset); + } + // Pad to fill remaining height so Tui::doRender() doesn't override alignment + while (\count($childLines) < $innerRows) { + $childLines[] = ''; + } + } + + // Apply horizontal alignment for child widgets and adjust tracked positions + if ($hasAlign) { + $alignOffset = $this->layoutEngine->computeAlignOffset($childLines, $innerColumns, $align); + if ($alignOffset > 0) { + $childLines = $this->layoutEngine->shiftLines($childLines, $alignOffset); + $this->positionTracker->shiftDescendantPositions($positionsBeforeLayout, $alignOffset); + } + } + + // Apply chrome (padding, border, background) + return $this->chromeApplier->apply($childLines, $columns, $resolvedStyle, $widget); + } +} diff --git a/extern/Tui/Render/ScreenWriter.php b/extern/Tui/Render/ScreenWriter.php new file mode 100644 index 00000000..0ba3983f --- /dev/null +++ b/extern/Tui/Render/ScreenWriter.php @@ -0,0 +1,548 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\RenderException; +use Symfony\Component\Tui\Terminal\TerminalInterface; + +/** + * Handles efficient terminal output with differential rendering. + * + * Accepts rendered lines (the composited screen state) and writes them + * to the terminal with minimal updates using line-level diffing. + * + * This class is responsible for: + * - Tracking screen state (previous lines, cursor position) + * - Computing minimal updates between frames + * - Writing ANSI sequences to the terminal + * - Managing cursor position for differential rendering + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class ScreenWriter +{ + private const PRINTABLE_ASCII = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + + /** @var string[] */ + private array $previousLines = []; + private int $previousWidth = 0; + private int $cursorRow = 0; + private int $hardwareCursorRow = 0; + private int $maxLinesRendered = 0; + private bool $showHardwareCursor = true; + private int $scrollOffset = 0; + + /** @var string[] */ + private array $previousRawLines = []; + + /** @var array{row: int, col: int, shape: int}|null */ + private ?array $previousCursorPos = null; + + public function __construct( + private readonly TerminalInterface $terminal, + ) { + } + + public function setShowHardwareCursor(bool $enabled): void + { + if ($this->showHardwareCursor === $enabled) { + return; + } + + $this->showHardwareCursor = $enabled; + + if (!$enabled) { + $this->terminal->hideCursor(); + } + } + + /** + * Set the scroll offset (lines from bottom). + * + * When the content exceeds the viewport, the viewport normally shows + * the bottom portion. A positive scroll offset shifts the viewport + * up by that many lines. + */ + public function setScrollOffset(int $offset): void + { + $offset = max(0, $offset); + if ($this->scrollOffset !== $offset) { + $this->scrollOffset = $offset; + $this->reset(); + } + } + + /** + * Get the current scroll offset. + */ + public function getScrollOffset(): int + { + return $this->scrollOffset; + } + + /** + * Write ANSI lines to the terminal using differential rendering. + * + * @param string[] $lines The new content to display + */ + public function writeLines(array $lines): void + { + // Apply scroll offset: when content exceeds the viewport, slice to + // show a window shifted up from the bottom by scrollOffset lines. + if ($this->scrollOffset > 0) { + $totalLines = \count($lines); + $rows = $this->terminal->getRows(); + if ($totalLines > $rows) { + $maxOffset = $totalLines - $rows; + $effectiveOffset = min($this->scrollOffset, $maxOffset); + $startLine = $totalLines - $rows - $effectiveOffset; + $lines = \array_slice($lines, $startLine, $rows); + } + } + + if ([] !== $this->previousLines && $this->previousWidth === $this->terminal->getColumns() && $lines === $this->previousRawLines) { + $this->positionHardwareCursor($this->previousCursorPos, \count($this->previousLines)); + + return; + } + + $rawLines = $lines; + ['lines' => $lines, 'cursor_pos' => $cursorPos, 'first_changed' => $firstChanged, 'last_changed' => $lastChanged] = $this->prepareLines($lines); + + $this->writeInternal($lines, $cursorPos, $firstChanged, $lastChanged); + $this->previousRawLines = $rawLines; + $this->previousCursorPos = $cursorPos; + } + + /** + * Clear rendering state, forcing a full re-render on next write. + * + * The scroll offset is preserved so that a forced re-render does not + * jump back to the bottom of the content. + */ + public function reset(): void + { + $this->previousLines = []; + $this->previousRawLines = []; + $this->previousCursorPos = null; + $this->previousWidth = -1; // -1 triggers widthChanged + $this->cursorRow = 0; + $this->hardwareCursorRow = 0; + $this->maxLinesRendered = 0; + } + + /** + * Get the final cursor position for cleanup when stopping. + * + * @return array{line_count: int, cursor_row: int} + */ + public function getState(): array + { + return [ + 'line_count' => \count($this->previousLines), + 'cursor_row' => $this->hardwareCursorRow, + ]; + } + + /** + * Internal write implementation. + * + * @param string[] $lines + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function writeInternal(array $lines, ?array $cursorPos, int $firstChanged, int $lastChanged): void + { + $columns = $this->terminal->getColumns(); + $rows = $this->terminal->getRows(); + + // Width changed - need full re-render + $widthChanged = 0 !== $this->previousWidth && $this->previousWidth !== $columns; + + // First render or width changed + if ([] === $this->previousLines || $widthChanged) { + $this->fullRender($lines, $cursorPos, $widthChanged); + + return; + } + + $lineCount = \count($lines); + + if (-1 === $firstChanged) { + $this->positionHardwareCursor($cursorPos, $lineCount); + + return; + } + + if ($firstChanged >= $lineCount) { + $this->handleDeletedLines($lines, $cursorPos, $rows); + + return; + } + + // Check if firstChanged is outside the viewport + $viewportTop = $this->terminal->isVirtual() + ? 0 + : max(0, $this->maxLinesRendered - $rows); + + if ($firstChanged < $viewportTop) { + $this->fullRender($lines, $cursorPos, true); + + return; + } + + // Differential render + $this->differentialRender($lines, $cursorPos, $firstChanged, $lastChanged, $columns); + } + + /** + * Writes all lines to the terminal from scratch. + * + * This is only used for the first render and for cases where incremental + * updates are not possible. For subsequent renders where changed lines are + * within the viewport, differentialRender() is used instead. + * + * When $clear is false, the output is appended from the current cursor + * position (used for the very first render when the screen is already + * empty). + * + * When $clear is true, the screen is erased and the cursor is moved home + * before writing. The consequence is that the display resets and starts + * from the top of the screen, which is a small caveat of the algorithm + * used. $clear must be true in three cases: + * + * - On terminal resize: a line may have been split into 2 lines by the + * terminal, making it impossible to update the display incrementally. + * - When changed lines are outside the viewport: there is no way to + * address lines that have scrolled out of the visible area. + * - When too many trailing lines were deleted: if the number of lines to + * erase exceeds the terminal height, clearing is more efficient than + * erasing them one by one. + * + * @param string[] $newLines + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function fullRender(array $newLines, ?array $cursorPos, bool $clear): void + { + $buffer = "\x1b[?2026h"; // Begin synchronized output + + if ($clear) { + $buffer .= "\x1b[2J\x1b[3J\x1b[H"; // Clear screen, clear scrollback, and home + } + + if ([] !== $newLines) { + $buffer .= implode("\r\n", $newLines); + } + + $buffer .= "\x1b[?2026l"; // End synchronized output + + $this->terminal->write($buffer); + $this->cursorRow = max(0, \count($newLines) - 1); + $this->hardwareCursorRow = $this->cursorRow; + + if ($clear) { + $this->maxLinesRendered = \count($newLines); + } else { + $this->maxLinesRendered = max($this->maxLinesRendered, \count($newLines)); + } + + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + } + + /** + * @param string[] $newLines + * @param array{row: int, col: int, shape: int}|null $cursorPos + * + * @return bool True when a full render fallback was used + */ + private function handleDeletedLines(array $newLines, ?array $cursorPos, int $height): bool + { + if (\count($this->previousLines) <= \count($newLines)) { + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + + return false; + } + + $buffer = "\x1b[?2026h"; + + $targetRow = max(0, \count($newLines) - 1); + $lineDiff = $targetRow - $this->hardwareCursorRow; + + if ($lineDiff > 0) { + $buffer .= "\x1b[{$lineDiff}B"; + } elseif ($lineDiff < 0) { + $buffer .= "\x1b[".(-$lineDiff).'A'; + } + + $buffer .= "\r"; + + $extraLines = \count($this->previousLines) - \count($newLines); + + if ($extraLines > $height) { + $this->fullRender($newLines, $cursorPos, true); + + return true; + } + + $newLineCount = \count($newLines); + + if ($extraLines > 0 && $newLineCount > 0) { + $buffer .= "\x1b[1B"; + } + + for ($i = 0; $i < $extraLines; ++$i) { + $buffer .= "\r\x1b[2K"; + if ($i < $extraLines - 1) { + $buffer .= "\x1b[1B"; + } + } + + $moveUp = $extraLines + ($newLineCount > 0 ? 0 : -1); + if ($moveUp > 0) { + $buffer .= "\x1b[{$moveUp}A"; + } + + $buffer .= "\x1b[?2026l"; + + $this->terminal->write($buffer); + $this->cursorRow = $targetRow; + $this->hardwareCursorRow = $targetRow; + + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + + return false; + } + + /** + * @param string[] $newLines + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function differentialRender(array $newLines, ?array $cursorPos, int $firstChanged, int $lastChanged, int $width): void + { + $buffer = "\x1b[?2026h"; // Begin synchronized output + + // Move cursor to first changed line + $lineDiff = $firstChanged - $this->hardwareCursorRow; + if ($lineDiff > 0) { + $buffer .= "\x1b[{$lineDiff}B"; + } elseif ($lineDiff < 0) { + $buffer .= "\x1b[".(-$lineDiff).'A'; + } + + $buffer .= "\r"; + + // Render changed lines + $renderEnd = min($lastChanged, \count($newLines) - 1); + + for ($i = $firstChanged; $i <= $renderEnd; ++$i) { + if ($i > $firstChanged) { + $buffer .= "\r\n"; + } + $buffer .= "\x1b[2K"; + + $line = $newLines[$i]; + $lineWidth = null; + $lineLength = \strlen($line); + + if ($lineLength === strcspn($line, "\x1b\t") && $lineLength === strspn($line, self::PRINTABLE_ASCII)) { + $lineWidth = $lineLength; + } elseif (!AnsiUtils::containsImage($line)) { + $lineWidth = AnsiUtils::visibleWidth($line); + } + + if (null !== $lineWidth && $lineWidth > $width) { + // End synchronized output before throwing so the terminal + // is not left in buffered mode and ScreenWriter state stays consistent + $buffer .= "\x1b[?2026l"; + $this->terminal->write($buffer); + + $this->hardwareCursorRow = $i; + // Force a full re-render with screen clear on next call + // since the screen is now in a partially updated state + $this->previousLines = []; + $this->previousWidth = -1; + + // Strip ANSI codes for readable debug output + $plainLine = preg_replace('/\x1b(?:\[[0-9;]*[a-zA-Z]|\][^\x07]*\x07)/', '', $line); + $preview = mb_substr($plainLine, 0, 100); + + throw new RenderException(\sprintf("Rendered line %d exceeds terminal width (%d > %d).\nLine preview: %d%d.", $i, $lineWidth, $width, $preview, mb_strlen($plainLine) > 100 ? '...' : ''), $i, $lineWidth, $width); + } + + $buffer .= $line; + } + + $finalCursorRow = $renderEnd; + + // Handle content size changes + if (\count($this->previousLines) > \count($newLines)) { + // Content shrunk - clear extra lines + if ($renderEnd < \count($newLines) - 1) { + $moveDown = \count($newLines) - 1 - $renderEnd; + $buffer .= "\x1b[{$moveDown}B"; + $finalCursorRow = \count($newLines) - 1; + } + + $extraLines = \count($this->previousLines) - \count($newLines); + $buffer .= str_repeat("\r\n\x1b[2K", $extraLines); + + $buffer .= "\x1b[{$extraLines}A"; + } elseif (\count($newLines) > \count($this->previousLines)) { + // Content grew - output any additional lines not already rendered + // Only needed if renderEnd < newLines.length - 1 (i.e., we didn't render to the end) + if ($renderEnd < \count($newLines) - 1) { + for ($i = $renderEnd + 1; $i < \count($newLines); ++$i) { + $buffer .= "\r\n\x1b[2K"; + $buffer .= $newLines[$i]; + $finalCursorRow = $i; + } + } + } + + $buffer .= "\x1b[?2026l"; // End synchronized output + + $this->terminal->write($buffer); + + $this->cursorRow = max(0, \count($newLines) - 1); + $this->hardwareCursorRow = $finalCursorRow; + $this->maxLinesRendered = max($this->maxLinesRendered, \count($newLines)); + + $this->positionHardwareCursor($cursorPos, \count($newLines)); + $this->previousLines = $newLines; + $this->previousWidth = $this->terminal->getColumns(); + } + + /** + * Strip cursor markers, apply line resets, and detect changed rows in one pass. + * + * @param string[] $lines + * + * @return array{lines: string[], cursor_pos: array{row: int, col: int, shape: int}|null, first_changed: int, last_changed: int} + */ + private function prepareLines(array $lines): array + { + $cursorPos = null; + $firstChanged = -1; + $lastChanged = -1; + $lineCount = \count($lines); + $previousLineCount = \count($this->previousLines); + + foreach ($lines as $row => $line) { + $oldLine = $row < $previousLineCount ? $this->previousLines[$row] : ''; + if ($oldLine === $line) { + continue; + } + + if (str_contains($line, "\x1b")) { + if ($oldLine === $line."\x1b[0m" || $oldLine === $line.AnsiUtils::SEGMENT_RESET) { + $lines[$row] = $oldLine; + continue; + } + + if (null === $cursorPos) { + $markerIndex = strpos($line, AnsiUtils::CURSOR_MARKER_PREFIX); + if (false !== $markerIndex) { + $endIndex = strpos($line, "\x07", $markerIndex); + if (false !== $endIndex) { + $markerLen = $endIndex - $markerIndex + 1; + $shapeStr = substr($line, $markerIndex + \strlen(AnsiUtils::CURSOR_MARKER_PREFIX), $endIndex - $markerIndex - \strlen(AnsiUtils::CURSOR_MARKER_PREFIX)); + $beforeMarker = substr($line, 0, $markerIndex); + $cursorPos = ['row' => $row, 'col' => AnsiUtils::visibleWidth($beforeMarker), 'shape' => (int) $shapeStr]; + $line = substr($line, 0, $markerIndex).substr($line, $markerIndex + $markerLen); + } + } + } + + if (str_contains($line, "\x1b") && !AnsiUtils::containsImage($line)) { + $line = str_contains($line, "\x1b]8;") + ? $line.AnsiUtils::SEGMENT_RESET + : $line."\x1b[0m"; + } + } + + $lines[$row] = $line; + + if ($oldLine !== $line) { + if (-1 === $firstChanged) { + $firstChanged = $row; + } + $lastChanged = $row; + } + } + + if ($previousLineCount > $lineCount) { + if (-1 === $firstChanged) { + $firstChanged = $lineCount; + } + $lastChanged = $previousLineCount - 1; + } + + return [ + 'lines' => $lines, + 'cursor_pos' => $cursorPos, + 'first_changed' => $firstChanged, + 'last_changed' => $lastChanged, + ]; + } + + /** + * Position the hardware cursor, set its shape, and manage visibility. + * + * @param array{row: int, col: int, shape: int}|null $cursorPos + */ + private function positionHardwareCursor(?array $cursorPos, int $totalLines): void + { + if (null === $cursorPos || $totalLines <= 0) { + $this->terminal->hideCursor(); + + return; + } + + $targetRow = max(0, min($cursorPos['row'], $totalLines - 1)); + $targetCol = max(0, $cursorPos['col']); + + $rowDelta = $targetRow - $this->hardwareCursorRow; + $buffer = ''; + + if ($rowDelta > 0) { + $buffer .= "\x1b[{$rowDelta}B"; + } elseif ($rowDelta < 0) { + $buffer .= "\x1b[".(-$rowDelta).'A'; + } + + // Move to absolute column (1-indexed) + $buffer .= "\x1b[".($targetCol + 1).'G'; + + // Set cursor shape via DECSCUSR (Set Cursor Style) + $buffer .= "\x1b[".$cursorPos['shape'].' q'; + + $this->terminal->write($buffer); + + $this->hardwareCursorRow = $targetRow; + + if ($this->showHardwareCursor) { + $this->terminal->showCursor(); + } else { + $this->terminal->hideCursor(); + } + } +} diff --git a/extern/Tui/Render/WidgetRect.php b/extern/Tui/Render/WidgetRect.php new file mode 100644 index 00000000..10b835a2 --- /dev/null +++ b/extern/Tui/Render/WidgetRect.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +/** + * Represents the absolute position and size of a rendered widget on screen. + * + * Coordinates are in terminal character cells, with (0, 0) at the top-left. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class WidgetRect +{ + public function __construct( + private int $row, + private int $col, + private int $columns, + private int $rows, + ) { + } + + public function getRow(): int + { + return $this->row; + } + + public function getCol(): int + { + return $this->col; + } + + public function getColumns(): int + { + return $this->columns; + } + + public function getRows(): int + { + return $this->rows; + } + + /** + * Check if the given screen coordinates are within this rect. + * + * @param int $row 0-indexed row + * @param int $col 0-indexed column + */ + public function contains(int $row, int $col): bool + { + return $row >= $this->row + && $row < $this->row + $this->rows + && $col >= $this->col + && $col < $this->col + $this->columns; + } + + /** + * Convert absolute screen coordinates to widget-relative coordinates. + * + * @return array{row: int, col: int} Widget-relative coordinates + */ + public function toRelative(int $row, int $col): array + { + return [ + 'row' => $row - $this->row, + 'col' => $col - $this->col, + ]; + } +} diff --git a/extern/Tui/Render/WidgetRendererInterface.php b/extern/Tui/Render/WidgetRendererInterface.php new file mode 100644 index 00000000..f7c09d7b --- /dev/null +++ b/extern/Tui/Render/WidgetRendererInterface.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Render; + +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * Interface for rendering individual widgets and resolving their styles. + * + * Used by LayoutEngine and ChromeApplier to call back into the Renderer + * without creating a circular class dependency. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +interface WidgetRendererInterface +{ + /** + * Render a single widget through the full pipeline. + * + * @return string[] + */ + public function renderWidget(AbstractWidget $widget, RenderContext $context): array; + + /** + * Resolve the style for a widget by merging cascade layers. + */ + public function resolveStyle(AbstractWidget $widget): Style; + + /** + * Measure the intrinsic width of a widget: content width + chrome (border/padding). + * + * Unlike renderWidget(), this does not pad lines to the allocated width. + * Used by the layout engine to measure flex: 0 children. + */ + public function measureIntrinsicWidth(AbstractWidget $widget, int $maxColumns, int $rows): int; +} diff --git a/extern/Tui/Style/Align.php b/extern/Tui/Style/Align.php new file mode 100644 index 00000000..cc0e36cc --- /dev/null +++ b/extern/Tui/Style/Align.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Horizontal alignment of child widgets within a container. + * + * Controls how a child widget's block is positioned when it renders + * narrower than the container's available width (e.g. due to maxColumns). + * + * @experimental + * + * @author Fabien Potencier + */ +enum Align: string +{ + case Left = 'left'; + case Center = 'center'; + case Right = 'right'; +} diff --git a/extern/Tui/Style/Border.php b/extern/Tui/Style/Border.php new file mode 100644 index 00000000..9ace8297 --- /dev/null +++ b/extern/Tui/Style/Border.php @@ -0,0 +1,238 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Represents border values like CSS border. + * + * Supports 1, 2, 3, or 4 values: + * - 1 value: all sides + * - 2 values: top/bottom, left/right + * - 3 values: top, left/right, bottom + * - 4 values: top, right, bottom, left + * + * @experimental + * + * @author Fabien Potencier + */ +final class Border +{ + private const DEFAULT_PATTERN = BorderPattern::NORMAL; + + private readonly int $top; + private readonly int $right; + private readonly int $bottom; + private readonly int $left; + + private readonly BorderPattern $pattern; + private readonly ?Color $color; + + public function __construct( + int $top, + int $right, + int $bottom, + int $left, + BorderPattern|string|null $pattern = null, + Color|string|int|null $color = null, + ) { + $this->top = max(0, $top); + $this->right = max(0, $right); + $this->bottom = max(0, $bottom); + $this->left = max(0, $left); + $this->pattern = self::normalizePattern($pattern); + $this->color = null !== $color ? Color::from($color) : null; + } + + public function getTop(): int + { + return $this->top; + } + + public function getRight(): int + { + return $this->right; + } + + public function getBottom(): int + { + return $this->bottom; + } + + public function getLeft(): int + { + return $this->left; + } + + /** + * Create a Border from various input formats. + * + * @param self|array $border Border specification: + * - Border instance: returned as-is + * - array with 1 element: all sides + * - array with 2 elements: [top/bottom, left/right] + * - array with 3 elements: [top, left/right, bottom] + * - array with 4 elements: [top, right, bottom, left] + */ + public static function from(self|array $border, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + if ($border instanceof self) { + if (null === $pattern && null === $color) { + return $border; + } + + return new self($border->top, $border->right, $border->bottom, $border->left, $pattern ?? $border->pattern, $color ?? $border->color); + } + + return match (\count($border)) { + 1 => new self($border[0], $border[0], $border[0], $border[0], $pattern, $color), + 2 => new self($border[0], $border[1], $border[0], $border[1], $pattern, $color), + 3 => new self($border[0], $border[1], $border[2], $border[1], $pattern, $color), + 4 => new self($border[0], $border[1], $border[2], $border[3], $pattern, $color), + default => throw new InvalidArgumentException('Border array must have 1, 2, 3, or 4 elements.'), + }; + } + + /** + * Create a border with all sides equal. + */ + public static function all(int $value, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + return new self($value, $value, $value, $value, $pattern, $color); + } + + /** + * Create a border with horizontal and vertical values. + * + * @param int $x Left/right border + * @param int $y Top/bottom border + */ + public static function xy(int $x, int $y = 0, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + return new self($y, $x, $y, $x, $pattern, $color); + } + + /** + * Get horizontal border (left + right). + */ + public function getHorizontal(): int + { + return $this->left + $this->right; + } + + /** + * Get vertical border (top + bottom). + */ + public function getVertical(): int + { + return $this->top + $this->bottom; + } + + /** + * Get the border pattern. + */ + public function getPattern(): BorderPattern + { + return $this->pattern; + } + + /** + * Get the border color. + */ + public function getColor(): ?Color + { + return $this->color; + } + + /** + * Create a new border with a different pattern. + */ + public function withPattern(BorderPattern|string|null $pattern): self + { + return new self($this->top, $this->right, $this->bottom, $this->left, $pattern, $this->color); + } + + /** + * Create a new border with a different color. + */ + public function withColor(Color|string|int|null $color): self + { + return new self($this->top, $this->right, $this->bottom, $this->left, $this->pattern, $color); + } + + /** + * @param string[] $innerLines + * + * @return string[] + * + * @internal + */ + public function wrapLines(array $innerLines, int $innerWidth, Style $innerStyle, ?Style $outerStyle = null): array + { + $pattern = $this->pattern; + $chars = $pattern->getChars(); + $strategies = $pattern->getStrategies(); + + $outerStyle ??= new Style(); + $borderColor = $this->color ?? $innerStyle->getColor(); + + $lines = []; + + if ($this->top > 0) { + for ($row = 0; $row < $this->top; ++$row) { + $lines[] = $this->buildBorderRow($pattern, $chars[0], $strategies[0], $innerWidth, $this->left, $this->right, $outerStyle, $innerStyle, $borderColor); + } + } + + $leftSegment = $this->left > 0 ? $pattern->applyBorderSegment(str_repeat('' !== $chars[1][0] ? $chars[1][0] : ' ', $this->left), $strategies[1][0], $outerStyle, $innerStyle, $borderColor) : ''; + $rightSegment = $this->right > 0 ? $pattern->applyBorderSegment(str_repeat('' !== $chars[1][2] ? $chars[1][2] : ' ', $this->right), $strategies[1][2], $outerStyle, $innerStyle, $borderColor) : ''; + + foreach ($innerLines as $line) { + $lines[] = $leftSegment.$line.$rightSegment; + } + + if ($this->bottom > 0) { + for ($row = 0; $row < $this->bottom; ++$row) { + $lines[] = $this->buildBorderRow($pattern, $chars[2], $strategies[2], $innerWidth, $this->left, $this->right, $outerStyle, $innerStyle, $borderColor); + } + } + + return $lines; + } + + /** + * @param array $chars + * @param array $strategies + */ + private function buildBorderRow(BorderPattern $pattern, array $chars, array $strategies, int $innerWidth, int $leftWidth, int $rightWidth, Style $outerStyle, Style $innerStyle, ?Color $borderColor): string + { + $left = $leftWidth > 0 + ? $pattern->applyBorderSegment(str_repeat('' !== $chars[0] ? $chars[0] : ' ', $leftWidth), $strategies[0], $outerStyle, $innerStyle, $borderColor) + : ''; + $middle = $pattern->applyBorderSegment(str_repeat('' !== $chars[1] ? $chars[1] : ' ', $innerWidth), $strategies[1], $outerStyle, $innerStyle, $borderColor); + $right = $rightWidth > 0 + ? $pattern->applyBorderSegment(str_repeat('' !== $chars[2] ? $chars[2] : ' ', $rightWidth), $strategies[2], $outerStyle, $innerStyle, $borderColor) + : ''; + + return $left.$middle.$right; + } + + private static function normalizePattern(BorderPattern|string|null $pattern): BorderPattern + { + if ($pattern instanceof BorderPattern) { + return $pattern; + } + + return BorderPattern::fromName($pattern ?? self::DEFAULT_PATTERN); + } +} diff --git a/extern/Tui/Style/BorderPattern.php b/extern/Tui/Style/BorderPattern.php new file mode 100644 index 00000000..1dbf6e99 --- /dev/null +++ b/extern/Tui/Style/BorderPattern.php @@ -0,0 +1,447 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Defines border pattern character and strategy matrices. + * + * The 3x3 strategy matrix is used by renderers to decide how to swap colors + * for block-style borders: + * - 0: border color on inner background (standard border rendering) + * - 1: border color on outer background (blend with outer background) + * - 2: outer background on border color (inverse left-style border) + * - 3: inner background on border color (inverse right-style border) + * + * @experimental + * + * @author Fabien Potencier + */ +final class BorderPattern +{ + public const NONE = 'none'; + public const NORMAL = 'normal'; + public const ROUNDED = 'rounded'; + public const DOUBLE = 'double'; + public const TALL = 'tall'; + public const WIDE = 'wide'; + public const TALL_MEDIUM = 'tall-medium'; + public const WIDE_MEDIUM = 'wide-medium'; + public const TALL_LARGE = 'tall-large'; + public const WIDE_LARGE = 'wide-large'; + + /** + * @param array> $chars + * @param array> $strategies + */ + public function __construct( + private array $chars = [ + ['', '', ''], + ['', '', ''], + ['', '', ''], + ], + private array $strategies = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + ) { + } + + /** + * @return array> + */ + public function getChars(): array + { + return $this->chars; + } + + /** + * @return array> + */ + public function getStrategies(): array + { + return $this->strategies; + } + + public function applyBorderSegment( + string $segment, + int $strategy, + Style $outerStyle, + Style $innerStyle, + ?Color $borderColor = null, + ): string { + $segment = '' !== $segment ? $segment : ' '; + + $outerForeground = $outerStyle->getColor(); + $outerBackground = $outerStyle->getBackground(); + $innerBackground = $innerStyle->getBackground(); + + return match ($strategy) { + 1 => $this->applyColors($segment, $borderColor, $outerBackground, $outerForeground, $outerBackground), + 2 => $this->applyColors($segment, $outerBackground, $borderColor, $outerForeground, $outerBackground), + 3 => $this->applyColors($segment, $innerBackground, $borderColor, $outerForeground, $outerBackground), + default => $this->applyColors($segment, $borderColor, $innerBackground, $outerForeground, $outerBackground), + }; + } + + public function applyInnerSegment(string $segment, Style $outerStyle, Style $innerStyle): string + { + return $this->applyColors( + $segment, + $innerStyle->getColor(), + $innerStyle->getBackground(), + $outerStyle->getColor(), + $outerStyle->getBackground(), + ); + } + + public function isNone(): bool + { + foreach ($this->chars as $row) { + foreach ($row as $char) { + if ('' !== $char) { + return false; + } + } + } + + return true; + } + + /** + * @return array{string, int} + */ + public function getTop(): array + { + return [$this->chars[0][1], $this->strategies[0][1]]; + } + + public function top(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[0][1] = $char; + $t->strategies[0][1] = $strategy; + + return $t; + } + + /** + * @return array{string, int} + */ + public function getBottom(): array + { + return [$this->chars[2][1], $this->strategies[2][1]]; + } + + public function bottom(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[2][1] = $char; + $t->strategies[2][1] = $strategy; + + return $t; + } + + /** + * @return array{string, int} + */ + public function getLeft(): array + { + return [$this->chars[1][0], $this->strategies[1][0]]; + } + + public function left(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[1][0] = $char; + $t->strategies[1][0] = $strategy; + + return $t; + } + + /** + * @return array{string, int} + */ + public function getRight(): array + { + return [$this->chars[1][2], $this->strategies[1][2]]; + } + + public function right(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[1][2] = $char; + $t->strategies[1][2] = $strategy; + + return $t; + } + + public function topLeft(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[0][0] = $char; + $t->strategies[0][0] = $strategy; + + return $t; + } + + public function topRight(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[0][2] = $char; + $t->strategies[0][2] = $strategy; + + return $t; + } + + public function bottomRight(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[2][2] = $char; + $t->strategies[2][2] = $strategy; + + return $t; + } + + public function bottomLeft(string $char, int $strategy = 0): static + { + $t = clone $this; + $t->chars[2][0] = $char; + $t->strategies[2][0] = $strategy; + + return $t; + } + + public static function fromName(string $style): self + { + return match ($style) { + self::NONE => new self(), + self::NORMAL => self::normal(), + self::ROUNDED => self::rounded(), + self::DOUBLE => self::double(), + self::TALL => self::tall(), + self::WIDE => self::wide(), + self::TALL_MEDIUM => self::tallMedium(), + self::WIDE_MEDIUM => self::wideMedium(), + self::TALL_LARGE => self::tallLarge(), + self::WIDE_LARGE => self::wideLarge(), + default => throw new InvalidArgumentException(\sprintf('Unknown border pattern "%s".', $style)), + }; + } + + public static function normal(): self + { + return new self( + [ + ['┌', '─', '┐'], + ['│', ' ', '│'], + ['└', '─', '┘'], + ], + ); + } + + public static function rounded(): self + { + return new self( + [ + ['╭', '─', '╮'], + ['│', ' ', '│'], + ['╰', '─', '╯'], + ], + ); + } + + public static function double(): self + { + return new self( + [ + ['╔', '═', '╗'], + ['║', ' ', '║'], + ['╚', '═', '╝'], + ], + ); + } + + public static function tall(): self + { + return new self( + [ + ['▊', '▔', '▎'], + ['▊', ' ', '▎'], + ['▊', '▁', '▎'], + ], + [ + [2, 0, 1], + [2, 0, 1], + [2, 0, 1], + ], + ); + } + + public static function wide(): self + { + return new self( + [ + ['▁', '▁', '▁'], + ['▎', ' ', '▊'], + ['▔', '▔', '▔'], + ], + [ + [1, 1, 1], + [0, 1, 3], + [1, 1, 1], + ], + ); + } + + /** + * Visually uniform 4px border with tall-style corners. + * + * Terminal cells are ~2× taller than wide, so 2px vertical (▆/▂) is + * paired with 4px horizontal (▌) for a balanced appearance (2px vertical + * appears as ~4px perceived with the 1:2 cell aspect ratio). Uses the + * same technique as tall(): left-aligned block character (▌) with + * strategy 2 for the left column (fg=outer fills left half, bg=border + * fills right half) and strategy 1 for the right column (fg=border + * fills left half, bg=outer fills right half). Top uses ▆ (lower 6/8) + * with strategy 3 (fg=inner, bg=border) to place the 2px border at the + * top of the cell. Bottom uses ▂ (lower 2/8) with strategy 0 (fg=border, + * bg=inner) to place the 2px border at the bottom. The side character + * extends through all rows including corners. + */ + public static function tallMedium(): self + { + return new self( + [ + ['▌', '▆', '▌'], + ['▌', ' ', '▌'], + ['▌', '▂', '▌'], + ], + [ + [2, 3, 1], + [2, 0, 1], + [2, 0, 1], + ], + ); + } + + /** + * Visually uniform ~4px border with wide-style corners. + * + * Terminal cells are ~2× taller than wide, so 2px vertical (▂/▆) is + * paired with 4px horizontal (▌) for a balanced appearance (2px vertical + * appears as ~4px perceived with the 1:2 cell aspect ratio). Uses the + * same technique as wide(): the horizontal bar character extends through + * all columns including corners. Top uses ▂ (lower 2/8) with strategy 1 + * (fg=border, bg=outer) to place the 2px border at the bottom of the + * cell. Bottom uses ▆ (lower 6/8) with strategy 2 (fg=outer, bg=border) + * to place the 2px border at the top. Left uses ▌ (left 4/8) with + * strategy 0 (fg=border, bg=inner). Right uses ▌ with strategy 3 + * (fg=inner, bg=border). + */ + public static function wideMedium(): self + { + return new self( + [ + ['▂', '▂', '▂'], + ['▌', ' ', '▌'], + ['▆', '▆', '▆'], + ], + [ + [1, 1, 1], + [0, 0, 3], + [2, 2, 2], + ], + ); + } + + /** + * Visually uniform ~8px border with tall-style corners. + * + * Terminal cells are ~2× taller than wide, so 4px vertical (▀/▄) is + * paired with 8px horizontal (█) for a balanced appearance (4px vertical + * appears as ~8px perceived with the 1:2 cell aspect ratio). Uses the + * same technique as tall(): the side character extends through all rows + * including corners. Since the sides are full-cell width (█), the corners + * are solid border color, differing from wide-large where corners show + * outer background in the non-bar half. Top/bottom use strategy 0 + * (fg=border, bg=inner) so that no outer background bleeds into the + * top/bottom bars: top uses ▀ (upper half = border, lower half = inner) + * and bottom uses ▄ (lower half = border, upper half = inner). + */ + public static function tallLarge(): self + { + return new self( + [ + ['█', '▀', '█'], + ['█', ' ', '█'], + ['█', '▄', '█'], + ], + [ + [1, 0, 1], + [1, 0, 1], + [1, 0, 1], + ], + ); + } + + /** + * Visually uniform ~8px border with wide-style corners. + * + * Terminal cells are ~2× taller than wide, so 4px vertical (▄/▀) is paired + * with 8px horizontal (█) for a balanced appearance. Corners repeat the + * horizontal bar character of their row since the side bars are full-cell + * width and naturally fill the corners. The outer background shows through + * the non-bar half of the corner cells. + */ + public static function wideLarge(): self + { + return new self( + [ + ['▄', '▄', '▄'], + ['█', ' ', '█'], + ['▀', '▀', '▀'], + ], + [ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], + ], + ); + } + + private function applyColors( + string $segment, + ?Color $foreground, + ?Color $background, + ?Color $outerForeground, + ?Color $outerBackground, + ): string { + return $this->foregroundCode($foreground) + .$this->backgroundCode($background) + .$segment + .$this->foregroundCode($outerForeground) + .$this->backgroundCode($outerBackground); + } + + private function foregroundCode(?Color $color): string + { + return $color?->toForegroundCode() ?? Color::resetForeground(); + } + + private function backgroundCode(?Color $color): string + { + return $color?->toBackgroundCode() ?? Color::resetBackground(); + } +} diff --git a/extern/Tui/Style/Color.php b/extern/Tui/Style/Color.php new file mode 100644 index 00000000..247cb134 --- /dev/null +++ b/extern/Tui/Style/Color.php @@ -0,0 +1,387 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Represents a terminal color. + * + * Supports multiple color formats: + * - Basic 16 ANSI colors (named: 'black', 'red', 'green', etc.) + * - 256-color palette (integers 0-255) + * - True color RGB (hex strings like '#ff5500' or '#f50') + * + * @experimental + * + * @author Fabien Potencier + */ +final class Color +{ + // Basic 16 ANSI color codes (foreground) + private const BASIC_COLORS = [ + 'black' => 30, + 'red' => 31, + 'green' => 32, + 'yellow' => 33, + 'blue' => 34, + 'magenta' => 35, + 'cyan' => 36, + 'white' => 37, + 'default' => 39, + 'bright_black' => 90, + 'bright_red' => 91, + 'bright_green' => 92, + 'bright_yellow' => 93, + 'bright_blue' => 94, + 'bright_magenta' => 95, + 'bright_cyan' => 96, + 'bright_white' => 97, + // Aliases + 'gray' => 90, + 'grey' => 90, + ]; + + /** Standard RGB values for named ANSI colors (xterm defaults). */ + private const NAMED_RGB = [ + 'black' => [0, 0, 0], + 'red' => [205, 0, 0], + 'green' => [0, 205, 0], + 'yellow' => [205, 205, 0], + 'blue' => [0, 0, 238], + 'magenta' => [205, 0, 205], + 'cyan' => [0, 205, 205], + 'white' => [229, 229, 229], + 'default' => [229, 229, 229], + 'bright_black' => [127, 127, 127], + 'bright_red' => [255, 0, 0], + 'bright_green' => [0, 255, 0], + 'bright_yellow' => [255, 255, 0], + 'bright_blue' => [92, 92, 255], + 'bright_magenta' => [255, 0, 255], + 'bright_cyan' => [0, 255, 255], + 'bright_white' => [255, 255, 255], + 'gray' => [127, 127, 127], + 'grey' => [127, 127, 127], + ]; + + /** + * Create a color from a named ANSI color. + */ + public static function named(string $name): self + { + $name = strtolower($name); + if (!isset(self::BASIC_COLORS[$name])) { + throw new InvalidArgumentException(\sprintf('Unknown color name: "%s".', $name)); + } + + return new self(ColorType::Named, $name); + } + + /** + * Create a color from the 256-color palette. + */ + public static function palette(int $index): self + { + if ($index < 0 || $index > 255) { + throw new InvalidArgumentException(\sprintf('Color palette index must be 0-255, got: %d', $index)); + } + + return new self(ColorType::Palette, $index); + } + + /** + * Create a color from RGB hex string. + * + * @param string $hex Hex color like '#ff5500', 'ff5500', '#f50', or 'f50' + */ + public static function hex(string $hex): self + { + $hex = ltrim($hex, '#'); + + // Expand short form (#f50 -> #ff5500) + if (3 === \strlen($hex)) { + $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; + } + + if (6 !== \strlen($hex) || !ctype_xdigit($hex)) { + throw new InvalidArgumentException(\sprintf('Invalid hex color: "%s".', $hex)); + } + + return new self(ColorType::Hex, $hex); + } + + /** + * Create a color from RGB values. + */ + public static function rgb(int $r, int $g, int $b): self + { + if ($r < 0 || $r > 255 || $g < 0 || $g > 255 || $b < 0 || $b > 255) { + throw new InvalidArgumentException(\sprintf('RGB values must be 0-255, got: %d, %d, %d', $r, $g, $b)); + } + + return new self(ColorType::Hex, \sprintf('%02x%02x%02x', $r, $g, $b)); + } + + /** + * Create a Color from various input types. + * + * @param string|int|self $color Color specification: + * - Color instance (returned as-is) + * - string starting with '#' -> hex color + * - string -> named color + * - int -> 256-palette index + */ + public static function from(string|int|self $color): self + { + if ($color instanceof self) { + return $color; + } + + if (\is_int($color)) { + return self::palette($color); + } + + if (str_starts_with($color, '#')) { + return self::hex($color); + } + + return self::named($color); + } + + /** + * Get the RGB components of this color. + * + * Named and palette colors are converted using standard xterm defaults. + * + * @return array{r: int, g: int, b: int} + */ + public function toRgb(): array + { + return match ($this->type) { + ColorType::Named => self::namedToRgb((string) $this->value), + ColorType::Palette => self::paletteToRgb((int) $this->value), + ColorType::Hex => self::hexToRgb((string) $this->value), + }; + } + + /** + * Mix this color with another by a given percentage. + * + * At 0 % the result is this color; at 100 % it is the other color. + * + * @param self|string $color The color to mix with + * @param int $percentage 0–100 + */ + public function mix(self|string $color, int $percentage): self + { + if ($percentage < 0 || $percentage > 100) { + throw new InvalidArgumentException(\sprintf('Percentage must be 0-100, got: %d', $percentage)); + } + + if (\is_string($color)) { + $color = self::from($color); + } + + $base = $this->toRgb(); + $other = $color->toRgb(); + $factor = $percentage / 100; + + return self::rgb( + (int) round($base['r'] * (1 - $factor) + $other['r'] * $factor), + (int) round($base['g'] * (1 - $factor) + $other['g'] * $factor), + (int) round($base['b'] * (1 - $factor) + $other['b'] * $factor), + ); + } + + /** + * Lighten this color by mixing it with white. + * + * @param int $percentage 0 (unchanged) to 100 (pure white) + */ + public function tint(int $percentage): self + { + return $this->mix('#ffffff', $percentage); + } + + /** + * Darken this color by mixing it with black. + * + * @param int $percentage 0 (unchanged) to 100 (pure black) + */ + public function shade(int $percentage): self + { + return $this->mix('#000000', $percentage); + } + + /** + * Lighten or darken this color. + * + * Positive values darken (shade), negative values lighten (tint). + * + * @param int $percentage -100 (white) to 100 (black) + */ + public function scale(int $percentage): self + { + return $percentage > 0 ? $this->shade($percentage) : $this->tint(-$percentage); + } + + /** + * Convert an SGR foreground color code (30-37, 90-97) to a Color. + * + * Returns null if the code is not a basic/bright foreground color. + */ + public static function fromSgrForeground(int $code): ?self + { + return match (true) { + $code >= 30 && $code <= 37 => self::palette($code - 30), + $code >= 90 && $code <= 97 => self::palette($code - 90 + 8), + default => null, + }; + } + + /** + * Convert an SGR background color code (40-47, 100-107) to a Color. + * + * Returns null if the code is not a basic/bright background color. + */ + public static function fromSgrBackground(int $code): ?self + { + return match (true) { + $code >= 40 && $code <= 47 => self::palette($code - 40), + $code >= 100 && $code <= 107 => self::palette($code - 100 + 8), + default => null, + }; + } + + /** + * Get the hex representation of this color (e.g. '#ff5500'). + */ + public function toHex(): string + { + $rgb = $this->toRgb(); + + return \sprintf('#%02x%02x%02x', $rgb['r'], $rgb['g'], $rgb['b']); + } + + /** + * Get the ANSI escape code for this color as foreground. + */ + public function toForegroundCode(): string + { + return match ($this->type) { + ColorType::Named => \sprintf("\x1b[%dm", self::BASIC_COLORS[(string) $this->value]), + ColorType::Palette => \sprintf("\x1b[38;5;%dm", (int) $this->value), + ColorType::Hex => \sprintf( + "\x1b[38;2;%d;%d;%dm", + hexdec(substr((string) $this->value, 0, 2)), + hexdec(substr((string) $this->value, 2, 2)), + hexdec(substr((string) $this->value, 4, 2)) + ), + }; + } + + /** + * Get the ANSI escape code for this color as background. + */ + public function toBackgroundCode(): string + { + return match ($this->type) { + ColorType::Named => \sprintf("\x1b[%dm", self::BASIC_COLORS[(string) $this->value] + 10), + ColorType::Palette => \sprintf("\x1b[48;5;%dm", (int) $this->value), + ColorType::Hex => \sprintf( + "\x1b[48;2;%d;%d;%dm", + hexdec(substr((string) $this->value, 0, 2)), + hexdec(substr((string) $this->value, 2, 2)), + hexdec(substr((string) $this->value, 4, 2)) + ), + }; + } + + /** + * Get the ANSI reset code for foreground color. + */ + public static function resetForeground(): string + { + return "\x1b[39m"; + } + + /** + * Get the ANSI reset code for background color. + */ + public static function resetBackground(): string + { + return "\x1b[49m"; + } + + private function __construct( + private readonly ColorType $type, + private readonly int|string $value, + ) { + } + + /** + * @return array{r: int, g: int, b: int} + */ + private static function namedToRgb(string $name): array + { + $rgb = self::NAMED_RGB[$name]; + + return ['r' => $rgb[0], 'g' => $rgb[1], 'b' => $rgb[2]]; + } + + /** + * @return array{r: int, g: int, b: int} + */ + private static function paletteToRgb(int $index): array + { + // 0–15: basic 16 colors + if ($index < 16) { + $names = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'bright_black', 'bright_red', 'bright_green', 'bright_yellow', + 'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white', + ]; + + return self::namedToRgb($names[$index]); + } + + // 16–231: 6×6×6 color cube + if ($index < 232) { + $i = $index - 16; + $levels = [0, 95, 135, 175, 215, 255]; + + return [ + 'r' => $levels[(int) ($i / 36)], + 'g' => $levels[(int) (($i % 36) / 6)], + 'b' => $levels[$i % 6], + ]; + } + + // 232–255: grayscale ramp + $level = 8 + 10 * ($index - 232); + + return ['r' => $level, 'g' => $level, 'b' => $level]; + } + + /** + * @return array{r: int, g: int, b: int} + */ + private static function hexToRgb(string $hex): array + { + return [ + 'r' => (int) hexdec(substr($hex, 0, 2)), + 'g' => (int) hexdec(substr($hex, 2, 2)), + 'b' => (int) hexdec(substr($hex, 4, 2)), + ]; + } +} diff --git a/extern/Tui/Style/ColorType.php b/extern/Tui/Style/ColorType.php new file mode 100644 index 00000000..2cde06f4 --- /dev/null +++ b/extern/Tui/Style/ColorType.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Represents the type of a terminal color. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +enum ColorType +{ + /** Basic 16 ANSI colors (named: 'black', 'red', 'green', etc.) */ + case Named; + + /** 256-color palette (integers 0-255) */ + case Palette; + + /** True color RGB (hex strings like '#ff5500' or '#f50') */ + case Hex; +} diff --git a/extern/Tui/Style/CursorShape.php b/extern/Tui/Style/CursorShape.php new file mode 100644 index 00000000..af026ab3 --- /dev/null +++ b/extern/Tui/Style/CursorShape.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Terminal cursor shape, mapped to DECSCUSR escape sequences. + * + * These values correspond to the parameter N in `ESC [ N SP q` + * (DECSCUSR: Set Cursor Style). Odd values produce a blinking + * cursor; even values produce a steady one. We use the blinking + * variants so the terminal provides native cursor animation at + * zero CPU cost. + * + * @experimental + * + * @author Fabien Potencier + */ +enum CursorShape: int +{ + case Block = 1; + case Underline = 3; + case Bar = 5; +} diff --git a/extern/Tui/Style/DefaultStyleSheet.php b/extern/Tui/Style/DefaultStyleSheet.php new file mode 100644 index 00000000..86c1e6e3 --- /dev/null +++ b/extern/Tui/Style/DefaultStyleSheet.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Widget\CancellableLoaderWidget; +use Symfony\Component\Tui\Widget\EditorWidget; +use Symfony\Component\Tui\Widget\InputWidget; +use Symfony\Component\Tui\Widget\LoaderWidget; +use Symfony\Component\Tui\Widget\MarkdownWidget; +use Symfony\Component\Tui\Widget\SelectListWidget; +use Symfony\Component\Tui\Widget\SettingsListWidget; + +/** + * Default TUI stylesheet with base styling rules. + * + * Provides sensible defaults for all core widget sub-elements. + * These can be overridden by application or theme stylesheets + * via the cascade mechanism. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class DefaultStyleSheet +{ + public static function create(): StyleSheet + { + return new StyleSheet([ + // Heading classes (used by

tag aliases) + '.h1' => new Style(bold: true, color: 'cyan'), + '.h2' => new Style(bold: true, color: 'blue'), + '.h3' => new Style(bold: true), + '.h4' => new Style(bold: true, dim: true), + '.h5' => new Style(dim: true), + '.h6' => new Style(dim: true, italic: true), + '.hr' => new Style(color: 'gray'), + '.p' => new Style(), + + // Layout aliases (used by / tag aliases) + '.columns' => new Style(direction: Direction::Horizontal, gap: 2), + '.column' => new Style(), + + // CancellableLoaderWidget + CancellableLoaderWidget::class.':focus' => new Style()->withBold(), + + // LoaderWidget + LoaderWidget::class.'::spinner' => new Style()->withColor('cyan'), + LoaderWidget::class.'::message' => new Style()->withColor('gray'), + + // InputWidget + InputWidget::class.'::cursor' => new Style(cursorShape: CursorShape::Block), + + // EditorWidget + EditorWidget::class.'::cursor' => new Style(cursorShape: CursorShape::Block), + EditorWidget::class.'::frame' => new Style()->withColor('gray'), + + // SelectListWidget + SelectListWidget::class.'::selected' => new Style()->withBold(), + SelectListWidget::class.'::selected:focus' => new Style()->withBold(), + SelectListWidget::class.'::description' => new Style()->withColor('gray'), + SelectListWidget::class.'::scroll-info' => new Style()->withColor('gray'), + SelectListWidget::class.'::no-match' => new Style()->withColor('yellow'), + + // SettingsListWidget + SettingsListWidget::class.'::label-selected' => new Style()->withBold(), + SettingsListWidget::class.'::label-selected:focus' => new Style()->withBold(), + SettingsListWidget::class.'::value' => new Style()->withColor('gray'), + SettingsListWidget::class.'::value-selected' => new Style()->withColor('cyan'), + SettingsListWidget::class.'::value-selected:focus' => new Style()->withColor('cyan'), + SettingsListWidget::class.'::description' => new Style()->withColor('gray'), + SettingsListWidget::class.'::hint' => new Style()->withColor('gray'), + + // MarkdownWidget + MarkdownWidget::class.'::heading' => new Style()->withColor('cyan')->withBold(), + MarkdownWidget::class.'::link' => new Style()->withColor('blue')->withUnderline(), + MarkdownWidget::class.'::link-url' => new Style()->withColor('gray'), + MarkdownWidget::class.'::code' => new Style()->withColor('yellow'), + MarkdownWidget::class.'::code-block-border' => new Style()->withColor('gray'), + MarkdownWidget::class.'::quote' => new Style()->withItalic(), + MarkdownWidget::class.'::quote-border' => new Style()->withColor('gray'), + MarkdownWidget::class.'::hr' => new Style()->withColor('gray'), + MarkdownWidget::class.'::list-bullet' => new Style()->withColor('cyan'), + MarkdownWidget::class.'::bold' => new Style()->withBold(), + MarkdownWidget::class.'::italic' => new Style()->withItalic(), + MarkdownWidget::class.'::strikethrough' => new Style()->withStrikethrough(), + ]); + } +} diff --git a/extern/Tui/Style/Direction.php b/extern/Tui/Style/Direction.php new file mode 100644 index 00000000..bb053d66 --- /dev/null +++ b/extern/Tui/Style/Direction.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Layout direction for container widgets. + * + * @experimental + * + * @author Fabien Potencier + */ +enum Direction: string +{ + case Vertical = 'vertical'; + case Horizontal = 'horizontal'; +} diff --git a/extern/Tui/Style/Padding.php b/extern/Tui/Style/Padding.php new file mode 100644 index 00000000..5c76bbb6 --- /dev/null +++ b/extern/Tui/Style/Padding.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Represents padding values like CSS padding. + * + * Supports 1, 2, 3, or 4 values: + * - 1 value: all sides + * - 2 values: top/bottom, left/right + * - 3 values: top, left/right, bottom + * - 4 values: top, right, bottom, left + * + * @experimental + * + * @author Fabien Potencier + */ +final class Padding +{ + private readonly int $top; + private readonly int $right; + private readonly int $bottom; + private readonly int $left; + + public function __construct(int $top, int $right, int $bottom, int $left) + { + $this->top = max(0, $top); + $this->right = max(0, $right); + $this->bottom = max(0, $bottom); + $this->left = max(0, $left); + } + + public function getTop(): int + { + return $this->top; + } + + public function getRight(): int + { + return $this->right; + } + + public function getBottom(): int + { + return $this->bottom; + } + + public function getLeft(): int + { + return $this->left; + } + + /** + * Create a Padding from various input formats. + * + * @param self|array $padding Padding specification: + * - Padding instance: returned as-is + * - array with 1 element: all sides + * - array with 2 elements: [top/bottom, left/right] + * - array with 3 elements: [top, left/right, bottom] + * - array with 4 elements: [top, right, bottom, left] + */ + public static function from(self|array $padding): self + { + if ($padding instanceof self) { + return $padding; + } + + return match (\count($padding)) { + 1 => new self($padding[0], $padding[0], $padding[0], $padding[0]), + 2 => new self($padding[0], $padding[1], $padding[0], $padding[1]), + 3 => new self($padding[0], $padding[1], $padding[2], $padding[1]), + 4 => new self($padding[0], $padding[1], $padding[2], $padding[3]), + default => throw new InvalidArgumentException('Padding array must have 1, 2, 3, or 4 elements.'), + }; + } + + /** + * Create padding with all sides equal. + */ + public static function all(int $value): self + { + return new self($value, $value, $value, $value); + } + + /** + * Create padding with horizontal and vertical values. + * + * @param int $x Left/right padding + * @param int $y Top/bottom padding + */ + public static function xy(int $x, int $y = 0): self + { + return new self($y, $x, $y, $x); + } + + /** + * Get horizontal padding (left + right). + */ + public function getHorizontal(): int + { + return $this->left + $this->right; + } + + /** + * Get vertical padding (top + bottom). + */ + public function getVertical(): int + { + return $this->top + $this->bottom; + } +} diff --git a/extern/Tui/Style/Style.php b/extern/Tui/Style/Style.php new file mode 100644 index 00000000..184af132 --- /dev/null +++ b/extern/Tui/Style/Style.php @@ -0,0 +1,823 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * Represents styling options for widgets including padding, borders, background, and text formatting. + * + * This class is an immutable value object. All with*() methods return a new + * instance rather than modifying the existing one. This design allows styles + * to be safely shared and reused without risk of unintended side effects. + * + * ## Nullable Properties for Style Inheritance + * + * All style properties are nullable to distinguish between "not set" and "explicitly set": + * + * - `null` means "not set" - the value will be inherited from parent styles during merge + * - An explicit value (even if zero/false) means "explicitly set" - overrides inheritance + * + * This applies to: + * - `padding` - null (inherit) vs Padding instance (explicit, even if all zeros) + * - `border` - null (inherit) vs Border instance (explicit, even if all zeros) + * - `background` - null (inherit) vs Color instance (explicit color) + * - `color` - null (inherit) vs Color instance (explicit color) + * - `bold`, `dim`, `italic`, `strikethrough`, `underline`, `reverse` - null (inherit) vs bool (explicit true/false) + * - `hidden` - null (inherit) vs bool (true = hidden, false = explicitly visible) + * + * Examples: + * + * // Color only - other properties will inherit from parent rules + * $style = new Style()->withColor('red'); + * $style->getPadding(); // null - will inherit + * $style->getBold(); // null - will inherit + * + * // Explicit bold=false to override a parent's bold=true + * $style = new Style()->withBold(false); + * $style->getBold(); // false - explicitly disabled + * + * // Explicit zero padding - will override inherited padding + * $style = Style::padding([0]); + * $style->getPadding(); // Padding(0, 0, 0, 0) - explicit zero + * + * Compare with Tui, which is a stateful service that returns $this from fluent + * methods to maintain object identity across the application. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Style +{ + private ?Color $backgroundColor; + private ?Color $foregroundColor; + private ?string $ansiPrefix = null; + private ?string $ansiSuffix = null; + private ?string $bgCode = null; + + /** + * @param Padding|null $padding Padding specification (null = not set, see Padding::from()) + * @param Border|null $border Border specification (null = not set, see Border::from()) + * @param Color|string|int|null $background Background color (null = not set) + * @param Color|string|int|null $color Foreground color (null = not set) + * @param bool|null $bold Bold text (null = not set, true/false = explicit) + * @param bool|null $dim Dim/faint text (null = not set, true/false = explicit) + * @param bool|null $italic Italic text (null = not set, true/false = explicit) + * @param bool|null $strikethrough Strikethrough text (null = not set, true/false = explicit) + * @param bool|null $underline Underlined text (null = not set, true/false = explicit) + * @param bool|null $reverse Reverse video (null = not set, true/false = explicit) + * @param Direction|null $direction Layout direction for containers (null = not set, defaults to vertical) + * @param int|null $gap Gap between children for containers (null = not set, defaults to 0) + * @param bool|null $hidden Whether the widget is hidden (null = not set, true = hidden, false = visible) + * @param CursorShape|null $cursorShape Cursor shape for ::cursor sub-elements (null = not set) + * @param TextAlign|null $textAlign Text alignment (null = not set, defaults to left) + * @param string|null $font FIGlet font name or path (null = not set, defaults to normal text) + * @param int|null $maxColumns Maximum width in columns (null = not set, no constraint) + * @param Align|null $align Horizontal alignment of child widgets (null = not set, defaults to left) + * @param VerticalAlign|null $verticalAlign Vertical alignment of child widgets (null = not set, defaults to top) + * @param int|null $flex Flex grow weight for horizontal layouts (null = not set, 0 = intrinsic width, 1+ = proportional) + */ + public function __construct( + private ?Padding $padding = null, + private ?Border $border = null, + Color|string|int|null $background = null, + Color|string|int|null $color = null, + private ?bool $bold = null, + private ?bool $dim = null, + private ?bool $italic = null, + private ?bool $strikethrough = null, + private ?bool $underline = null, + private ?bool $reverse = null, + private ?Direction $direction = null, + private ?int $gap = null, + private ?bool $hidden = null, + private ?CursorShape $cursorShape = null, + private ?TextAlign $textAlign = null, + private ?string $font = null, + private ?int $maxColumns = null, + private ?Align $align = null, + private ?VerticalAlign $verticalAlign = null, + private ?int $flex = null, + ) { + $this->backgroundColor = null !== $background ? Color::from($background) : null; + $this->foregroundColor = null !== $color ? Color::from($color) : null; + $this->gap = null !== $gap ? max(0, $gap) : null; + $this->flex = null !== $flex ? max(0, $flex) : null; + } + + public function __clone(): void + { + $this->ansiPrefix = null; + $this->ansiSuffix = null; + $this->bgCode = null; + } + + /** + * Get the background color. + */ + public function getBackground(): ?Color + { + return $this->backgroundColor; + } + + /** + * Get the foreground color. + */ + public function getColor(): ?Color + { + return $this->foregroundColor; + } + + /** + * Get the padding. + */ + public function getPadding(): ?Padding + { + return $this->padding; + } + + /** + * Get the border. + */ + public function getBorder(): ?Border + { + return $this->border; + } + + /** + * Get the bold flag. + */ + public function getBold(): ?bool + { + return $this->bold; + } + + /** + * Get the dim flag. + */ + public function getDim(): ?bool + { + return $this->dim; + } + + /** + * Get the italic flag. + */ + public function getItalic(): ?bool + { + return $this->italic; + } + + /** + * Get the strikethrough flag. + */ + public function getStrikethrough(): ?bool + { + return $this->strikethrough; + } + + /** + * Get the underline flag. + */ + public function getUnderline(): ?bool + { + return $this->underline; + } + + /** + * Get the reverse flag. + */ + public function getReverse(): ?bool + { + return $this->reverse; + } + + /** + * Get the layout direction. + */ + public function getDirection(): ?Direction + { + return $this->direction; + } + + /** + * Get the gap between children. + */ + public function getGap(): ?int + { + return $this->gap; + } + + /** + * Get the hidden flag. + */ + public function getHidden(): ?bool + { + return $this->hidden; + } + + /** + * Get the cursor shape. + */ + public function getCursorShape(): ?CursorShape + { + return $this->cursorShape; + } + + /** + * Get the text alignment. + */ + public function getTextAlign(): ?TextAlign + { + return $this->textAlign; + } + + /** + * Get the FIGlet font name or path. + */ + public function getFont(): ?string + { + return $this->font; + } + + /** + * Get the maximum width in columns. + */ + public function getMaxColumns(): ?int + { + return $this->maxColumns; + } + + /** + * Get the horizontal alignment of child widgets. + */ + public function getAlign(): ?Align + { + return $this->align; + } + + /** + * Get the vertical alignment of child widgets. + */ + public function getVerticalAlign(): ?VerticalAlign + { + return $this->verticalAlign; + } + + /** + * Get the flex grow weight for horizontal layouts. + * + * - null: not set (inherits default behavior: equal distribution) + * - 0: use intrinsic (content) width + * - 1+: proportional weight (higher values get more space) + */ + public function getFlex(): ?int + { + return $this->flex; + } + + /** + * Check whether the style applies no visual formatting. + * + * @internal + */ + public function isPlain(): bool + { + return null === $this->backgroundColor + && null === $this->foregroundColor + && null === $this->bold + && null === $this->dim + && null === $this->italic + && null === $this->strikethrough + && null === $this->underline + && null === $this->reverse; + } + + /** + * Create a style with padding only. + * + * @param Padding|array $padding Padding specification (see Padding::from()) + */ + public static function padding(Padding|array $padding): self + { + return new self(padding: Padding::from($padding)); + } + + /** + * Create a style with border only. + * + * @param Border|array $border Border specification (see Border::from()) + * @param BorderPattern|string|null $pattern Border pattern (see BorderPattern::fromName()) + * @param Color|string|int|null $color Border color + */ + public static function border(Border|array $border, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + return new self(border: Border::from($border, $pattern, $color)); + } + + /** + * Merge multiple styles into one in a single pass. + * + * Later styles override earlier ones for non-null properties. + * Allocates a single Style object regardless of the number of inputs. + * + * @param Style[] $styles + */ + public static function mergeAll(array $styles): self + { + $padding = null; + $border = null; + $background = null; + $color = null; + $bold = null; + $dim = null; + $italic = null; + $strikethrough = null; + $underline = null; + $reverse = null; + $direction = null; + $gap = null; + $hidden = null; + $cursorShape = null; + $textAlign = null; + $font = null; + $maxColumns = null; + $align = null; + $verticalAlign = null; + $flex = null; + + foreach ($styles as $style) { + $padding = $style->padding ?? $padding; + $border = $style->border ?? $border; + $background = $style->backgroundColor ?? $background; + $color = $style->foregroundColor ?? $color; + $bold = $style->bold ?? $bold; + $dim = $style->dim ?? $dim; + $italic = $style->italic ?? $italic; + $strikethrough = $style->strikethrough ?? $strikethrough; + $underline = $style->underline ?? $underline; + $reverse = $style->reverse ?? $reverse; + $direction = $style->direction ?? $direction; + $gap = $style->gap ?? $gap; + $hidden = $style->hidden ?? $hidden; + $cursorShape = $style->cursorShape ?? $cursorShape; + $textAlign = $style->textAlign ?? $textAlign; + $font = $style->font ?? $font; + $maxColumns = $style->maxColumns ?? $maxColumns; + $align = $style->align ?? $align; + $verticalAlign = $style->verticalAlign ?? $verticalAlign; + $flex = $style->flex ?? $flex; + } + + return new self( + $padding, + $border, + $background, + $color, + $bold, + $dim, + $italic, + $strikethrough, + $underline, + $reverse, + $direction, + $gap, + $hidden, + $cursorShape, + $textAlign, + $font, + $maxColumns, + $align, + $verticalAlign, + $flex, + ); + } + + /** + * Create new style with different padding. + * + * @param Padding|array $padding Padding specification (see Padding::from()) + */ + public function withPadding(Padding|array $padding): self + { + $clone = clone $this; + $clone->padding = Padding::from($padding); + + return $clone; + } + + /** + * Create new style with different border. + * + * @param Border|array $border Border specification (see Border::from()) + * @param BorderPattern|string|null $pattern Border pattern (see BorderPattern::fromName()) + * @param Color|string|int|null $color Border color + */ + public function withBorder(Border|array $border, BorderPattern|string|null $pattern = null, Color|string|int|null $color = null): self + { + $clone = clone $this; + $clone->border = Border::from($border, $pattern, $color); + + return $clone; + } + + /** + * Create new style with a different border pattern. + */ + public function withBorderPattern(BorderPattern|string|null $pattern): self + { + $border = $this->border ?? new Border(0, 0, 0, 0); + + return $this->withBorder($border->withPattern($pattern)); + } + + /** + * Create new style with a different border color. + */ + public function withBorderColor(Color|string|int|null $color): self + { + $border = $this->border ?? new Border(0, 0, 0, 0); + + return $this->withBorder($border->withColor($color)); + } + + /** + * Create new style with background color. + * + * @param Color|string|int|null $background Color specification: + * - Color instance + * - string starting with '#' -> hex color + * - string -> named color ('red', 'blue', etc.) + * - int -> 256-palette index (0-255) + * - null -> no background + */ + public function withBackground(Color|string|int|null $background): self + { + $clone = clone $this; + $clone->backgroundColor = null !== $background ? Color::from($background) : null; + + return $clone; + } + + /** + * Create new style with foreground color. + * + * @param Color|string|int|null $color Color specification: + * - Color instance + * - string starting with '#' -> hex color + * - string -> named color ('red', 'blue', etc.) + * - int -> 256-palette index (0-255) + * - null -> no color + */ + public function withColor(Color|string|int|null $color): self + { + $clone = clone $this; + $clone->foregroundColor = null !== $color ? Color::from($color) : null; + + return $clone; + } + + /** + * Create new style with bold enabled. + */ + public function withBold(bool $bold = true): self + { + $clone = clone $this; + $clone->bold = $bold; + + return $clone; + } + + /** + * Create new style with dim/faint enabled. + */ + public function withDim(bool $dim = true): self + { + $clone = clone $this; + $clone->dim = $dim; + + return $clone; + } + + /** + * Create new style with italic enabled. + */ + public function withItalic(bool $italic = true): self + { + $clone = clone $this; + $clone->italic = $italic; + + return $clone; + } + + /** + * Create new style with strikethrough enabled. + */ + public function withStrikethrough(bool $strikethrough = true): self + { + $clone = clone $this; + $clone->strikethrough = $strikethrough; + + return $clone; + } + + /** + * Create new style with underline enabled. + */ + public function withUnderline(bool $underline = true): self + { + $clone = clone $this; + $clone->underline = $underline; + + return $clone; + } + + /** + * Create new style with reverse video enabled. + */ + public function withReverse(bool $reverse = true): self + { + $clone = clone $this; + $clone->reverse = $reverse; + + return $clone; + } + + /** + * Create new style with layout direction. + */ + public function withDirection(Direction $direction): self + { + $clone = clone $this; + $clone->direction = $direction; + + return $clone; + } + + /** + * Create new style with gap between children. + */ + public function withGap(int $gap): self + { + $clone = clone $this; + $clone->gap = max(0, $gap); + + return $clone; + } + + /** + * Create new style with hidden flag. + * + * Hidden widgets are skipped during rendering; they produce no output + * and take no space, similar to CSS `display: none`. + */ + public function withHidden(bool $hidden = true): self + { + $clone = clone $this; + $clone->hidden = $hidden; + + return $clone; + } + + /** + * Create new style with a cursor shape. + */ + public function withCursorShape(CursorShape $cursorShape): self + { + $clone = clone $this; + $clone->cursorShape = $cursorShape; + + return $clone; + } + + /** + * Create new style with text alignment. + */ + public function withTextAlign(TextAlign $textAlign): self + { + $clone = clone $this; + $clone->textAlign = $textAlign; + + return $clone; + } + + /** + * Create new style with a FIGlet font. + * + * @param string|null $font Bundled font name (big, small, slant, standard, mini) or path to a .flf file, or null to clear + */ + public function withFont(?string $font): self + { + $clone = clone $this; + $clone->font = $font; + + return $clone; + } + + /** + * Create new style with a maximum column width. + * + * @param int|null $maxColumns Maximum width in columns, or null to clear + */ + public function withMaxColumns(?int $maxColumns): self + { + $clone = clone $this; + $clone->maxColumns = $maxColumns; + + return $clone; + } + + /** + * Create new style with horizontal alignment for child widgets. + */ + public function withAlign(Align $align): self + { + $clone = clone $this; + $clone->align = $align; + + return $clone; + } + + /** + * Create new style with vertical alignment for child widgets. + */ + public function withVerticalAlign(VerticalAlign $verticalAlign): self + { + $clone = clone $this; + $clone->verticalAlign = $verticalAlign; + + return $clone; + } + + /** + * Create new style with a flex grow weight for horizontal layouts. + * + * @param int|null $flex 0 = intrinsic width, 1+ = proportional weight, null = clear + */ + public function withFlex(?int $flex): self + { + $clone = clone $this; + $clone->flex = null !== $flex ? max(0, $flex) : null; + + return $clone; + } + + /** + * Create a copy with only visual formatting and content properties. + * + * Strips layout properties that the Renderer owns: padding, border, + * gap, direction, hidden, cursorShape, textAlign, maxColumns, align, + * verticalAlign, and flex. + * Used by the Renderer to build the inner context for leaf widgets, + * enforcing a clear contract: the Renderer owns layout, widgets own + * content styling. + * + * Content properties like font are preserved because widgets need them + * during render() to produce the correct output. + * + * @internal + */ + public function withoutLayoutProperties(): self + { + if (null === $this->padding && null === $this->border + && null === $this->gap && null === $this->direction + && null === $this->hidden && null === $this->cursorShape + && null === $this->textAlign && null === $this->maxColumns + && null === $this->align && null === $this->verticalAlign + && null === $this->flex) { + return $this; + } + + $clone = clone $this; + $clone->padding = null; + $clone->border = null; + $clone->gap = null; + $clone->direction = null; + $clone->hidden = null; + $clone->cursorShape = null; + $clone->textAlign = null; + $clone->maxColumns = null; + $clone->align = null; + $clone->verticalAlign = null; + $clone->flex = null; + + return $clone; + } + + /** + * Get the ANSI codes that activate this style's formatting. + * + * This returns only the "turn on" codes (foreground, background, bold, etc.) + * without any corresponding reset codes. Useful for restoring a parent style + * after a child style's reset codes have run. + * + * Returns an empty string if no formatting properties are set. + */ + public function getAnsiRestore(): string + { + if (null === $this->ansiPrefix) { + $this->computeAnsiCodes(); + } + + return $this->ansiPrefix; + } + + /** + * Apply all formatting styles to a string. + * + * Applies color, background, bold, dim, italic, strikethrough, and underline. + * Padding and borders are not applied (that's a layout concern for the widget). + * + * Uses attribute-specific reset codes to preserve other styles that may + * be set by parent containers. + * + * When a boolean property is explicitly false (not null), it emits a reset + * code to cancel any inherited styling from parent containers. + */ + public function apply(string $text): string + { + if (null === $this->ansiPrefix) { + $this->computeAnsiCodes(); + } + + $processedText = $text; + if (null !== $this->bgCode) { + $processedText = AnsiUtils::reapplyBackgroundAfterResets($text, $this->bgCode); + } + + return $this->ansiPrefix.$processedText.$this->ansiSuffix; + } + + /** + * Compute and cache the ANSI prefix/suffix codes for this style. + * Called lazily on first apply(). + */ + private function computeAnsiCodes(): void + { + $prefix = ''; + $suffix = ''; + + if (null !== $this->foregroundColor) { + $prefix .= $this->foregroundColor->toForegroundCode(); + $suffix = Color::resetForeground().$suffix; + } + if (null !== $this->backgroundColor) { + $prefix .= $this->backgroundColor->toBackgroundCode(); + $suffix = Color::resetBackground().$suffix; + $this->bgCode = $this->backgroundColor->toBackgroundCode(); + } + // Bold (SGR 1) and dim (SGR 2) share the same reset code (SGR 22), + // so they must be handled together. Emit the reset first, then + // re-enable whichever attributes should be active. + $needsBoldDimReset = false === $this->bold || false === $this->dim; + if ($needsBoldDimReset) { + $prefix .= "\x1b[22m"; + } + if (true === $this->bold) { + $prefix .= "\x1b[1m"; + } + if (true === $this->dim) { + $prefix .= "\x1b[2m"; + } + if (true === $this->bold || true === $this->dim) { + $suffix = "\x1b[22m".$suffix; + } + if (true === $this->italic) { + $prefix .= "\x1b[3m"; + $suffix = "\x1b[23m".$suffix; + } elseif (false === $this->italic) { + $prefix .= "\x1b[23m"; + } + if (true === $this->strikethrough) { + $prefix .= "\x1b[9m"; + $suffix = "\x1b[29m".$suffix; + } elseif (false === $this->strikethrough) { + $prefix .= "\x1b[29m"; + } + if (true === $this->underline) { + $prefix .= "\x1b[4m"; + $suffix = "\x1b[24m".$suffix; + } elseif (false === $this->underline) { + $prefix .= "\x1b[24m"; + } + if (true === $this->reverse) { + $prefix .= "\x1b[7m"; + $suffix = "\x1b[27m".$suffix; + } elseif (false === $this->reverse) { + $prefix .= "\x1b[27m"; + } + + $this->ansiPrefix = $prefix; + $this->ansiSuffix = $suffix; + } +} diff --git a/extern/Tui/Style/StyleSheet.php b/extern/Tui/Style/StyleSheet.php new file mode 100644 index 00000000..a30533ef --- /dev/null +++ b/extern/Tui/Style/StyleSheet.php @@ -0,0 +1,474 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * A collection of style rules with CSS-like selectors. + * + * Selectors can be: + * - FQCN: 'Symfony\Component\Tui\Widget\Input' or Input::class + * - FQCN with state: 'Symfony\Component\Tui\Widget\Input:focus' + * - CSS class: '.sidebar' + * - CSS class with state: '.sidebar:focus' + * - Standalone pseudo-class: ':root' (matches the root widget) + * - Universal: '*' (matches all widgets) + * - Sub-element (pseudo-element): SelectList::class.'::selected' + * - Sub-element with state: SelectList::class.'::selected:focus' + * - Class sub-element: '.my-list::selected' + * - Class sub-element with state: '.my-list::selected:focus' + * + * ## Style Inheritance + * + * When resolving styles, rules are applied in this order (later rules override earlier): + * 1. Universal selector ('*') + * 2. Widget FQCN selector (e.g., Text::class) + * 3. CSS class selectors (e.g., '.header') + * 4. State selectors (e.g., ':root', Input::class.':focus') + * 5. Instance style (widget's own setStyle()) + * + * All style properties use `null` to mean "inherit from earlier rules": + * + * // This rule sets color but not bold - bold will be inherited + * $stylesheet->addRule('.link', new Style()->withColor('blue')); + * + * To explicitly override inherited values: + * + * // Explicitly set padding to 0, overriding any inherited padding + * $stylesheet->addRule('.no-padding', Style::padding([0])); + * + * // Explicitly disable bold, overriding a parent's bold=true + * $stylesheet->addRule('.normal', new Style()->withBold(false)); + * + * ## Cascading Stylesheets + * + * Multiple stylesheets can be merged together like CSS cascading: + * later rules override earlier ones with the same selector. + * + * $defaults = new StyleSheet([ + * Overlay::class => new Style()->withBackground('#1e1e2e'), + * ]); + * + * $theme = new StyleSheet([ + * Overlay::class => new Style()->withBorder([1]), + * ]); + * + * // Merge theme on top of defaults - theme rules override defaults + * $merged = $defaults->merge($theme); + * + * ## Responsive Breakpoints + * + * Rules can be scoped to terminal width using breakpoints, similar to CSS + * `@media (min-width: ...)`. Breakpoint rules apply when the terminal has + * at least the specified number of columns: + * + * $stylesheet->addBreakpoint(120, '.panes', new Style(direction: Direction::Horizontal)); + * // Below 120 columns: .panes uses default (vertical) + * // At 120+ columns: .panes switches to horizontal + * + * Multiple breakpoints can be defined. They are evaluated in ascending order + * of column threshold, so narrower breakpoints are overridden by wider ones + * when both match. + * + * Breakpoint rules are applied after base rules and state selectors but + * before instance styles in the cascade. + * + * @experimental + * + * @author Fabien Potencier + */ +class StyleSheet +{ + /** @var array> Breakpoint rules keyed by min-columns threshold */ + private array $breakpoints = []; + + /** + * @param array $rules Map of selectors to styles + */ + public function __construct( + private array $rules = [], + ) { + } + + /** + * Add a rule to the stylesheet. + * + * @return $this + */ + public function addRule(string $selector, Style $style): static + { + $this->rules[$selector] = $style; + + return $this; + } + + /** + * Add a responsive breakpoint rule. + * + * The rule applies only when the terminal has at least $minColumns columns. + * This is equivalent to CSS `@media (min-width: ...)`. + * + * @return $this + */ + public function addBreakpoint(int $minColumns, string $selector, Style $style): static + { + $this->breakpoints[$minColumns][$selector] = $style; + + return $this; + } + + /** + * Merge another stylesheet's rules into this one. + * + * Rules from the other stylesheet override rules in this one + * for the same selector. This works like CSS cascading: later + * stylesheets win. + * + * @return $this + */ + public function merge(self $other): static + { + foreach ($other->rules as $selector => $style) { + $this->rules[$selector] = $style; + } + + foreach ($other->breakpoints as $minColumns => $rules) { + foreach ($rules as $selector => $style) { + $this->breakpoints[$minColumns][$selector] = $style; + } + } + + return $this; + } + + /** + * Merge another stylesheet's rules as defaults (lower priority). + * + * Rules from the other stylesheet are added only for selectors + * that are not already defined in this stylesheet. This is the + * reverse of merge(): existing rules are preserved. + * + * @return $this + */ + public function mergeDefaults(self $defaults): static + { + foreach ($defaults->rules as $selector => $style) { + if (!isset($this->rules[$selector])) { + $this->rules[$selector] = $style; + } + } + + foreach ($defaults->breakpoints as $minColumns => $rules) { + foreach ($rules as $selector => $style) { + if (!isset($this->breakpoints[$minColumns][$selector])) { + $this->breakpoints[$minColumns][$selector] = $style; + } + } + } + + return $this; + } + + /** + * Get all rules. + * + * @return array + */ + public function getRules(): array + { + return $this->rules; + } + + /** + * Resolve the style for a widget by merging applicable rules. + * + * Resolution order (later overrides earlier): + * 1. Universal selector (*) + * 2. FQCN selector (widget class and parent classes, parent first) + * 3. CSS class selectors (.class): only classes from {@see getCssClasses()} + * 4. State selectors (:focus, :disabled, etc.) + * 5. Breakpoint rules (ascending min-columns order) + * 6. Extra styles from subclasses (see {@see resolveExtraStyles()}) + * 7. Instance style (widget's own style) + * + * @param int|null $columns Current terminal width (for responsive breakpoints) + */ + public function resolve(AbstractWidget $widget, ?int $columns = null): Style + { + $fqcn = $widget::class; + $cssClasses = $this->getCssClasses($widget); + $classHierarchy = static::getClassHierarchy($fqcn); + $applicableStyles = []; + + // 1. Universal selector + if (isset($this->rules['*'])) { + $applicableStyles[] = $this->rules['*']; + } + + // 2. FQCN selector (walk class hierarchy, parent classes first for lower priority) + foreach ($classHierarchy as $class) { + if (isset($this->rules[$class])) { + $applicableStyles[] = $this->rules[$class]; + } + } + + // 3. CSS class selectors + foreach ($cssClasses as $class) { + $selector = '.'.$class; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + // 4. State selectors (applied standalone, to FQCN hierarchy and CSS classes) + foreach ($widget->getStateFlags() as $state) { + // :state (standalone pseudo-class, e.g. :root) + if (isset($this->rules[':'.$state])) { + $applicableStyles[] = $this->rules[':'.$state]; + } + + // FQCN:state (walk class hierarchy, parent classes first) + foreach ($classHierarchy as $class) { + $classStateSelector = $class.':'.$state; + if (isset($this->rules[$classStateSelector])) { + $applicableStyles[] = $this->rules[$classStateSelector]; + } + } + + // .class:state + foreach ($cssClasses as $class) { + $classStateSelector = '.'.$class.':'.$state; + if (isset($this->rules[$classStateSelector])) { + $applicableStyles[] = $this->rules[$classStateSelector]; + } + } + } + + // 5. Breakpoint rules (ascending min-columns order) + if (null !== $columns && [] !== $this->breakpoints) { + $applicableStyles = $this->resolveBreakpoints($widget, $columns, $applicableStyles, $cssClasses); + } + + // 6. Extra styles from subclasses (e.g. utility classes) + $applicableStyles = $this->resolveExtraStyles($widget, $applicableStyles); + + // 7. Instance style + if ($widget->getStyle()) { + $applicableStyles[] = $widget->getStyle(); + } + + // Merge all applicable styles + return static::mergeStyles($applicableStyles); + } + + /** + * Resolve the style for a sub-element of a widget. + * + * Sub-elements are parts within a widget (e.g., "selected item", "description") + * that need independent styling. They use CSS pseudo-element syntax (::). + * + * Resolution order (later overrides earlier): + * 1. FQCN::element (e.g., SelectListWidget::class.'::selected') + * 2. .class::element (e.g., '.my-list::selected') + * 3. FQCN::element:state (e.g., SelectListWidget::class.'::selected:focus') + * 4. .class::element:state (e.g., '.my-list::selected:focus') + * + * Example stylesheet rules: + * + * SelectListWidget::class.'::selected' => new Style()->withBold(), + * SelectListWidget::class.'::selected:focus' => new Style()->withBold()->withColor('cyan'), + * '.my-list::selected' => new Style()->withColor('green'), + */ + public function resolveElement(AbstractWidget $widget, string $element): Style + { + $fqcn = $widget::class; + $cssClasses = $this->getCssClasses($widget); + $classHierarchy = static::getClassHierarchy($fqcn); + $applicableStyles = []; + + // 1. FQCN::element (walk class hierarchy, parent classes first for lower priority) + foreach ($classHierarchy as $class) { + $selector = $class.'::'.$element; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + // 2. .class::element + foreach ($cssClasses as $class) { + $selector = '.'.$class.'::'.$element; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + // 3. FQCN::element:state and .class::element:state + foreach ($widget->getStateFlags() as $state) { + // Walk class hierarchy for state selectors too + foreach ($classHierarchy as $class) { + $selector = $class.'::'.$element.':'.$state; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + + foreach ($cssClasses as $class) { + $selector = '.'.$class.'::'.$element.':'.$state; + if (isset($this->rules[$selector])) { + $applicableStyles[] = $this->rules[$selector]; + } + } + } + + return static::mergeStyles($applicableStyles); + } + + /** + * Return the widget classes that participate in CSS selector matching. + * + * By default, all style classes are CSS classes. Subclasses may override + * this to filter out classes handled separately (e.g. utility classes). + * + * @return string[] + */ + protected function getCssClasses(AbstractWidget $widget): array + { + return $widget->getStyleClasses(); + } + + /** + * Hook for subclasses to inject extra styles into the cascade. + * + * Called after breakpoint rules and before the instance style. + * The default implementation returns the styles unchanged. + * + * @param Style[] $applicableStyles Current styles in cascade order + * + * @return Style[] + */ + protected function resolveExtraStyles(AbstractWidget $widget, array $applicableStyles): array + { + return $applicableStyles; + } + + /** + * Resolve breakpoint rules that apply at the given column width. + * + * Evaluates breakpoints in ascending order of min-columns threshold. + * Each matching breakpoint's rules go through the same selector matching + * as base rules (universal, FQCN, CSS class, state). + * + * @param Style[] $applicableStyles Current styles in cascade order + * @param string[] $cssClasses CSS classes to use for selector matching + * + * @return Style[] + */ + protected function resolveBreakpoints(AbstractWidget $widget, int $columns, array $applicableStyles, array $cssClasses): array + { + $classHierarchy = static::getClassHierarchy($widget::class); + + // Sort breakpoints by threshold ascending so narrower ones apply first + $thresholds = array_keys($this->breakpoints); + sort($thresholds); + + foreach ($thresholds as $minColumns) { + if ($columns < $minColumns) { + continue; + } + + $rules = $this->breakpoints[$minColumns]; + + // Apply same selector matching as base rules + if (isset($rules['*'])) { + $applicableStyles[] = $rules['*']; + } + + foreach ($classHierarchy as $class) { + if (isset($rules[$class])) { + $applicableStyles[] = $rules[$class]; + } + } + + foreach ($cssClasses as $class) { + $selector = '.'.$class; + if (isset($rules[$selector])) { + $applicableStyles[] = $rules[$selector]; + } + } + + foreach ($widget->getStateFlags() as $state) { + if (isset($rules[':'.$state])) { + $applicableStyles[] = $rules[':'.$state]; + } + + foreach ($classHierarchy as $class) { + $classStateSelector = $class.':'.$state; + if (isset($rules[$classStateSelector])) { + $applicableStyles[] = $rules[$classStateSelector]; + } + } + + foreach ($cssClasses as $class) { + $classStateSelector = '.'.$class.':'.$state; + if (isset($rules[$classStateSelector])) { + $applicableStyles[] = $rules[$classStateSelector]; + } + } + } + } + + return $applicableStyles; + } + + /** + * Get the class hierarchy for a widget class (parent classes first, concrete class last). + * + * Stops at AbstractWidget (excluded) since rules should not target it directly. + * + * @return string[] + */ + protected static function getClassHierarchy(string $fqcn): array + { + $hierarchy = []; + $class = $fqcn; + + while ($class && AbstractWidget::class !== $class) { + $hierarchy[] = $class; + $class = get_parent_class($class); + } + + return array_reverse($hierarchy); + } + + /** + * Merge multiple styles into one. + * + * Later styles override earlier ones for non-null properties. + * Uses {@see Style::mergeAll()} for a single-pass merge that + * allocates one Style object instead of N-1 intermediates. + * + * @param Style[] $styles + */ + protected static function mergeStyles(array $styles): Style + { + if ([] === $styles) { + return new Style(); + } + + if (1 === \count($styles)) { + return $styles[0]; + } + + return Style::mergeAll($styles); + } +} diff --git a/extern/Tui/Style/TailwindStylesheet.php b/extern/Tui/Style/TailwindStylesheet.php new file mode 100644 index 00000000..4e9875e5 --- /dev/null +++ b/extern/Tui/Style/TailwindStylesheet.php @@ -0,0 +1,469 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +use Symfony\Component\Tui\Widget\AbstractWidget; + +/** + * A stylesheet that supports Tailwind-like utility classes. + * + * Utility classes are parsed from widget style classes and dynamically + * converted to Style objects. They coexist with regular CSS-like rules + * and take precedence over them in the cascade (they are "immutable"). + * + * ## Supported utility classes + * + * ### Padding + * p-{n} All sides + * px-{n} Left and right + * py-{n} Top and bottom + * pt-{n} pr-{n} pb-{n} pl-{n} Individual sides + * + * ### Border + * border All sides, width 1 + * border-{n} All sides, width n + * border-t border-r border-b border-l Individual side, width 1 + * border-t-{n} border-r-{n} border-b-{n} border-l-{n} Individual side, width n + * border-none Remove border + * border-{pattern} Pattern: normal, rounded, double, tall, wide, tall-medium, wide-medium, tall-large, wide-large + * border-{color} Color: {family}-{shade}, [#hex], or palette index + * + * ### Background color + * bg-{family}-{shade} Tailwind shade (e.g., bg-red-300, bg-emerald-700) + * bg-[#hex] Hex color (e.g., bg-[#ff5500], bg-[#f50]) + * bg-{0-255} 256-palette index + * + * ### Text color + * text-{family}-{shade} Tailwind shade (e.g., text-blue-700, text-sky-400) + * text-[#hex] Hex color + * text-{0-255} 256-palette index + * + * Color families: slate, gray, zinc, neutral, stone, red, orange, amber, + * yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, + * purple, fuchsia, pink, rose. Shade numbers: 50–950. + * + * ### Text decoration + * bold / not-bold + * dim / not-dim + * italic / not-italic + * underline / no-underline + * line-through / no-line-through + * reverse / no-reverse + * + * ### Text alignment + * text-left Left-align text (default) + * text-center Center-align text + * text-right Right-align text + * + * ### Font + * font-{name} FIGlet font (big, small, slant, standard, mini, or path) + * + * ### Layout + * flex-row Horizontal direction + * flex-col Vertical direction + * flex-{n} Flex grow weight (0 = intrinsic width, 1+ = proportional) + * gap-{n} Gap between children + * hidden Hide widget + * visible Show widget + * align-left Left-align child widgets (default) + * align-center Center child widgets horizontally + * align-right Right-align child widgets + * valign-top Top-align child widgets + * valign-center Center child widgets vertically + * valign-bottom Bottom-align child widgets (default) + * + * ## Cascade order + * + * Inherits the standard {@see StyleSheet} cascade, with utility classes + * injected at step 6 (above breakpoints, below instance style): + * 1. Universal selector (*) + * 2. FQCN selector + * 3. CSS class selectors (.class): utility classes excluded + * 4. State selectors (:focus) + * 5. Breakpoint rules + * 6. **Utility class styles** (immutable, override all above) + * 7. Instance style (widget's own setStyle()) + * + * ## Composability + * + * Multiple utility classes compose naturally: + * + * $widget->addStyleClass('p-2') + * ->addStyleClass('bg-red-500') + * ->addStyleClass('bold') + * ->addStyleClass('border') + * ->addStyleClass('border-rounded') + * ->addStyleClass('border-cyan-400'); + * + * Border-related classes (width, pattern, color) are combined into + * a single Border object. When the same property is set twice, + * the last class wins (e.g., `p-2 p-4` results in padding 4). + * + * @experimental + * + * @author Fabien Potencier + */ +class TailwindStylesheet extends StyleSheet +{ + /** + * Tailwind color palette: base (500) hex values. + * + * These are the official Tailwind CSS v4 color families. + * Shade variants (e.g. red-300) are computed from these by + * tinting toward white or shading toward black. + */ + private const TAILWIND_COLORS = [ + 'slate' => '#64748b', + 'gray' => '#6b7280', + 'zinc' => '#71717a', + 'neutral' => '#737373', + 'stone' => '#78716c', + 'red' => '#ef4444', + 'orange' => '#f97316', + 'amber' => '#f59e0b', + 'yellow' => '#eab308', + 'lime' => '#84cc16', + 'green' => '#22c55e', + 'emerald' => '#10b981', + 'teal' => '#14b8a6', + 'cyan' => '#06b6d4', + 'sky' => '#0ea5e9', + 'blue' => '#3b82f6', + 'indigo' => '#6366f1', + 'violet' => '#8b5cf6', + 'purple' => '#a855f7', + 'fuchsia' => '#d946ef', + 'pink' => '#ec4899', + 'rose' => '#f43f5e', + ]; + + /** + * Maps Tailwind shade numbers to scale() percentages. + * + * Negative = tint (lighter), positive = shade (darker), 0 = base. + */ + private const SHADE_SCALE = [ + 50 => -95, + 100 => -80, + 200 => -60, + 300 => -40, + 400 => -20, + 500 => 0, + 600 => 20, + 700 => 40, + 800 => 60, + 900 => 80, + 950 => 90, + ]; + + protected function getCssClasses(AbstractWidget $widget): array + { + return $this->partitionClasses($widget)['css']; + } + + protected function resolveExtraStyles(AbstractWidget $widget, array $applicableStyles): array + { + $utilityClasses = $this->partitionClasses($widget)['utility']; + + if ([] === $utilityClasses) { + return $applicableStyles; + } + + $utilityStyle = $this->resolveUtilityClasses($utilityClasses); + if (null !== $utilityStyle) { + $applicableStyles[] = $utilityStyle; + } + + return $applicableStyles; + } + + /** + * Partition widget classes into CSS classes and utility classes. + * + * @return array{css: string[], utility: string[]} + */ + private function partitionClasses(AbstractWidget $widget): array + { + $css = []; + $utility = []; + + foreach ($widget->getStyleClasses() as $class) { + if (null !== $this->parseSingleUtility($class)) { + $utility[] = $class; + } else { + $css[] = $class; + } + } + + return ['css' => $css, 'utility' => $utility]; + } + + /** + * Resolve all utility classes into a single combined Style. + * + * @param string[] $classes Utility class names + */ + private function resolveUtilityClasses(array $classes): ?Style + { + /** @var array $slots */ + $slots = []; + foreach ($classes as $class) { + $parsed = $this->parseSingleUtility($class); + if (null !== $parsed) { + $slots = array_merge($slots, $parsed); + } + } + + if ([] === $slots) { + return null; + } + + return $this->buildStyleFromSlots($slots); + } + + /** + * Parse a single utility class name into property slots. + * + * Returns null if the class is not a recognized utility. + * + * @return array|null + */ + private function parseSingleUtility(string $class): ?array + { + // === PADDING === + if (preg_match('/^p-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['pt' => $v, 'pr' => $v, 'pb' => $v, 'pl' => $v]; + } + if (preg_match('/^px-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['pr' => $v, 'pl' => $v]; + } + if (preg_match('/^py-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['pt' => $v, 'pb' => $v]; + } + if (preg_match('/^pt-(\d+)$/', $class, $m)) { + return ['pt' => (int) $m[1]]; + } + if (preg_match('/^pr-(\d+)$/', $class, $m)) { + return ['pr' => (int) $m[1]]; + } + if (preg_match('/^pb-(\d+)$/', $class, $m)) { + return ['pb' => (int) $m[1]]; + } + if (preg_match('/^pl-(\d+)$/', $class, $m)) { + return ['pl' => (int) $m[1]]; + } + + // === BORDER === + if ('border' === $class) { + return ['bt' => 1, 'br' => 1, 'bb' => 1, 'bl' => 1]; + } + if ('border-none' === $class) { + return ['bt' => 0, 'br' => 0, 'bb' => 0, 'bl' => 0, 'border-pattern' => 'none']; + } + if (preg_match('/^border-(\d+)$/', $class, $m)) { + $v = (int) $m[1]; + + return ['bt' => $v, 'br' => $v, 'bb' => $v, 'bl' => $v]; + } + if (preg_match('/^border-(t|r|b|l)$/', $class, $m)) { + return ['b'.$m[1] => 1]; + } + if (preg_match('/^border-(t|r|b|l)-(\d+)$/', $class, $m)) { + return ['b'.$m[1] => (int) $m[2]]; + } + if (preg_match('/^border-(normal|rounded|double|tall|wide|tall-medium|wide-medium|tall-large|wide-large)$/', $class, $m)) { + return ['border-pattern' => $m[1]]; + } + if (preg_match('/^border-(.+)$/', $class, $m)) { + $color = $this->parseColorValue($m[1]); + if (null !== $color) { + return ['border-color' => $color]; + } + } + + // === BACKGROUND === + if (preg_match('/^bg-(.+)$/', $class, $m)) { + $color = $this->parseColorValue($m[1]); + if (null !== $color) { + return ['bg' => $color]; + } + } + + // === TEXT ALIGNMENT === + $textAlign = match ($class) { + 'text-left' => TextAlign::Left, + 'text-center' => TextAlign::Center, + 'text-right' => TextAlign::Right, + default => null, + }; + if (null !== $textAlign) { + return ['text_align' => $textAlign]; + } + + // === TEXT COLOR === + if (preg_match('/^text-(.+)$/', $class, $m)) { + $color = $this->parseColorValue($m[1]); + if (null !== $color) { + return ['fg' => $color]; + } + } + + // === GAP === + if (preg_match('/^gap-(\d+)$/', $class, $m)) { + return ['gap' => (int) $m[1]]; + } + + // === FLEX WEIGHT === + if (preg_match('/^flex-(\d+)$/', $class, $m)) { + return ['flex' => (int) $m[1]]; + } + + // === FONT === + if (preg_match('/^font-(.+)$/', $class, $m)) { + return ['font' => $m[1]]; + } + + // === ALIGN === + $align = match ($class) { + 'align-left' => Align::Left, + 'align-center' => Align::Center, + 'align-right' => Align::Right, + default => null, + }; + if (null !== $align) { + return ['align' => $align]; + } + + // === VERTICAL ALIGN === + $verticalAlign = match ($class) { + 'valign-top' => VerticalAlign::Top, + 'valign-center' => VerticalAlign::Center, + 'valign-bottom' => VerticalAlign::Bottom, + default => null, + }; + if (null !== $verticalAlign) { + return ['vertical_align' => $verticalAlign]; + } + + // === SIMPLE KEYWORDS === + return match ($class) { + 'bold' => ['bold' => true], + 'not-bold' => ['bold' => false], + 'dim' => ['dim' => true], + 'not-dim' => ['dim' => false], + 'italic' => ['italic' => true], + 'not-italic' => ['italic' => false], + 'underline' => ['underline' => true], + 'no-underline' => ['underline' => false], + 'line-through' => ['strikethrough' => true], + 'no-line-through' => ['strikethrough' => false], + 'reverse' => ['reverse' => true], + 'no-reverse' => ['reverse' => false], + 'flex-col' => ['direction' => Direction::Vertical], + 'flex-row' => ['direction' => Direction::Horizontal], + 'hidden' => ['hidden' => true], + 'visible' => ['hidden' => false], + default => null, + }; + } + + /** + * Parse a color value from a utility class suffix. + * + * Supports: + * - Tailwind shade: red-300, emerald-700, sky-400, etc. + * - Hex with brackets: [#ff5500], [#f50] + * - 256-palette index: 0-255 + */ + private function parseColorValue(string $value): Color|string|int|null + { + // Bracket syntax for arbitrary hex: [#ff5500] + if (preg_match('/^\[#([0-9a-fA-F]{3,6})]$/', $value, $m)) { + return '#'.$m[1]; + } + + // Numeric palette: 0-255 + if (preg_match('/^\d+$/', $value)) { + $index = (int) $value; + if ($index >= 0 && $index <= 255) { + return $index; + } + } + + // Tailwind shade syntax: {family}-{shade} + if (preg_match('/^([a-z]+)-(\d+)$/', $value, $m)) { + $shade = (int) $m[2]; + if (isset(self::TAILWIND_COLORS[$m[1]]) && isset(self::SHADE_SCALE[$shade])) { + return Color::hex(self::TAILWIND_COLORS[$m[1]])->scale(self::SHADE_SCALE[$shade]); + } + } + + return null; + } + + /** + * Build a Style from accumulated property slots. + * + * @param array $slots + */ + private function buildStyleFromSlots(array $slots): Style + { + $padding = null; + if (isset($slots['pt']) || isset($slots['pr']) || isset($slots['pb']) || isset($slots['pl'])) { + $padding = new Padding( + $slots['pt'] ?? 0, + $slots['pr'] ?? 0, + $slots['pb'] ?? 0, + $slots['pl'] ?? 0, + ); + } + + $border = null; + if (isset($slots['bt']) || isset($slots['br']) || isset($slots['bb']) || isset($slots['bl']) || isset($slots['border-pattern']) || isset($slots['border-color'])) { + $border = new Border( + $slots['bt'] ?? 0, + $slots['br'] ?? 0, + $slots['bb'] ?? 0, + $slots['bl'] ?? 0, + $slots['border-pattern'] ?? null, + $slots['border-color'] ?? null, + ); + } + + return new Style( + padding: $padding, + border: $border, + background: $slots['bg'] ?? null, + color: $slots['fg'] ?? null, + bold: $slots['bold'] ?? null, + dim: $slots['dim'] ?? null, + italic: $slots['italic'] ?? null, + strikethrough: $slots['strikethrough'] ?? null, + underline: $slots['underline'] ?? null, + reverse: $slots['reverse'] ?? null, + direction: $slots['direction'] ?? null, + gap: $slots['gap'] ?? null, + hidden: $slots['hidden'] ?? null, + textAlign: $slots['text_align'] ?? null, + font: $slots['font'] ?? null, + align: $slots['align'] ?? null, + verticalAlign: $slots['vertical_align'] ?? null, + flex: $slots['flex'] ?? null, + ); + } +} diff --git a/extern/Tui/Style/TextAlign.php b/extern/Tui/Style/TextAlign.php new file mode 100644 index 00000000..264d68e7 --- /dev/null +++ b/extern/Tui/Style/TextAlign.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Text alignment within a widget's content area. + * + * @experimental + * + * @author Fabien Potencier + */ +enum TextAlign: string +{ + case Left = 'left'; + case Center = 'center'; + case Right = 'right'; +} diff --git a/extern/Tui/Style/VerticalAlign.php b/extern/Tui/Style/VerticalAlign.php new file mode 100644 index 00000000..e462b9f4 --- /dev/null +++ b/extern/Tui/Style/VerticalAlign.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Style; + +/** + * Vertical alignment of child widgets within a container. + * + * Controls how child widget content is positioned vertically when it + * renders shorter than the container's available height. + * + * @experimental + * + * @author Fabien Potencier + */ +enum VerticalAlign: string +{ + case Top = 'top'; + case Center = 'center'; + case Bottom = 'bottom'; +} diff --git a/extern/Tui/Terminal/ScreenBuffer.php b/extern/Tui/Terminal/ScreenBuffer.php new file mode 100644 index 00000000..f3cadde3 --- /dev/null +++ b/extern/Tui/Terminal/ScreenBuffer.php @@ -0,0 +1,840 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * A simple terminal emulator that interprets ANSI escape sequences + * and maintains a screen buffer representing what the user actually sees. + * + * This is used in tests to convert raw terminal output (with differential + * updates, cursor movements, etc.) into the actual rendered screen state. + * It preserves ANSI styling (colors, bold, etc.) for accurate visual comparison. + * + * @experimental + */ +final class ScreenBuffer +{ + /** + * @var array{ + * bold: bool, + * dim: bool, + * italic: bool, + * underline: bool, + * blink: bool, + * reverse: bool, + * strikethrough: bool, + * fg: string|null, + * bg: string|null, + * underline_color: string|null + * } + */ + private const DEFAULT_STYLE_STATE = [ + 'bold' => false, + 'dim' => false, + 'italic' => false, + 'underline' => false, + 'blink' => false, + 'reverse' => false, + 'strikethrough' => false, + 'fg' => null, + 'bg' => null, + 'underline_color' => null, + ]; + + /** @var array> */ + private array $cells = []; + private int $cursorRow = 0; + private int $cursorCol = 0; + private int $width; + private int $height; + + /** + * Current style state - tracks individual attributes. + * + * @var array{ + * bold: bool, + * dim: bool, + * italic: bool, + * underline: bool, + * blink: bool, + * reverse: bool, + * strikethrough: bool, + * fg: string|null, + * bg: string|null, + * underline_color: string|null + * } + */ + private array $styleState = self::DEFAULT_STYLE_STATE; + + public function __construct(int $width = 80, int $height = 24) + { + $this->width = $width; + $this->height = $height; + $this->clear(); + } + + /** + * Clear the screen buffer. + */ + public function clear(): void + { + $this->cells = []; + for ($row = 0; $row < $this->height; ++$row) { + $this->cells[$row] = []; + } + $this->cursorRow = 0; + $this->cursorCol = 0; + $this->styleState = self::DEFAULT_STYLE_STATE; + } + + /** + * Process terminal output and update the screen buffer. + */ + public function write(string $data): void + { + $i = 0; + $len = \strlen($data); + + while ($i < $len) { + $char = $data[$i]; + + // Handle escape sequences + if ("\x1b" === $char) { + $consumed = $this->parseEscapeSequence($data, $i); + $i += $consumed; + continue; + } + + // Handle special characters + if ("\r" === $char) { + $this->cursorCol = 0; + ++$i; + continue; + } + + if ("\n" === $char) { + ++$this->cursorRow; + $this->cursorCol = 0; // Newline also resets column + if ($this->cursorRow >= $this->height) { + $this->scrollUp(); + $this->cursorRow = $this->height - 1; + } + ++$i; + continue; + } + + // Handle tab + if ("\t" === $char) { + $spaces = 8 - ($this->cursorCol % 8); + for ($j = 0; $j < $spaces && $this->cursorCol < $this->width; ++$j) { + $this->putChar(' '); + } + ++$i; + continue; + } + + // Handle backspace (move cursor back) + if ("\x08" === $char) { + if ($this->cursorCol > 0) { + --$this->cursorCol; + } + ++$i; + continue; + } + + // Handle DEL (delete character at cursor, move cursor back) + if ("\x7f" === $char) { + if ($this->cursorCol > 0) { + --$this->cursorCol; + // Clear the character at the new cursor position + $this->cells[$this->cursorRow][$this->cursorCol] = ['char' => ' ', 'style' => '']; + } + ++$i; + continue; + } + + // Skip other control characters + if (\ord($char) < 32 && "\x1b" !== $char) { + ++$i; + continue; + } + + // Regular character - extract full grapheme cluster + $grapheme = grapheme_extract($data, 1, \GRAPHEME_EXTR_COUNT, $i, $next); + if (false !== $grapheme && '' !== $grapheme) { + $this->putChar($grapheme); + $i = $next; + } else { + ++$i; + } + } + } + + /** + * Get the current screen content as a string (without styles). + */ + public function getScreen(): string + { + $result = []; + $lastNonEmpty = -1; + + for ($row = 0; $row < $this->height; ++$row) { + $line = $this->getLineText($row); + $trimmed = rtrim($line); + if ('' !== $trimmed) { + $lastNonEmpty = $row; + } + $result[] = $trimmed; + } + + // Only include lines up to the last non-empty line + if ($lastNonEmpty >= 0) { + $result = \array_slice($result, 0, $lastNonEmpty + 1); + } else { + $result = []; + } + + return implode("\n", $result); + } + + /** + * Get the current screen content with ANSI styles preserved. + */ + public function getStyledScreen(): string + { + $result = []; + $lastNonEmpty = -1; + + for ($row = 0; $row < $this->height; ++$row) { + $line = $this->getLineStyled($row); + if (isset($this->cells[$row])) { + foreach ($this->cells[$row] as $cell) { + if (' ' !== $cell['char'] && '' !== $cell['char']) { + $lastNonEmpty = $row; + break; + } + } + } + $result[] = rtrim($line); + } + + // Only include lines up to the last non-empty line + if ($lastNonEmpty >= 0) { + $result = \array_slice($result, 0, $lastNonEmpty + 1); + } else { + $result = []; + } + + return implode("\n", $result); + } + + /** + * Get screen lines as array (without styles). + * + * @return string[] + */ + public function getLines(): array + { + $lines = []; + for ($row = 0; $row < $this->height; ++$row) { + $lines[] = $this->getLineText($row); + } + + return $lines; + } + + /** + * Get the cell data for external processing (e.g., HTML conversion). + * + * @return array> + */ + public function getCells(): array + { + return $this->cells; + } + + /** + * Get the screen height. + */ + public function getHeight(): int + { + return $this->height; + } + + /** + * Get a single line's text content. + */ + private function getLineText(int $row): string + { + if (!isset($this->cells[$row]) || [] === $this->cells[$row]) { + return ''; + } + + $line = ''; + $maxCol = max(array_keys($this->cells[$row])); + + for ($col = 0; $col <= $maxCol; ++$col) { + $char = $this->cells[$row][$col]['char'] ?? ' '; + // Skip wide character continuation cells (empty string placeholders) + if ('' === $char) { + continue; + } + $line .= $char; + } + + return $line; + } + + /** + * Get a single line with ANSI styles. + */ + private function getLineStyled(int $row): string + { + if (!isset($this->cells[$row]) || [] === $this->cells[$row]) { + return ''; + } + + $line = ''; + $maxCol = max(array_keys($this->cells[$row])); + + $lastStyle = ''; + for ($col = 0; $col <= $maxCol; ++$col) { + $cell = $this->cells[$row][$col] ?? ['char' => ' ', 'style' => '']; + + // Skip wide character continuation cells (empty string placeholders) + if ('' === $cell['char']) { + continue; + } + + $style = $cell['style']; + + if ($style !== $lastStyle) { + if ('' !== $lastStyle) { + $line .= "\x1b[0m"; // Reset before changing + } + if ('' !== $style) { + $line .= $style; + } + $lastStyle = $style; + } + + $line .= $cell['char']; + } + + if ('' !== $lastStyle) { + $line .= "\x1b[0m"; + } + + return $line; + } + + /** + * Put a character at the current cursor position. + */ + private function putChar(string $char): void + { + if ($this->cursorRow < 0 || $this->cursorRow >= $this->height) { + return; + } + + if (!isset($this->cells[$this->cursorRow])) { + $this->cells[$this->cursorRow] = []; + } + + // Fill any gaps with spaces + for ($col = \count($this->cells[$this->cursorRow]); $col < $this->cursorCol; ++$col) { + $this->cells[$this->cursorRow][$col] = ['char' => ' ', 'style' => '']; + } + + $style = $this->buildStyleString(); + $charWidth = AnsiUtils::graphemeWidth($char); + + // If the wide character doesn't fit at the right edge, skip it + if ($charWidth > 1 && $this->cursorCol + $charWidth > $this->width) { + return; + } + + $row = &$this->cells[$this->cursorRow]; + + // Clean up wide character fragments in cells being overwritten + for ($w = 0; $w < $charWidth; ++$w) { + $col = $this->cursorCol + $w; + if (isset($row[$col])) { + if ('' === $row[$col]['char']) { + // This is a continuation cell, clear the wide char to its left + if ($col > 0 && isset($row[$col - 1]) && '' !== $row[$col - 1]['char'] && ' ' !== $row[$col - 1]['char']) { + $row[$col - 1] = ['char' => ' ', 'style' => '']; + } + } elseif (' ' !== $row[$col]['char']) { + // This cell may be a wide char, clear its continuation cell to the right + if (isset($row[$col + 1]) && '' === $row[$col + 1]['char']) { + $row[$col + 1] = ['char' => ' ', 'style' => '']; + } + } + } + } + + $row[$this->cursorCol] = [ + 'char' => $char, + 'style' => $style, + ]; + + // For wide characters (e.g. CJK), mark continuation cell(s) as placeholders + for ($w = 1; $w < $charWidth; ++$w) { + $row[$this->cursorCol + $w] = [ + 'char' => '', + 'style' => $style, + ]; + } + + $this->cursorCol += $charWidth; + } + + /** + * Build an ANSI style string from the current style state. + */ + private function buildStyleString(): string + { + $codes = []; + + if ($this->styleState['bold']) { + $codes[] = '1'; + } + if ($this->styleState['dim']) { + $codes[] = '2'; + } + if ($this->styleState['italic']) { + $codes[] = '3'; + } + if ($this->styleState['underline']) { + $codes[] = '4'; + } + if ($this->styleState['blink']) { + $codes[] = '5'; + } + if ($this->styleState['reverse']) { + $codes[] = '7'; + } + if ($this->styleState['strikethrough']) { + $codes[] = '9'; + } + if (null !== $this->styleState['fg']) { + $codes[] = $this->styleState['fg']; + } + if (null !== $this->styleState['bg']) { + $codes[] = $this->styleState['bg']; + } + if (null !== $this->styleState['underline_color']) { + $codes[] = $this->styleState['underline_color']; + } + + if ([] === $codes) { + return ''; + } + + return "\x1b[".implode(';', $codes).'m'; + } + + /** + * Scroll the screen up by one line. + */ + private function scrollUp(): void + { + array_shift($this->cells); + $this->cells[] = []; + } + + /** + * Parse an escape sequence and return the number of bytes consumed. + */ + private function parseEscapeSequence(string $data, int $start): int + { + $len = \strlen($data); + if ($start + 1 >= $len) { + return 1; + } + + $next = $data[$start + 1]; + $nextOrd = \ord($next); + + // CSI sequence: ESC [ + if ('[' === $next) { + return $this->parseCsiSequence($data, $start); + } + + // String sequences: OSC (ESC ]), DCS (ESC P), APC (ESC _), PM (ESC ^), SOS (ESC X) + // All terminated by BEL (0x07) or ST (ESC \) + if (']' === $next || 'P' === $next || '_' === $next || '^' === $next || 'X' === $next) { + return $this->parseStringSequence($data, $start); + } + + // nF announced sequences: ESC + intermediate bytes (0x20-0x2F)+ + final byte (0x30-0x7E) + if ($nextOrd >= 0x20 && $nextOrd <= 0x2F) { + $j = $start + 2; + while ($j < $len && \ord($data[$j]) >= 0x20 && \ord($data[$j]) <= 0x2F) { + ++$j; + } + if ($j < $len && \ord($data[$j]) >= 0x30 && \ord($data[$j]) <= 0x7E) { + return $j + 1 - $start; + } + + return $len - $start; + } + + // Fe (0x40-0x5F), Fp (0x30-0x3F), Fs (0x60-0x7E) two-byte sequences + if ($nextOrd >= 0x30 && $nextOrd <= 0x7E) { + return 2; + } + + // Unknown: skip the ESC byte + return 1; + } + + /** + * Parse string sequence (OSC, DCS, APC, PM, SOS). + * Format: ESC ... ST (where ST is ESC \ or BEL). + */ + private function parseStringSequence(string $data, int $start): int + { + $len = \strlen($data); + $i = $start + 2; // Skip ESC + introducer byte + + // Find terminator: ST (ESC \) or BEL (\x07) + while ($i < $len) { + if ("\x07" === $data[$i]) { + // BEL terminator + return $i - $start + 1; + } + if ("\x1b" === $data[$i] && $i + 1 < $len && '\\' === $data[$i + 1]) { + // ST terminator (ESC \) + return $i - $start + 2; + } + ++$i; + } + + // No terminator found - consume what we have + return $len - $start; + } + + /** + * Parse CSI (Control Sequence Introducer) sequence. + */ + private function parseCsiSequence(string $data, int $start): int + { + $len = \strlen($data); + $i = $start + 2; // Skip ESC [ + + // Collect parameter bytes (0x30-0x3F) + $params = ''; + while ($i < $len && \ord($data[$i]) >= 0x30 && \ord($data[$i]) <= 0x3F) { + $params .= $data[$i]; + ++$i; + } + + // Collect intermediate bytes (0x20-0x2F) + while ($i < $len && \ord($data[$i]) >= 0x20 && \ord($data[$i]) <= 0x2F) { + ++$i; + } + + // Final byte (0x40-0x7E) + if ($i >= $len) { + return $i - $start; + } + + $finalByte = $data[$i]; + $consumed = $i - $start + 1; + + // Strip private mode prefix ("?") before parsing numeric parameters + $paramStr = str_starts_with($params, '?') ? substr($params, 1) : $params; + $nums = '' !== $paramStr ? array_map('intval', explode(';', $paramStr)) : []; + + switch ($finalByte) { + case 'A': // Cursor Up + $n = $nums[0] ?? 1; + $this->cursorRow = max(0, $this->cursorRow - $n); + break; + + case 'B': // Cursor Down + $n = $nums[0] ?? 1; + $this->cursorRow = min($this->height - 1, $this->cursorRow + $n); + break; + + case 'C': // Cursor Forward + $n = $nums[0] ?? 1; + $this->cursorCol = min($this->width - 1, $this->cursorCol + $n); + break; + + case 'D': // Cursor Back + $n = $nums[0] ?? 1; + $this->cursorCol = max(0, $this->cursorCol - $n); + break; + + case 'G': // Cursor Horizontal Absolute + $col = ($nums[0] ?? 1) - 1; // 1-indexed + $this->cursorCol = max(0, min($this->width - 1, $col)); + break; + + case 'H': // Cursor Position + case 'f': + $row = ($nums[0] ?? 1) - 1; + $col = ($nums[1] ?? 1) - 1; + $this->cursorRow = max(0, min($this->height - 1, $row)); + $this->cursorCol = max(0, min($this->width - 1, $col)); + break; + + case 'J': // Erase in Display + $mode = $nums[0] ?? 0; + $this->eraseInDisplay($mode); + break; + + case 'K': // Erase in Line + $mode = $nums[0] ?? 0; + $this->eraseInLine($mode); + break; + + case 'm': // SGR (Select Graphic Rendition) + $this->handleSgr($paramStr); + break; + + case 'h': // Set Mode - ignore + case 'l': // Reset Mode - ignore + break; + } + + return $consumed; + } + + /** + * Handle SGR (Select Graphic Rendition) - colors and styles. + */ + private function handleSgr(string $params): void + { + if ('' === $params) { + $params = '0'; + } + + $codes = array_map('intval', explode(';', $params)); + $i = 0; + $codeCount = \count($codes); + + while ($i < $codeCount) { + $code = $codes[$i]; + + switch ($code) { + case 0: // Reset all + $this->styleState = self::DEFAULT_STYLE_STATE; + break; + + case 1: // Bold + $this->styleState['bold'] = true; + break; + case 2: // Dim + $this->styleState['dim'] = true; + break; + case 3: // Italic + $this->styleState['italic'] = true; + break; + case 4: // Underline + $this->styleState['underline'] = true; + break; + case 5: // Blink + $this->styleState['blink'] = true; + break; + case 7: // Reverse + $this->styleState['reverse'] = true; + break; + case 9: // Strikethrough + $this->styleState['strikethrough'] = true; + break; + + // Reset individual attributes + case 22: // Reset bold and dim + $this->styleState['bold'] = false; + $this->styleState['dim'] = false; + break; + case 23: // Reset italic + $this->styleState['italic'] = false; + break; + case 24: // Reset underline + $this->styleState['underline'] = false; + break; + case 25: // Reset blink + $this->styleState['blink'] = false; + break; + case 27: // Reset reverse + $this->styleState['reverse'] = false; + break; + case 29: // Reset strikethrough + $this->styleState['strikethrough'] = false; + break; + + // Standard foreground colors (30-37) + case 30: case 31: case 32: case 33: case 34: case 35: case 36: case 37: + $this->styleState['fg'] = (string) $code; + break; + + // Default foreground color + case 39: + $this->styleState['fg'] = null; + break; + + // Standard background colors (40-47) + case 40: case 41: case 42: case 43: case 44: case 45: case 46: case 47: + $this->styleState['bg'] = (string) $code; + break; + + // Default background color + case 49: + $this->styleState['bg'] = null; + break; + + // Bright foreground colors (90-97) + case 90: case 91: case 92: case 93: case 94: case 95: case 96: case 97: + $this->styleState['fg'] = (string) $code; + break; + + // Bright background colors (100-107) + case 100: case 101: case 102: case 103: case 104: case 105: case 106: case 107: + $this->styleState['bg'] = (string) $code; + break; + + // 256-color and true-color foreground: 38;5;N or 38;2;R;G;B + case 38: + if (isset($codes[$i + 1])) { + if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { + // 256-color mode + $this->styleState['fg'] = '38;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { + // True-color mode + $this->styleState['fg'] = '38;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + break; + + // 256-color and true-color background: 48;5;N or 48;2;R;G;B + case 48: + if (isset($codes[$i + 1])) { + if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { + // 256-color mode + $this->styleState['bg'] = '48;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { + // True-color mode + $this->styleState['bg'] = '48;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + break; + + // 256-color and true-color underline color: 58;5;N or 58;2;R;G;B + case 58: + if (isset($codes[$i + 1])) { + if (5 === $codes[$i + 1] && isset($codes[$i + 2])) { + // 256-color mode + $this->styleState['underline_color'] = '58;5;'.$codes[$i + 2]; + $i += 2; + } elseif (2 === $codes[$i + 1] && isset($codes[$i + 2], $codes[$i + 3], $codes[$i + 4])) { + // True-color mode + $this->styleState['underline_color'] = '58;2;'.$codes[$i + 2].';'.$codes[$i + 3].';'.$codes[$i + 4]; + $i += 4; + } + } + break; + + // Default underline color + case 59: + $this->styleState['underline_color'] = null; + break; + } + + ++$i; + } + } + + /** + * Erase in display. + */ + private function eraseInDisplay(int $mode): void + { + switch ($mode) { + case 0: // Erase from cursor to end of screen + $this->eraseInLine(0); + for ($i = $this->cursorRow + 1; $i < $this->height; ++$i) { + $this->cells[$i] = []; + } + break; + + case 1: // Erase from start to cursor + for ($i = 0; $i < $this->cursorRow; ++$i) { + $this->cells[$i] = []; + } + $this->eraseInLine(1); + break; + + case 2: // Erase entire screen (but don't move cursor) + case 3: + for ($i = 0; $i < $this->height; ++$i) { + $this->cells[$i] = []; + } + break; + } + } + + /** + * Erase in line. + */ + private function eraseInLine(int $mode): void + { + if (!isset($this->cells[$this->cursorRow])) { + $this->cells[$this->cursorRow] = []; + + return; + } + + $row = &$this->cells[$this->cursorRow]; + + switch ($mode) { + case 0: // Erase from cursor to end of line + // If cursor is on a continuation cell, also clear the wide char's main cell + if (isset($row[$this->cursorCol]) && '' === $row[$this->cursorCol]['char'] + && $this->cursorCol > 0 && isset($row[$this->cursorCol - 1])) { + unset($row[$this->cursorCol - 1]); + } + foreach ($row as $col => $cell) { + if ($col >= $this->cursorCol) { + unset($row[$col]); + } + } + break; + + case 1: // Erase from start of line to cursor + foreach ($row as $col => $cell) { + if ($col <= $this->cursorCol) { + unset($row[$col]); + } + } + // If the last erased cell was a wide char's main cell, also clear its continuation + if (isset($row[$this->cursorCol + 1]) && '' === $row[$this->cursorCol + 1]['char']) { + unset($row[$this->cursorCol + 1]); + } + break; + + case 2: // Erase entire line + $row = []; + break; + } + } +} diff --git a/extern/Tui/Terminal/TeeTerminal.php b/extern/Tui/Terminal/TeeTerminal.php new file mode 100644 index 00000000..dc5e5705 --- /dev/null +++ b/extern/Tui/Terminal/TeeTerminal.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +/** + * Terminal that delegates to multiple terminals simultaneously. + * + * This is useful for: + * - Running examples with both a real terminal and a VirtualTerminal for testing + * - Recording terminal output while displaying it + * - Debugging terminal output + * + * The primary terminal is used for input handling and dimension queries. + * All terminals receive write operations. + * + * @experimental + * + * @author Fabien Potencier + */ +final class TeeTerminal implements TerminalInterface +{ + /** + * @param TerminalInterface $primary The primary terminal (used for input and dimensions) + * @param TerminalInterface $secondary Additional terminal that receives writes + */ + public function __construct( + private TerminalInterface $primary, + private TerminalInterface $secondary, + ) { + } + + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void + { + // Start primary with real callbacks + $this->primary->start($onInput, $onResize, $onKittyProtocolActivated); + + // Start secondary with no-op callbacks (they just record) + $noopInput = static function (string $data): void {}; + $noopResize = static function (): void {}; + $noopKitty = static function (): void {}; + + $this->secondary->start($noopInput, $noopResize, $noopKitty); + } + + public function stop(): void + { + $this->primary->stop(); + $this->secondary->stop(); + } + + public function write(string $data): void + { + $this->primary->write($data); + $this->secondary->write($data); + } + + public function getColumns(): int + { + return $this->primary->getColumns(); + } + + public function getRows(): int + { + return $this->primary->getRows(); + } + + public function isKittyProtocolActive(): bool + { + return $this->primary->isKittyProtocolActive(); + } + + public function moveBy(int $lines): void + { + $this->primary->moveBy($lines); + $this->secondary->moveBy($lines); + } + + public function hideCursor(): void + { + $this->primary->hideCursor(); + $this->secondary->hideCursor(); + } + + public function showCursor(): void + { + $this->primary->showCursor(); + $this->secondary->showCursor(); + } + + public function clearLine(): void + { + $this->primary->clearLine(); + $this->secondary->clearLine(); + } + + public function clearFromCursor(): void + { + $this->primary->clearFromCursor(); + $this->secondary->clearFromCursor(); + } + + public function clearScreen(): void + { + $this->primary->clearScreen(); + $this->secondary->clearScreen(); + } + + public function setTitle(string $title): void + { + $this->primary->setTitle($title); + $this->secondary->setTitle($title); + } + + public function bell(): void + { + $this->primary->bell(); + $this->secondary->bell(); + } + + public function isVirtual(): bool + { + return $this->primary->isVirtual(); + } +} diff --git a/extern/Tui/Terminal/Terminal.php b/extern/Tui/Terminal/Terminal.php new file mode 100644 index 00000000..f67cd127 --- /dev/null +++ b/extern/Tui/Terminal/Terminal.php @@ -0,0 +1,348 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +use Revolt\EventLoop; +use Symfony\Component\Tui\Input\StdinBuffer; + +/** + * Real terminal implementation using stdin/stdout. + * + * @experimental + * + * @author Fabien Potencier + */ +final class Terminal implements TerminalInterface +{ + private ?StdinBuffer $stdinBuffer = null; + + private string $initialSttyState = ''; + private bool $kittyProtocolActive = false; + private bool $started = false; + private ?string $stdinCallbackId = null; + private ?string $signalCallbackId = null; + + /** @var callable(string): void|null */ + private $onInput; + + /** @var callable(): void|null */ + private $onResize; + + /** @var callable(): void|null */ + private $onKittyProtocolActivated; + + // Cached terminal dimensions (refreshed on SIGWINCH) + private ?int $cachedColumns = null; + private ?int $cachedRows = null; + + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void + { + if ($this->started) { + return; + } + + $this->onInput = $onInput; + $this->onResize = $onResize; + $this->onKittyProtocolActivated = $onKittyProtocolActivated; + $this->started = true; + + // Save initial terminal state and enable raw mode + if ($this->hasSttyAvailable()) { + $this->initialSttyState = (string) shell_exec('stty -g'); + + // Enable raw mode, equivalent to cfmakeraw(), matching Node.js + // setRawMode(true) used by the Pi reference implementation. + // This disables canonical mode, echo, signal interpretation, and + // extended input processing so that ALL key combinations (including + // Ctrl+C, Ctrl+Z, Alt+Backspace) are delivered as raw bytes to the + // application rather than being intercepted by the kernel. + shell_exec('stty raw -echo'); + } + + // Set up stdin buffer for proper sequence parsing - must be done + // BEFORE sending any queries so responses can be captured + $this->setupStdinBuffer(); + + // Enable bracketed paste mode + $this->write("\x1b[?2004h"); + + // Set up signal handlers for resize using Revolt's event loop + if (\defined('SIGWINCH')) { + $this->signalCallbackId = EventLoop::onSignal(\SIGWINCH, function (): void { + // Clear cached dimensions so they get re-read + $this->cachedColumns = null; + $this->cachedRows = null; + + if (null !== $this->onResize) { + ($this->onResize)(); + } + }); + } + + // Query for Kitty keyboard protocol support + // If terminal supports it, it will respond with \x1b[?u + // which is handled in setupStdinBuffer() + $this->write("\x1b[?u"); + + // Register STDIN watcher with Revolt's event loop for non-blocking input + $this->stdinCallbackId = EventLoop::onReadable(\STDIN, function (): void { + $data = fread(\STDIN, 4096); + if (false !== $data && '' !== $data && null !== $this->stdinBuffer) { + $this->stdinBuffer->process($data); + // Flush any pending lone ESC byte. OS terminals deliver + // complete escape sequences atomically, so a lone \x1b + // remaining after process() can only mean the Escape key. + // Use nullsafe because an InputEvent listener may call + // stop(), which sets stdinBuffer to null during process(). + $this->stdinBuffer?->flush(); + } + }); + } + + public function stop(): void + { + if (!$this->started) { + return; + } + $this->started = false; + + // Cancel STDIN watcher + if (null !== $this->stdinCallbackId) { + EventLoop::cancel($this->stdinCallbackId); + $this->stdinCallbackId = null; + } + + // Cancel signal watcher + if (null !== $this->signalCallbackId) { + EventLoop::cancel($this->signalCallbackId); + $this->signalCallbackId = null; + } + + // Disable bracketed paste mode + $this->write("\x1b[?2004l"); + + // Disable Kitty keyboard protocol if we enabled it + if ($this->kittyProtocolActive) { + $this->write("\x1b[kittyProtocolActive = false; + } + + // Clear stdin buffer + if (null !== $this->stdinBuffer) { + $this->stdinBuffer->clear(); + $this->stdinBuffer = null; + } + + // Restore terminal state + if ('' !== $this->initialSttyState) { + shell_exec('stty '.escapeshellarg(trim($this->initialSttyState))); + } + + $this->onInput = null; + $this->onResize = null; + $this->onKittyProtocolActivated = null; + } + + public function write(string $data): void + { + fwrite(\STDOUT, $data); + fflush(\STDOUT); + } + + public function getColumns(): int + { + if (null === $this->cachedColumns) { + $this->refreshDimensions(); + } + + return $this->cachedColumns ?? 80; + } + + public function getRows(): int + { + if (null === $this->cachedRows) { + $this->refreshDimensions(); + } + + return $this->cachedRows ?? 24; + } + + public function isKittyProtocolActive(): bool + { + return $this->kittyProtocolActive; + } + + public function moveBy(int $lines): void + { + if ($lines > 0) { + $this->write("\x1b[{$lines}B"); + } elseif ($lines < 0) { + $this->write("\x1b[".(-$lines).'A'); + } + } + + public function hideCursor(): void + { + $this->write("\x1b[?25l"); + } + + public function showCursor(): void + { + $this->write("\x1b[?25h"); + } + + public function clearLine(): void + { + $this->write("\x1b[2K"); + } + + public function clearFromCursor(): void + { + $this->write("\x1b[0J"); + } + + public function clearScreen(): void + { + $this->write("\x1b[2J\x1b[H"); + } + + public function setTitle(string $title): void + { + $safe = preg_replace("/[\x00-\x1f\x7f]/", '', $title); + $this->write("\x1b]0;{$safe}\x07"); + } + + public function bell(): void + { + if ('Darwin' === \PHP_OS_FAMILY && file_exists('/System/Library/Sounds/Glass.aiff')) { + // On macOS, play the system sound in the background to avoid + // blocking the event loop. + $this->fireAndForget(['afplay', '/System/Library/Sounds/Glass.aiff']); + + return; + } + + $this->write("\x07"); + } + + public function isVirtual(): bool + { + return false; + } + + /** + * Start a command in the background (fire-and-forget). + * + * Does not wait for the process to complete or collect output. + * + * @param list $command + */ + public function fireAndForget(array $command): void + { + $process = proc_open( + $command, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + + if (\is_resource($process)) { + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + // Do not call proc_close(), let the process run detached. + } + } + + /** + * Refresh terminal dimensions from stty. + */ + private function refreshDimensions(): void + { + // Query terminal size directly using stty + // shell_exec is required here because stty must operate on the + // process's own tty; proc_open gives the child a pipe, not the tty. + $sttyOutput = shell_exec('stty size 2>/dev/null'); + + if (null !== $sttyOutput && false !== $sttyOutput && preg_match('/^(\d+)\s+(\d+)$/', trim($sttyOutput), $matches)) { + $this->cachedRows = (int) $matches[1]; + $this->cachedColumns = (int) $matches[2]; + } else { + // Default fallback + $this->cachedColumns = 80; + $this->cachedRows = 24; + } + } + + /** + * Set up StdinBuffer to split batched input into individual sequences. + */ + private function setupStdinBuffer(): void + { + $this->stdinBuffer = new StdinBuffer(); + + // Kitty protocol response pattern: \x1b[?u + $kittyResponsePattern = '/^\x1b\[\?(\d+)u$/'; + + // Forward individual sequences to the input handler + $this->stdinBuffer->onData(function (string $sequence) use ($kittyResponsePattern): void { + // Check for Kitty protocol response (only if not already enabled) + if (!$this->kittyProtocolActive && preg_match($kittyResponsePattern, $sequence)) { + $this->kittyProtocolActive = true; + // Enable Kitty keyboard protocol with enhanced features + // Flag 1 = disambiguate escape codes + // Flag 2 = report event types (press/repeat/release) + // Flag 4 = report alternate keys + $this->write("\x1b[>7u"); + + // Notify the TUI that Kitty protocol is active + if (null !== $this->onKittyProtocolActivated) { + ($this->onKittyProtocolActivated)(); + } + + return; // Don't forward protocol response to TUI + } + + if (null !== $this->onInput) { + ($this->onInput)($sequence); + } + }); + + // Re-wrap paste content with bracketed paste markers + $this->stdinBuffer->onPaste(function (string $content): void { + if (null !== $this->onInput) { + ($this->onInput)("\x1b[200~".$content."\x1b[201~"); + } + }); + } + + /** + * Check if stty is available on this system. + */ + private function hasSttyAvailable(): bool + { + static $available = null; + + if (null !== $available) { + return $available; + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + return $available = false; + } + + return $available = (bool) shell_exec('stty 2>/dev/null'); + } +} diff --git a/extern/Tui/Terminal/TerminalInterface.php b/extern/Tui/Terminal/TerminalInterface.php new file mode 100644 index 00000000..ab7df73a --- /dev/null +++ b/extern/Tui/Terminal/TerminalInterface.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +/** + * Interface for terminal implementations. + * + * Provides abstraction over terminal I/O for the TUI framework. + * Implementations handle raw mode, input reading, and output writing. + * + * @experimental + * + * @author Fabien Potencier + */ +interface TerminalInterface +{ + /** + * Start the terminal with input and resize handlers. + * + * This typically enables raw mode, sets up signal handlers, + * and prepares the terminal for TUI operation. + * + * @param callable(string): void $onInput Called when input is received + * @param callable(): void $onResize Called when terminal is resized + * @param callable(): void $onKittyProtocolActivated Called when Kitty keyboard protocol is detected + */ + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void; + + /** + * Stop the terminal and restore original state. + * + * This should restore the terminal to its original mode, + * remove signal handlers, and clean up resources. + */ + public function stop(): void; + + /** + * Write data to the terminal output. + */ + public function write(string $data): void; + + /** + * Get the terminal width in columns. + */ + public function getColumns(): int; + + /** + * Get the terminal height in rows. + */ + public function getRows(): int; + + /** + * Check if Kitty keyboard protocol is active. + * + * The Kitty protocol provides enhanced key reporting including + * key release events and modifier disambiguation. + */ + public function isKittyProtocolActive(): bool; + + /** + * Move cursor up (negative) or down (positive) by N lines. + */ + public function moveBy(int $lines): void; + + /** + * Hide the terminal cursor. + */ + public function hideCursor(): void; + + /** + * Show the terminal cursor. + */ + public function showCursor(): void; + + /** + * Clear the current line. + */ + public function clearLine(): void; + + /** + * Clear from cursor to end of screen. + */ + public function clearFromCursor(): void; + + /** + * Clear entire screen and move cursor to home position. + */ + public function clearScreen(): void; + + /** + * Set the terminal window title. + */ + public function setTitle(string $title): void; + + /** + * Ring the terminal bell. + * + * Emits the BEL character (\x07) which causes the terminal + * to produce an audible or visual notification. + */ + public function bell(): void; + + /** + * Whether this is a virtual (non-TTY) terminal. + */ + public function isVirtual(): bool; +} diff --git a/extern/Tui/Terminal/VirtualTerminal.php b/extern/Tui/Terminal/VirtualTerminal.php new file mode 100644 index 00000000..4edc9893 --- /dev/null +++ b/extern/Tui/Terminal/VirtualTerminal.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Terminal; + +use Symfony\Component\Tui\Input\StdinBuffer; + +/** + * Virtual terminal for testing. + * + * Captures output and allows simulating input for unit tests. + * Uses StdinBuffer for input parsing to match real Terminal behavior. + * + * Virtual terminals don't have a physical screen: they don't scroll + * content out of the addressable area and don't respond to terminal + * queries (e.g. cell size). This is used by the rendering pipeline + * to adjust its behavior accordingly. + * + * @experimental + * + * @author Fabien Potencier + */ +final class VirtualTerminal implements TerminalInterface +{ + private string $output = ''; + private ?StdinBuffer $stdinBuffer = null; + + /** @var callable(): void|null */ + private $onResize; + + public function __construct( + private int $columns = 80, + private int $rows = 24, + private bool $kittyProtocolActive = false, + ) { + } + + public function start(callable $onInput, callable $onResize, callable $onKittyProtocolActivated): void + { + $this->onResize = $onResize; + + // Set up StdinBuffer for input parsing (matches real Terminal behavior) + $this->stdinBuffer = new StdinBuffer(); + $this->stdinBuffer->onData($onInput); + + // Re-wrap paste content with bracketed paste markers (matches real Terminal behavior) + $this->stdinBuffer->onPaste(static function (string $content) use ($onInput): void { + $onInput("\x1b[200~".$content."\x1b[201~"); + }); + } + + public function stop(): void + { + $this->onResize = null; + $this->stdinBuffer = null; + } + + public function write(string $data): void + { + $this->output .= $data; + } + + public function getColumns(): int + { + return $this->columns; + } + + public function getRows(): int + { + return $this->rows; + } + + public function isKittyProtocolActive(): bool + { + return $this->kittyProtocolActive; + } + + public function moveBy(int $lines): void + { + if ($lines > 0) { + $this->write("\x1b[{$lines}B"); + } elseif ($lines < 0) { + $this->write("\x1b[".(-$lines).'A'); + } + } + + public function hideCursor(): void + { + $this->write("\x1b[?25l"); + } + + public function showCursor(): void + { + $this->write("\x1b[?25h"); + } + + public function clearLine(): void + { + $this->write("\x1b[2K"); + } + + public function clearFromCursor(): void + { + $this->write("\x1b[0J"); + } + + public function clearScreen(): void + { + $this->write("\x1b[2J\x1b[H"); + } + + public function setTitle(string $title): void + { + $safe = preg_replace("/[\x00-\x1f\x7f]/", '', $title); + $this->write("\x1b]0;{$safe}\x07"); + } + + public function bell(): void + { + $this->write("\x07"); + } + + public function isVirtual(): bool + { + return true; + } + + // Testing helpers + + /** + * Get all output written to the terminal. + */ + public function getOutput(): string + { + return $this->output; + } + + /** + * Clear the output buffer. + */ + public function clearOutput(): void + { + $this->output = ''; + } + + /** + * Get all output written since the last call and clear the buffer. + * + * This is useful for streaming scenarios where you need to get + * the output diff since the last read (e.g. publishing to Mercure). + */ + public function consumeOutput(): string + { + $output = $this->output; + $this->output = ''; + + return $output; + } + + /** + * Simulate raw input from the user. + * + * Input is processed through StdinBuffer for proper sequence parsing, + * matching the behavior of the real Terminal. + */ + public function simulateInput(string $data): void + { + if (null !== $this->stdinBuffer) { + $this->stdinBuffer->process($data); + $this->stdinBuffer?->flush(); + } + } + + /** + * Simulate terminal resize. + */ + public function simulateResize(int $columns, int $rows): void + { + $this->columns = $columns; + $this->rows = $rows; + + if (null !== $this->onResize) { + ($this->onResize)(); + } + } + + /** + * Set Kitty protocol state. + */ + public function setKittyProtocolActive(bool $active): void + { + $this->kittyProtocolActive = $active; + } +} diff --git a/extern/Tui/Tui.php b/extern/Tui/Tui.php new file mode 100644 index 00000000..01dba2e2 --- /dev/null +++ b/extern/Tui/Tui.php @@ -0,0 +1,516 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui; + +use Revolt\EventLoop; +use Revolt\EventLoop\Suspension; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Event\InputEvent; +use Symfony\Component\Tui\Event\TickEvent; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Loop\AdaptativeTicker; +use Symfony\Component\Tui\Loop\TickRuntimeInterface; +use Symfony\Component\Tui\Loop\TickScheduler; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Render\RenderRequestorInterface; +use Symfony\Component\Tui\Render\ScreenWriter; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Terminal\Terminal; +use Symfony\Component\Tui\Terminal\TerminalInterface; +use Symfony\Component\Tui\Widget\AbstractWidget; +use Symfony\Component\Tui\Widget\ContainerWidget; +use Symfony\Component\Tui\Widget\Figlet\FontRegistry; +use Symfony\Component\Tui\Widget\FocusableInterface; +use Symfony\Component\Tui\Widget\WidgetTree; + +/** + * Main TUI class for managing terminal UI. + * + * This class orchestrates: + * - Terminal lifecycle (start/stop) + * - Event loop integration + * - Focus management + * - Input handling + * + * The root container is created internally. + * Use add(), remove(), and clear() to build the widget tree. + * Style the root via the stylesheet using the ":root" pseudo-class selector. + * + * Rendering is delegated to: + * - Renderer: widget tree → lines (content generation) + * - ScreenWriter: lines → terminal (differential output) + * + * @experimental + * + * @author Fabien Potencier + */ +class Tui implements RenderRequestorInterface, TickRuntimeInterface +{ + private ContainerWidget $root; + private Keybindings $keybindings; + private Renderer $renderer; + private ScreenWriter $screenWriter; + private WidgetTree $widgetTree; + private FocusManager $focusManager; + private TickScheduler $tickScheduler; + private AdaptativeTicker $adaptativeTicker; + private EventDispatcherInterface $eventDispatcher; + + /** @var callable(TickEvent): mixed */ + private $onTick; + + private bool $renderRequested = false; + private bool $running = false; + private bool $stopped = false; + private bool $ticking = false; + private ?float $lastTickAt = null; + private ?bool $lastTickBusyHint = null; + + /** @var Suspension|null */ + private ?Suspension $runSuspension = null; + + public function __construct( + ?StyleSheet $styleSheet = null, + private readonly TerminalInterface $terminal = new Terminal(), + ?Keybindings $keybindings = null, + ?FontRegistry $fontRegistry = null, + ?Renderer $renderer = null, + ?ScreenWriter $screenWriter = null, + ?EventDispatcherInterface $eventDispatcher = null, + ) { + $this->keybindings = $keybindings ?? new Keybindings(); + $this->root = new ContainerWidget(); + $this->root->expandVertically(true); + $this->renderer = $renderer ?? new Renderer($styleSheet, $fontRegistry); + $this->screenWriter = $screenWriter ?? new ScreenWriter($terminal); + $this->eventDispatcher = $eventDispatcher ?? new EventDispatcher(); + + // Share the KeyParser so Kitty protocol state is consistent + $this->focusManager = new FocusManager( + $this, + parser: $this->keybindings->getParser(), + eventDispatcher: $this->eventDispatcher, + ); + + $this->widgetTree = new WidgetTree($this, $this->keybindings, $this->focusManager, $this->renderer, $this->terminal, $this->eventDispatcher); + $this->widgetTree->setRoot($this->root); + $this->tickScheduler = new TickScheduler(); + $this->adaptativeTicker = new AdaptativeTicker($this); + } + + /** + * Add a child widget to the root container. + * + * @return $this + */ + public function add(AbstractWidget $widget): static + { + $this->root->add($widget); + + return $this; + } + + /** + * Remove a child widget from the root container. + * + * @return $this + */ + public function remove(AbstractWidget $widget): static + { + $this->root->remove($widget); + + return $this; + } + + /** + * Remove all child widgets from the root container. + * + * @return $this + */ + public function clear(): static + { + $this->root->clear(); + + return $this; + } + + /** + * Find a widget by ID in the widget tree. + */ + public function getById(string $id): AbstractWidget + { + $widget = $this->root->findById($id); + + if (null === $widget) { + throw new InvalidArgumentException(\sprintf('No widget found with id "%s".', $id)); + } + + return $widget; + } + + /** + * Add a stylesheet on top of the existing ones. + * + * User stylesheets are merged on top of defaults (last wins for same selectors). + * + * @return $this + */ + public function addStyleSheet(StyleSheet $styleSheet): static + { + $this->renderer->addStyleSheet($styleSheet); + + return $this; + } + + /** + * Run the main event loop using Revolt. + * + * This method runs the TUI using Revolt's event loop, allowing + * async operations (like HTTP streaming) to run concurrently. + * + * This blocks until stop() is called. + */ + public function run(): void + { + $this->start(); + $this->runSuspension = EventLoop::getSuspension(); + $this->refreshLoopDriver(); + + try { + // Block until stop() is called + $this->runSuspension->suspend(); + } finally { + $this->runSuspension = null; + // Ensure terminal is restored + $this->stop(); + } + } + + /** + * Start the TUI. + */ + public function start(): void + { + $this->running = true; + $this->stopped = false; + $this->lastTickAt = null; + $this->lastTickBusyHint = null; + $this->terminal->start($this->handleInput(...), $this->requestRender(...), function (): void { + $this->keybindings->setKittyProtocolActive(true); + }); + $this->terminal->hideCursor(); + $this->requestRender(); + } + + /** + * Run one iteration of the TUI loop. + * Processes scheduled tasks, rendering, and tick callback. + */ + public function tick(): void + { + // Guard against re-entrant ticks. When the onTick callback suspends + // a fiber (e.g., async file I/O via Amp), the Revolt event loop may + // fire another repeat-timer callback while the previous tick is still + // suspended. Without this guard, two fibers would concurrently mutate + // the agent state machine and render to the terminal, corrupting the + // display. + if ($this->ticking) { + return; + } + + $this->ticking = true; + $now = microtime(true); + $deltaTime = null === $this->lastTickAt ? 0.0 : max(0.0, $now - $this->lastTickAt); + $this->lastTickAt = $now; + $revisionBeforeTick = $this->root->getRenderRevision(); + + try { + $this->tickScheduler->runDue(); + $this->processRender(); + $this->lastTickBusyHint = $this->invokeTickCallback($deltaTime); + + if ($this->root->getRenderRevision() !== $revisionBeforeTick) { + $this->requestRender(); + } + } finally { + $this->ticking = false; + $this->refreshLoopDriver(); + } + } + + /** + * Stop the TUI and restore terminal state. + */ + public function stop(): void + { + $this->running = false; + $this->adaptativeTicker->stop(); + $this->tickScheduler->clear(); + $this->lastTickAt = null; + $this->lastTickBusyHint = null; + $this->resumeRunSuspension(); + + if ($this->stopped) { + return; + } + $this->stopped = true; + + // Move cursor to end of content + $state = $this->screenWriter->getState(); + if ($state['line_count'] > 0) { + $lineDiff = $state['line_count'] - $state['cursor_row']; + + if ($lineDiff > 0) { + $this->terminal->write("\x1b[{$lineDiff}B"); + } elseif ($lineDiff < 0) { + $this->terminal->write("\x1b[".(-$lineDiff).'A'); + } + + $this->terminal->write("\r\n"); + } + + // Restore default cursor shape (DECSCUSR 0) and show cursor + $this->terminal->write("\x1b[0 q"); + $this->terminal->showCursor(); + $this->terminal->stop(); + } + + /** + * Check if the TUI is running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * @param callable(TickEvent): mixed $onTick + * + * Return true while active work is in progress (fast 100Hz ticking), + * false when idle (no polling), or null/void to use fallback idle polling + * + * @return $this + */ + public function onTick(?callable $onTick): static + { + $this->onTick = $onTick; + $this->lastTickAt = null; + $this->lastTickBusyHint = null; + $this->refreshLoopDriver(); + + return $this; + } + + /** + * Register a listener for a widget event. + * + * This is the primary way to react to widget events (submit, cancel, + * change, select, etc.). All events dispatched by any widget in the + * tree are routed through this single dispatcher. + * + * Use {@see AbstractEvent::getTarget()} to filter by source widget when + * listening for a shared event type like CancelEvent. + * + * @template T of AbstractEvent + * + * @param class-string $eventClass The event class to listen for + * @param callable(T): void $listener The listener to invoke + * @param int $priority Higher = called earlier (default 0) + * + * @return $this + */ + public function on(string $eventClass, callable $listener, int $priority = 0): static + { + $this->eventDispatcher->addListener($eventClass, $listener, $priority); + + return $this; + } + + /** + * Get the event dispatcher. + */ + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->eventDispatcher; + } + + /** + * Get the terminal. + */ + public function getTerminal(): TerminalInterface + { + return $this->terminal; + } + + /** + * Set the focused component. + * + * @return $this + */ + public function setFocus(?AbstractWidget $component): static + { + $this->focusManager->setFocus($component); + + return $this; + } + + /** + * Get the currently focused component. + */ + public function getFocus(): ?AbstractWidget + { + return $this->focusManager->getFocus(); + } + + /** + * Get the focus manager. + */ + public function getFocusManager(): FocusManager + { + return $this->focusManager; + } + + /** + * Request a render on the next tick. + * + * @param bool $force If true, clear all cached state and do full re-render + */ + public function requestRender(bool $force = false): void + { + if ($force) { + $this->screenWriter->reset(); + } + + $this->renderRequested = true; + $this->refreshLoopDriver(); + } + + /** + * Set the scroll offset (lines from bottom). + * + * When the content exceeds the viewport, the viewport normally shows + * the bottom portion. A positive scroll offset shifts the viewport + * up by that many lines. + */ + public function setScrollOffset(int $offset): void + { + $this->screenWriter->setScrollOffset($offset); + } + + /** + * Schedule a repeating callback in the internal TUI scheduler. + * + * @internal + * + * @param callable(): void $callback + */ + public function scheduleInterval(callable $callback, float $intervalSeconds): string + { + $id = $this->tickScheduler->schedule($callback, $intervalSeconds); + + $this->refreshLoopDriver(); + + return $id; + } + + /** + * Cancel a callback previously registered with scheduleInterval(). + * + * @internal + */ + public function cancelInterval(string $id): void + { + $this->tickScheduler->cancel($id); + $this->refreshLoopDriver(); + } + + /** + * Process any pending renders. + * + * Called automatically by tick(). Only call this directly + * if you are driving the loop manually instead of using run(). + */ + public function processRender(): void + { + if ($this->renderRequested) { + $this->renderRequested = false; + $columns = $this->terminal->getColumns(); + $rows = $this->terminal->getRows(); + $this->screenWriter->writeLines($this->renderer->render($this->root, $columns, $rows)); + } + } + + /** + * Handle input from the terminal. + */ + public function handleInput(string $data): void + { + $event = $this->eventDispatcher->dispatch(new InputEvent($data)); + if ($event->isPropagationStopped()) { + return; + } + + if ($this->focusManager->handleInput($data)) { + return; + } + + // Pass input to focused component + $focused = $this->focusManager->getFocus(); + if ($focused instanceof FocusableInterface) { + $revisionBeforeInput = $this->root->getRenderRevision(); + $focused->handleInput($data); + if ($this->root->getRenderRevision() !== $revisionBeforeInput) { + $this->requestRender(); + } + } + } + + private function invokeTickCallback(float $deltaTime): ?bool + { + if (null === $this->onTick) { + return null; + } + + $event = new TickEvent($deltaTime); + $result = ($this->onTick)($event); + + if (\is_bool($result)) { + return $result; + } + + if ($event->hasBusyHint()) { + return $event->isBusy(); + } + + return null; + } + + private function refreshLoopDriver(): void + { + $this->adaptativeTicker->refresh($this->running, $this->renderRequested, $this->tickScheduler->getNextDelay(), null !== $this->onTick, $this->lastTickBusyHint); + } + + private function resumeRunSuspension(): void + { + if (null === $this->runSuspension) { + return; + } + + $suspension = $this->runSuspension; + $this->runSuspension = null; + $suspension->resume(null); + } +} diff --git a/extern/Tui/Widget/AbstractWidget.php b/extern/Tui/Widget/AbstractWidget.php new file mode 100644 index 00000000..36e933a0 --- /dev/null +++ b/extern/Tui/Widget/AbstractWidget.php @@ -0,0 +1,459 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Exception\RenderException; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Style\StyleSheet; +use Symfony\Component\Tui\Tui; + +/** + * Base widget class with lifecycle hooks and dirty tracking. + * + * @experimental + * + * @author Fabien Potencier + */ +abstract class AbstractWidget +{ + use DirtyWidgetTrait { invalidate as private invalidateSelf; } + + private ?string $id = null; + private ?string $label = null; + private ?AbstractWidget $parent = null; + private ?WidgetContext $context = null; + private ?Style $internalStyle = null; + + /** @var string[] */ + private array $styleClasses = []; + + /** @var array, list> */ + private array $listeners = []; + + // Render cache: stores the last output of Renderer::renderWidget() + // keyed on (renderRevision, columns, rows) so unchanged widgets + // skip style resolution, layout, chrome, and content rendering. + + /** @var string[]|null */ + private ?array $renderCacheLines = null; + private int $renderCacheRevision = -1; + private int $renderCacheColumns = -1; + private int $renderCacheRows = -1; + + /** + * @return $this + */ + final public function setId(string $id): static + { + $this->id = $id; + + return $this; + } + + final public function getId(): ?string + { + return $this->id; + } + + /** + * Set an optional human-readable label for the widget. + * + * Used by parent widgets (e.g., TabsWidget) to extract metadata + * from children added via templates. + * + * @return $this + */ + final public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + final public function getLabel(): ?string + { + return $this->label; + } + + /** + * Find a descendant widget by ID (depth-first search). + * + * Searches this widget and its subtree. Returns null if not found. + */ + final public function findById(string $id): ?self + { + if ($this->id === $id) { + return $this; + } + + if ($this instanceof ParentInterface) { + foreach ($this->all() as $child) { + $found = $child->findById($id); + if (null !== $found) { + return $found; + } + } + } + + return null; + } + + final public function getParent(): ?self + { + return $this->parent; + } + + final public function getContext(): ?WidgetContext + { + return $this->context; + } + + /** + * @return string[] + */ + final public function getStyleClasses(): array + { + return $this->styleClasses; + } + + /** + * @param string[] $classes + */ + final public function setStyleClasses(array $classes): void + { + if ($this->styleClasses !== $classes) { + $this->styleClasses = $classes; + $this->invalidate(); + } + } + + /** + * @return $this + */ + final public function addStyleClass(string $class): static + { + if (!\in_array($class, $this->styleClasses, true)) { + $this->styleClasses[] = $class; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + final public function removeStyleClass(string $class): static + { + $newClasses = array_values(array_filter( + $this->styleClasses, + static fn (string $c) => $c !== $class, + )); + + if ($newClasses !== $this->styleClasses) { + $this->styleClasses = $newClasses; + $this->invalidate(); + } + + return $this; + } + + /** + * @return string[] + */ + final public function getStateFlags(): array + { + $flags = []; + + if (null === $this->parent) { + $flags[] = 'root'; + } + + if ($this instanceof FocusableInterface && $this->isFocused()) { + $flags[] = 'focus'; + } + + return $flags; + } + + final public function invalidate(): void + { + $this->invalidateSelf(); + $this->renderCacheLines = null; + + if (null !== $this->parent) { + $this->parent->invalidate(); + } + } + + /** + * @internal + */ + final public function attach(?self $parent, WidgetContext $context): void + { + $this->parent = $parent; + $this->context = $context; + + if ($this instanceof FocusableInterface) { + $context->getFocusManager()->add($this); + } + + $this->onAttach($context); + } + + /** + * @internal + */ + final public function detach(): void + { + $context = $this->context; + + if (null !== $context && $this instanceof FocusableInterface) { + $context->getFocusManager()->remove($this); + } + + $this->listeners = []; + + $this->onDetach(); + $this->parent = null; + $this->context = null; + } + + /** + * @return $this + */ + final public function setStyle(?Style $style): static + { + if ($this->internalStyle !== $style) { + $this->internalStyle = $style; + $this->invalidate(); + } + + return $this; + } + + final public function getStyle(): ?Style + { + return $this->internalStyle; + } + + /** + * Collect and return an escape sequence the terminal must process when + * this widget is removed from the tree, and reset the associated state. + * + * The {@see WidgetTree} calls this just before detaching the widget and + * writes the returned data to the terminal. Override this in widgets + * that allocate terminal-side resources (e.g. Kitty image data). + * + * Implementations should clear any resource IDs they return sequences + * for, so a second call is a no-op. + * + * The default implementation returns an empty string (no cleanup needed). + */ + public function collectTerminalCleanupSequence(): string + { + return ''; + } + + /** + * Check if this widget has any local listeners for the given event type. + * + * @param class-string $eventClass + */ + final public function hasListeners(string $eventClass): bool + { + return isset($this->listeners[$eventClass]) && [] !== $this->listeners[$eventClass]; + } + + /** + * Register a listener for a specific event type on this widget. + * + * The listener is only called when this specific widget dispatches the event. + * Listeners are stored locally on the widget and automatically cleared on detach. + * + * @param class-string $eventClass The event class to listen for + * @param callable $listener The listener to invoke + * + * @return $this + */ + final public function on(string $eventClass, callable $listener): static + { + $this->listeners[$eventClass][] = $listener; + + return $this; + } + + /** + * Return the cached render output if still valid. + * + * The cache is keyed on (renderRevision, columns, rows). A cache hit + * means the widget's state and available dimensions have not changed + * since the last render, so the Renderer can skip the entire pipeline + * (style resolution, layout, chrome, content rendering). + * + * @internal Used by the Renderer + * + * @return string[]|null Cached lines, or null on miss + */ + final public function getRenderCache(int $columns, int $rows): ?array + { + if ($this->renderCacheRevision === $this->getRenderRevision() + && $this->renderCacheColumns === $columns + && $this->renderCacheRows === $rows + ) { + return $this->renderCacheLines; + } + + return null; + } + + /** + * Store the render output for future cache lookups. + * + * @internal Used by the Renderer + * + * @param string[] $lines + */ + final public function setRenderCache(array $lines, int $columns, int $rows): void + { + $this->renderCacheLines = $lines; + $this->renderCacheRevision = $this->getRenderRevision(); + $this->renderCacheColumns = $columns; + $this->renderCacheRows = $rows; + } + + /** + * Clear the render cache without changing the render revision. + * + * Used by the layout engine to force a re-render for position tracking + * after the measurement pass has already cached the output. + * + * @internal Used by the LayoutEngine + */ + final public function clearRenderCache(): void + { + $this->renderCacheLines = null; + } + + /** + * Lifecycle hook: override to sync state before rendering. + * + * Called by the Renderer on every frame, even when the render cache is + * valid. Use it to update child widget content, manage overlays, or + * perform other pre-render state updates. Keep it lightweight; heavy + * work should be guarded by dirty checks. + */ + public function beforeRender(): void + { + } + + /** + * Render the widget content into terminal lines. + * + * The returned lines represent the widget's visual content, one array + * element per terminal row. The Renderer calls this method with a + * context whose dimensions already exclude chrome (padding, border). + * + * ## Contract + * + * - Lines MAY contain ANSI escape sequences for styling + * - Lines MUST NOT exceed `$context->getColumns()` in visible width + * - Lines MUST NOT contain newline characters (each element is one row) + * - Empty strings are valid (blank rows) + * - Image protocol sequences (Kitty/iTerm2) are exempt from the width constraint + * + * The Renderer validates the width constraint and throws a + * {@see RenderException} if any line exceeds + * the available columns. + * + * @return string[] One element per terminal row + */ + abstract public function render(RenderContext $context): array; + + /** + * @internal + */ + final protected function setParent(?self $parent): void + { + $this->parent = $parent; + $this->invalidate(); + } + + protected function onAttach(WidgetContext $context): void + { + } + + protected function onDetach(): void + { + } + + /** + * Resolve a sub-element style from the stylesheet. + * + * Sub-elements are parts within a widget that need independent styling + * (e.g., "cursor", "selected", "description"). The stylesheet resolves + * them using CSS pseudo-element syntax (::). + * + * Resolution order: + * 1. FQCN::element + * 2. .class::element + * 3. FQCN::element:state (e.g., :focus) + * 4. .class::element:state + * + * @see StyleSheet::resolveElement() + */ + final protected function resolveElement(string $element): Style + { + $context = $this->getContext(); + if (null === $context) { + return new Style(); + } + + return $context->resolveElement($this, $element); + } + + /** + * Apply a sub-element style to text. + * + * Shorthand for `$this->resolveElement($element)->apply($text)`. + */ + final protected function applyElement(string $element, string $text): string + { + if ('' === $text) { + return $text; + } + + return $this->resolveElement($element)->apply($text); + } + + /** + * Dispatch a widget event. + * + * Invokes per-widget listeners first (registered via {@see on()}), + * then dispatches to the global EventDispatcher for listeners + * registered via {@see Tui::on()}. + * + * Also requests a render after dispatching, since listeners typically + * mutate UI state. + */ + final protected function dispatch(AbstractEvent $event): void + { + // Per-widget listeners (no target check needed, they're already scoped) + foreach ($this->listeners[$event::class] ?? [] as $listener) { + $listener($event); + } + + $this->context?->dispatch($event); + } +} diff --git a/extern/Tui/Widget/BracketedPasteTrait.php b/extern/Tui/Widget/BracketedPasteTrait.php new file mode 100644 index 00000000..ea8f7533 --- /dev/null +++ b/extern/Tui/Widget/BracketedPasteTrait.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Handles bracketed paste mode detection and buffering. + * + * Terminals that support bracketed paste wrap pasted text between + * ESC[200~ (start) and ESC[201~ (end) sequences. This trait + * accumulates chunks until the end marker is received, then + * returns the complete paste content. + * + * @experimental + * + * @author Fabien Potencier + */ +trait BracketedPasteTrait +{ + private bool $inPaste = false; + private string $pasteBuffer = ''; + + private function isBufferingPaste(): bool + { + return $this->inPaste; + } + + /** + * Process bracketed paste sequences in input data. + * + * Detects paste start/end markers and buffers content across + * multiple input chunks. Modifies $data in place to remove + * paste markers and consumed content. + * + * @param string $data Input data; modified to contain only the portion + * after the paste end marker (if any), or emptied + * if still buffering + * + * @return string|null The complete pasted text when the end marker is + * received, or null if still buffering + */ + private function processBracketedPaste(string &$data): ?string + { + if (str_contains($data, "\x1b[200~")) { + $this->inPaste = true; + $this->pasteBuffer = ''; + $data = str_replace("\x1b[200~", '', $data); + } + + if (!$this->inPaste) { + return null; + } + + $endIndex = strpos($data, "\x1b[201~"); + if (false !== $endIndex) { + $this->pasteBuffer .= substr($data, 0, $endIndex); + $pastedText = $this->pasteBuffer; + $this->inPaste = false; + $this->pasteBuffer = ''; + $data = substr($data, $endIndex + 6); + + return $pastedText; + } + + $this->pasteBuffer .= $data; + $data = ''; + + return null; + } +} diff --git a/extern/Tui/Widget/CancellableLoaderWidget.php b/extern/Tui/Widget/CancellableLoaderWidget.php new file mode 100644 index 00000000..72689637 --- /dev/null +++ b/extern/Tui/Widget/CancellableLoaderWidget.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Input\Key; + +/** + * Loader that can be cancelled with Escape key. + * + * @experimental + * + * @author Fabien Potencier + */ +class CancellableLoaderWidget extends LoaderWidget implements FocusableInterface +{ + use FocusableTrait; + use KeybindingsTrait; + + private bool $cancelled = false; + + public function __construct( + string $message = 'Loading...', + ) { + parent::__construct($message); + } + + /** + * Check if the loader was cancelled. + */ + public function isCancelled(): bool + { + return $this->cancelled; + } + + /** + * Reset the cancelled state. + */ + public function reset(): void + { + $this->cancelled = false; + } + + public function start(): void + { + parent::start(); + $this->cancelled = false; + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): static + { + return $this->on(CancelEvent::class, $callback); + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + if ($this->getKeybindings()->matches($data, 'select_cancel')) { + $this->cancelled = true; + $this->dispatch(new CancelEvent($this)); + } + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + ]; + } +} diff --git a/extern/Tui/Widget/ContainerInterface.php b/extern/Tui/Widget/ContainerInterface.php new file mode 100644 index 00000000..6c7a4bb2 --- /dev/null +++ b/extern/Tui/Widget/ContainerInterface.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Interface for widgets that can contain and mutate child widgets. + * + * Extends ParentInterface with mutation methods (add, remove, clear). + * Use ParentInterface when you only need read-only tree traversal. + * + * @experimental + * + * @author Fabien Potencier + */ +interface ContainerInterface extends ParentInterface +{ + /** + * @return $this + */ + public function add(AbstractWidget $widget): static; + + /** + * @return $this + */ + public function remove(AbstractWidget $widget): static; + + /** + * Remove all child widgets. + * + * @return $this + */ + public function clear(): static; +} diff --git a/extern/Tui/Widget/ContainerWidget.php b/extern/Tui/Widget/ContainerWidget.php new file mode 100644 index 00000000..d116d9d4 --- /dev/null +++ b/extern/Tui/Widget/ContainerWidget.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Exception\LogicException; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Container widget that groups child widgets with optional styling. + * + * Supports: + * - Vertical or horizontal layout (via Style::direction) + * - Gap between children (via Style::gap) + * - Padding, border, background via Style + * - Vertically expandable children + * + * Layout direction and gap are style properties, configurable via + * stylesheets or inline styles: + * + * $container->setStyle(new Style(direction: Direction::Horizontal, gap: 1)); + * + * Or via stylesheet rules: + * + * $stylesheet->addRule('.panes', new Style(direction: Direction::Horizontal, gap: 2)); + * + * Layout and chrome rendering is handled by the Renderer. + * + * @experimental + * + * @author Fabien Potencier + */ +class ContainerWidget extends AbstractWidget implements ContainerInterface, VerticallyExpandableInterface +{ + /** @var AbstractWidget[] */ + private array $children = []; + private bool $verticallyExpanded = false; + + /** + * @return $this + */ + public function add(AbstractWidget $widget): static + { + $widget->setParent($this); + $this->children[] = $widget; + $this->invalidate(); + + if (null !== $this->getContext()) { + $this->getContext()->attachChild($this, $widget); + } + + return $this; + } + + /** + * @return $this + */ + public function remove(AbstractWidget $widget): static + { + $index = array_search($widget, $this->children, true); + if (false !== $index) { + $child = $this->children[(int) $index]; + $child->setParent(null); + if (null !== $this->getContext()) { + $this->getContext()->detachChild($child); + } + array_splice($this->children, (int) $index, 1); + $this->invalidate(); + } + + return $this; + } + + /** + * Remove all child widgets. + * + * @return $this + */ + public function clear(): static + { + foreach ($this->children as $child) { + $child->setParent(null); + if (null !== $this->getContext()) { + $this->getContext()->detachChild($child); + } + } + if ([] !== $this->children) { + $this->children = []; + $this->invalidate(); + } + + return $this; + } + + /** + * Get all child widgets. + * + * @return AbstractWidget[] + */ + public function all(): array + { + return $this->children; + } + + /** + * Set whether the container should fill available height. + * + * @return $this + */ + public function expandVertically(bool $expand): static + { + if ($this->verticallyExpanded !== $expand) { + $this->verticallyExpanded = $expand; + $this->invalidate(); + } + + return $this; + } + + /** + * Check if the container should fill available height. + * + * Returns true if explicitly set, or if any child needs to expand vertically. + * This allows vertical expansion to propagate up automatically from descendants. + */ + public function isVerticallyExpanded(): bool + { + if ($this->verticallyExpanded) { + return true; + } + + // Check if any child needs fill height + foreach ($this->all() as $child) { + if ($child instanceof VerticallyExpandableInterface && $child->isVerticallyExpanded()) { + return true; + } + } + + return false; + } + + /** + * Not called in the standard rendering pipeline. + * + * The Renderer dispatches all ContainerWidget instances to + * renderContainer(), which owns layout (direction, gap) and chrome + * (padding, border, background). This method only exists to satisfy + * the abstract contract from AbstractWidget. + * + * @return string[] + */ + public function render(RenderContext $context): array + { + throw new LogicException(\sprintf('"%s" rendering is handled by the Renderer via "renderContainer()"; this method should never be called directly.', static::class)); + } +} diff --git a/extern/Tui/Widget/DirtyWidgetTrait.php b/extern/Tui/Widget/DirtyWidgetTrait.php new file mode 100644 index 00000000..c171426a --- /dev/null +++ b/extern/Tui/Widget/DirtyWidgetTrait.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Tracks render revisions for dirty widgets. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +trait DirtyWidgetTrait +{ + private int $renderRevision = 0; + + public function getRenderRevision(): int + { + return $this->renderRevision; + } + + public function invalidate(): void + { + ++$this->renderRevision; + } +} diff --git a/extern/Tui/Widget/Editor/EditorDocument.php b/extern/Tui/Widget/Editor/EditorDocument.php new file mode 100644 index 00000000..f6d90d6f --- /dev/null +++ b/extern/Tui/Widget/Editor/EditorDocument.php @@ -0,0 +1,732 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Editor; + +use Symfony\Component\Tui\Widget\Util\KillRing; +use Symfony\Component\Tui\Widget\Util\Line; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +/** + * Multi-line text buffer with cursor, undo/redo, and kill ring. + * + * Pure model: no rendering, no terminal I/O, no scroll/viewport logic. + * The EditorWidget orchestrates input → document → viewport → render. + * + * @experimental + * + * @author Fabien Potencier + * + * @internal + */ +final class EditorDocument +{ + /** @var string[] */ + private array $lines = ['']; + private int $cursorLine = 0; + private int $cursorCol = 0; + + private KillRing $killRing; + + // Undo/Redo + /** @var array */ + private array $undoStack = []; + /** @var array */ + private array $redoStack = []; + + // Character jump mode + private ?string $jumpMode = null; // 'forward' or 'backward' + + // Paste handling + private int $pasteCount = 0; + /** @var array */ + private array $pasteMarkers = []; + + public function __construct(?KillRing $killRing = null) + { + $this->killRing = $killRing ?? new KillRing(); + } + + // --- Accessors --- + + /** + * @return string[] + */ + public function getLines(): array + { + return $this->lines; + } + + public function getCursorLine(): int + { + return $this->cursorLine; + } + + public function getCursorCol(): int + { + return $this->cursorCol; + } + + public function setCursorLine(int $line): void + { + $this->cursorLine = $line; + } + + public function setCursorCol(int $col): void + { + $this->cursorCol = $col; + } + + public function getKillRing(): KillRing + { + return $this->killRing; + } + + /** + * Get paste markers for large pastes. + * + * @return array + */ + public function getPasteMarkers(): array + { + return $this->pasteMarkers; + } + + public function getText(): string + { + $text = implode("\n", $this->lines); + + if ([] === $this->pasteMarkers) { + return $text; + } + + $replacements = []; + foreach ($this->pasteMarkers as $pasteMarker) { + $replacements[$pasteMarker['marker']] = $pasteMarker['content']; + } + + return strtr($text, $replacements); + } + + // --- Character Jump Mode --- + + public function getJumpMode(): ?string + { + return $this->jumpMode; + } + + public function setJumpMode(?string $mode): void + { + $this->jumpMode = $mode; + } + + // --- Text Mutation --- + + public function setText(string $text): bool + { + $text = StringUtils::sanitizeUtf8($text); + $text = str_replace(["\r\n", "\r"], "\n", $text); + $lines = '' === $text ? [''] : explode("\n", $text); + $needsReset = $lines !== $this->lines || 0 !== $this->cursorLine || 0 !== $this->cursorCol; + + $this->lines = $lines; + $this->cursorLine = 0; + $this->cursorCol = 0; + $this->undoStack = []; + $this->redoStack = []; + $this->pasteMarkers = []; + $this->pasteCount = 0; + + return $needsReset; + } + + public function insertText(string $text): void + { + $text = StringUtils::sanitizeUtf8($text); + if ('' === $text) { + return; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + $this->insertTextAtCursor($text); + } + + public function insertNewLine(): void + { + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + $currentLine = $this->lines[$this->cursorLine]; + $beforeCursor = substr($currentLine, 0, $this->cursorCol); + $afterCursor = substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $beforeCursor; + array_splice($this->lines, $this->cursorLine + 1, 0, [$afterCursor]); + + ++$this->cursorLine; + $this->cursorCol = 0; + } + + public function deleteCharBackward(): bool + { + if (0 === $this->cursorCol && 0 === $this->cursorLine) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + if ($this->cursorCol > 0) { + $line = $this->currentLine(); + $line->deleteCharBackward(); + $this->applyLine($line); + } elseif ($this->cursorLine > 0) { + // Merge with previous line + $currentLine = $this->lines[$this->cursorLine]; + $prevLine = $this->lines[$this->cursorLine - 1]; + + $this->cursorCol = \strlen($prevLine); + $this->lines[$this->cursorLine - 1] = $prevLine.$currentLine; + + array_splice($this->lines, $this->cursorLine, 1); + --$this->cursorLine; + } + + return true; + } + + public function deleteCharForward(): bool + { + $currentLine = $this->lines[$this->cursorLine]; + + if ($this->cursorCol >= \strlen($currentLine) && $this->cursorLine >= \count($this->lines) - 1) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + if ($this->cursorCol < \strlen($currentLine)) { + $line = $this->currentLine(); + $line->deleteCharForward(); + $this->applyLine($line); + } elseif ($this->cursorLine < \count($this->lines) - 1) { + // Merge with next line + $nextLine = $this->lines[$this->cursorLine + 1]; + $this->lines[$this->cursorLine] = $currentLine.$nextLine; + array_splice($this->lines, $this->cursorLine + 1, 1); + } + + return true; + } + + public function deleteLine(): bool + { + if (1 === \count($this->lines) && '' === $this->lines[0]) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->resetAction(); + + if (\count($this->lines) > 1) { + array_splice($this->lines, $this->cursorLine, 1); + if ($this->cursorLine >= \count($this->lines)) { + --$this->cursorLine; + } + } else { + $this->lines = ['']; + } + + $this->cursorCol = min($this->cursorCol, \strlen($this->lines[$this->cursorLine])); + + return true; + } + + public function deleteToLineEnd(): bool + { + $line = $this->currentLine(); + $deletedText = $line->deleteToEnd(); + if ('' === $deletedText) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->add($deletedText, false); + $this->applyLine($line); + + return true; + } + + public function deleteToLineStart(): bool + { + $line = $this->currentLine(); + $deletedText = $line->deleteToStart(); + if ('' === $deletedText) { + return false; + } + + $this->pushUndoSnapshot(); + $this->killRing->add($deletedText, true); + $this->applyLine($line); + + return true; + } + + public function deleteWordBackward(): bool + { + if (0 === $this->cursorCol) { + return $this->deleteCharBackward(); + } + + $this->pushUndoSnapshot(); + + $line = $this->currentLine(); + $deletedText = $line->deleteWordBackward(); + $this->killRing->add($deletedText, true); + $this->applyLine($line); + + return true; + } + + public function deleteWordForward(): bool + { + $currentLine = $this->lines[$this->cursorLine]; + + if ($this->cursorCol >= \strlen($currentLine)) { + return $this->deleteCharForward(); + } + + $this->pushUndoSnapshot(); + + $line = $this->currentLine(); + $deletedText = $line->deleteWordForward(); + $this->killRing->add($deletedText, false); + $this->applyLine($line); + + return true; + } + + // --- Cursor Navigation --- + + public function isOnFirstLine(): bool + { + return 0 === $this->cursorLine; + } + + public function isOnLastLine(): bool + { + return $this->cursorLine >= \count($this->lines) - 1; + } + + public function moveToLineStart(): bool + { + if (0 !== $this->cursorCol) { + $this->cursorCol = 0; + + return true; + } + + return false; + } + + public function moveToLineEnd(): bool + { + $lineLength = \strlen($this->lines[$this->cursorLine]); + if ($this->cursorCol !== $lineLength) { + $this->cursorCol = $lineLength; + + return true; + } + + return false; + } + + public function moveCursorUp(): bool + { + if ($this->cursorLine > 0) { + --$this->cursorLine; + $this->cursorCol = min($this->cursorCol, \strlen($this->lines[$this->cursorLine])); + + return true; + } + + return false; + } + + public function moveCursorDown(): bool + { + if ($this->cursorLine < \count($this->lines) - 1) { + ++$this->cursorLine; + $this->cursorCol = min($this->cursorCol, \strlen($this->lines[$this->cursorLine])); + + return true; + } + + return false; + } + + public function moveCursorLeft(): bool + { + $line = $this->currentLine(); + if ($line->moveCursorLeft()) { + $this->cursorCol = $line->getCursor(); + + return true; + } + + if ($this->cursorLine > 0) { + --$this->cursorLine; + $this->cursorCol = \strlen($this->lines[$this->cursorLine]); + + return true; + } + + return false; + } + + public function moveCursorRight(): bool + { + $line = $this->currentLine(); + if ($line->moveCursorRight()) { + $this->cursorCol = $line->getCursor(); + + return true; + } + + if ($this->cursorLine < \count($this->lines) - 1) { + ++$this->cursorLine; + $this->cursorCol = 0; + + return true; + } + + return false; + } + + public function moveWordBackwards(): bool + { + $this->killRing->resetAction(); + $oldLine = $this->cursorLine; + $oldCol = $this->cursorCol; + + if (0 === $this->cursorCol) { + if ($this->cursorLine > 0) { + --$this->cursorLine; + $this->cursorCol = \strlen($this->lines[$this->cursorLine]); + } + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + $line = $this->currentLine(); + $line->moveWordBackward(); + $this->cursorCol = $line->getCursor(); + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + public function moveWordForwards(): bool + { + $this->killRing->resetAction(); + $oldLine = $this->cursorLine; + $oldCol = $this->cursorCol; + + if ($this->cursorCol >= \strlen($this->lines[$this->cursorLine])) { + if ($this->cursorLine < \count($this->lines) - 1) { + ++$this->cursorLine; + $this->cursorCol = 0; + } + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + $line = $this->currentLine(); + $line->moveWordForward(); + $this->cursorCol = $line->getCursor(); + + return $this->cursorLine !== $oldLine || $this->cursorCol !== $oldCol; + } + + /** + * Jump to the first occurrence of a character in the specified direction. + * Multi-line search. Case-sensitive. Skips the current cursor position. + */ + public function jumpToChar(string $char, string $direction): bool + { + $this->killRing->resetAction(); + $isForward = 'forward' === $direction; + $lineCount = \count($this->lines); + + $end = $isForward ? $lineCount : -1; + $step = $isForward ? 1 : -1; + + for ($lineIdx = $this->cursorLine; $lineIdx !== $end; $lineIdx += $step) { + $line = $this->lines[$lineIdx]; + $isCurrentLine = $lineIdx === $this->cursorLine; + + if ($isCurrentLine) { + if ($isForward) { + $nextByteOffset = $this->cursorCol; + grapheme_extract($line, 1, \GRAPHEME_EXTR_COUNT, $nextByteOffset, $nextByteOffset); + $idx = strpos($line, $char, $nextByteOffset); + } else { + $searchIn = 0 === $this->cursorCol ? false : substr($line, 0, $this->cursorCol); + $idx = false !== $searchIn ? strrpos($searchIn, $char) : false; + } + } else { + $idx = $isForward ? strpos($line, $char) : strrpos($line, $char); + } + + if (false !== $idx) { + $this->cursorLine = $lineIdx; + $this->cursorCol = $idx; + + return true; + } + } + + return false; + } + + // --- Undo/Redo --- + + public function undo(): bool + { + if ([] === $this->undoStack) { + return false; + } + + $this->redoStack[] = $this->createSnapshot(); + + $snapshot = array_pop($this->undoStack); + $this->restoreSnapshot($snapshot); + $this->killRing->resetAll(); + + return true; + } + + public function redo(): bool + { + if ([] === $this->redoStack) { + return false; + } + + $this->undoStack[] = $this->createSnapshot(); + + $snapshot = array_pop($this->redoStack); + $this->restoreSnapshot($snapshot); + $this->killRing->resetAll(); + + return true; + } + + // --- Kill Ring --- + + public function yank(): bool + { + $text = $this->killRing->peek(); + if (null === $text) { + return false; + } + + $this->pushUndoSnapshot(); + + $startLine = $this->cursorLine; + $startCol = $this->cursorCol; + + $this->insertTextAtCursor($text); + + $this->killRing->recordYank([ + 'start_line' => $startLine, + 'start_col' => $startCol, + 'end_line' => $this->cursorLine, + 'end_col' => $this->cursorCol, + ]); + + return true; + } + + public function yankPop(): bool + { + if (!$this->killRing->canYankPop()) { + return false; + } + + $this->pushUndoSnapshot(); + $this->deleteYankedText(); + + $text = $this->killRing->rotate(); + if (null === $text) { + return false; + } + + $startLine = $this->cursorLine; + $startCol = $this->cursorCol; + + $this->insertTextAtCursor($text); + + $this->killRing->recordYank([ + 'start_line' => $startLine, + 'start_col' => $startCol, + 'end_line' => $this->cursorLine, + 'end_col' => $this->cursorCol, + ]); + + return true; + } + + // --- Paste Handling --- + + /** + * Handle pasted content. + * + * Large pastes (>10 lines) create a marker for efficient display. + */ + public function handlePaste(string $content): void + { + $content = str_replace(["\r\n", "\r"], "\n", StringUtils::sanitizeUtf8($content)); + if ('' === $content) { + return; + } + + $lines = explode("\n", $content); + + // For large pastes, create a marker + if (\count($lines) > 10) { + ++$this->pasteCount; + $id = bin2hex(random_bytes(8)); + $marker = \sprintf('[paste #%d +%d lines <%s>]', $this->pasteCount, \count($lines), $id); + $this->pasteMarkers[] = ['marker' => $marker, 'content' => $content]; + $this->insertText($marker); + + return; + } + + // Insert first line at cursor + $this->insertText($lines[0]); + + // Insert remaining lines + for ($i = 1; $i < \count($lines); ++$i) { + $this->insertNewLine(); + $this->insertText($lines[$i]); + } + } + + // --- Internal --- + + private function insertTextAtCursor(string $text): void + { + $text = str_replace(["\r\n", "\r"], "\n", $text); + $lines = explode("\n", $text); + + if (1 === \count($lines)) { + $currentLine = $this->lines[$this->cursorLine]; + $this->lines[$this->cursorLine] = substr($currentLine, 0, $this->cursorCol) + .$text + .substr($currentLine, $this->cursorCol); + $this->cursorCol += \strlen($text); + + return; + } + + $currentLine = $this->lines[$this->cursorLine]; + $before = substr($currentLine, 0, $this->cursorCol); + $after = substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $before.$lines[0]; + + for ($i = 1; $i < \count($lines) - 1; ++$i) { + array_splice($this->lines, $this->cursorLine + $i, 0, [$lines[$i]]); + } + + $lastLineIndex = $this->cursorLine + \count($lines) - 1; + array_splice($this->lines, $lastLineIndex, 0, [($lines[\count($lines) - 1] ?? '').$after]); + + $this->cursorLine = $lastLineIndex; + $this->cursorCol = \strlen($lines[\count($lines) - 1] ?? ''); + } + + private function currentLine(): Line + { + return new Line($this->lines[$this->cursorLine], $this->cursorCol); + } + + private function applyLine(Line $line): void + { + $this->lines[$this->cursorLine] = $line->getText(); + $this->cursorCol = $line->getCursor(); + } + + private function pushUndoSnapshot(): void + { + $this->undoStack[] = $this->createSnapshot(); + + if (\count($this->undoStack) > 100) { + array_shift($this->undoStack); + } + + $this->redoStack = []; + } + + /** + * @return array{lines: string[], cursor_line: int, cursor_col: int} + */ + private function createSnapshot(): array + { + return [ + 'lines' => $this->lines, + 'cursor_line' => $this->cursorLine, + 'cursor_col' => $this->cursorCol, + ]; + } + + /** + * @param array{lines: string[], cursor_line: int, cursor_col: int} $snapshot + */ + private function restoreSnapshot(array $snapshot): void + { + $this->lines = $snapshot['lines']; + $this->cursorLine = $snapshot['cursor_line']; + $this->cursorCol = $snapshot['cursor_col']; + } + + private function deleteYankedText(): void + { + $range = $this->killRing->getLastYankRange(); + if (null === $range) { + return; + } + + $startLine = $range['start_line']; + $startCol = $range['start_col']; + $endLine = $range['end_line']; + $endCol = $range['end_col']; + + if ($startLine === $endLine) { + $line = $this->lines[$startLine]; + $this->lines[$startLine] = substr($line, 0, $startCol) + .substr($line, $endCol); + } else { + $startText = substr($this->lines[$startLine], 0, $startCol); + $endText = substr($this->lines[$endLine], $endCol); + $this->lines[$startLine] = $startText.$endText; + + $removeCount = $endLine - $startLine; + array_splice($this->lines, $startLine + 1, $removeCount); + } + + $this->cursorLine = $startLine; + $this->cursorCol = $startCol; + } +} diff --git a/extern/Tui/Widget/Editor/EditorRenderer.php b/extern/Tui/Widget/Editor/EditorRenderer.php new file mode 100644 index 00000000..66a01024 --- /dev/null +++ b/extern/Tui/Widget/Editor/EditorRenderer.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Editor; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Style\Style; + +/** + * Renders editor content lines with cursor and word-wrap. + * + * This is a stateless helper: all state (document content, cursor position, + * scroll offset) is passed in from the EditorWidget. + * + * @experimental + * + * @author Fabien Potencier + * + * @internal + */ +final class EditorRenderer +{ + /** + * Render the full editor output: borders + content lines + padding. + * + * @param string[] $lines Document lines + * @param array{scroll_offset: int, visible_line_count: int, lines_above: int, lines_below: int} $viewport Viewport parameters + * @param int $cursorLine Current cursor line + * @param int $cursorCol Current cursor column + * @param int $columns Terminal columns + * @param int $maxDisplayRows Maximum display rows + * @param bool $verticallyExpanded Whether to fill all rows + * @param bool $focused Whether the editor has focus + * @param CursorShape $cursorShape Cursor shape + * @param Style $frameStyle Style for borders + * + * @return string[] + */ + public function render( + array $lines, + array $viewport, + int $cursorLine, + int $cursorCol, + int $columns, + int $maxDisplayRows, + bool $verticallyExpanded, + bool $focused, + CursorShape $cursorShape, + Style $frameStyle, + ): array { + $result = []; + + // Top border (with scroll indicator if scrolled down) + if ($viewport['lines_above'] > 0) { + $indicator = "─── ↑ {$viewport['lines_above']} more "; + $remaining = $columns - AnsiUtils::visibleWidth($indicator); + $result[] = $frameStyle->apply($indicator.str_repeat('─', max(0, $remaining))); + } else { + $result[] = $frameStyle->apply(str_repeat('─', $columns)); + } + + // Render visible lines + $displayRowsRendered = 0; + for ($i = 0; $i < $viewport['visible_line_count']; ++$i) { + $lineIndex = $viewport['scroll_offset'] + $i; + $line = $lines[$lineIndex] ?? ''; + $isCursorLine = $lineIndex === $cursorLine; + + $renderedLines = $this->renderLine($line, $isCursorLine, $cursorCol, $columns, $cursorShape, $focused); + foreach ($renderedLines as $renderedLine) { + $result[] = $renderedLine; + } + $displayRowsRendered += \count($renderedLines); + } + + // In fill mode, pad with empty rows to fill the allocated space + if ($verticallyExpanded && $displayRowsRendered < $maxDisplayRows) { + $emptyLine = str_repeat(' ', $columns); + for ($i = $displayRowsRendered; $i < $maxDisplayRows; ++$i) { + $result[] = $emptyLine; + } + } + + // Bottom border (with scroll indicator if more content below) + if ($viewport['lines_below'] > 0) { + $indicator = "─── ↓ {$viewport['lines_below']} more "; + $remaining = $columns - AnsiUtils::visibleWidth($indicator); + $result[] = $frameStyle->apply($indicator.str_repeat('─', max(0, $remaining))); + } else { + $result[] = $frameStyle->apply(str_repeat('─', $columns)); + } + + return $result; + } + + /** + * Render a logical line, possibly wrapped into multiple display lines. + * + * @return string[] Array of display lines (one or more if wrapped) + */ + private function renderLine(string $line, bool $isCursorLine, int $cursorCol, int $columns, CursorShape $cursorShape, bool $focused): array + { + $chunks = TextWrapper::wrapLineIntoChunks($line, $columns); + + $result = []; + $chunkCount = \count($chunks); + + foreach ($chunks as $i => $chunk) { + $chunkText = $chunk['text']; + $displayLine = rtrim($chunkText); + $isLastChunk = $i === $chunkCount - 1; + + // Determine if the cursor is in this chunk + $hasCursor = false; + $cursorPosInChunk = 0; + + if ($isCursorLine) { + if ($isLastChunk) { + if ($cursorCol >= $chunk['start_index']) { + $hasCursor = true; + $cursorPosInChunk = $cursorCol - $chunk['start_index']; + } + } elseif ($cursorCol >= $chunk['start_index'] && $cursorCol < $chunk['end_index']) { + $hasCursor = true; + $cursorPosInChunk = $cursorCol - $chunk['start_index']; + } + } + + if ($hasCursor) { + $displayLine = $this->renderCursorInChunk($chunkText, $cursorPosInChunk, $columns, $cursorShape, $focused); + } + + // Pad to width + $visibleWidth = AnsiUtils::visibleWidth($displayLine); + $padding = max(0, $columns - $visibleWidth); + + $result[] = $displayLine.str_repeat(' ', $padding); + } + + return $result; + } + + /** + * Render a chunk of text with the cursor marker inserted at the given byte position. + */ + private function renderCursorInChunk(string $chunkText, int $cursorPosInChunk, int $columns, CursorShape $cursorShape, bool $focused): string + { + $atCursor = ''; + $afterCursor = ''; + $beforeCursor = ''; + $cursorCharIndex = 0; + + $graphemes = grapheme_str_split($chunkText); + if (false !== $graphemes) { + $bytePos = 0; + $found = false; + foreach ($graphemes as $index => $grapheme) { + $graphemeBytes = \strlen($grapheme); + if ($cursorPosInChunk < $bytePos) { + $cursorCharIndex = $index; + $found = true; + break; + } + if ($cursorPosInChunk < $bytePos + $graphemeBytes) { + $cursorCharIndex = $index; + $found = true; + break; + } + $bytePos += $graphemeBytes; + } + if (!$found || !isset($graphemes[$cursorCharIndex])) { + $cursorCharIndex = \count($graphemes); + } + + $beforeCursor = implode('', \array_slice($graphemes, 0, $cursorCharIndex)); + if (isset($graphemes[$cursorCharIndex])) { + $atCursor = $graphemes[$cursorCharIndex]; + $afterCursor = implode('', \array_slice($graphemes, $cursorCharIndex + 1)); + } + } + if (false === $graphemes) { + $beforeCursor = substr($chunkText, 0, $cursorPosInChunk); + $afterCursor = $cursorPosInChunk < \strlen($chunkText) ? substr($chunkText, $cursorPosInChunk + 1) : ''; + $atCursor = $chunkText[$cursorPosInChunk] ?? ''; + } + + $marker = $focused ? AnsiUtils::cursorMarker($cursorShape) : ''; + + if ('' !== $afterCursor || '' !== $atCursor) { + // Cursor is on a character + return $beforeCursor.$marker.$atCursor.$afterCursor; + } + + // Cursor is at the end of the line + $beforeCursorWidth = AnsiUtils::visibleWidth($beforeCursor); + if ($beforeCursorWidth < $columns) { + // Room for cursor after the text + return $beforeCursor.$marker.' '; + } + + // Full width, place cursor on the last grapheme + $graphemesFallback = grapheme_str_split($beforeCursor); + if (false !== $graphemesFallback && [] !== $graphemesFallback) { + /** @var string $lastGrapheme */ + $lastGrapheme = array_pop($graphemesFallback); + + return implode('', $graphemesFallback).$marker.$lastGrapheme; + } + + return $beforeCursor; + } +} diff --git a/extern/Tui/Widget/Editor/EditorViewport.php b/extern/Tui/Widget/Editor/EditorViewport.php new file mode 100644 index 00000000..b7a3a16f --- /dev/null +++ b/extern/Tui/Widget/Editor/EditorViewport.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Editor; + +use Symfony\Component\Tui\Ansi\TextWrapper; + +/** + * Manages scroll offset and viewport calculations for the editor. + * + * Owns the scroll offset and computes which logical lines are visible + * in the terminal viewport, accounting for word-wrap. Also handles + * mouse cursor placement (display-row → logical line+col mapping). + * + * @experimental + * + * @author Fabien Potencier + * + * @internal + */ +final class EditorViewport +{ + private int $scrollOffset = 0; + + public function getScrollOffset(): int + { + return $this->scrollOffset; + } + + public function reset(): void + { + $this->scrollOffset = 0; + } + + /** + * Scroll by a full page. + * + * @param string[] $lines Document lines + * @param int $direction 1 for down, -1 for up + * @param int $pageSize Number of lines per page + * @param int $cursorLine Current cursor line + * @param int $cursorCol Current cursor column + * + * @return array{cursor_line: int, cursor_col: int}|null New cursor state, or null if unchanged + */ + public function pageScroll(array $lines, int $direction, int $pageSize, int $cursorLine, int $cursorCol): ?array + { + $targetLine = max(0, min(\count($lines) - 1, $cursorLine + $direction * $pageSize)); + + if ($targetLine !== $cursorLine) { + return [ + 'cursor_line' => $targetLine, + 'cursor_col' => min($cursorCol, \strlen($lines[$targetLine])), + ]; + } + + return null; + } + + /** + * Adjust scroll offset so the cursor is visible, and return viewport parameters. + * + * @param string[] $lines Document lines + * @param int $cursorLine Current cursor line + * @param int $maxDisplayRows Maximum display rows available + * @param int $columns Terminal columns + * @param bool $verticallyExpanded Whether to fill all available rows + * @param int $minVisibleLines Minimum visible lines + * + * @return array{scroll_offset: int, visible_line_count: int, lines_above: int, lines_below: int} + */ + public function computeViewport(array $lines, int $cursorLine, int $maxDisplayRows, int $columns, bool $verticallyExpanded, int $minVisibleLines): array + { + $totalLines = \count($lines); + + // Calculate how many logical lines fit from the current scroll offset + $logicalLinesFitting = self::logicalLinesFitting($lines, $this->scrollOffset, $maxDisplayRows, $columns); + + // Adjust scroll offset to keep cursor visible + if ($cursorLine < $this->scrollOffset) { + $this->scrollOffset = $cursorLine; + } elseif ($cursorLine >= $this->scrollOffset + $logicalLinesFitting) { + $this->scrollOffset = self::scrollOffsetForCursorLine($lines, $cursorLine, $maxDisplayRows, $columns); + } + + // Clamp scroll offset to valid range + $this->scrollOffset = max(0, min($this->scrollOffset, max(0, $totalLines - 1))); + + // Recalculate after potential scroll offset change + $logicalLinesFitting = self::logicalLinesFitting($lines, $this->scrollOffset, $maxDisplayRows, $columns); + + // Calculate visible line count + if ($verticallyExpanded) { + $visibleLineCount = $logicalLinesFitting; + } else { + $visibleLineCount = min(max($minVisibleLines, $totalLines), $logicalLinesFitting); + } + + return [ + 'scroll_offset' => $this->scrollOffset, + 'visible_line_count' => $visibleLineCount, + 'lines_above' => $this->scrollOffset, + 'lines_below' => max(0, $totalLines - $this->scrollOffset - $visibleLineCount), + ]; + } + + /** + * Calculate how many logical lines fit in a given number of display rows, + * starting from a given offset, accounting for wrapping. + * + * @param string[] $lines + */ + private static function logicalLinesFitting(array $lines, int $fromLine, int $maxDisplayRows, int $columns): int + { + $displayRows = 0; + $count = 0; + $totalLines = \count($lines); + + for ($i = $fromLine; $i < $totalLines; ++$i) { + $lineDisplayRows = \count(TextWrapper::wrapLineIntoChunks($lines[$i], $columns)); + if ($displayRows + $lineDisplayRows > $maxDisplayRows) { + break; + } + $displayRows += $lineDisplayRows; + ++$count; + } + + return max(1, $count); + } + + /** + * Find the scroll offset that places cursorLine as the last visible + * logical line, accounting for wrapping. + * + * @param string[] $lines + */ + private static function scrollOffsetForCursorLine(array $lines, int $cursorLine, int $maxDisplayRows, int $columns): int + { + $displayRows = 0; + $offset = $cursorLine; + + for ($i = $cursorLine; $i >= 0; --$i) { + $lineDisplayRows = \count(TextWrapper::wrapLineIntoChunks($lines[$i], $columns)); + if ($displayRows + $lineDisplayRows > $maxDisplayRows) { + break; + } + $displayRows += $lineDisplayRows; + $offset = $i; + } + + return $offset; + } +} diff --git a/extern/Tui/Widget/EditorWidget.php b/extern/Tui/Widget/EditorWidget.php new file mode 100644 index 00000000..6bde529f --- /dev/null +++ b/extern/Tui/Widget/EditorWidget.php @@ -0,0 +1,599 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\ChangeEvent; +use Symfony\Component\Tui\Event\SubmitEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Widget\Editor\EditorDocument; +use Symfony\Component\Tui\Widget\Editor\EditorRenderer; +use Symfony\Component\Tui\Widget\Editor\EditorViewport; +use Symfony\Component\Tui\Widget\Util\KillRing; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +/** + * Multi-line text editor. + * + * Orchestrates input routing between collaborators: + * - {@see EditorDocument}: text buffer, cursor, undo/redo, kill ring + * - {@see EditorViewport}: scroll offset, viewport calculations + * - {@see EditorRenderer}: line rendering with cursor and word-wrap + * + * @experimental + * + * @author Fabien Potencier + */ +class EditorWidget extends AbstractWidget implements FocusableInterface, VerticallyExpandableInterface +{ + use BracketedPasteTrait; + use FocusableTrait; + use KeybindingsTrait; + + private EditorDocument $document; + private EditorViewport $viewport; + private EditorRenderer $editorRenderer; + private int $minVisibleLines = 0; + private ?int $maxVisibleLines = null; + private bool $verticallyExpanded = false; + private ?int $lastMaxVisibleLines = null; + + private bool $submitted = false; + + public function __construct( + ?Keybindings $keybindings = null, + ?KillRing $killRing = null, + ) { + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + $this->document = new EditorDocument($killRing); + $this->viewport = new EditorViewport(); + $this->editorRenderer = new EditorRenderer(); + } + + public function getText(): string + { + return $this->document->getText(); + } + + /** + * Check if the editor was submitted (Ctrl+Enter) vs cancelled (Escape). + */ + public function wasSubmitted(): bool + { + return $this->submitted; + } + + /** + * Get paste markers for large pastes. + * + * @return array + */ + public function getPasteMarkers(): array + { + return $this->document->getPasteMarkers(); + } + + /** + * @return $this + */ + public function setText(string $text): static + { + if ($this->document->setText($text)) { + $this->viewport->reset(); + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setMinVisibleLines(int $minVisibleLines): static + { + $minVisibleLines = max(0, $minVisibleLines); + if ($this->minVisibleLines !== $minVisibleLines) { + $this->minVisibleLines = $minVisibleLines; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setMaxVisibleLines(?int $maxVisibleLines): static + { + if (null !== $maxVisibleLines) { + $maxVisibleLines = max(1, $maxVisibleLines); + } + + if ($this->maxVisibleLines !== $maxVisibleLines) { + $this->maxVisibleLines = $maxVisibleLines; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function expandVertically(bool $fill): static + { + if ($this->verticallyExpanded !== $fill) { + $this->verticallyExpanded = $fill; + $this->invalidate(); + } + + return $this; + } + + public function isVerticallyExpanded(): bool + { + return $this->verticallyExpanded; + } + + public function setFocused(bool $focused): static + { + if ($this->focused !== $focused) { + $this->focused = $focused; + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + return $this; + } + + /** + * @param callable(SubmitEvent): void $callback + * + * @return $this + */ + public function onSubmit(callable $callback): static + { + return $this->on(SubmitEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): static + { + return $this->on(CancelEvent::class, $callback); + } + + /** + * @param callable(ChangeEvent): void $callback + * + * @return $this + */ + public function onChange(callable $callback): static + { + return $this->on(ChangeEvent::class, $callback); + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + // Handle bracketed paste + $pastedText = $this->processBracketedPaste($data); + if (null !== $pastedText) { + $this->document->handlePaste($pastedText); + $this->notifyChange(); + if ('' === $data) { + return; + } + } elseif ($this->isBufferingPaste()) { + return; + } + + $kb = $this->getKeybindings(); + + // Handle character jump mode (awaiting next character to jump to) + if (null !== $this->document->getJumpMode()) { + if ($kb->matches($data, 'jump_forward') || $kb->matches($data, 'jump_backward')) { + $this->document->setJumpMode(null); + + return; + } + + if (\ord($data[0]) >= 32 && !str_starts_with($data, "\x1b")) { + $direction = $this->document->getJumpMode(); + $this->document->setJumpMode(null); + if ($this->document->jumpToChar($data, $direction)) { + $this->invalidate(); + } + + return; + } + + // Control character - cancel and fall through to normal handling + $this->document->setJumpMode(null); + } + + // Copy (leave to parent) + if ($kb->matches($data, 'copy')) { + return; + } + + // Undo/Redo + if ($kb->matches($data, 'undo')) { + if ($this->document->undo()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'redo')) { + if ($this->document->redo()) { + $this->notifyChange(); + } + + return; + } + + // Kill ring + if ($kb->matches($data, 'yank')) { + if ($this->document->yank()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'yank_pop')) { + if ($this->document->yankPop()) { + $this->notifyChange(); + } + + return; + } + + // New line (check before submit to allow Shift+Enter) + if ($kb->matches($data, 'new_line')) { + $this->document->insertNewLine(); + $this->notifyChange(); + + return; + } + + // Submit + if ($kb->matches($data, 'submit')) { + $this->submitted = true; + $this->dispatch(new SubmitEvent($this, $this->getText())); + + return; + } + + // Navigation + if ($kb->matches($data, 'cursor_up')) { + if ($this->document->isOnFirstLine()) { + $this->document->moveToLineStart(); + } else { + $this->document->moveCursorUp(); + } + $this->invalidate(); + + return; + } + + if ($kb->matches($data, 'cursor_down')) { + if ($this->document->isOnLastLine()) { + $this->document->moveToLineEnd(); + } else { + $this->document->moveCursorDown(); + } + $this->invalidate(); + + return; + } + + if ($kb->matches($data, 'cursor_left')) { + if ($this->document->moveCursorLeft()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_right')) { + if ($this->document->moveCursorRight()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_line_start')) { + if ($this->document->moveToLineStart()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_line_end')) { + if ($this->document->moveToLineEnd()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_word_left')) { + if ($this->document->moveWordBackwards()) { + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'cursor_word_right')) { + if ($this->document->moveWordForwards()) { + $this->invalidate(); + } + + return; + } + + // Page scroll + if ($kb->matches($data, 'page_up')) { + $result = $this->viewport->pageScroll($this->document->getLines(), -1, $this->getPageSize(), $this->document->getCursorLine(), $this->document->getCursorCol()); + if (null !== $result) { + $this->document->setCursorLine($result['cursor_line']); + $this->document->setCursorCol($result['cursor_col']); + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'page_down')) { + $result = $this->viewport->pageScroll($this->document->getLines(), 1, $this->getPageSize(), $this->document->getCursorLine(), $this->document->getCursorCol()); + if (null !== $result) { + $this->document->setCursorLine($result['cursor_line']); + $this->document->setCursorCol($result['cursor_col']); + $this->invalidate(); + } + + return; + } + + // Character jump mode triggers + if ($kb->matches($data, 'jump_forward')) { + $this->document->setJumpMode('forward'); + + return; + } + + if ($kb->matches($data, 'jump_backward')) { + $this->document->setJumpMode('backward'); + + return; + } + + // Deletion (line-level, then word-level, then char-level) + if ($kb->matches($data, 'delete_line')) { + if ($this->document->deleteLine()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_to_line_end')) { + if ($this->document->deleteToLineEnd()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_to_line_start')) { + if ($this->document->deleteToLineStart()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_word_backward')) { + if ($this->document->deleteWordBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_word_forward')) { + if ($this->document->deleteWordForward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_backward')) { + if ($this->document->deleteCharBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_forward')) { + if ($this->document->deleteCharForward()) { + $this->notifyChange(); + } + + return; + } + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->submitted = false; + $this->dispatch(new CancelEvent($this)); + + return; + } + + // Shift+Space - insert regular space + if ($this->getKeybindings()->matches($data, 'insert_space')) { + $this->document->insertText(' '); + $this->notifyChange(); + + return; + } + + // Regular character input + if (!StringUtils::hasControlChars($data)) { + $data = StringUtils::sanitizeUtf8($data); + if ('' === $data) { + return; + } + + $this->document->insertText($data); + $this->notifyChange(); + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + + // Calculate max visible lines based on context height or terminal + $minVisibleLines = $this->minVisibleLines; + if ($this->verticallyExpanded && $context->getRows() > 0) { + $maxDisplayRows = max(5, $context->getRows() - 2); + } else { + $terminalRows = $this->getContext()?->getTerminalRows() ?? 24; + $maxDisplayRows = max(5, $minVisibleLines, (int) floor($terminalRows * 0.3)); + } + + if (null !== $this->maxVisibleLines) { + $maxDisplayRows = min($maxDisplayRows, $this->maxVisibleLines); + } + + $this->lastMaxVisibleLines = $maxDisplayRows; + + // Compute viewport (adjusts scroll offset, returns visible range) + $viewport = $this->viewport->computeViewport( + $this->document->getLines(), + $this->document->getCursorLine(), + $maxDisplayRows, + $columns, + $this->verticallyExpanded && $context->getRows() > 0, + $minVisibleLines, + ); + + // Render content + $cursorStyle = $this->resolveElement('cursor'); + $result = $this->editorRenderer->render( + $this->document->getLines(), + $viewport, + $this->document->getCursorLine(), + $this->document->getCursorCol(), + $columns, + $maxDisplayRows, + $this->verticallyExpanded && $context->getRows() > 0, + $this->focused, + $cursorStyle->getCursorShape() ?? CursorShape::Block, + $this->resolveElement('frame'), + ); + + return $result; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + // Cursor movement + 'cursor_up' => [Key::UP], + 'cursor_down' => [Key::DOWN], + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + 'cursor_word_left' => ['alt+left', 'ctrl+left', 'alt+b'], + 'cursor_word_right' => ['alt+right', 'ctrl+right', 'alt+f'], + 'cursor_line_start' => [Key::HOME, 'ctrl+a'], + 'cursor_line_end' => [Key::END, 'ctrl+e'], + 'jump_forward' => ['ctrl+]'], + 'jump_backward' => ['ctrl+alt+]'], + 'page_up' => [Key::PAGE_UP], + 'page_down' => [Key::PAGE_DOWN], + + // Deletion + 'delete_char_backward' => [Key::BACKSPACE, 'shift+backspace'], + 'delete_char_forward' => [Key::DELETE, 'ctrl+d', 'shift+delete'], + 'delete_word_backward' => ['ctrl+w', 'alt+backspace'], + 'delete_word_forward' => ['alt+d', 'alt+delete'], + 'delete_line' => ['ctrl+shift+k'], + 'delete_to_line_start' => ['ctrl+u'], + 'delete_to_line_end' => ['ctrl+k'], + + // Text input + 'insert_space' => ['shift+space'], + 'new_line' => ['shift+enter'], + 'submit' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + + // Clipboard + 'copy' => ['ctrl+c'], + + // Kill ring + 'yank' => ['ctrl+y'], + 'yank_pop' => ['alt+y'], + + // Undo/Redo + 'undo' => ['ctrl+-'], + 'redo' => ['ctrl+shift+z'], + + // Tool output + 'expand_tools' => ['ctrl+o'], + ]; + } + + private function notifyChange(): void + { + $this->invalidate(); + $this->dispatch(new ChangeEvent($this, $this->getText())); + } + + private function getPageSize(): int + { + if (null !== $this->lastMaxVisibleLines) { + return $this->lastMaxVisibleLines; + } + + $terminalRows = $this->getContext()?->getTerminalRows() ?? 24; + + return max(5, (int) floor($terminalRows * 0.3)); + } +} diff --git a/extern/Tui/Widget/Figlet/FigletFont.php b/extern/Tui/Widget/Figlet/FigletFont.php new file mode 100644 index 00000000..244ba1ce --- /dev/null +++ b/extern/Tui/Widget/Figlet/FigletFont.php @@ -0,0 +1,207 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Figlet; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Parses and represents a FIGlet font (.flf file). + * + * The FIGlet font format stores ASCII art representations of characters. + * Each character is defined as a fixed number of lines (the font height). + * Lines are terminated by the font's "end mark" character (@), and the + * last line of each character uses a double end mark (@@). + * + * The "hardblank" character (typically $) is rendered as a visible space + * that prevents smushing; it's replaced with a regular space on output. + * + * @see https://github.com/cmatsuoka/figlet/blob/master/figfont.txt + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class FigletFont +{ + /** @var array codepoint → array of lines */ + private array $characters = []; + + /** + * Load a font from a .flf file path. + */ + public static function load(string $path): self + { + if (!is_file($path)) { + throw new InvalidArgumentException(\sprintf('FIGlet font file "%s" does not exist.', $path)); + } + + $content = file_get_contents($path); + + if (false === $content) { + throw new InvalidArgumentException(\sprintf('Cannot read FIGlet font file "%s".', $path)); + } + + // Auto-detect ZIP-compressed .flf files (some font sites distribute them this way) + if (str_starts_with($content, "PK\x03\x04")) { + $content = self::extractFromZip($path); + } + + return self::parse($content); + } + + /** + * Parse a FIGlet font from its raw string content. + */ + public static function parse(string $content): self + { + $lines = explode("\n", $content); + + // Parse header: flf2a ... + $header = $lines[0]; + if (!str_starts_with($header, 'flf2')) { + throw new InvalidArgumentException('Invalid FIGlet font: missing flf2 signature.'); + } + + $hardblank = $header[5]; // Character after "flf2a" + $headerParts = preg_split('/\s+/', $header) ?: []; + $height = (int) ($headerParts[1] ?? 0); + $commentLines = (int) ($headerParts[5] ?? 0); + + $font = new self($height, $hardblank); + + // Character data starts after header + comments + $lineIndex = 1 + $commentLines; + + // Phase 1: Parse required ASCII characters (32–126 = 95 characters) + for ($codepoint = 32; $codepoint <= 126; ++$codepoint) { + $charLines = $font->readCharacterLines($lines, $lineIndex); + if (null === $charLines) { + break; + } + $font->characters[$codepoint] = $charLines; + } + + // Phase 2: Parse code-tagged characters (extended) + while ($lineIndex < \count($lines)) { + $tagLine = $lines[$lineIndex] ?? ''; + + // Code-tagged lines start with a number + if (!preg_match('/^(-?\d+)/', $tagLine, $matches)) { + break; + } + + $codepoint = (int) $matches[1]; + ++$lineIndex; + + $charLines = $font->readCharacterLines($lines, $lineIndex); + if (null === $charLines) { + break; + } + $font->characters[$codepoint] = $charLines; + } + + return $font; + } + + /** + * Get the font height in lines. + */ + public function getHeight(): int + { + return $this->height; + } + + /** + * Get the character art lines for a given codepoint. + * + * @return string[] Array of $height lines, or empty strings if character is not defined + */ + public function getCharacter(int $codepoint): array + { + return $this->characters[$codepoint] ?? array_fill(0, $this->height, ''); + } + + /** + * Check if a character is defined in this font. + */ + public function hasCharacter(int $codepoint): bool + { + return isset($this->characters[$codepoint]); + } + + private function __construct( + private int $height, + private string $hardblank, + ) { + } + + /** + * Read one character's worth of lines from the font data. + * + * @param string[] $lines All lines of the font file + * @param int $lineIndex Current read position (modified by reference) + * + * @return string[]|null The character lines, or null if not enough data + */ + private function readCharacterLines(array $lines, int &$lineIndex): ?array + { + $charLines = []; + + for ($row = 0; $row < $this->height; ++$row) { + if ($lineIndex >= \count($lines)) { + return null; + } + + $line = $lines[$lineIndex]; + ++$lineIndex; + + // Strip end marks (@ or @@) from the right + $line = rtrim($line, "\r\n"); + $line = preg_replace('/@{1,2}$/', '', $line); + + // Replace hardblank with space + $charLines[] = str_replace($this->hardblank, ' ', $line); + } + + return $charLines; + } + + /** + * Extract the first .flf file from a ZIP archive. + */ + private static function extractFromZip(string $path): string + { + $zip = new \ZipArchive(); + + if (true !== $zip->open($path)) { + throw new InvalidArgumentException(\sprintf('Cannot open ZIP archive "%s".', $path)); + } + + try { + for ($i = 0; $i < $zip->numFiles; ++$i) { + $name = $zip->getNameIndex($i); + if (false !== $name && str_ends_with($name, '.flf')) { + $content = $zip->getFromIndex($i); + if (false !== $content) { + return $content; + } + } + } + } finally { + $zip->close(); + } + + throw new InvalidArgumentException(\sprintf('No .flf file found inside ZIP archive "%s".', $path)); + } +} diff --git a/extern/Tui/Widget/Figlet/FigletRenderer.php b/extern/Tui/Widget/Figlet/FigletRenderer.php new file mode 100644 index 00000000..054da48a --- /dev/null +++ b/extern/Tui/Widget/Figlet/FigletRenderer.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Figlet; + +use Symfony\Component\Tui\Render\Compositor; +use Symfony\Component\Tui\Style\Color; + +/** + * Renders text using a FIGlet font. + * + * Concatenates the ASCII art for each character horizontally, line by line. + * Strips trailing whitespace from each line and removes blank trailing lines. + * + * When a color is provided, each line is wrapped with the foreground ANSI + * escape code. Since trailing whitespace is already stripped, the colored + * output is safe for use with the {@see Compositor} + * in transparent mode: spaces at the end of lines won't carry styling that + * would prevent lower layers from showing through. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class FigletRenderer +{ + public function __construct( + private FigletFont $font, + ) { + } + + /** + * Render a string as FIGlet ASCII art. + * + * @param string|int|Color|null $color Optional foreground color applied to each line + * + * @return string[] One entry per output line + */ + public function render(string $text, string|int|Color|null $color = null): array + { + if ('' === $text) { + return []; + } + + $height = $this->font->getHeight(); + $outputLines = array_fill(0, $height, ''); + + // Build output by appending each character's art horizontally + $length = mb_strlen($text); + for ($i = 0; $i < $length; ++$i) { + $char = mb_substr($text, $i, 1); + $codepoint = mb_ord($char); + $charLines = $this->font->getCharacter($codepoint); + + for ($row = 0; $row < $height; ++$row) { + $outputLines[$row] .= $charLines[$row]; + } + } + + // Strip trailing whitespace from each line + $outputLines = array_map('rtrim', $outputLines); + + // Remove blank trailing lines + while ([] !== $outputLines && '' === end($outputLines)) { + array_pop($outputLines); + } + + if (null !== $color) { + $fgCode = Color::from($color)->toForegroundCode(); + $outputLines = array_map( + static fn (string $line) => '' !== $line ? $fgCode.$line."\x1b[0m" : $line, + $outputLines, + ); + } + + return $outputLines; + } +} diff --git a/extern/Tui/Widget/Figlet/FontRegistry.php b/extern/Tui/Widget/Figlet/FontRegistry.php new file mode 100644 index 00000000..b25e811b --- /dev/null +++ b/extern/Tui/Widget/Figlet/FontRegistry.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Figlet; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Registry for FIGlet fonts. + * + * Maps font names to file paths and lazily loads FigletFont instances. + * Bundled fonts (big, small, slant, standard, mini) are registered + * by default. Custom fonts can be registered by name: + * + * $registry = new FontRegistry(); + * $registry->register('custom', '/path/to/custom.flf'); + * + * Fonts are referenced by name throughout the Style system: + * + * $stylesheet->addRule('.title', new Style(font: 'custom')); + * $widget->addStyleClass('font-custom'); + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class FontRegistry +{ + private const string BUNDLED_FONTS_DIR = __DIR__.'/fonts'; + + private const array BUNDLED_FONTS = ['big', 'small', 'slant', 'standard', 'mini']; + + /** @var array name → file path */ + private array $paths = []; + + /** @var array name → loaded font (cache) */ + private array $fonts = []; + + public function __construct() + { + foreach (self::BUNDLED_FONTS as $name) { + $this->paths[$name] = self::BUNDLED_FONTS_DIR.'/'.$name.'.flf'; + } + } + + /** + * Register a font by name with a path to a .flf file. + * + * @return $this + */ + public function register(string $name, string $path): self + { + $this->paths[$name] = $path; + unset($this->fonts[$name]); // invalidate cache if re-registering + + return $this; + } + + /** + * Load and return a font by name. + * + * @throws InvalidArgumentException if the font name is not registered + */ + public function get(string $name): FigletFont + { + if (isset($this->fonts[$name])) { + return $this->fonts[$name]; + } + + if (!isset($this->paths[$name])) { + throw new InvalidArgumentException(\sprintf('Font "%s" is not registered. Available fonts: "%s".', $name, implode('", "', array_keys($this->paths)))); + } + + return $this->fonts[$name] = FigletFont::load($this->paths[$name]); + } + + /** + * Check whether a font name is registered. + */ + public function has(string $name): bool + { + return isset($this->paths[$name]); + } + + /** + * Get all registered font names. + * + * @return string[] + */ + public function getNames(): array + { + return array_keys($this->paths); + } +} diff --git a/extern/Tui/Widget/Figlet/fonts/big.flf b/extern/Tui/Widget/Figlet/fonts/big.flf new file mode 100644 index 00000000..07c468c9 --- /dev/null +++ b/extern/Tui/Widget/Figlet/fonts/big.flf @@ -0,0 +1,2204 @@ +flf2a$ 8 6 59 15 10 0 24463 153 +Big by Glenn Chappell 4/93 -- based on Standard +Includes ISO Latin-1 +Greek characters by Bruce Jakeway +figlet release 2.2 -- November 1996 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + $@ + $@ + $@ + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + | |@ + | |@ + |_|@ + (_)@ + @ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + $ @ + $ @ + @ + @@ + _ _ @ + _| || |_ @ + |_ __ _|@ + _| || |_ @ + |_ __ _|@ + |_||_| @ + @ + @@ + _ @ + | | @ + / __)@ + \__ \@ + ( /@ + |_| @ + @ + @@ + _ __@ + (_) / /@ + / / @ + / / @ + / / _ @ + /_/ (_)@ + @ + @@ + @ + ___ @ + ( _ ) @ + / _ \/\@ + | (_> <@ + \___/\/@ + @ + @@ + _ @ + ( )@ + |/ @ + $ @ + $ @ + $ @ + @ + @@ + __@ + / /@ + | | @ + | | @ + | | @ + | | @ + \_\@ + @@ + __ @ + \ \ @ + | |@ + | |@ + | |@ + | |@ + /_/ @ + @@ + _ @ + /\| |/\ @ + \ ` ' / @ + |_ _|@ + / , . \ @ + \/|_|\/ @ + @ + @@ + @ + _ @ + _| |_ @ + |_ _|@ + |_| @ + $ @ + @ + @@ + @ + @ + @ + @ + _ @ + ( )@ + |/ @ + @@ + @ + @ + ______ @ + |______|@ + $ @ + $ @ + @ + @@ + @ + @ + @ + @ + _ @ + (_)@ + @ + @@ + __@ + / /@ + / / @ + / / @ + / / @ + /_/ @ + @ + @@ + ___ @ + / _ \ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ + __ @ + /_ |@ + | |@ + | |@ + | |@ + |_|@ + @ + @@ + ___ @ + |__ \ @ + $) |@ + / / @ + / /_ @ + |____|@ + @ + @@ + ____ @ + |___ \ @ + __) |@ + |__ < @ + ___) |@ + |____/ @ + @ + @@ + _ _ @ + | || | @ + | || |_ @ + |__ _|@ + | | @ + |_| @ + @ + @@ + _____ @ + | ____|@ + | |__ @ + |___ \ @ + ___) |@ + |____/ @ + @ + @@ + __ @ + / / @ + / /_ @ + | '_ \ @ + | (_) |@ + \___/ @ + @ + @@ + ______ @ + |____ |@ + $/ / @ + / / @ + / / @ + /_/ @ + @ + @@ + ___ @ + / _ \ @ + | (_) |@ + > _ < @ + | (_) |@ + \___/ @ + @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__, |@ + / / @ + /_/ @ + @ + @@ + @ + _ @ + (_)@ + $ @ + _ @ + (_)@ + @ + @@ + @ + _ @ + (_)@ + $ @ + _ @ + ( )@ + |/ @ + @@ + __@ + / /@ + / / @ + < < @ + \ \ @ + \_\@ + @ + @@ + @ + ______ @ + |______|@ + ______ @ + |______|@ + @ + @ + @@ + __ @ + \ \ @ + \ \ @ + > >@ + / / @ + /_/ @ + @ + @@ + ___ @ + |__ \ @ + ) |@ + / / @ + |_| @ + (_) @ + @ + @@ + @ + ____ @ + / __ \ @ + / / _` |@ + | | (_| |@ + \ \__,_|@ + \____/ @ + @@ + @ + /\ @ + / \ @ + / /\ \ @ + / ____ \ @ + /_/ \_\@ + @ + @@ + ____ @ + | _ \ @ + | |_) |@ + | _ < @ + | |_) |@ + |____/ @ + @ + @@ + _____ @ + / ____|@ + | | $ @ + | | $ @ + | |____ @ + \_____|@ + @ + @@ + _____ @ + | __ \ @ + | | | |@ + | | | |@ + | |__| |@ + |_____/ @ + @ + @@ + ______ @ + | ____|@ + | |__ @ + | __| @ + | |____ @ + |______|@ + @ + @@ + ______ @ + | ____|@ + | |__ @ + | __| @ + | | @ + |_| @ + @ + @@ + _____ @ + / ____|@ + | | __ @ + | | |_ |@ + | |__| |@ + \_____|@ + @ + @@ + _ _ @ + | | | |@ + | |__| |@ + | __ |@ + | | | |@ + |_| |_|@ + @ + @@ + _____ @ + |_ _|@ + | | @ + | | @ + _| |_ @ + |_____|@ + @ + @@ + _ @ + | |@ + | |@ + _ | |@ + | |__| |@ + \____/ @ + @ + @@ + _ __@ + | |/ /@ + | ' / @ + | < @ + | . \ @ + |_|\_\@ + @ + @@ + _ @ + | | @ + | | @ + | | @ + | |____ @ + |______|@ + @ + @@ + __ __ @ + | \/ |@ + | \ / |@ + | |\/| |@ + | | | |@ + |_| |_|@ + @ + @@ + _ _ @ + | \ | |@ + | \| |@ + | . ` |@ + | |\ |@ + |_| \_|@ + @ + @@ + ____ @ + / __ \ @ + | | | |@ + | | | |@ + | |__| |@ + \____/ @ + @ + @@ + _____ @ + | __ \ @ + | |__) |@ + | ___/ @ + | | @ + |_| @ + @ + @@ + ____ @ + / __ \ @ + | | | |@ + | | | |@ + | |__| |@ + \___\_\@ + @ + @@ + _____ @ + | __ \ @ + | |__) |@ + | _ / @ + | | \ \ @ + |_| \_\@ + @ + @@ + _____ @ + / ____|@ + | (___ @ + \___ \ @ + ____) |@ + |_____/ @ + @ + @@ + _______ @ + |__ __|@ + | | @ + | | @ + | | @ + |_| @ + @ + @@ + _ _ @ + | | | |@ + | | | |@ + | | | |@ + | |__| |@ + \____/ @ + @ + @@ + __ __@ + \ \ / /@ + \ \ / / @ + \ \/ / @ + \ / @ + \/ @ + @ + @@ + __ __@ + \ \ / /@ + \ \ /\ / / @ + \ \/ \/ / @ + \ /\ / @ + \/ \/ @ + @ + @@ + __ __@ + \ \ / /@ + \ V / @ + > < @ + / . \ @ + /_/ \_\@ + @ + @@ + __ __@ + \ \ / /@ + \ \_/ / @ + \ / @ + | | @ + |_| @ + @ + @@ + ______@ + |___ /@ + $/ / @ + / / @ + / /__ @ + /_____|@ + @ + @@ + ___ @ + | _|@ + | | @ + | | @ + | | @ + | |_ @ + |___|@ + @@ + __ @ + \ \ @ + \ \ @ + \ \ @ + \ \ @ + \_\@ + @ + @@ + ___ @ + |_ |@ + | |@ + | |@ + | |@ + _| |@ + |___|@ + @@ + /\ @ + |/\|@ + $ @ + $ @ + $ @ + $ @ + @ + @@ + @ + @ + @ + @ + @ + $ @ + ______ @ + |______|@@ + _ @ + ( )@ + \|@ + $ @ + $ @ + $ @ + @ + @@ + @ + @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ + _ @ + | | @ + | |__ @ + | '_ \ @ + | |_) |@ + |_.__/ @ + @ + @@ + @ + @ + ___ @ + / __|@ + | (__ @ + \___|@ + @ + @@ + _ @ + | |@ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ + @ + @ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ + __ @ + / _|@ + | |_ @ + | _|@ + | | @ + |_| @ + @ + @@ + @ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + __/ |@ + |___/ @@ + _ @ + | | @ + | |__ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @ + @@ + _ @ + (_)@ + _ @ + | |@ + | |@ + |_|@ + @ + @@ + _ @ + (_)@ + _ @ + | |@ + | |@ + | |@ + _/ |@ + |__/ @@ + _ @ + | | @ + | | __@ + | |/ /@ + | < @ + |_|\_\@ + @ + @@ + _ @ + | |@ + | |@ + | |@ + | |@ + |_|@ + @ + @@ + @ + @ + _ __ ___ @ + | '_ ` _ \ @ + | | | | | |@ + |_| |_| |_|@ + @ + @@ + @ + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @ + @@ + @ + @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ + @ + @ + _ __ @ + | '_ \ @ + | |_) |@ + | .__/ @ + | | @ + |_| @@ + @ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + | |@ + |_|@@ + @ + @ + _ __ @ + | '__|@ + | | @ + |_| @ + @ + @@ + @ + @ + ___ @ + / __|@ + \__ \@ + |___/@ + @ + @@ + _ @ + | | @ + | |_ @ + | __|@ + | |_ @ + \__|@ + @ + @@ + @ + @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ + @ + @ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @ + @@ + @ + @ + __ __@ + \ \ /\ / /@ + \ V V / @ + \_/\_/ @ + @ + @@ + @ + @ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @ + @@ + @ + @ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + __/ |@ + |___/ @@ + @ + @ + ____@ + |_ /@ + / / @ + /___|@ + @ + @@ + __@ + / /@ + | | @ + / / @ + \ \ @ + | | @ + \_\@ + @@ + _ @ + | |@ + | |@ + | |@ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | | @ + \ \@ + / /@ + | | @ + /_/ @ + @@ + /\/|@ + |/\/ @ + $ @ + $ @ + $ @ + $ @ + @ + @@ + _ _ @ + (_)_(_) @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ + _ _ @ + (_) (_)@ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ + _ _ @ + (_) (_)@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ + _ _ @ + (_) (_)@ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ + ___ @ + / _ \ @ + | | ) |@ + | |< < @ + | | ) |@ + | ||_/ @ + |_| @ + @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + | |@ + | |@ + |_|@ + @ + @@ +162 CENT SIGN + @ + _ @ + | | @ + / __)@ + | (__ @ + \ )@ + |_| @ + @@ +163 POUND SIGN + ___ @ + / ,_\ @ + _| |_ @ + |__ __| @ + | |____ @ + (_,_____|@ + @ + @@ +164 CURRENCY SIGN + @ + /\___/\@ + \ _ /@ + | (_) |@ + / ___ \@ + \/ \/@ + @ + @@ +165 YEN SIGN + __ __ @ + \ \ / / @ + _\ V /_ @ + |___ ___|@ + |___ ___|@ + |_| @ + @ + @@ +166 BROKEN BAR + _ @ + | |@ + | |@ + |_|@ + _ @ + | |@ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + _/ _)@ + / \ \ @ + \ \\ \@ + \ \_/@ + (__/ @ + @ + @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ + $ $ @ + $ $ @ + @ + @@ +169 COPYRIGHT SIGN + ________ @ + / ____ \ @ + / / ___| \ @ + | | | |@ + | | |___ |@ + \ \____| / @ + \________/ @ + @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + |_____|@ + $ @ + @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + / / / @ + < < < @ + \ \ \ @ + \_\_\@ + @ + @@ +172 NOT SIGN + @ + @ + ______ @ + |____ |@ + |_|@ + $ @ + @ + @@ +173 SOFT HYPHEN + @ + @ + _____ @ + |_____|@ + $ @ + $ @ + @ + @@ +174 REGISTERED SIGN + ________ @ + / ____ \ @ + / | _ \ \ @ + | | |_) | |@ + | | _ < |@ + \ |_| \_\ / @ + \________/ @ + @@ +175 MACRON + ______ @ + |______|@ + $ @ + $ @ + $ @ + $ @ + @ + @@ +176 DEGREE SIGN + __ @ + / \ @ + | () |@ + \__/ @ + $ @ + $ @ + @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + |_| @ + _____ @ + |_____|@ + @ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ )@ + / / @ + /___|@ + $ @ + $ @ + @ + @@ +179 SUPERSCRIPT THREE + ____@ + |__ /@ + |_ \@ + |___/@ + $ @ + $ @ + @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + $ @ + $ @ + @ + @@ +181 MICRO SIGN + @ + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + | | @ + |_| @@ +182 PILCROW SIGN + ______ @ + / |@ + | (| || |@ + \__ || |@ + | || |@ + |_||_|@ + @ + @@ +183 MIDDLE DOT + @ + @ + _ @ + (_)@ + $ @ + $ @ + @ + @@ +184 CEDILLA + @ + @ + @ + @ + @ + _ @ + )_)@ + @@ +185 SUPERSCRIPT ONE + _ @ + / |@ + | |@ + |_|@ + $ @ + $ @ + @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + |_____|@ + $ @ + @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + \ \ \ @ + > > >@ + / / / @ + /_/_/ @ + @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / | / / @ + | |/ / _ @ + |_/ / | | @ + / /|_ _|@ + /_/ |_| @ + @ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / | / / @ + | |/ /__ @ + |_/ /_ )@ + / / / / @ + /_/ /___|@ + @ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |__ / / / @ + |_ \/ / _ @ + |___/ / | | @ + / /|_ _|@ + /_/ |_| @ + @ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + | | @ + / / @ + | (__ @ + \___|@ + @ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //\ @ + |/_\| @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/| @ + |/\/ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_) @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + _ @ + (o) @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @ + @@ +198 LATIN CAPITAL LETTER AE + _______ @ + / ____|@ + / |__ @ + / /| __| @ + / ___ |____ @ + /_/ |______|@ + @ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + _____ @ + / ____|@ + | | $ @ + | | $ @ + | |____ @ + \_____|@ + )_) @ + @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\_\_ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __ @ + _/_/_ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //\ @ + |/ \| @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_) (_)@ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + | | @ + | | @ + |___|@ + @ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + | | @ + | | @ + |___|@ + @ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + | | @ + | | @ + |___|@ + @ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + | | @ + | | @ + |___| @ + @ + @@ +208 LATIN CAPITAL LETTER ETH + _____ @ + | __ \ @ + _| |_ | |@ + |__ __|| |@ + | |__| |@ + |_____/ @ + @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/| @ + |/\/_ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/| @ + |/\/ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /\/\@ + > <@ + \/\/@ + $ @ + @ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + _____ @ + / __// @ + | | // |@ + | |//| |@ + | //_| |@ + //___/ @ + @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + __/_/__@ + \ \ / /@ + \ V / @ + | | @ + |_| @ + @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |___ @ + | __ \ @ + | |__) |@ + | ___/ @ + |_| @ + @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \ @ + | | ) |@ + | |< < @ + | | ) |@ + | ||_/ @ + |_| @ + @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\ @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + /_/ @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //\ @ + |/ \| @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/| @ + |/\/ @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_) (_)@ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @ + @@ +230 LATIN SMALL LETTER AE + @ + @ + __ ____ @ + / _` _ \@ + | (_| __/@ + \__,____|@ + @ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + @ + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @ + @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/ \|@ + ___ @ + / _ \@ + | __/@ + \___|@ + @ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_) (_)@ + ___ @ + / _ \ @ + | __/ @ + \___| @ + @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + _ @ + | |@ + | |@ + |_|@ + @ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + _ @ + | |@ + | |@ + |_|@ + @ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/ \|@ + _ @ + | | @ + | | @ + |_| @ + @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_) (_)@ + _ @ + | | @ + | | @ + |_| @ + @ + @@ +240 LATIN SMALL LETTER ETH + /\/\ @ + > < @ + \/\ \ @ + / _` |@ + | (_) |@ + \___/ @ + @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/ \| @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/| @ + |/\/ @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_) (_)@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @ + @@ +247 DIVISION SIGN + _ @ + (_) @ + _______ @ + |_______|@ + _ @ + (_) @ + @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + @ + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + \_\ @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + /_/ @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + /_/ @ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + __/ |@ + |___/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | | @ + | |__ @ + | '_ \ @ + | |_) |@ + | .__/ @ + | | @ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + __/ |@ + |___/ @@ +0x02BC MODIFIER LETTER APOSTROPHE + @ + @ + ))@ + @ + @ + @ + @ + @@ +0x02BD MODIFIER LETTER REVERSED COMMA + @ + @ + ((@ + @ + @ + @ + @ + @@ +0x037A GREEK YPOGEGRAMMENI + @ + @ + @ + @ + @ + @ + @ + ||@@ +0x0387 GREEK ANO TELEIA + @ + $ @ + _ @ + (_)@ + @ + $ @ + @ + @@ +0x0391 GREEK CAPITAL LETTER ALPHA + ___ @ + / _ \ @ + | |_| |@ + | _ |@ + | | | |@ + |_| |_|@ + @ + @@ +0x0392 GREEK CAPITAL LETTER BETA + ____ @ + | _ \ @ + | |_) )@ + | _ ( @ + | |_) )@ + |____/ @ + @ + @@ +0x0393 GREEK CAPITAL LETTER GAMMA + _____ @ + | ___)@ + | |$ @ + | |$ @ + | | @ + |_| @ + @ + @@ +0x0394 GREEK CAPITAL LETTER DELTA + @ + /\ @ + / \ @ + / /\ \ @ + / /__\ \ @ + /________\@ + @ + @@ +0x0395 GREEK CAPITAL LETTER EPSILON + _____ @ + | ___)@ + | |_ @ + | _) @ + | |___ @ + |_____)@ + @ + @@ +0x0396 GREEK CAPITAL LETTER ZETA + ______@ + (___ /@ + / / @ + / / @ + / /__ @ + /_____)@ + @ + @@ +0x0397 GREEK CAPITAL LETTER ETA + _ _ @ + | | | |@ + | |_| |@ + | _ |@ + | | | |@ + |_| |_|@ + @ + @@ +0x0398 GREEK CAPITAL LETTER THETA + ____ @ + / __ \ @ + | |__| |@ + | __ |@ + | |__| |@ + \____/ @ + @ + @@ +0x0399 GREEK CAPITAL LETTER IOTA + ___ @ + ( )@ + | | @ + | | @ + | | @ + (___)@ + @ + @@ +0x039A GREEK CAPITAL LETTER KAPPA + _ __@ + | | / /@ + | |/ / @ + | < @ + | |\ \ @ + |_| \_\@ + @ + @@ +0x039B GREEK CAPITAL LETTER LAMDA + @ + /\ @ + / \ @ + / /\ \ @ + / / \ \ @ + /_/ \_\@ + @ + @@ +0x039C GREEK CAPITAL LETTER MU + __ __ @ + | \ / |@ + | v |@ + | |\_/| |@ + | | | |@ + |_| |_|@ + @ + @@ +0x039D GREEK CAPITAL LETTER NU + _ _ @ + | \ | |@ + | \| |@ + | |@ + | |\ |@ + |_| \_|@ + @ + @@ +0x039E GREEK CAPITAL LETTER XI + _____ @ + (_____)@ + ___ @ + (___) @ + _____ @ + (_____)@ + @ + @@ +0x039F GREEK CAPITAL LETTER OMICRON + ___ @ + / _ \ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +0x03A0 GREEK CAPITAL LETTER PI + _______ @ + ( _ )@ + | | | | @ + | | | | @ + | | | | @ + |_| |_| @ + @ + @@ +0x03A1 GREEK CAPITAL LETTER RHO + ____ @ + | _ \ @ + | |_) )@ + | __/ @ + | | @ + |_| @ + @ + @@ +0x03A3 GREEK CAPITAL LETTER SIGMA + ______ @ + \ ___)@ + \ \ @ + > > @ + / /__ @ + /_____)@ + @ + @@ +0x03A4 GREEK CAPITAL LETTER TAU + _____ @ + (_ _)@ + | | @ + | | @ + | | @ + |_| @ + @ + @@ +0x03A5 GREEK CAPITAL LETTER UPSILON + __ __ @ + (_ \ / _)@ + \ v / @ + | | @ + | | @ + |_| @ + @ + @@ +0x03A6 GREEK CAPITAL LETTER PHI + _ @ + _| |_ @ + / \ @ + ( (| |) )@ + \_ _/ @ + |_| @ + @ + @@ +0x03A7 GREEK CAPITAL LETTER CHI + __ __@ + \ \ / /@ + \ v / @ + > < @ + / ^ \ @ + /_/ \_\@ + @ + @@ +0x03A8 GREEK CAPITAL LETTER PSI + _ _ _ @ + | || || |@ + | \| |/ |@ + \_ _/ @ + | | @ + |_| @ + @ + @@ +0x03A9 GREEK CAPITAL LETTER OMEGA + ____ @ + / __ \ @ + | | | | @ + | | | | @ + _\ \/ /_ @ + (___||___)@ + @ + @@ +0x03B1 GREEK SMALL LETTER ALPHA + @ + @ + __ __@ + / \/ /@ + ( () < @ + \__/\_\@ + @ + @@ +0x03B2 GREEK SMALL LETTER BETA + ___ @ + / _ \ @ + | |_) )@ + | _ < @ + | |_) )@ + | __/ @ + | | @ + |_| @@ +0x03B3 GREEK SMALL LETTER GAMMA + @ + @ + _ _ @ + ( \ / )@ + \ v / @ + | | @ + | | @ + |_| @@ +0x03B4 GREEK SMALL LETTER DELTA + __ @ + / _) @ + \ \ @ + / _ \ @ + ( (_) )@ + \___/ @ + @ + @@ +0x03B5 GREEK SMALL LETTER EPSILON + @ + @ + ___ @ + / __)@ + > _) @ + \___)@ + @ + @@ +0x03B6 GREEK SMALL LETTER ZETA + _____ @ + \__ ) @ + / / @ + / / @ + | |__ @ + \__ \ @ + ) )@ + (_/ @@ +0x03B7 GREEK SMALL LETTER ETA + @ + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| | |@ + | |@ + |_|@@ +0x03B8 GREEK SMALL LETTER THETA + ___ @ + / _ \ @ + | |_| |@ + | _ |@ + | |_| |@ + \___/ @ + @ + @@ +0x03B9 GREEK SMALL LETTER IOTA + @ + @ + _ @ + | | @ + | | @ + \_)@ + @ + @@ +0x03BA GREEK SMALL LETTER KAPPA + @ + @ + _ __@ + | |/ /@ + | < @ + |_|\_\@ + @ + @@ +0x03BB GREEK SMALL LETTER LAMDA + __ @ + \ \ @ + \ \ @ + > \ @ + / ^ \ @ + /_/ \_\@ + @ + @@ +0x03BC GREEK SMALL LETTER MU + @ + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + | | @ + |_| @@ +0x03BD GREEK SMALL LETTER NU + @ + @ + _ __@ + | |/ /@ + | / / @ + |__/ @ + @ + @@ +0x03BE GREEK SMALL LETTER XI + \=\__ @ + > __) @ + ( (_ @ + > _) @ + ( (__ @ + \__ \ @ + ) )@ + (_/ @@ +0x03BF GREEK SMALL LETTER OMICRON + @ + @ + ___ @ + / _ \ @ + ( (_) )@ + \___/ @ + @ + @@ +0x03C0 GREEK SMALL LETTER PI + @ + @ + ______ @ + ( __ )@ + | || | @ + |_||_| @ + @ + @@ +0x03C1 GREEK SMALL LETTER RHO + @ + @ + ___ @ + / _ \ @ + | |_) )@ + | __/ @ + | | @ + |_| @@ +0x03C2 GREEK SMALL LETTER FINAL SIGMA + @ + @ + ____ @ + / ___)@ + ( (__ @ + \__ \ @ + _) )@ + (__/ @@ +0x03C3 GREEK SMALL LETTER SIGMA + @ + @ + ____ @ + / ._)@ + ( () ) @ + \__/ @ + @ + @@ +0x03C4 GREEK SMALL LETTER TAU + @ + @ + ___ @ + ( )@ + | | @ + \_)@ + @ + @@ +0x03C5 GREEK SMALL LETTER UPSILON + @ + @ + _ _ @ + | | | |@ + | |_| |@ + \___/ @ + @ + @@ +0x03C6 GREEK SMALL LETTER PHI + _ @ + | | @ + _| |_ @ + / \ @ + ( (| |) )@ + \_ _/ @ + | | @ + |_| @@ +0x03C7 GREEK SMALL LETTER CHI + @ + @ + __ __@ + \ \ / /@ + \ v / @ + > < @ + / ^ \ @ + /_/ \_\@@ +0x03C8 GREEK SMALL LETTER PSI + @ + @ + _ _ _ @ + | || || |@ + | \| |/ |@ + \_ _/ @ + | | @ + |_| @@ +0x03C9 GREEK SMALL LETTER OMEGA + @ + @ + __ __ @ + / / _ \ \ @ + | |_/ \_| |@ + \___^___/ @ + @ + @@ +0x03D1 GREEK THETA SYMBOL + ___ @ + / _ \ @ + ( (_| |_ @ + _ \ _ _)@ + | |___| | @ + \_____/ @ + @ + @@ +0x03D5 GREEK PHI SYMBOL + @ + @ + _ __ @ + | | / \ @ + | || || )@ + \_ _/ @ + | | @ + |_| @@ +0x03D6 GREEK PI SYMBOL + @ + @ + _________ @ + ( _____ )@ + | |_/ \_| |@ + \___^___/ @ + @ + @@ +-0x0005 +alpha = a, beta = b, gamma = g, delta = d, epsilon = e @ +zeta = z, eta = h, theta = q, iota = i, lamda = l, mu = m@ +nu = n, xi = x, omicron = o, pi = p, rho = r, sigma = s @ +phi = f, chi = c, psi = y, omega = w, final sigma = V @ + pi symbol = v, theta symbol = J, phi symbol = j @ + middle dot = :, ypogegrammeni = _ @ + rough breathing = (, smooth breathing = ) @ + acute accent = ', grave accent = `, dialytika = ^ @@ diff --git a/extern/Tui/Widget/Figlet/fonts/mini.flf b/extern/Tui/Widget/Figlet/fonts/mini.flf new file mode 100644 index 00000000..3b72606c --- /dev/null +++ b/extern/Tui/Widget/Figlet/fonts/mini.flf @@ -0,0 +1,899 @@ +flf2a$ 4 3 10 0 10 0 1920 96 +Mini by Glenn Chappell 4/93 +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + +$$@ +$$@ +$$@ +$$@@ + @ + |$@ + o$@ + @@ + @ + ||$@ + @ + @@ + @ + -|-|-$@ + -|-|-$@ + @@ + _$@ + (|$ @ + _|)$@ + @@ + @ + O/$@ + /O$@ + @@ + @ + ()$ @ + (_X$@ + @@ + @ + /$@ + @ + @@ + @ + /$@ + |$ @ + \$@@ + @ + \$ @ + |$@ + /$ @@ + @ + \|/$@ + /|\$@ + @@ + @ + _|_$@ + |$ @ + @@ + @ + @ + o$@ + /$@@ + @ + __$@ + @ + @@ + @ + @ + o$@ + @@ + @ + /$@ + /$ @ + @@ + _$ @ + / \$@ + \_/$@ + @@ + @ + /|$@ + |$@ + @@ + _$ @ + )$@ + /_$@ + @@ + _$ @ + _)$@ + _)$@ + @@ + @ + |_|_$@ + |$ @ + @@ + _$ @ + |_$ @ + _)$@ + @@ + _$ @ + |_$ @ + |_)$@ + @@ + __$@ + /$@ + /$ @ + @@ + _$ @ + (_)$@ + (_)$@ + @@ + _$ @ + (_|$@ + |$@ + @@ + @ + o$@ + o$@ + @@ + @ + o$@ + o$@ + /$@@ + @ + /$@ + \$@ + @@ + @ + --$@ + --$@ + @@ + @ + \$@ + /$@ + @@ + _$ @ + )$@ + o$ @ + @@ + __$ @ + / \$@ + | (|/$@ + \__$ @@ + @ + /\$ @ + /--\$@ + @@ + _$ @ + |_)$@ + |_)$@ + @@ + _$@ + /$ @ + \_$@ + @@ + _$ @ + | \$@ + |_/$@ + @@ + _$@ + |_$@ + |_$@ + @@ + _$@ + |_$@ + |$ @ + @@ + __$@ + /__$@ + \_|$@ + @@ + @ + |_|$@ + | |$@ + @@ + ___$@ + |$ @ + _|_$@ + @@ + @ + |$@ + \_|$@ + @@ + @ + |/$@ + |\$@ + @@ + @ + |$ @ + |_$@ + @@ + @ + |\/|$@ + | |$@ + @@ + @ + |\ |$@ + | \|$@ + @@ + _$ @ + / \$@ + \_/$@ + @@ + _$ @ + |_)$@ + |$ @ + @@ + _$ @ + / \$@ + \_X$@ + @@ + _$ @ + |_)$@ + | \$@ + @@ + __$@ + (_$ @ + __)$@ + @@ + ___$@ + |$ @ + |$ @ + @@ + @ + | |$@ + |_|$@ + @@ + @ + \ /$@ + \/$ @ + @@ + @ + \ /$@ + \/\/$ @ + @@ + @ + \/$@ + /\$@ + @@ + @ + \_/$@ + |$ @ + @@ + __$@ + /$@ + /_$@ + @@ + _$@ + |$ @ + |_$@ + @@ + @ + \$ @ + \$@ + @@ + _$ @ + |$@ + _|$@ + @@ + /\$@ + @ + @ + @@ + @ + @ + @ + __$@@ + @ + \$@ + @ + @@ + @ + _.$@ + (_|$@ + @@ + @ + |_$ @ + |_)$@ + @@ + @ + _$@ + (_$@ + @@ + @ + _|$@ + (_|$@ + @@ + @ + _$ @ + (/_$@ + @@ + _$@ + _|_$@ + |$ @ + @@ + @ + _$ @ + (_|$@ + _|$@@ + @ + |_$ @ + | |$@ + @@ + @ + o$@ + |$@ + @@ + @ + o$@ + |$@ + _|$@@ + @ + |$ @ + |<$@ + @@ + @ + |$@ + |$@ + @@ + @ + ._ _$ @ + | | |$@ + @@ + @ + ._$ @ + | |$@ + @@ + @ + _$ @ + (_)$@ + @@ + @ + ._$ @ + |_)$@ + |$ @@ + @ + _.$@ + (_|$@ + |$@@ + @ + ._$@ + |$ @ + @@ + @ + _$@ + _>$@ + @@ + @ + _|_$@ + |_$@ + @@ + @ + @ + |_|$@ + @@ + @ + @ + \/$@ + @@ + @ + @ + \/\/$@ + @@ + @ + @ + ><$@ + @@ + @ + @ + \/$@ + /$ @@ + @ + _$ @ + /_$@ + @@ + ,-$@ + _|$ @ + |$ @ + `-$@@ + |$@ + |$@ + |$@ + |$@@ + -.$ @ + |_$@ + |$ @ + -'$ @@ + /\/$@ + @ + @ + @@ + o o$@ + /\$ @ + /--\$@ + @@ + o_o$@ + / \$@ + \_/$@ + @@ + o o$@ + | |$@ + |_|$@ + @@ + o o$@ + _.$@ + (_|$@ + @@ + o o$@ + _$ @ + (_)$@ + @@ + o o$@ + @ + |_|$@ + @@ + _$ @ + | )$@ + | )$@ + |$ @@ +160 NO-BREAK SPACE + $$@ + $$@ + $$@ + $$@@ +161 INVERTED EXCLAMATION MARK + @ + o$@ + |$@ + @@ +162 CENT SIGN + @ + |_$@ + (__$@ + |$ @@ +163 POUND SIGN + _$ @ + _/_`$ @ + |___$@ + @@ +164 CURRENCY SIGN + @ + `o'$@ + ' `$@ + @@ +165 YEN SIGN + @ + _\_/_$@ + --|--$@ + @@ +166 BROKEN BAR + |$@ + |$@ + |$@ + |$@@ +167 SECTION SIGN + _$@ + ($ @ + ()$@ + _)$@@ +168 DIAERESIS + o o$@ + @ + @ + @@ +169 COPYRIGHT SIGN + _$ @ + |C|$@ + `-'$@ + @@ +170 FEMININE ORDINAL INDICATOR + _.$@ + (_|$@ + ---$@ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + @ + //$@ + \\$@ + @@ +172 NOT SIGN + @ + __$ @ + |$@ + @@ +173 SOFT HYPHEN + @ + _$@ + @ + @@ +174 REGISTERED SIGN + _$ @ + |R|$@ + `-'$@ + @@ +175 MACRON + __$@ + @ + @ + @@ +176 DEGREE SIGN + O$@ + @ + @ + @@ +177 PLUS-MINUS SIGN + @ + _|_$@ + _|_$@ + @@ +178 SUPERSCRIPT TWO + 2$@ + @ + @ + @@ +179 SUPERSCRIPT THREE + 3$@ + @ + @ + @@ +180 ACUTE ACCENT + /$@ + @ + @ + @@ +181 MICRO SIGN + @ + @ + |_|$@ + |$ @@ +182 PILCROW SIGN + __$ @ + (| |$@ + | |$@ + @@ +183 MIDDLE DOT + @ + o$@ + @ + @@ +184 CEDILLA + @ + @ + @ + S$@@ +185 SUPERSCRIPT ONE + 1$@ + @ + @ + @@ +186 MASCULINE ORDINAL INDICATOR + _$ @ + (_)$@ + ---$@ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + @ + \\$@ + //$@ + @@ +188 VULGAR FRACTION ONE QUARTER + @ + 1/$@ + /4$@ + @@ +189 VULGAR FRACTION ONE HALF + @ + 1/$@ + /2$@ + @@ +190 VULGAR FRACTION THREE QUARTERS + @ + 3/$@ + /4$@ + @@ +191 INVERTED QUESTION MARK + @ + o$@ + (_$@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + \$ @ + /\$ @ + /--\$@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + /$ @ + /\$ @ + /--\$@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + /\$ @ + /\$ @ + /--\$@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/$@ + /\$ @ + /--\$@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + o o$@ + /\$ @ + /--\$@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + O$ @ + / \$ @ + /---\$@ + @@ +198 LATIN CAPITAL LETTER AE + _$@ + /|_$@ + /-|_$@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + _$@ + /$ @ + \_$@ + S$@@ +200 LATIN CAPITAL LETTER E WITH GRAVE + \_$@ + |_$@ + |_$@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + _/$@ + |_$ @ + |_$ @ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + /\$@ + |_$ @ + |_$ @ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + o_o$@ + |_$ @ + |_$ @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + \__$@ + |$ @ + _|_$@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __/$@ + |$ @ + _|_$@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + /\$@ + ___$@ + _|_$@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + o_o$@ + |$ @ + _|_$@ + @@ +208 LATIN CAPITAL LETTER ETH + _$ @ + _|_\$@ + |_/$@ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/$@ + |\ |$@ + | \|$@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + \$ @ + / \$@ + \_/$@ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + /$ @ + / \$@ + \_/$@ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + /\$@ + / \$@ + \_/$@ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/$@ + / \$@ + \_/$@ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + o_o$@ + / \$@ + \_/$@ + @@ +215 MULTIPLICATION SIGN + @ + @ + X$@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + __$ @ + / /\$@ + \/_/$@ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + \$ @ + | |$@ + |_|$@ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + /$ @ + | |$@ + |_|$@ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + /\$@ + | |$@ + |_|$@ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + o o$@ + | |$@ + |_|$@ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + /$ @ + \_/$@ + |$ @ + @@ +222 LATIN CAPITAL LETTER THORN + |_$ @ + |_)$@ + |$ @ + @@ +223 LATIN SMALL LETTER SHARP S + _$ @ + | )$@ + | )$@ + |$ @@ +224 LATIN SMALL LETTER A WITH GRAVE + \$ @ + _.$@ + (_|$@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + /$ @ + _.$@ + (_|$@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + /\$@ + _.$@ + (_|$@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/$@ + _.$@ + (_|$@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + o o$@ + _.$@ + (_|$@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + O$ @ + _.$@ + (_|$@ + @@ +230 LATIN SMALL LETTER AE + @ + ___$ @ + (_|/_$@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + _$@ + (_$@ + S$@@ +232 LATIN SMALL LETTER E WITH GRAVE + \$ @ + _$ @ + (/_$@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + /$ @ + _$ @ + (/_$@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + /\$@ + _$ @ + (/_$@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + o o$@ + _$ @ + (/_$@ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + \$@ + @ + |$@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + /$@ + @ + |$@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + /\$@ + @ + |$ @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + o o$@ + @ + |$ @ + @@ +240 LATIN SMALL LETTER ETH + X$ @ + \$ @ + (_|$@ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/$@ + ._$ @ + | |$@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + \$ @ + _$ @ + (_)$@ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + /$ @ + _$ @ + (_)$@ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + /\$@ + _$ @ + (_)$@ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/$@ + _$ @ + (_)$@ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + o o$@ + _$ @ + (_)$@ + @@ +247 DIVISION SIGN + o$ @ + ---$@ + o$ @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + _$ @ + (/)$@ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + \$ @ + @ + |_|$@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + /$ @ + @ + |_|$@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + /\$@ + @ + |_|$@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + o o$@ + @ + |_|$@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + /$@ + @ + \/$@ + /$ @@ +254 LATIN SMALL LETTER THORN + @ + |_$ @ + |_)$@ + |$ @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + oo$@ + @ + \/$@ + /$ @@ diff --git a/extern/Tui/Widget/Figlet/fonts/slant.flf b/extern/Tui/Widget/Figlet/fonts/slant.flf new file mode 100644 index 00000000..43fe3986 --- /dev/null +++ b/extern/Tui/Widget/Figlet/fonts/slant.flf @@ -0,0 +1,1295 @@ +flf2a$ 6 5 16 15 10 0 18319 96 +Slant by Glenn Chappell 3/93 -- based on Standard +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + + $$@ + $$ @ + $$ @ + $$ @ + $$ @ +$$ @@ + __@ + / /@ + / / @ + /_/ @ +(_) @ + @@ + _ _ @ +( | )@ +|/|/ @ + $ @ +$ @ + @@ + __ __ @ + __/ // /_@ + /_ _ __/@ +/_ _ __/ @ + /_//_/ @ + @@ + __@ + _/ /@ + / __/@ + (_ ) @ +/ _/ @ +/_/ @@ + _ __@ + (_)_/_/@ + _/_/ @ + _/_/_ @ +/_/ (_) @ + @@ + ___ @ + ( _ ) @ + / __ \/|@ +/ /_/ < @ +\____/\/ @ + @@ + _ @ + ( )@ + |/ @ + $ @ +$ @ + @@ + __@ + _/_/@ + / / @ + / / @ +/ / @ +|_| @@ + _ @ + | |@ + / /@ + / / @ + _/_/ @ +/_/ @@ + @ + __/|_@ + | /@ +/_ __| @ + |/ @ + @@ + @ + __ @ + __/ /_@ +/_ __/@ + /_/ @ + @@ + @ + @ + @ + _ @ +( )@ +|/ @@ + @ + @ + ______@ +/_____/@ + $ @ + @@ + @ + @ + @ + _ @ +(_)@ + @@ + __@ + _/_/@ + _/_/ @ + _/_/ @ +/_/ @ + @@ + ____ @ + / __ \@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ + ___@ + < /@ + / / @ + / / @ +/_/ @ + @@ + ___ @ + |__ \@ + __/ /@ + / __/ @ +/____/ @ + @@ + _____@ + |__ /@ + /_ < @ + ___/ / @ +/____/ @ + @@ + __ __@ + / // /@ + / // /_@ +/__ __/@ + /_/ @ + @@ + ______@ + / ____/@ + /___ \ @ + ____/ / @ +/_____/ @ + @@ + _____@ + / ___/@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ + _____@ +/__ /@ + / / @ + / / @ +/_/ @ + @@ + ____ @ + ( __ )@ + / __ |@ +/ /_/ / @ +\____/ @ + @@ + ____ @ + / __ \@ + / /_/ /@ + \__, / @ +/____/ @ + @@ + @ + _ @ + (_)@ + _ @ +(_) @ + @@ + @ + _ @ + (_)@ + _ @ +( ) @ +|/ @@ + __@ + / /@ +/ / @ +\ \ @ + \_\@ + @@ + @ + _____@ + /____/@ +/____/ @ + $ @ + @@ +__ @ +\ \ @ + \ \@ + / /@ +/_/ @ + @@ + ___ @ + /__ \@ + / _/@ + /_/ @ +(_) @ + @@ + ______ @ + / ____ \@ + / / __ `/@ +/ / /_/ / @ +\ \__,_/ @ + \____/ @@ + ___ @ + / |@ + / /| |@ + / ___ |@ +/_/ |_|@ + @@ + ____ @ + / __ )@ + / __ |@ + / /_/ / @ +/_____/ @ + @@ + ______@ + / ____/@ + / / @ +/ /___ @ +\____/ @ + @@ + ____ @ + / __ \@ + / / / /@ + / /_/ / @ +/_____/ @ + @@ + ______@ + / ____/@ + / __/ @ + / /___ @ +/_____/ @ + @@ + ______@ + / ____/@ + / /_ @ + / __/ @ +/_/ @ + @@ + ______@ + / ____/@ + / / __ @ +/ /_/ / @ +\____/ @ + @@ + __ __@ + / / / /@ + / /_/ / @ + / __ / @ +/_/ /_/ @ + @@ + ____@ + / _/@ + / / @ + _/ / @ +/___/ @ + @@ + __@ + / /@ + __ / / @ +/ /_/ / @ +\____/ @ + @@ + __ __@ + / //_/@ + / ,< @ + / /| | @ +/_/ |_| @ + @@ + __ @ + / / @ + / / @ + / /___@ +/_____/@ + @@ + __ ___@ + / |/ /@ + / /|_/ / @ + / / / / @ +/_/ /_/ @ + @@ + _ __@ + / | / /@ + / |/ / @ + / /| / @ +/_/ |_/ @ + @@ + ____ @ + / __ \@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ + ____ @ + / __ \@ + / /_/ /@ + / ____/ @ +/_/ @ + @@ + ____ @ + / __ \@ + / / / /@ +/ /_/ / @ +\___\_\ @ + @@ + ____ @ + / __ \@ + / /_/ /@ + / _, _/ @ +/_/ |_| @ + @@ + _____@ + / ___/@ + \__ \ @ + ___/ / @ +/____/ @ + @@ + ______@ + /_ __/@ + / / @ + / / @ +/_/ @ + @@ + __ __@ + / / / /@ + / / / / @ +/ /_/ / @ +\____/ @ + @@ + _ __@ +| | / /@ +| | / / @ +| |/ / @ +|___/ @ + @@ + _ __@ +| | / /@ +| | /| / / @ +| |/ |/ / @ +|__/|__/ @ + @@ + _ __@ + | |/ /@ + | / @ + / | @ +/_/|_| @ + @@ +__ __@ +\ \/ /@ + \ / @ + / / @ +/_/ @ + @@ + _____@ +/__ /@ + / / @ + / /__@ +/____/@ + @@ + ___@ + / _/@ + / / @ + / / @ + / / @ +/__/ @@ +__ @ +\ \ @ + \ \ @ + \ \ @ + \_\@ + @@ + ___@ + / /@ + / / @ + / / @ + _/ / @ +/__/ @@ + //|@ + |/||@ + $ @ + $ @ +$ @ + @@ + @ + @ + @ + @ + ______@ +/_____/@@ + _ @ + ( )@ + V @ + $ @ +$ @ + @@ + @ + ____ _@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ + __ @ + / /_ @ + / __ \@ + / /_/ /@ +/_.___/ @ + @@ + @ + _____@ + / ___/@ +/ /__ @ +\___/ @ + @@ + __@ + ____/ /@ + / __ / @ +/ /_/ / @ +\__,_/ @ + @@ + @ + ___ @ + / _ \@ +/ __/@ +\___/ @ + @@ + ____@ + / __/@ + / /_ @ + / __/ @ +/_/ @ + @@ + @ + ____ _@ + / __ `/@ + / /_/ / @ + \__, / @ +/____/ @@ + __ @ + / /_ @ + / __ \@ + / / / /@ +/_/ /_/ @ + @@ + _ @ + (_)@ + / / @ + / / @ +/_/ @ + @@ + _ @ + (_)@ + / / @ + / / @ + __/ / @ +/___/ @@ + __ @ + / /__@ + / //_/@ + / ,< @ +/_/|_| @ + @@ + __@ + / /@ + / / @ + / / @ +/_/ @ + @@ + @ + ____ ___ @ + / __ `__ \@ + / / / / / /@ +/_/ /_/ /_/ @ + @@ + @ + ____ @ + / __ \@ + / / / /@ +/_/ /_/ @ + @@ + @ + ____ @ + / __ \@ +/ /_/ /@ +\____/ @ + @@ + @ + ____ @ + / __ \@ + / /_/ /@ + / .___/ @ +/_/ @@ + @ + ____ _@ + / __ `/@ +/ /_/ / @ +\__, / @ + /_/ @@ + @ + _____@ + / ___/@ + / / @ +/_/ @ + @@ + @ + _____@ + / ___/@ + (__ ) @ +/____/ @ + @@ + __ @ + / /_@ + / __/@ +/ /_ @ +\__/ @ + @@ + @ + __ __@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ + @ + _ __@ +| | / /@ +| |/ / @ +|___/ @ + @@ + @ + _ __@ +| | /| / /@ +| |/ |/ / @ +|__/|__/ @ + @@ + @ + _ __@ + | |/_/@ + _> < @ +/_/|_| @ + @@ + @ + __ __@ + / / / /@ + / /_/ / @ + \__, / @ +/____/ @@ + @ + ____@ +/_ /@ + / /_@ +/___/@ + @@ + __@ + _/_/@ + _/_/ @ +< < @ +/ / @ +\_\ @@ + __@ + / /@ + / / @ + / / @ + / / @ +/_/ @@ + _ @ + | |@ + / /@ + _>_>@ + _/_/ @ +/_/ @@ + /\//@ + //\/ @ + $ @ + $ @ +$ @ + @@ + _ _ @ + (_)(_)@ + / _ | @ + / __ | @ +/_/ |_| @ + @@ + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\____/ @ + @@ + _ _ @ + (_)_(_)@ + / __ `/ @ +/ /_/ / @ +\__,_/ @ + @@ + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\__,_/ @ + @@ + ____ @ + / __ \@ + / / / /@ + / /_| | @ + / //__/ @ +/_/ @@ +160 NO-BREAK SPACE + $$@ + $$ @ + $$ @ + $$ @ + $$ @ +$$ @@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + / / @ + / / @ +/_/ @ + @@ +162 CENT SIGN + __@ + __/ /@ + / ___/@ +/ /__ @ +\ _/ @ +/_/ @@ +163 POUND SIGN + ____ @ + / ,__\@ + __/ /_ @ + _/ /___ @ +(_,____/ @ + @@ +164 CURRENCY SIGN + /|___/|@ + | __ / @ + / /_/ / @ + /___ | @ +|/ |/ @ + @@ +165 YEN SIGN + ____@ + _| / /@ + /_ __/@ +/_ __/ @ + /_/ @ + @@ +166 BROKEN BAR + __@ + / /@ + /_/ @ + __ @ + / / @ +/_/ @@ +167 SECTION SIGN + __ @ + _/ _)@ + / | | @ + | || | @ + | |_/ @ +(__/ @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ +$ $ @ + @@ +169 COPYRIGHT SIGN + ______ @ + / _____\ @ + / / ___/ |@ + / / /__ / @ +| \___/ / @ + \______/ @@ +170 FEMININE ORDINAL INDICATOR + ___ _@ + / _ `/@ + _\_,_/ @ +/____/ @ + $ @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ +/ / / @ +\ \ \ @ + \_\_\@ + @@ +172 NOT SIGN + @ + ______@ +/___ /@ + /_/ @ + $ @ + @@ +173 SOFT HYPHEN + @ + @ + _____@ +/____/@ + $ @ + @@ +174 REGISTERED SIGN + ______ @ + / ___ \ @ + / / _ \ |@ + / / , _/ / @ +| /_/|_| / @ + \______/ @@ +175 MACRON + ______@ +/_____/@ + $ @ + $ @ +$ @ + @@ +176 DEGREE SIGN + ___ @ + / _ \@ +/ // /@ +\___/ @ + $ @ + @@ +177 PLUS-MINUS SIGN + __ @ + __/ /_@ + /_ __/@ + __/_/_ @ +/_____/ @ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ |@ + / __/ @ +/____/ @ + $ @ + @@ +179 SUPERSCRIPT THREE + ____@ + |_ /@ + _/_ < @ +/____/ @ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ +$ @ + @@ +181 MICRO SIGN + @ + __ __@ + / / / /@ + / /_/ / @ + / ._,_/ @ +/_/ @@ +182 PILCROW SIGN + _______@ + / _ /@ +/ (/ / / @ +\_ / / @ + /_/_/ @ + @@ +183 MIDDLE DOT + @ + _ @ +(_)@ + $ @ +$ @ + @@ +184 CEDILLA + @ + @ + @ + @ + _ @ +/_)@@ +185 SUPERSCRIPT ONE + ___@ + < /@ + / / @ +/_/ @ +$ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + _\___/@ +/____/ @ + $ @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK +____ @ +\ \ \ @ + \ \ \@ + / / /@ +/_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + ___ __ @ + < / _/_/ @ + / /_/_/___@ +/_//_// / /@ + /_/ /_ _/@ + /_/ @@ +189 VULGAR FRACTION ONE HALF + ___ __ @ + < / _/_/__ @ + / /_/_/|_ |@ +/_//_/ / __/ @ + /_/ /____/ @ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |_ / _/_/ @ + _/_ < _/_/___@ +/____//_// / /@ + /_/ /_ _/@ + /_/ @@ +191 INVERTED QUESTION MARK + _ @ + (_)@ + _/ / @ +/ _/_ @ +\___/ @ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + _\_\@ + / _ |@ + / __ |@ +/_/ |_|@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __@ + _/_/@ + / _ |@ + / __ |@ +/_/ |_|@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //|@ + _|/||@ + / _ | @ + / __ | @ +/_/ |_| @ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\//@ + _//\/ @ + / _ | @ + / __ | @ +/_/ |_| @ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + / _ | @ + / __ | @ +/_/ |_| @ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + (())@ + / |@ + / /| |@ + / ___ |@ +/_/ |_|@ + @@ +198 LATIN CAPITAL LETTER AE + __________@ + / ____/@ + / /| __/ @ + / __ /___ @ +/_/ /_____/ @ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ______@ + / ____/@ + / / @ +/ /___ @ +\____/ @ + /_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\_\@ + / __/@ + / _/ @ +/___/ @ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __@ + _/_/@ + / __/@ + / _/ @ +/___/ @ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //|@ + _|/||@ + / __/ @ + / _/ @ +/___/ @ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + / __/ @ + / _/ @ +/___/ @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + _\_\@ + / _/@ + _/ / @ +/___/ @ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __@ + _/_/@ + / _/@ + _/ / @ +/___/ @ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //|@ + _|/||@ + / _/ @ + _/ / @ +/___/ @ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)(_)@ + / _/ @ + _/ / @ +/___/ @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + / __ \@ + __/ /_/ /@ +/_ __/ / @ + /_____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\//@ + _//\/ @ + / |/ / @ + / / @ +/_/|_/ @ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + __\_\@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __@ + __/_/@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //|@ + _|/||@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\//@ + _//\/ @ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /|/|@ + > < @ +|/|/ @ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + _____ @ + / _// \@ + / //// /@ +/ //// / @ +\_//__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + __\_\_@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + __/_/_@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //| @ + _|/||_@ + / / / /@ +/ /_/ / @ +\____/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\____/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ +__/_/_@ +\ \/ /@ + \ / @ + /_/ @ + @@ +222 LATIN CAPITAL LETTER THORN + __ @ + / /_ @ + / __ \@ + / ____/@ +/_/ @ + @@ +223 LATIN SMALL LETTER SHARP S + ____ @ + / __ \@ + / / / /@ + / /_| | @ + / //__/ @ +/_/ @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + __\_\_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + __/_/_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //| @ + _|/||_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\//@ + _//\/_@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + / __ `/ @ +/ /_/ / @ +\__,_/ @ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + __(())@ + / __ `/@ +/ /_/ / @ +\__,_/ @ + @@ +230 LATIN SMALL LETTER AE + @ + ____ ___ @ + / __ ` _ \@ +/ /_/ __/@ +\__,_____/ @ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + _____@ + / ___/@ +/ /__ @ +\___/ @ +/_) @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + _\_\@ + / _ \@ +/ __/@ +\___/ @ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __@ + _/_/@ + / _ \@ +/ __/@ +\___/ @ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //|@ + _|/||@ + / _ \ @ +/ __/ @ +\___/ @ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + / _ \ @ +/ __/ @ +\___/ @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + / / @ + / / @ +/_/ @ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + / / @ + / / @ +/_/ @ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //|@ + |/||@ + / / @ + / / @ +/_/ @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + / / @ + / / @ +/_/ @ + @@ +240 LATIN SMALL LETTER ETH + || @ + =||=@ + ___ || @ +/ __` | @ +\____/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\//@ + _//\/ @ + / __ \ @ + / / / / @ +/_/ /_/ @ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + __\_\@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __@ + __/_/@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //|@ + _|/||@ + / __ \@ +/ /_/ /@ +\____/ @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\//@ + _//\/ @ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / __ \ @ +/ /_/ / @ +\____/ @ + @@ +247 DIVISION SIGN + @ + _ @ + __(_)_@ +/_____/@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + _____ @ + / _// \@ +/ //// /@ +\_//__/ @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + __\_\_@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + __/_/_@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //| @ + _|/||_@ + / / / /@ +/ /_/ / @ +\__,_/ @ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + / / / / @ +/ /_/ / @ +\__,_/ @ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + __/_/_@ + / / / /@ + / /_/ / @ + \__, / @ +/____/ @@ +254 LATIN SMALL LETTER THORN + __ @ + / /_ @ + / __ \@ + / /_/ /@ + / .___/ @ +/_/ @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + / / / / @ + / /_/ / @ + \__, / @ +/____/ @@ diff --git a/extern/Tui/Widget/Figlet/fonts/small.flf b/extern/Tui/Widget/Figlet/fonts/small.flf new file mode 100644 index 00000000..c6b5bfcd --- /dev/null +++ b/extern/Tui/Widget/Figlet/fonts/small.flf @@ -0,0 +1,1097 @@ +flf2a$ 5 4 13 15 10 0 22415 96 +Small by Glenn Chappell 4/93 -- based on Standard +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + @@ + _ _ @ + _| | |_ @ + |_ . _|@ + |_ _|@ + |_|_| @@ + @ + ||_@ + (_-<@ + / _/@ + || @@ + _ __ @ + (_)/ / @ + / /_ @ + /_/(_)@ + @@ + __ @ + / _|___ @ + > _|_ _|@ + \_____| @ + @@ + _ @ + ( )@ + |/ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + \_\@@ + __ @ + \ \ @ + | |@ + | |@ + /_/ @@ + @ + _/\_@ + > <@ + \/ @ + @@ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + _ @ + ( )@ + |/ @@ + @ + ___ @ + |___|@ + $ @ + @@ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + /_/ @ + @@ + __ @ + / \ @ + | () |@ + \__/ @ + @@ + _ @ + / |@ + | |@ + |_|@ + @@ + ___ @ + |_ )@ + / / @ + /___|@ + @@ + ____@ + |__ /@ + |_ \@ + |___/@ + @@ + _ _ @ + | | | @ + |_ _|@ + |_| @ + @@ + ___ @ + | __|@ + |__ \@ + |___/@ + @@ + __ @ + / / @ + / _ \@ + \___/@ + @@ + ____ @ + |__ |@ + / / @ + /_/ @ + @@ + ___ @ + ( _ )@ + / _ \@ + \___/@ + @@ + ___ @ + / _ \@ + \_, /@ + /_/ @ + @@ + _ @ + (_)@ + _ @ + (_)@ + @@ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + < < @ + \_\@ + @@ + @ + ___ @ + |___|@ + |___|@ + @@ + __ @ + \ \ @ + > >@ + /_/ @ + @@ + ___ @ + |__ \@ + /_/@ + (_) @ + @@ + ____ @ + / __ \ @ + / / _` |@ + \ \__,_|@ + \____/ @@ + _ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ + ___ @ + | _ )@ + | _ \@ + |___/@ + @@ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ + ___ @ + | \ @ + | |) |@ + |___/ @ + @@ + ___ @ + | __|@ + | _| @ + |___|@ + @@ + ___ @ + | __|@ + | _| @ + |_| @ + @@ + ___ @ + / __|@ + | (_ |@ + \___|@ + @@ + _ _ @ + | || |@ + | __ |@ + |_||_|@ + @@ + ___ @ + |_ _|@ + | | @ + |___|@ + @@ + _ @ + _ | |@ + | || |@ + \__/ @ + @@ + _ __@ + | |/ /@ + | ' < @ + |_|\_\@ + @@ + _ @ + | | @ + | |__ @ + |____|@ + @@ + __ __ @ + | \/ |@ + | |\/| |@ + |_| |_|@ + @@ + _ _ @ + | \| |@ + | .` |@ + |_|\_|@ + @@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + ___ @ + | _ \@ + | _/@ + |_| @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__\_\@ + @@ + ___ @ + | _ \@ + | /@ + |_|_\@ + @@ + ___ @ + / __|@ + \__ \@ + |___/@ + @@ + _____ @ + |_ _|@ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @@ + __ __@ + \ \ / /@ + \ \/\/ / @ + \_/\_/ @ + @@ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @@ + __ __@ + \ \ / /@ + \ V / @ + |_| @ + @@ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __ @ + | _|@ + | | @ + | | @ + |__|@@ + __ @ + \ \ @ + \ \ @ + \_\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + |__|@@ + /\ @ + |/\|@ + $ @ + $ @ + @@ + @ + @ + @ + ___ @ + |___|@@ + _ @ + ( )@ + \|@ + $ @ + @@ + @ + __ _ @ + / _` |@ + \__,_|@ + @@ + _ @ + | |__ @ + | '_ \@ + |_.__/@ + @@ + @ + __ @ + / _|@ + \__|@ + @@ + _ @ + __| |@ + / _` |@ + \__,_|@ + @@ + @ + ___ @ + / -_)@ + \___|@ + @@ + __ @ + / _|@ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _` |@ + \__, |@ + |___/ @@ + _ @ + | |_ @ + | ' \ @ + |_||_|@ + @@ + _ @ + (_)@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + _/ |@ + |__/ @@ + _ @ + | |__@ + | / /@ + |_\_\@ + @@ + _ @ + | |@ + | |@ + |_|@ + @@ + @ + _ __ @ + | ' \ @ + |_|_|_|@ + @@ + @ + _ _ @ + | ' \ @ + |_||_|@ + @@ + @ + ___ @ + / _ \@ + \___/@ + @@ + @ + _ __ @ + | '_ \@ + | .__/@ + |_| @@ + @ + __ _ @ + / _` |@ + \__, |@ + |_|@@ + @ + _ _ @ + | '_|@ + |_| @ + @@ + @ + ___@ + (_-<@ + /__/@ + @@ + _ @ + | |_ @ + | _|@ + \__|@ + @@ + @ + _ _ @ + | || |@ + \_,_|@ + @@ + @ + __ __@ + \ V /@ + \_/ @ + @@ + @ + __ __ __@ + \ V V /@ + \_/\_/ @ + @@ + @ + __ __@ + \ \ /@ + /_\_\@ + @@ + @ + _ _ @ + | || |@ + \_, |@ + |__/ @@ + @ + ___@ + |_ /@ + /__|@ + @@ + __@ + / /@ + _| | @ + | | @ + \_\@@ + _ @ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | |_@ + | | @ + /_/ @@ + /\/|@ + |/\/ @ + $ @ + $ @ + @@ + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + |_|@ + @@ +162 CENT SIGN + @ + || @ + / _)@ + \ _)@ + || @@ +163 POUND SIGN + __ @ + _/ _\ @ + |_ _|_ @ + (_,___|@ + @@ +164 CURRENCY SIGN + /\_/\@ + \ . /@ + / _ \@ + \/ \/@ + @@ +165 YEN SIGN + __ __ @ + \ V / @ + |__ __|@ + |__ __|@ + |_| @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + / _)@ + /\ \ @ + \ \/ @ + (__/ @@ +168 DIAERESIS + _ _ @ + (_)(_)@ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + ____ @ + / __ \ @ + / / _| \@ + \ \__| /@ + \____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + \__,_|@ + |____|@ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + < < < @ + \_\_\@ + @@ +172 NOT SIGN + ____ @ + |__ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + __ @ + |__|@ + $ @ + @@ +174 REGISTERED SIGN + ____ @ + / __ \ @ + / | -) \@ + \ ||\\ /@ + \____/ @@ +175 MACRON + ___ @ + |___|@ + $ @ + $ @ + @@ +176 DEGREE SIGN + _ @ + /.\@ + \_/@ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@@ +178 SUPERSCRIPT TWO + __ @ + |_ )@ + /__|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ___@ + |_ /@ + |__)@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | || |@ + | .,_|@ + |_| @@ +182 PILCROW SIGN + ____ @ + / |@ + \_ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + @@ +184 CEDILLA + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + \___/@ + |___|@ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + > > >@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / |/ /__ @ + |_/ /_' |@ + /_/ |_|@ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / |/ /_ @ + |_/ /_ )@ + /_//__|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ___ __ @ + |_ // /__ @ + |__) /_' |@ + /_/ |_|@ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + / /_ @ + \___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + /--\ @ + /_/\_\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /--\ @ + /_/\_\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + /--\ @ + /_/\_\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/|@ + |/\/ @ + /--\ @ + /_/\_\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + __ @ + (()) @ + /--\ @ + /_/\_\@ + @@ +198 LATIN CAPITAL LETTER AE + ____ @ + /, __|@ + / _ _| @ + /_/|___|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + \_\@ + | -<@ + |__<@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __@ + /_/@ + | -<@ + |__<@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + /\ @ + |/\|@ + | -<@ + |__<@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + | -< @ + |__< @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | __ \ @ + |_ _|) |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/|@ + |/\/ @ + | \| |@ + |_|\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\_ @ + / __ \@ + \____/@ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + _/_/ @ + / __ \@ + \____/@ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + /\ @ + |/\| @ + / __ \@ + \____/@ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / __ \@ + \____/@ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ +215 MULTIPLICATION SIGN + @ + /\/\@ + > <@ + \/\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | |_| |@ + \___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | |_| |@ + \___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | |_| |@ + \___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + _/_/_@ + \ V /@ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |_ @ + | -_)@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\_ @ + / _` |@ + \__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + _/_/ @ + / _` |@ + \__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + / _` |@ + \__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/|@ + |/\/ @ + / _` |@ + \__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _` |@ + \__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ___ @ + / _` -_)@ + \__,___|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + __ @ + / _|@ + \__|@ + )_)@@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + / -_)@ + \___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / -_)@ + \___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\|@ + / -_)@ + \___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / -_) @ + \___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + \\/\ @ + \/\\ @ + / _` |@ + \___/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + | ' \ @ + |_||_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \@ + \___/@ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \@ + \___/@ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\|@ + / _ \@ + \___/@ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / _ \@ + \___/@ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ +247 DIVISION SIGN + _ @ + (_) @ + |___|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ___ @ + / //\@ + \//_/@ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + \_\_ @ + | || |@ + \_,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + /\ @ + |/\| @ + | || |@ + \_,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_, |@ + |__/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \@ + | .__/@ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_, |@ + |__/ @@ diff --git a/extern/Tui/Widget/Figlet/fonts/standard.flf b/extern/Tui/Widget/Figlet/fonts/standard.flf new file mode 100644 index 00000000..bb15241b --- /dev/null +++ b/extern/Tui/Widget/Figlet/fonts/standard.flf @@ -0,0 +1,2238 @@ +flf2a$ 6 5 16 15 15 0 24463 229 +Standard by Glenn Chappell & Ian Chai 3/93 -- based on Frank's .sig +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Modified for figlet 2.2 by John Cowan + to add Latin-{2,3,4,5} support (Unicode U+0100-017F). +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +--- + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + +Modified 2012-05 by Patrick Gillespie (patorjk@gmail.com) to add the 0xCA0 character. + $@ + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + $ @ + @@ + _ _ @ + _| || |_ @ + |_ .. _|@ + |_ _|@ + |_||_| @ + @@ + _ @ + | | @ + / __)@ + \__ \@ + ( /@ + |_| @@ + _ __@ + (_)/ /@ + / / @ + / /_ @ + /_/(_)@ + @@ + ___ @ + ( _ ) @ + / _ \/\@ + | (_> <@ + \___/\/@ + @@ + _ @ + ( )@ + |/ @ + $ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + | | @ + \_\@@ + __ @ + \ \ @ + | |@ + | |@ + | |@ + /_/ @@ + @ + __/\__@ + \ /@ + /_ _\@ + \/ @ + @@ + @ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + @ + _ @ + ( )@ + |/ @@ + @ + @ + _____ @ + |_____|@ + $ @ + @@ + @ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + / / @ + /_/ @ + @@ + ___ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + _ @ + / |@ + | |@ + | |@ + |_|@ + @@ + ____ @ + |___ \ @ + __) |@ + / __/ @ + |_____|@ + @@ + _____ @ + |___ / @ + |_ \ @ + ___) |@ + |____/ @ + @@ + _ _ @ + | || | @ + | || |_ @ + |__ _|@ + |_| @ + @@ + ____ @ + | ___| @ + |___ \ @ + ___) |@ + |____/ @ + @@ + __ @ + / /_ @ + | '_ \ @ + | (_) |@ + \___/ @ + @@ + _____ @ + |___ |@ + / / @ + / / @ + /_/ @ + @@ + ___ @ + ( _ ) @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__, |@ + /_/ @ + @@ + @ + _ @ + (_)@ + _ @ + (_)@ + @@ + @ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + / / @ + \ \ @ + \_\@ + @@ + @ + _____ @ + |_____|@ + |_____|@ + $ @ + @@ + __ @ + \ \ @ + \ \@ + / /@ + /_/ @ + @@ + ___ @ + |__ \@ + / /@ + |_| @ + (_) @ + @@ + ____ @ + / __ \ @ + / / _` |@ + | | (_| |@ + \ \__,_|@ + \____/ @@ + _ @ + / \ @ + / _ \ @ + / ___ \ @ + /_/ \_\@ + @@ + ____ @ + | __ ) @ + | _ \ @ + | |_) |@ + |____/ @ + @@ + ____ @ + / ___|@ + | | @ + | |___ @ + \____|@ + @@ + ____ @ + | _ \ @ + | | | |@ + | |_| |@ + |____/ @ + @@ + _____ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @@ + _____ @ + | ___|@ + | |_ @ + | _| @ + |_| @ + @@ + ____ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ + _ _ @ + | | | |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ + ___ @ + |_ _|@ + | | @ + | | @ + |___|@ + @@ + _ @ + | |@ + _ | |@ + | |_| |@ + \___/ @ + @@ + _ __@ + | |/ /@ + | ' / @ + | . \ @ + |_|\_\@ + @@ + _ @ + | | @ + | | @ + | |___ @ + |_____|@ + @@ + __ __ @ + | \/ |@ + | |\/| |@ + | | | |@ + |_| |_|@ + @@ + _ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @@ + ___ @ + / _ \ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + ____ @ + | _ \ @ + | |_) |@ + | __/ @ + |_| @ + @@ + ___ @ + / _ \ @ + | | | |@ + | |_| |@ + \__\_\@ + @@ + ____ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + @@ + ____ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + @@ + __ __@ + \ \ / /@ + \ \ / / @ + \ V / @ + \_/ @ + @@ + __ __@ + \ \ / /@ + \ \ /\ / / @ + \ V V / @ + \_/\_/ @ + @@ + __ __@ + \ \/ /@ + \ / @ + / \ @ + /_/\_\@ + @@ + __ __@ + \ \ / /@ + \ V / @ + | | @ + |_| @ + @@ + _____@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ + __ @ + | _|@ + | | @ + | | @ + | | @ + |__|@@ + __ @ + \ \ @ + \ \ @ + \ \ @ + \_\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + | |@ + |__|@@ + /\ @ + |/\|@ + $ @ + $ @ + $ @ + @@ + @ + @ + @ + @ + _____ @ + |_____|@@ + _ @ + ( )@ + \|@ + $ @ + $ @ + @@ + @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ + _ @ + | |__ @ + | '_ \ @ + | |_) |@ + |_.__/ @ + @@ + @ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ + _ @ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @@ + @ + ___ @ + / _ \@ + | __/@ + \___|@ + @@ + __ @ + / _|@ + | |_ @ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ + _ @ + | |__ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ + _ @ + (_)@ + | |@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + | |@ + _/ |@ + |__/ @@ + _ @ + | | __@ + | |/ /@ + | < @ + |_|\_\@ + @@ + _ @ + | |@ + | |@ + | |@ + |_|@ + @@ + @ + _ __ ___ @ + | '_ ` _ \ @ + | | | | | |@ + |_| |_| |_|@ + @@ + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ + @ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + @ + _ __ @ + | '_ \ @ + | |_) |@ + | .__/ @ + |_| @@ + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |_|@@ + @ + _ __ @ + | '__|@ + | | @ + |_| @ + @@ + @ + ___ @ + / __|@ + \__ \@ + |___/@ + @@ + _ @ + | |_ @ + | __|@ + | |_ @ + \__|@ + @@ + @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ + @ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @@ + @ + __ __@ + \ \ /\ / /@ + \ V V / @ + \_/\_/ @ + @@ + @ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @@ + @ + _ _ @ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ + @ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __@ + / /@ + | | @ + < < @ + | | @ + \_\@@ + _ @ + | |@ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | | @ + > >@ + | | @ + /_/ @@ + /\/|@ + |/\/ @ + $ @ + $ @ + $ @ + @@ + _ _ @ + (_)_(_)@ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_)_(_)@ + / _` |@ + | (_| |@ + \__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + | (_) |@ + \___/ @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \__,_|@ + @@ + ___ @ + / _ \@ + | |/ /@ + | |\ \@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + | |@ + |_|@ + @@ +162 CENT SIGN + _ @ + | | @ + / __)@ + | (__ @ + \ )@ + |_| @@ +163 POUND SIGN + ___ @ + / ,_\ @ + _| |_ @ + | |___ @ + (_,____|@ + @@ +164 CURRENCY SIGN + /\___/\@ + \ _ /@ + | (_) |@ + / ___ \@ + \/ \/@ + @@ +165 YEN SIGN + __ __ @ + \ V / @ + |__ __|@ + |__ __|@ + |_| @ + @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + _ @ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + _/ _)@ + / \ \ @ + \ \\ \@ + \ \_/@ + (__/ @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + _____ @ + / ___ \ @ + / / __| \ @ + | | (__ |@ + \ \___| / @ + \_____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + \__,_|@ + |____|@ + $ @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + / / / @ + \ \ \ @ + \_\_\@ + @@ +172 NOT SIGN + @ + _____ @ + |___ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + @ + ____ @ + |____|@ + $ @ + @@ +174 REGISTERED SIGN + _____ @ + / ___ \ @ + / | _ \ \ @ + | | / |@ + \ |_|_\ / @ + \_____/ @@ +175 MACRON + _____ @ + |_____|@ + $ @ + $ @ + $ @ + @@ +176 DEGREE SIGN + __ @ + / \ @ + | () |@ + \__/ @ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ )@ + / / @ + /___|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ____@ + |__ /@ + |_ \@ + |___/@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + |_| @@ +182 PILCROW SIGN + _____ @ + / |@ + | (| | |@ + \__ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + $ @ + @@ +184 CEDILLA + @ + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + | |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + \___/@ + |___|@ + $ @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + \ \ \@ + / / /@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / | / / _ @ + | |/ / | | @ + |_/ /|_ _|@ + /_/ |_| @ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / | / /__ @ + | |/ /_ )@ + |_/ / / / @ + /_/ /___|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |__ / / / _ @ + |_ \/ / | | @ + |___/ /|_ _|@ + /_/ |_| @ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + | | @ + / /_ @ + \___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //\ @ + |/_\| @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/| @ + |/\/ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + _ @ + (o) @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +198 LATIN CAPITAL LETTER AE + ______ @ + / ____|@ + / _ _| @ + / __ |___ @ + /_/ |_____|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ____ @ + / ___|@ + | | @ + | |___ @ + \____|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\_\_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __ @ + _/_/_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\| @ + | ____|@ + | _|_ @ + |_____|@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + | ____|@ + | _|_ @ + |_____|@ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + | | @ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + | | @ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + | | @ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + | | @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | _ \ @ + _| |_| |@ + |__ __| |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/|@ + |/\/ @ + | \| |@ + | .` |@ + |_|\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/| @ + |/\/ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | |_| |@ + \___/ @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /\/\@ + > <@ + \/\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _// @ + | |// |@ + | //| |@ + //__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | | | |@ + | |_| |@ + \___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + __/_/__@ + \ \ / /@ + \ V / @ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |___ @ + | __ \@ + | ___/@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \@ + | |/ /@ + | |\ \@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\_ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + /_/_ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/| @ + |/\/_ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _ '|@ + | (_| |@ + \__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ____ @ + / _` _ \@ + | (_| __/@ + \__,____|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + / _ \@ + | __/@ + \___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / _ \@ + | __/@ + \___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\|@ + / _ \@ + | __/@ + \___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | __/ @ + \___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + | |@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + | | @ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + /\/\ @ + > < @ + _\/\ |@ + / __` |@ + \____/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\| @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/| @ + |/\/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + | (_) |@ + \___/ @ + @@ +247 DIVISION SIGN + @ + _ @ + _(_)_ @ + |_____|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \__,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \ @ + | |_) |@ + | .__/ @ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ +0x0100 LATIN CAPITAL LETTER A WITH MACRON + ____ @ + /___/ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +0x0101 LATIN SMALL LETTER A WITH MACRON + ___ @ + /_ _/@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0102 LATIN CAPITAL LETTER A WITH BREVE + _ _ @ + \\_// @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ +0x0103 LATIN SMALL LETTER A WITH BREVE + \_/ @ + ___ @ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0104 LATIN CAPITAL LETTER A WITH OGONEK + @ + _ @ + /_\ @ + / _ \ @ + /_/ \_\@ + (_(@@ +0x0105 LATIN SMALL LETTER A WITH OGONEK + @ + __ _ @ + / _` |@ + | (_| |@ + \__,_|@ + (_(@@ +0x0106 LATIN CAPITAL LETTER C WITH ACUTE + __ @ + _/_/ @ + / ___|@ + | |___ @ + \____|@ + @@ +0x0107 LATIN SMALL LETTER C WITH ACUTE + __ @ + /__/@ + / __|@ + | (__ @ + \___|@ + @@ +0x0108 LATIN CAPITAL LETTER C WITH CIRCUMFLEX + /\ @ + _//\\@ + / ___|@ + | |___ @ + \____|@ + @@ +0x0109 LATIN SMALL LETTER C WITH CIRCUMFLEX + /\ @ + /_\ @ + / __|@ + | (__ @ + \___|@ + @@ +0x010A LATIN CAPITAL LETTER C WITH DOT ABOVE + [] @ + ____ @ + / ___|@ + | |___ @ + \____|@ + @@ +0x010B LATIN SMALL LETTER C WITH DOT ABOVE + [] @ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ +0x010C LATIN CAPITAL LETTER C WITH CARON + \\// @ + _\/_ @ + / ___|@ + | |___ @ + \____|@ + @@ +0x010D LATIN SMALL LETTER C WITH CARON + \\//@ + _\/ @ + / __|@ + | (__ @ + \___|@ + @@ +0x010E LATIN CAPITAL LETTER D WITH CARON + \\// @ + __\/ @ + | _ \ @ + | |_| |@ + |____/ @ + @@ +0x010F LATIN SMALL LETTER D WITH CARON + \/ _ @ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0110 LATIN CAPITAL LETTER D WITH STROKE + ____ @ + |_ __ \ @ + /| |/ | |@ + /|_|/_| |@ + |_____/ @ + @@ +0x0111 LATIN SMALL LETTER D WITH STROKE + ---|@ + __| |@ + / _` |@ + | (_| |@ + \__,_|@ + @@ +0x0112 LATIN CAPITAL LETTER E WITH MACRON + ____ @ + /___/ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0113 LATIN SMALL LETTER E WITH MACRON + ____@ + /_ _/@ + / _ \ @ + | __/ @ + \___| @ + @@ +0x0114 LATIN CAPITAL LETTER E WITH BREVE + _ _ @ + \\_// @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0115 LATIN SMALL LETTER E WITH BREVE + \\ //@ + -- @ + / _ \ @ + | __/ @ + \___| @ + @@ +0x0116 LATIN CAPITAL LETTER E WITH DOT ABOVE + [] @ + _____ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0117 LATIN SMALL LETTER E WITH DOT ABOVE + [] @ + __ @ + / _ \@ + | __/@ + \___|@ + @@ +0x0118 LATIN CAPITAL LETTER E WITH OGONEK + @ + _____ @ + | ____|@ + | _|_ @ + |_____|@ + (__(@@ +0x0119 LATIN SMALL LETTER E WITH OGONEK + @ + ___ @ + / _ \@ + | __/@ + \___|@ + (_(@@ +0x011A LATIN CAPITAL LETTER E WITH CARON + \\// @ + __\/_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x011B LATIN SMALL LETTER E WITH CARON + \\//@ + \/ @ + / _ \@ + | __/@ + \___|@ + @@ +0x011C LATIN CAPITAL LETTER G WITH CIRCUMFLEX + _/\_ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ +0x011D LATIN SMALL LETTER G WITH CIRCUMFLEX + /\ @ + _/_ \@ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ +0x011E LATIN CAPITAL LETTER G WITH BREVE + _\/_ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ +0x011F LATIN SMALL LETTER G WITH BREVE + \___/ @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ +0x0120 LATIN CAPITAL LETTER G WITH DOT ABOVE + _[]_ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + @@ +0x0121 LATIN SMALL LETTER G WITH DOT ABOVE + [] @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |___/ @@ +0x0122 LATIN CAPITAL LETTER G WITH CEDILLA + ____ @ + / ___|@ + | | _ @ + | |_| |@ + \____|@ + )__) @@ +0x0123 LATIN SMALL LETTER G WITH CEDILLA + @ + __ _ @ + / _` |@ + | (_| |@ + \__, |@ + |_))))@@ +0x0124 LATIN CAPITAL LETTER H WITH CIRCUMFLEX + _/ \_ @ + | / \ |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ +0x0125 LATIN SMALL LETTER H WITH CIRCUMFLEX + _ /\ @ + | |//\ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0126 LATIN CAPITAL LETTER H WITH STROKE + _ _ @ + | |=| |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ +0x0127 LATIN SMALL LETTER H WITH STROKE + _ @ + |=|__ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0128 LATIN CAPITAL LETTER I WITH TILDE + /\//@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x0129 LATIN SMALL LETTER I WITH TILDE + @ + /\/@ + | |@ + | |@ + |_|@ + @@ +0x012A LATIN CAPITAL LETTER I WITH MACRON + /___/@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x012B LATIN SMALL LETTER I WITH MACRON + ____@ + /___/@ + | | @ + | | @ + |_| @ + @@ +0x012C LATIN CAPITAL LETTER I WITH BREVE + \__/@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x012D LATIN SMALL LETTER I WITH BREVE + @ + \_/@ + | |@ + | |@ + |_|@ + @@ +0x012E LATIN CAPITAL LETTER I WITH OGONEK + ___ @ + |_ _|@ + | | @ + | | @ + |___|@ + (__(@@ +0x012F LATIN SMALL LETTER I WITH OGONEK + _ @ + (_) @ + | | @ + | | @ + |_|_@ + (_(@@ +0x0130 LATIN CAPITAL LETTER I WITH DOT ABOVE + _[] @ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x0131 LATIN SMALL LETTER DOTLESS I + @ + _ @ + | |@ + | |@ + |_|@ + @@ +0x0132 LATIN CAPITAL LIGATURE IJ + ___ _ @ + |_ _|| |@ + | | | |@ + | |_| |@ + |__|__/ @ + @@ +0x0133 LATIN SMALL LIGATURE IJ + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + |_|_/ |@ + |__/ @@ +0x0134 LATIN CAPITAL LETTER J WITH CIRCUMFLEX + /\ @ + /_\|@ + _ | | @ + | |_| | @ + \___/ @ + @@ +0x0135 LATIN SMALL LETTER J WITH CIRCUMFLEX + /\@ + /_\@ + | |@ + | |@ + _/ |@ + |__/ @@ +0x0136 LATIN CAPITAL LETTER K WITH CEDILLA + _ _ @ + | |/ / @ + | ' / @ + | . \ @ + |_|\_\ @ + )__)@@ +0x0137 LATIN SMALL LETTER K WITH CEDILLA + _ @ + | | __@ + | |/ /@ + | < @ + |_|\_\@ + )_)@@ +0x0138 LATIN SMALL LETTER KRA + @ + _ __ @ + | |/ \@ + | < @ + |_|\_\@ + @@ +0x0139 LATIN CAPITAL LETTER L WITH ACUTE + _ //@ + | | // @ + | | @ + | |___ @ + |_____|@ + @@ +0x013A LATIN SMALL LETTER L WITH ACUTE + //@ + | |@ + | |@ + | |@ + |_|@ + @@ +0x013B LATIN CAPITAL LETTER L WITH CEDILLA + _ @ + | | @ + | | @ + | |___ @ + |_____|@ + )__)@@ +0x013C LATIN SMALL LETTER L WITH CEDILLA + _ @ + | | @ + | | @ + | | @ + |_| @ + )_)@@ +0x013D LATIN CAPITAL LETTER L WITH CARON + _ \\//@ + | | \/ @ + | | @ + | |___ @ + |_____|@ + @@ +0x013E LATIN SMALL LETTER L WITH CARON + _ \\//@ + | | \/ @ + | | @ + | | @ + |_| @ + @@ +0x013F LATIN CAPITAL LETTER L WITH MIDDLE DOT + _ @ + | | @ + | | [] @ + | |___ @ + |_____|@ + @@ +0x0140 LATIN SMALL LETTER L WITH MIDDLE DOT + _ @ + | | @ + | | []@ + | | @ + |_| @ + @@ +0x0141 LATIN CAPITAL LETTER L WITH STROKE + __ @ + | // @ + |//| @ + // |__ @ + |_____|@ + @@ +0x0142 LATIN SMALL LETTER L WITH STROKE + _ @ + | |@ + |//@ + //|@ + |_|@ + @@ +0x0143 LATIN CAPITAL LETTER N WITH ACUTE + _/ /_ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @@ +0x0144 LATIN SMALL LETTER N WITH ACUTE + _ @ + _ /_/ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0145 LATIN CAPITAL LETTER N WITH CEDILLA + _ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + )_) @@ +0x0146 LATIN SMALL LETTER N WITH CEDILLA + @ + _ __ @ + | '_ \ @ + | | | |@ + |_| |_|@ + )_) @@ +0x0147 LATIN CAPITAL LETTER N WITH CARON + _\/ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \_|@ + @@ +0x0148 LATIN SMALL LETTER N WITH CARON + \\// @ + _\/_ @ + | '_ \ @ + | | | |@ + |_| |_|@ + @@ +0x0149 LATIN SMALL LETTER N PRECEDED BY APOSTROPHE + @ + _ __ @ + ( )| '_\ @ + |/| | | |@ + |_| |_|@ + @@ +0x014A LATIN CAPITAL LETTER ENG + _ _ @ + | \ | |@ + | \| |@ + | |\ |@ + |_| \ |@ + )_)@@ +0x014B LATIN SMALL LETTER ENG + _ __ @ + | '_ \ @ + | | | |@ + |_| | |@ + | |@ + |__ @@ +0x014C LATIN CAPITAL LETTER O WITH MACRON + ____ @ + /_ _/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +0x014D LATIN SMALL LETTER O WITH MACRON + ____ @ + /_ _/ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ +0x014E LATIN CAPITAL LETTER O WITH BREVE + \ / @ + _-_ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x014F LATIN SMALL LETTER O WITH BREVE + \ / @ + _-_ @ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x0150 LATIN CAPITAL LETTER O WITH DOUBLE ACUTE + ___ @ + /_/_/@ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x0151 LATIN SMALL LETTER O WITH DOUBLE ACUTE + ___ @ + /_/_/@ + / _ \ @ + | |_| |@ + \___/ @ + @@ +0x0152 LATIN CAPITAL LIGATURE OE + ___ ___ @ + / _ \| __|@ + | | | | | @ + | |_| | |__@ + \___/|____@ + @@ +0x0153 LATIN SMALL LIGATURE OE + @ + ___ ___ @ + / _ \ / _ \@ + | (_) | __/@ + \___/ \___|@ + @@ +0x0154 LATIN CAPITAL LETTER R WITH ACUTE + _/_/ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + @@ +0x0155 LATIN SMALL LETTER R WITH ACUTE + __@ + _ /_/@ + | '__|@ + | | @ + |_| @ + @@ +0x0156 LATIN CAPITAL LETTER R WITH CEDILLA + ____ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + )_) @@ +0x0157 LATIN SMALL LETTER R WITH CEDILLA + @ + _ __ @ + | '__|@ + | | @ + |_| @ + )_) @@ +0x0158 LATIN CAPITAL LETTER R WITH CARON + _\_/ @ + | _ \ @ + | |_) |@ + | _ < @ + |_| \_\@ + @@ +0x0159 LATIN SMALL LETTER R WITH CARON + \\// @ + _\/_ @ + | '__|@ + | | @ + |_| @ + @@ +0x015A LATIN CAPITAL LETTER S WITH ACUTE + _/_/ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ +0x015B LATIN SMALL LETTER S WITH ACUTE + __@ + _/_/@ + / __|@ + \__ \@ + |___/@ + @@ +0x015C LATIN CAPITAL LETTER S WITH CIRCUMFLEX + _/\_ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ +0x015D LATIN SMALL LETTER S WITH CIRCUMFLEX + @ + /_\_@ + / __|@ + \__ \@ + |___/@ + @@ +0x015E LATIN CAPITAL LETTER S WITH CEDILLA + ____ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + )__)@@ +0x015F LATIN SMALL LETTER S WITH CEDILLA + @ + ___ @ + / __|@ + \__ \@ + |___/@ + )_)@@ +0x0160 LATIN CAPITAL LETTER S WITH CARON + _\_/ @ + / ___| @ + \___ \ @ + ___) |@ + |____/ @ + @@ +0x0161 LATIN SMALL LETTER S WITH CARON + \\//@ + _\/ @ + / __|@ + \__ \@ + |___/@ + @@ +0x0162 LATIN CAPITAL LETTER T WITH CEDILLA + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + )__)@@ +0x0163 LATIN SMALL LETTER T WITH CEDILLA + _ @ + | |_ @ + | __|@ + | |_ @ + \__|@ + )_)@@ +0x0164 LATIN CAPITAL LETTER T WITH CARON + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + @@ +0x0165 LATIN SMALL LETTER T WITH CARON + \/ @ + | |_ @ + | __|@ + | |_ @ + \__|@ + @@ +0x0166 LATIN CAPITAL LETTER T WITH STROKE + _____ @ + |_ _|@ + | | @ + -|-|- @ + |_| @ + @@ +0x0167 LATIN SMALL LETTER T WITH STROKE + _ @ + | |_ @ + | __|@ + |-|_ @ + \__|@ + @@ +0x0168 LATIN CAPITAL LETTER U WITH TILDE + @ + _/\/_ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x0169 LATIN SMALL LETTER U WITH TILDE + @ + _/\/_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x016A LATIN CAPITAL LETTER U WITH MACRON + ____ @ + /__ _/@ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x016B LATIN SMALL LETTER U WITH MACRON + ____ @ + / _ /@ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x016C LATIN CAPITAL LETTER U WITH BREVE + @ + \_/_ @ + | | | |@ + | |_| |@ + \____|@ + @@ +0x016D LATIN SMALL LETTER U WITH BREVE + @ + \_/_ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x016E LATIN CAPITAL LETTER U WITH RING ABOVE + O @ + __ _ @ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x016F LATIN SMALL LETTER U WITH RING ABOVE + O @ + __ __ @ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x0170 LATIN CAPITAL LETTER U WITH DOUBLE ACUTE + -- --@ + /_//_/@ + | | | |@ + | |_| |@ + \___/ @ + @@ +0x0171 LATIN SMALL LETTER U WITH DOUBLE ACUTE + ____@ + _/_/_/@ + | | | |@ + | |_| |@ + \__,_|@ + @@ +0x0172 LATIN CAPITAL LETTER U WITH OGONEK + _ _ @ + | | | |@ + | | | |@ + | |_| |@ + \___/ @ + (__(@@ +0x0173 LATIN SMALL LETTER U WITH OGONEK + @ + _ _ @ + | | | |@ + | |_| |@ + \__,_|@ + (_(@@ +0x0174 LATIN CAPITAL LETTER W WITH CIRCUMFLEX + __ /\ __@ + \ \ //\\/ /@ + \ \ /\ / / @ + \ V V / @ + \_/\_/ @ + @@ +0x0175 LATIN SMALL LETTER W WITH CIRCUMFLEX + /\ @ + __ //\\__@ + \ \ /\ / /@ + \ V V / @ + \_/\_/ @ + @@ +0x0176 LATIN CAPITAL LETTER Y WITH CIRCUMFLEX + /\ @ + __//\\ @ + \ \ / /@ + \ V / @ + |_| @ + @@ +0x0177 LATIN SMALL LETTER Y WITH CIRCUMFLEX + /\ @ + //\\ @ + | | | |@ + | |_| |@ + \__, |@ + |___/ @@ +0x0178 LATIN CAPITAL LETTER Y WITH DIAERESIS + [] []@ + __ _@ + \ \ / /@ + \ V / @ + |_| @ + @@ +0x0179 LATIN CAPITAL LETTER Z WITH ACUTE + __/_/@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017A LATIN SMALL LETTER Z WITH ACUTE + _ @ + _/_/@ + |_ /@ + / / @ + /___|@ + @@ +0x017B LATIN CAPITAL LETTER Z WITH DOT ABOVE + __[]_@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017C LATIN SMALL LETTER Z WITH DOT ABOVE + [] @ + ____@ + |_ /@ + / / @ + /___|@ + @@ +0x017D LATIN CAPITAL LETTER Z WITH CARON + _\_/_@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017E LATIN SMALL LETTER Z WITH CARON + \\//@ + _\/_@ + |_ /@ + / / @ + /___|@ + @@ +0x017F LATIN SMALL LETTER LONG S + __ @ + / _|@ + |-| | @ + |-| | @ + |_| @ + @@ +0x02C7 CARON + \\//@ + \/ @ + $@ + $@ + $@ + $@@ +0x02D8 BREVE + \\_//@ + \_/ @ + $@ + $@ + $@ + $@@ +0x02D9 DOT ABOVE + []@ + $@ + $@ + $@ + $@ + $@@ +0x02DB OGONEK + $@ + $@ + $@ + $@ + $@ + )_) @@ +0x02DD DOUBLE ACUTE ACCENT + _ _ @ + /_/_/@ + $@ + $@ + $@ + $@@ +0xCA0 KANNADA LETTER TTHA + _____)@ + /_ ___/@ + / _ \ @ + | (_) | @ + $\___/$ @ + @@ diff --git a/extern/Tui/Widget/FocusableInterface.php b/extern/Tui/Widget/FocusableInterface.php new file mode 100644 index 00000000..7c1ea895 --- /dev/null +++ b/extern/Tui/Widget/FocusableInterface.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Input\Keybindings; + +/** + * Interface for widgets that can receive focus. + * + * Widgets that accept user input (text editors, inputs, lists) implement + * this interface so the focus manager can route keyboard events to them. + * + * Widgets that display a text cursor should emit + * {@see AnsiUtils::cursorMarker()} at the cursor position when focused + * so the terminal's hardware cursor handles blinking natively and IME + * candidate windows appear at the right spot. + * + * @experimental + * + * @author Fabien Potencier + */ +interface FocusableInterface +{ + /** + * Check if the widget currently has focus. + */ + public function isFocused(): bool; + + /** + * Set the focus state of the widget. + * + * @return $this + */ + public function setFocused(bool $focused): static; + + /** + * Register a callback invoked before handleInput(). + * + * The callback receives the raw input string and should return true + * to consume the event (preventing handleInput() from processing it) + * or false to let the widget handle it normally. + * + * @param (callable(string): bool)|null $callback + * + * @return $this + */ + public function onInput(?callable $callback): static; + + /** + * Handle keyboard/terminal input when focused. + */ + public function handleInput(string $data): void; + + /** + * Get the keybindings for this widget. + * + * Resolution order (later overrides earlier): + * 1. Widget defaults (from getDefaultKeybindings()) + * 2. Global keybindings from the TUI (via WidgetContext) + * 3. Explicit keybindings set on this widget (via setKeybindings()) + */ + public function getKeybindings(): Keybindings; + + /** + * Set explicit keybindings for this widget. + * + * When set, these keybindings take priority over the TUI's default. + * + * @return $this + */ + public function setKeybindings(?Keybindings $keybindings): static; +} diff --git a/extern/Tui/Widget/FocusableTrait.php b/extern/Tui/Widget/FocusableTrait.php new file mode 100644 index 00000000..e5c69731 --- /dev/null +++ b/extern/Tui/Widget/FocusableTrait.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Default implementation of focus state for focusable widgets. + * + * Invalidates the widget when focus changes. Override setFocused() + * for custom behavior (e.g. cursor blinker management). + * + * @experimental + * + * @author Fabien Potencier + */ +trait FocusableTrait +{ + private bool $focused = false; + + public function isFocused(): bool + { + return $this->focused; + } + + /** + * @return $this + */ + public function setFocused(bool $focused): static + { + if ($this->focused !== $focused) { + $this->focused = $focused; + $this->invalidate(); + } + + return $this; + } +} diff --git a/extern/Tui/Widget/InputWidget.php b/extern/Tui/Widget/InputWidget.php new file mode 100644 index 00000000..f396eea5 --- /dev/null +++ b/extern/Tui/Widget/InputWidget.php @@ -0,0 +1,450 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\ChangeEvent; +use Symfony\Component\Tui\Event\SubmitEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Style\CursorShape; +use Symfony\Component\Tui\Widget\Util\Line; +use Symfony\Component\Tui\Widget\Util\StringUtils; + +/** + * Single-line text input with horizontal scrolling. + * + * @experimental + * + * @author Fabien Potencier + */ +class InputWidget extends AbstractWidget implements FocusableInterface +{ + use BracketedPasteTrait; + use FocusableTrait; + use KeybindingsTrait; + + private Line $line; + private string $prompt = '> '; + private bool $submitted = false; + + public function __construct( + ?Keybindings $keybindings = null, + ) { + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + $this->line = new Line(); + } + + /** + * @param callable(SubmitEvent): void $callback + * + * @return $this + */ + public function onSubmit(callable $callback): static + { + return $this->on(SubmitEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): static + { + return $this->on(CancelEvent::class, $callback); + } + + /** + * @param callable(ChangeEvent): void $callback + * + * @return $this + */ + public function onChange(callable $callback): static + { + return $this->on(ChangeEvent::class, $callback); + } + + public function getValue(): string + { + return $this->line->getText(); + } + + /** + * Check if the input was submitted (Enter pressed) vs cancelled (Escape pressed). + */ + public function wasSubmitted(): bool + { + return $this->submitted; + } + + /** + * @return $this + */ + public function setPrompt(string $prompt): static + { + if ($this->prompt !== $prompt) { + $this->prompt = $prompt; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setValue(string $value): static + { + // When setting a new value, move cursor to the end of the string + $newCursor = \strlen($value); + if ($this->line->getText() !== $value || $this->line->getCursor() !== $newCursor) { + $this->line->setText($value); + $this->line->setCursor($newCursor); + $this->invalidate(); + } + + return $this; + } + + public function setFocused(bool $focused): static + { + if ($this->focused !== $focused) { + $this->focused = $focused; + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + return $this; + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + $beforeValue = $this->line->getText(); + $beforeCursor = $this->line->getCursor(); + + try { + // Handle bracketed paste mode + $pastedText = $this->processBracketedPaste($data); + if (null !== $pastedText) { + $this->handlePaste($pastedText); + if ('' === $data) { + return; + } + } elseif ($this->isBufferingPaste()) { + return; + } + + $kb = $this->getKeybindings(); + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->submitted = false; + $this->dispatch(new CancelEvent($this)); + + return; + } + + // Submit + if ($kb->matches($data, 'submit') || "\n" === $data) { + $this->submitted = true; + $this->dispatch(new SubmitEvent($this, $this->line->getText())); + + return; + } + + // Deletion (line-level, then word-level, then char-level) + if ($kb->matches($data, 'delete_to_line_start')) { + if ('' !== $this->line->deleteToStart()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_to_line_end')) { + if ('' !== $this->line->deleteToEnd()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_word_backward')) { + if ('' !== $this->line->deleteWordBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_backward')) { + if ($this->line->deleteCharBackward()) { + $this->notifyChange(); + } + + return; + } + + if ($kb->matches($data, 'delete_char_forward')) { + if ($this->line->deleteCharForward()) { + $this->notifyChange(); + } + + return; + } + + // Cursor movement + if ($kb->matches($data, 'cursor_left')) { + $this->line->moveCursorLeft(); + + return; + } + + if ($kb->matches($data, 'cursor_right')) { + $this->line->moveCursorRight(); + + return; + } + + if ($kb->matches($data, 'cursor_line_start')) { + $this->line->moveCursorToStart(); + + return; + } + + if ($kb->matches($data, 'cursor_line_end')) { + $this->line->moveCursorToEnd(); + + return; + } + + if ($kb->matches($data, 'cursor_word_left')) { + $this->line->moveWordBackward(); + + return; + } + + if ($kb->matches($data, 'cursor_word_right')) { + $this->line->moveWordForward(); + + return; + } + + // Regular character input + if (!StringUtils::hasControlChars($data)) { + $this->line->insert($data); + $this->notifyChange(); + } + } finally { + if ($this->line->getText() === $beforeValue && $this->line->getCursor() !== $beforeCursor) { + $this->invalidate(); + } + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + $prompt = $this->prompt; + $availableColumns = $columns - AnsiUtils::visibleWidth($prompt); + + if ($availableColumns <= 0) { + return [$prompt]; + } + + $value = $this->line->getText(); + $cursor = $this->line->getCursor(); + + // Split into graphemes for width-aware scrolling + $graphemes = grapheme_str_split($value) ?: []; + + // Find cursor grapheme index from byte offset + $cursorGraphemeIndex = \count($graphemes); + $bytePos = 0; + foreach ($graphemes as $i => $g) { + if ($cursor < $bytePos + \strlen($g)) { + $cursorGraphemeIndex = $i; + break; + } + $bytePos += \strlen($g); + } + + $totalWidth = AnsiUtils::visibleWidth($value); + + if ($totalWidth < $availableColumns) { + $visibleGraphemes = $graphemes; + $cursorVisibleIndex = $cursorGraphemeIndex; + } else { + // Horizontal scrolling in grapheme/display-width space + $atEnd = $cursorGraphemeIndex === \count($graphemes); + $scrollColumns = $atEnd ? $availableColumns - 1 : $availableColumns; + $halfColumns = (int) floor($scrollColumns / 2); + + // Measure display width of graphemes before cursor + $widthBeforeCursor = AnsiUtils::visibleWidth(implode('', \array_slice($graphemes, 0, $cursorGraphemeIndex))); + + if ($widthBeforeCursor < $halfColumns) { + // Cursor near start, take graphemes from the beginning that fit + $visibleGraphemes = self::takeGraphemesByWidth($graphemes, 0, $scrollColumns); + $cursorVisibleIndex = $cursorGraphemeIndex; + } elseif ($widthBeforeCursor > $totalWidth - $halfColumns) { + // Cursor near end, take graphemes from the end that fit + $visibleGraphemes = self::takeGraphemesFromEndByWidth($graphemes, $scrollColumns); + $startIndex = \count($graphemes) - \count($visibleGraphemes); + $cursorVisibleIndex = $cursorGraphemeIndex - $startIndex; + } else { + // Cursor in middle, center around cursor + [$visibleGraphemes, $startIndex] = self::takeGraphemesCenteredByWidth($graphemes, $cursorGraphemeIndex, $scrollColumns); + $cursorVisibleIndex = $cursorGraphemeIndex - $startIndex; + } + } + + // Build before/at/after cursor from visible graphemes + $beforeCursor = implode('', \array_slice($visibleGraphemes, 0, $cursorVisibleIndex)); + $atCursor = $visibleGraphemes[$cursorVisibleIndex] ?? ' '; + $afterCursor = implode('', \array_slice($visibleGraphemes, $cursorVisibleIndex + 1)); + + $cursorStyle = $this->resolveElement('cursor'); + $marker = $this->focused ? AnsiUtils::cursorMarker($cursorStyle->getCursorShape() ?? CursorShape::Block) : ''; + $textWithCursor = $beforeCursor.$marker.$atCursor.$afterCursor; + + // Pad to width + $visualLength = AnsiUtils::visibleWidth($textWithCursor); + $padding = str_repeat(' ', max(0, $availableColumns - $visualLength)); + + $line = $prompt.$textWithCursor.$padding; + + return [$line]; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + 'cursor_word_left' => ['alt+left', 'ctrl+left', 'alt+b'], + 'cursor_word_right' => ['alt+right', 'ctrl+right', 'alt+f'], + 'cursor_line_start' => [Key::HOME, 'ctrl+a'], + 'cursor_line_end' => [Key::END, 'ctrl+e'], + 'delete_char_backward' => [Key::BACKSPACE, 'shift+backspace'], + 'delete_char_forward' => [Key::DELETE, 'ctrl+d', 'shift+delete'], + 'delete_word_backward' => ['ctrl+w', 'alt+backspace'], + 'delete_to_line_start' => ['ctrl+u'], + 'delete_to_line_end' => ['ctrl+k'], + 'submit' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + ]; + } + + /** + * Take graphemes from $startIndex forward that fit within $maxWidth display columns. + * + * @param string[] $graphemes + * + * @return string[] + */ + private static function takeGraphemesByWidth(array $graphemes, int $startIndex, int $maxWidth): array + { + $result = []; + $width = 0; + for ($i = $startIndex; $i < \count($graphemes); ++$i) { + $gw = AnsiUtils::visibleWidth($graphemes[$i]); + if ($width + $gw > $maxWidth) { + break; + } + $result[] = $graphemes[$i]; + $width += $gw; + } + + return $result; + } + + /** + * Take graphemes from the end that fit within $maxWidth display columns. + * + * @param string[] $graphemes + * + * @return string[] + */ + private static function takeGraphemesFromEndByWidth(array $graphemes, int $maxWidth): array + { + $result = []; + $width = 0; + for ($i = \count($graphemes) - 1; $i >= 0; --$i) { + $gw = AnsiUtils::visibleWidth($graphemes[$i]); + if ($width + $gw > $maxWidth) { + break; + } + array_unshift($result, $graphemes[$i]); + $width += $gw; + } + + return $result; + } + + /** + * Take graphemes centered around $centerIndex that fit within $maxWidth display columns. + * + * @param string[] $graphemes + * + * @return array{string[], int} The visible graphemes and the start index in the original array + */ + private static function takeGraphemesCenteredByWidth(array $graphemes, int $centerIndex, int $maxWidth): array + { + $halfWidth = (int) floor($maxWidth / 2); + + // Expand left from center until we reach halfWidth + $startIndex = $centerIndex; + $leftWidth = 0; + while ($startIndex > 0) { + $gw = AnsiUtils::visibleWidth($graphemes[$startIndex - 1]); + if ($leftWidth + $gw > $halfWidth) { + break; + } + --$startIndex; + $leftWidth += $gw; + } + + // Take graphemes from startIndex that fit within maxWidth + return [self::takeGraphemesByWidth($graphemes, $startIndex, $maxWidth), $startIndex]; + } + + private function handlePaste(string $text): void + { + // Clean pasted text - remove newlines + $cleanText = str_replace(["\r\n", "\r", "\n"], '', $text); + + $this->line->insert($cleanText); + $this->notifyChange(); + } + + private function notifyChange(): void + { + $this->invalidate(); + $this->dispatch(new ChangeEvent($this, $this->line->getText())); + } +} diff --git a/extern/Tui/Widget/KeybindingsTrait.php b/extern/Tui/Widget/KeybindingsTrait.php new file mode 100644 index 00000000..726fdf5b --- /dev/null +++ b/extern/Tui/Widget/KeybindingsTrait.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Input\Keybindings; + +/** + * Default implementation of keybindings for focusable widgets. + * + * Resolution order (later overrides earlier): + * 1. Widget defaults (from getDefaultKeybindings()) + * 2. Global keybindings from the TUI (via WidgetContext) + * 3. Explicit keybindings set on this widget (via setKeybindings()) + * + * @experimental + * + * @author Fabien Potencier + */ +trait KeybindingsTrait +{ + private ?Keybindings $keybindings = null; + + /** @var (callable(string): bool)|null */ + private $onInput; + + /** + * Return the effective keybindings for this widget. + * + * Resolution order (later overrides earlier): + * 1. Widget defaults (from getDefaultKeybindings()) + * 2. Global keybindings from the TUI (via WidgetContext) + * 3. Explicit keybindings set on this widget (via setKeybindings()) + */ + public function getKeybindings(): Keybindings + { + $bindings = static::getDefaultKeybindings(); + + $context = $this->getContext()?->keybindings(); + if (null !== $context) { + $bindings = array_merge($bindings, $context->all()); + } + + if (null !== $this->keybindings) { + $bindings = array_merge($bindings, $this->keybindings->all()); + } + + return new Keybindings($bindings, $context?->getParser()); + } + + /** + * @return $this + */ + public function setKeybindings(?Keybindings $keybindings): static + { + $this->keybindings = $keybindings; + + return $this; + } + + /** + * @param (callable(string): bool)|null $callback + */ + public function onInput(?callable $callback): static + { + $this->onInput = $callback; + + return $this; + } + + /** + * Return the default keybindings for this widget. + * + * Override in widgets that define their own actions. + * + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return []; + } +} diff --git a/extern/Tui/Widget/LoaderWidget.php b/extern/Tui/Widget/LoaderWidget.php new file mode 100644 index 00000000..213d7733 --- /dev/null +++ b/extern/Tui/Widget/LoaderWidget.php @@ -0,0 +1,261 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Exception\InvalidArgumentException; +use Symfony\Component\Tui\Loop\PeriodicStepper; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Animated loading spinner. + * + * @experimental + * + * @author Fabien Potencier + */ +class LoaderWidget extends AbstractWidget +{ + use ScheduledTickTrait; + + private const string DEFAULT_STYLE = 'dots'; + private const DEFAULT_INTERVAL_MS = 80; + + /** @var array */ + private static array $styles = [ + 'line' => ['-', '\\', '|', '/'], + 'dots' => ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + 'bounce' => ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], + 'pulse' => ['⠁', '⠉', '⠋', '⠛', '⠟', '⠿', '⡿', '⣿', '⡿', '⠿', '⠟', '⠛', '⠋', '⠉', '⠁'], + 'bar' => ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂', '▁'], + 'shade' => ['░', '▒', '▓', '█', '▓', '▒', '░'], + 'arc' => ['◜', '◝', '◞', '◟'], + 'circle' => ['◐', '◓', '◑', '◒'], + ]; + + /** @var string[] */ + private array $frames; + private string $finishedIndicator = ''; + private int $frame = 0; + private bool $running = false; + private bool $finished = false; + private PeriodicStepper $frameStepper; + + public function __construct( + private string $message = 'Loading...', + ) { + $this->frames = self::$styles[self::DEFAULT_STYLE]; + $this->frameStepper = PeriodicStepper::everyMs(self::DEFAULT_INTERVAL_MS, 8); + $this->start(); + } + + /** + * Start the loader animation. + */ + public function start(): void + { + if ($this->running) { + return; + } + + $this->running = true; + $this->finished = false; + $this->frame = 0; + $this->frameStepper->reset(); + $this->invalidate(); + $this->getContext()?->requestRender(); + $this->startScheduledTick($this->frameStepper->getIntervalSeconds()); + } + + /** + * Stop the loader animation. + */ + public function stop(): void + { + if (!$this->running) { + return; + } + + $this->running = false; + $this->finished = true; + + $this->clearScheduledTick(); + + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + /** + * Check if the loader is running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * @return $this + */ + public function setMessage(string $message): static + { + if ($this->message !== $message) { + $this->message = $message; + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + return $this; + } + + /** + * @param string[] $frames Frame characters (at least 2) + */ + public static function addSpinner(string $name, array $frames): void + { + $frames = array_values($frames); + + if (\count($frames) < 2) { + throw new InvalidArgumentException('Must have at least 2 indicator frame characters.'); + } + + self::$styles[$name] = $frames; + } + + /** + * @return $this + */ + public function setSpinner(string $name): static + { + if (!isset(self::$styles[$name])) { + throw new InvalidArgumentException(\sprintf('Unknown loader style "%s". Available styles: "%s".', $name, implode('", "', array_keys(self::$styles)))); + } + + $this->frames = self::$styles[$name]; + $this->frame = 0; + $this->invalidate(); + + return $this; + } + + /** + * @return $this + */ + public function setIntervalMs(int $intervalMs): static + { + $this->frameStepper->setIntervalMs($intervalMs); + + if ($this->running) { + $this->startScheduledTick($this->frameStepper->getIntervalSeconds()); + } + + return $this; + } + + /** + * @return $this + */ + public function setFinishedIndicator(string $finishedIndicator): static + { + $this->finishedIndicator = $finishedIndicator; + $this->invalidate(); + + return $this; + } + + /** + * Get the current message. + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Get the current spinner frame character. + */ + public function getSpinnerFrame(): string + { + return $this->frames[$this->frame]; + } + + /** + * Advance the animation frame if enough time has passed. + * + * @return bool True if the frame was advanced + */ + public function tick(?float $deltaTime = null): bool + { + if (!$this->running) { + return false; + } + + $steps = $this->frameStepper->advance($deltaTime); + if (0 === $steps) { + return false; + } + + $this->frame = ($this->frame + $steps) % \count($this->frames); + $this->invalidate(); + + return true; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + + if ($this->running) { + $indicator = $this->frames[$this->frame]; + } elseif ($this->finished && '' !== $this->finishedIndicator) { + $indicator = $this->finishedIndicator; + } else { + return []; + } + + $styledIndicator = $this->applyElement('spinner', $indicator); + $styledMessage = $this->applyElement('message', $this->message); + + $content = $styledIndicator.' '.$styledMessage; + $line = AnsiUtils::truncateToWidth($content, $columns); + + $visibleLen = AnsiUtils::visibleWidth($line); + $rightFill = str_repeat(' ', max(0, $columns - $visibleLen)); + + return ['', $line.$rightFill]; + } + + protected function onAttach(WidgetContext $context): void + { + if ($this->running) { + $this->frameStepper->reset(); + $this->resumeScheduledTick(); + } + } + + protected function onDetach(): void + { + $this->stopScheduledTick(); + } + + protected function onScheduledTick(): void + { + $this->tick(); + } + + protected function resolveScheduledTickContext(): ?WidgetContext + { + return $this->getContext(); + } +} diff --git a/extern/Tui/Widget/Markdown/DarkTerminalTheme.php b/extern/Tui/Widget/Markdown/DarkTerminalTheme.php new file mode 100644 index 00000000..5ba2d8c6 --- /dev/null +++ b/extern/Tui/Widget/Markdown/DarkTerminalTheme.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Markdown; + +use Tempest\Highlight\TerminalTheme; +use Tempest\Highlight\Themes\EscapesTerminalTheme; +use Tempest\Highlight\Tokens\TokenType; +use Tempest\Highlight\Tokens\TokenTypeEnum; + +/** + * Dark terminal theme for syntax highlighting. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class DarkTerminalTheme implements TerminalTheme +{ + use EscapesTerminalTheme; + + public function before(TokenType $tokenType): string + { + $rgb = match ($tokenType) { + TokenTypeEnum::KEYWORD => [255, 122, 178], // #ff7ab2 + TokenTypeEnum::TYPE => [172, 242, 228], // #acf2e4 + TokenTypeEnum::PROPERTY => [120, 199, 255], // #78c7ff (variable) + TokenTypeEnum::VARIABLE => [120, 199, 255], // #78c7ff (variable) + TokenTypeEnum::GENERIC => [78, 176, 255], // #4eb0ff (function) + TokenTypeEnum::COMMENT => [106, 106, 122], // #6a6a7a + TokenTypeEnum::VALUE => [217, 201, 124], // #d9c97c (string/number) + TokenTypeEnum::ATTRIBUTE => [178, 129, 235], // #b281eb + TokenTypeEnum::OPERATOR => [178, 129, 235], // #b281eb + default => null, + }; + + if (null === $rgb) { + return ''; + } + + // Use 24-bit RGB escape sequence + return \sprintf("\x1b[38;2;%d;%d;%dm", $rgb[0], $rgb[1], $rgb[2]); + } + + public function after(TokenType $tokenType): string + { + return "\x1b[39m"; // Reset foreground only + } +} diff --git a/extern/Tui/Widget/MarkdownWidget.php b/extern/Tui/Widget/MarkdownWidget.php new file mode 100644 index 00000000..6ab1101b --- /dev/null +++ b/extern/Tui/Widget/MarkdownWidget.php @@ -0,0 +1,572 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\CommonMark\Node\Block\BlockQuote; +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Extension\CommonMark\Node\Block\Heading; +use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode; +use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock; +use League\CommonMark\Extension\CommonMark\Node\Block\ListItem; +use League\CommonMark\Extension\CommonMark\Node\Block\ThematicBreak; +use League\CommonMark\Extension\CommonMark\Node\Inline\Code; +use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; +use League\CommonMark\Extension\CommonMark\Node\Inline\Strong; +use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; +use League\CommonMark\Extension\Strikethrough\Strikethrough; +use League\CommonMark\Extension\Table\Table; +use League\CommonMark\Extension\Table\TableCell; +use League\CommonMark\Extension\Table\TableRow; +use League\CommonMark\Extension\Table\TableSection; +use League\CommonMark\Node\Block\Document; +use League\CommonMark\Node\Block\Paragraph; +use League\CommonMark\Node\Inline\Newline; +use League\CommonMark\Node\Inline\Text; +use League\CommonMark\Node\Node; +use League\CommonMark\Parser\MarkdownParser; +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Exception\LogicException; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Widget\Markdown\DarkTerminalTheme; +use Symfony\Component\Tui\Widget\Util\StringUtils; +use Tempest\Highlight\Highlighter; + +/** + * Renders markdown text with styling using league/commonmark and tempest/highlight. + * + * Supports headings, bold, italic, code, lists, links, blockquotes, tables, and horizontal rules. + * + * @experimental + * + * @author Fabien Potencier + */ +class MarkdownWidget extends AbstractWidget +{ + private MarkdownParser $parser; + private Highlighter $highlighter; + + /** + * ANSI codes to restore the context's style after inline style overrides. + * + * When the Markdown widget is rendered inside a styled context (e.g. gray italic + * for thinking blocks), inline styles (yellow for code, bold for strong, etc.) + * emit reset codes that cancel the context's attributes. This restore sequence + * re-applies the context's formatting after each inline style. + */ + private string $restoreContext = ''; + + public function __construct( + private string $text = '', + ?MarkdownParser $parser = null, + ?Highlighter $highlighter = null, + ) { + if (!class_exists(MarkdownParser::class)) { + throw new LogicException(\sprintf('You cannot use "%s" as the CommonMark package is not installed. Try running "composer require league/commonmark".', __CLASS__)); + } + + $this->text = StringUtils::sanitizeUtf8($text); + if (null === $parser) { + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + $parser = new MarkdownParser($environment); + } + $this->parser = $parser; + + if (null === $highlighter && !class_exists(Highlighter::class)) { + throw new LogicException(\sprintf('You cannot use "%s" as the Tempest Highlight package is not installed. Try running "composer require tempest/highlight".', __CLASS__)); + } + $this->highlighter = $highlighter ?? new Highlighter(new DarkTerminalTheme()); + } + + /** + * @return $this + */ + public function setText(string $text): static + { + $this->text = StringUtils::sanitizeUtf8($text); + $this->invalidate(); + + return $this; + } + + /** + * Get the markdown text. + */ + public function getText(): string + { + return $this->text; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + if ('' === trim($this->text)) { + return []; + } + + return $this->renderMarkdown($context); + } + + /** + * @return string[] + */ + private function renderMarkdown(RenderContext $context): array + { + // Context already has inner dimensions (chrome subtracted by the Renderer) + $contentColumns = $context->getColumns(); + + // Compute the restore sequence from the context's resolved style. + // When the context has styling (e.g. gray italic for thinking blocks), + // inline styles (yellow for code, bold for strong, etc.) emit reset codes + // that cancel the context's attributes. This restore sequence re-applies them. + $this->restoreContext = $context->getStyle()->getAnsiRestore(); + + // Parse markdown to AST + $document = $this->parser->parse($this->text); + + // Render AST to styled lines + $renderedLines = $this->renderDocument($document, $contentColumns); + + // Wrap all lines to ensure they fit within width + $wrappedLines = []; + foreach ($renderedLines as $line) { + array_push($wrappedLines, ...TextWrapper::wrapTextWithAnsi($line, $contentColumns)); + } + + return $wrappedLines; + } + + /** + * @return string[] + */ + private function renderDocument(Document $document, int $columns): array + { + $lines = []; + $isFirst = true; + + foreach ($document->children() as $child) { + // Add spacing between blocks + if (!$isFirst && !$child instanceof TableRow) { + $lines[] = ''; + } + $isFirst = false; + + $blockLines = $this->renderNode($child, $columns); + array_push($lines, ...$blockLines); + } + + return $lines; + } + + /** + * @return string[] + */ + private function renderNode(Node $node, int $columns): array + { + return match (true) { + $node instanceof Heading => $this->renderHeading($node, $columns), + $node instanceof Paragraph => $this->renderParagraph($node, $columns), + $node instanceof FencedCode => $this->renderFencedCode($node, $columns), + $node instanceof IndentedCode => $this->renderIndentedCode($node, $columns), + $node instanceof BlockQuote => $this->renderBlockQuote($node, $columns), + $node instanceof ListBlock => $this->renderList($node, $columns), + $node instanceof ThematicBreak => [$this->resolveElement('hr')->apply(str_repeat('─', $columns))], + $node instanceof Table => $this->renderTable($node, $columns), + default => $this->renderGenericBlock($node, $columns), + }; + } + + /** + * @return string[] + */ + private function renderHeading(Heading $heading, int $columns): array + { + $level = $heading->getLevel(); + $text = $this->renderInlineNodes($heading); + $prefix = str_repeat('#', $level).' '; + + $styledText = $this->resolveElement('heading')->apply($prefix.$text); + + return TextWrapper::wrapTextWithAnsi($styledText, $columns); + } + + /** + * @return string[] + */ + private function renderParagraph(Paragraph $paragraph, int $columns): array + { + $text = $this->renderInlineNodes($paragraph); + + return TextWrapper::wrapTextWithAnsi($text, $columns); + } + + /** + * @return string[] + */ + private function renderFencedCode(FencedCode $code, int $columns): array + { + $language = $code->getInfoWords()[0] ?? null; + $content = rtrim($code->getLiteral(), "\n"); + + return $this->renderCodeBlock($content, $language, $columns); + } + + /** + * @return string[] + */ + private function renderIndentedCode(IndentedCode $code, int $columns): array + { + $content = rtrim($code->getLiteral(), "\n"); + + return $this->renderCodeBlock($content, null, $columns); + } + + /** + * @return string[] + */ + private function renderCodeBlock(string $code, ?string $language, int $columns): array + { + $lines = []; + + $codeBlockBorderStyle = $this->resolveElement('code-block-border'); + + // Top border + $lines[] = $codeBlockBorderStyle->apply(str_repeat('─', $columns)); + + $indent = ' '; // Code block indent + $availableColumns = max(1, $columns - \strlen($indent)); + + // Try syntax highlighting with tempest/highlight + $highlighted = null; + if (null !== $language && '' !== $language) { + try { + $highlighted = $this->highlighter->parse($code, $language); + } catch (\Throwable) { + // Fall back to plain text + } + } + + if (null !== $highlighted) { + foreach (explode("\n", $highlighted) as $line) { + $padded = AnsiUtils::truncateToWidth($line, $availableColumns, '', true); + $lines[] = $indent.$padded; + } + } else { + foreach (explode("\n", $code) as $line) { + $padded = AnsiUtils::truncateToWidth($line, $availableColumns, '', true); + $lines[] = $indent.$padded; + } + } + + // Bottom border + $lines[] = $codeBlockBorderStyle->apply(str_repeat('─', $columns)); + + return $lines; + } + + /** + * @return string[] + */ + private function renderBlockQuote(BlockQuote $quote, int $columns): array + { + $lines = []; + $quoteColumns = max(1, $columns - 2); + $quoteStyle = $this->resolveElement('quote'); + $quoteBorderStyle = $this->resolveElement('quote-border'); + + foreach ($quote->children() as $child) { + $childLines = $this->renderNode($child, $quoteColumns); + foreach ($childLines as $line) { + $styledLine = $quoteStyle->apply($line); + $border = $quoteBorderStyle->apply('│ ').$this->restoreContext; + $lines[] = $border.$styledLine; + } + } + + return $lines; + } + + /** + * @return string[] + */ + private function renderList(ListBlock $list, int $columns): array + { + $lines = []; + $itemColumns = max(1, $columns - 2); + $isOrdered = 'ordered' === $list->getListData()->type; + $index = $list->getListData()->start ?? 1; + $listBulletStyle = $this->resolveElement('list-bullet'); + + foreach ($list->children() as $item) { + if (!$item instanceof ListItem) { + continue; + } + + $bullet = $isOrdered + ? $listBulletStyle->apply($index.'. ').$this->restoreContext + : $listBulletStyle->apply('• ').$this->restoreContext; + + $content = $this->renderListItemContent($item, $itemColumns); + foreach ($content as $i => $line) { + if (0 === $i) { + $lines[] = $bullet.$line; + } else { + $lines[] = ' '.$line; + } + } + + ++$index; + } + + return $lines; + } + + /** + * @return string[] + */ + private function renderListItemContent(ListItem $item, int $columns): array + { + $parts = []; + + foreach ($item->children() as $child) { + if ($child instanceof Paragraph) { + $text = $this->renderInlineNodes($child); + $wrapped = TextWrapper::wrapTextWithAnsi($text, $columns); + array_push($parts, ...$wrapped); + } else { + $childLines = $this->renderNode($child, $columns); + array_push($parts, ...$childLines); + } + } + + return $parts; + } + + /** + * @return string[] + */ + private function renderTable(Table $table, int $columns): array + { + $headers = []; + $rows = []; + + foreach ($table->children() as $section) { + if (!$section instanceof TableSection) { + continue; + } + + foreach ($section->children() as $row) { + if (!$row instanceof TableRow) { + continue; + } + + $cells = []; + foreach ($row->children() as $cell) { + if ($cell instanceof TableCell) { + $cells[] = $this->renderInlineNodes($cell); + } + } + + if ($section->isHead()) { + $headers = $cells; + } else { + $rows[] = $cells; + } + } + } + + if ([] === $headers && [] === $rows) { + return []; + } + + return $this->formatTable($headers, $rows, $columns); + } + + /** + * @param string[] $headers + * @param array $rows + * + * @return string[] + */ + private function formatTable(array $headers, array $rows, int $availableColumns): array + { + $columnCounts = array_map('count', $rows); + $columnCounts[] = \count($headers); + $numCols = max($columnCounts); + + if (0 === $numCols) { + return []; + } + + $borderOverhead = 3 * $numCols + 1; + $minTableWidth = $borderOverhead + $numCols; + + if ($availableColumns < $minTableWidth) { + // Fall back to simple text rendering + $lines = []; + if ([] !== $headers) { + $lines[] = implode(' | ', $headers); + } + foreach ($rows as $row) { + $lines[] = implode(' | ', $row); + } + + return $lines; + } + + // Calculate natural widths + $naturalWidths = []; + for ($i = 0; $i < $numCols; ++$i) { + $naturalWidths[$i] = AnsiUtils::visibleWidth($headers[$i] ?? ''); + } + + foreach ($rows as $row) { + for ($i = 0; $i < $numCols; ++$i) { + $naturalWidths[$i] = max($naturalWidths[$i] ?? 0, AnsiUtils::visibleWidth($row[$i] ?? '')); + } + } + + $totalNaturalWidth = array_sum($naturalWidths) + $borderOverhead; + $columnWidths = []; + + if ($totalNaturalWidth <= $availableColumns) { + $columnWidths = $naturalWidths; + } else { + $availableForCells = $availableColumns - $borderOverhead; + $totalNatural = array_sum($naturalWidths); + + foreach ($naturalWidths as $width) { + $proportion = $totalNatural > 0 ? $width / $totalNatural : 1 / $numCols; + $columnWidths[] = max(1, (int) floor($proportion * $availableForCells)); + } + + $allocated = array_sum($columnWidths); + $remaining = $availableForCells - $allocated; + for ($i = 0; $remaining > 0 && $i < $numCols; ++$i) { + ++$columnWidths[$i]; + --$remaining; + } + } + + $lines = []; + + // Top border + $topBorderCells = array_map(static fn (int $w) => str_repeat('─', $w), $columnWidths); + $lines[] = '┌─'.implode('─┬─', $topBorderCells).'─┐'; + + // Header row + if ([] !== $headers) { + $headerCellLines = []; + for ($i = 0; $i < $numCols; ++$i) { + $text = $headers[$i] ?? ''; + $headerCellLines[] = $this->wrapCellText($text, $columnWidths[$i]); + } + + $headerLineCount = max(array_map('count', $headerCellLines)); + + for ($lineIdx = 0; $lineIdx < $headerLineCount; ++$lineIdx) { + $rowParts = []; + for ($colIdx = 0; $colIdx < $numCols; ++$colIdx) { + $text = $headerCellLines[$colIdx][$lineIdx] ?? ''; + $padded = $text.str_repeat(' ', max(0, $columnWidths[$colIdx] - AnsiUtils::visibleWidth($text))); + $rowParts[] = $this->resolveElement('bold')->apply($padded); + } + $lines[] = '│ '.implode(' │ ', $rowParts).' │'; + } + + // Separator + $separatorCells = array_map(static fn (int $w) => str_repeat('─', $w), $columnWidths); + $lines[] = '├─'.implode('─┼─', $separatorCells).'─┤'; + } + + // Data rows + foreach ($rows as $row) { + $rowCellLines = []; + for ($i = 0; $i < $numCols; ++$i) { + $text = $row[$i] ?? ''; + $rowCellLines[] = $this->wrapCellText($text, $columnWidths[$i]); + } + + $rowLineCount = max(array_map('count', $rowCellLines)); + for ($lineIdx = 0; $lineIdx < $rowLineCount; ++$lineIdx) { + $rowParts = []; + for ($colIdx = 0; $colIdx < $numCols; ++$colIdx) { + $text = $rowCellLines[$colIdx][$lineIdx] ?? ''; + $rowParts[] = $text.str_repeat(' ', max(0, $columnWidths[$colIdx] - AnsiUtils::visibleWidth($text))); + } + $lines[] = '│ '.implode(' │ ', $rowParts).' │'; + } + } + + // Bottom border + $bottomBorderCells = array_map(static fn (int $w) => str_repeat('─', $w), $columnWidths); + $lines[] = '└─'.implode('─┴─', $bottomBorderCells).'─┘'; + + return $lines; + } + + /** + * @return string[] + */ + private function wrapCellText(string $text, int $maxWidth): array + { + return TextWrapper::wrapTextWithAnsi($text, max(1, $maxWidth)); + } + + /** + * @return string[] + */ + private function renderGenericBlock(Node $node, int $columns): array + { + $text = $this->renderInlineNodes($node); + if ('' === $text) { + return []; + } + + return TextWrapper::wrapTextWithAnsi($text, $columns); + } + + /** + * Render all inline nodes within a container to a single styled string. + */ + private function renderInlineNodes(Node $container): string + { + $result = ''; + + foreach ($container->children() as $child) { + $result .= $this->renderInlineNode($child); + } + + return $result; + } + + private function renderInlineNode(Node $node): string + { + return match (true) { + $node instanceof Text => $node->getLiteral(), + $node instanceof Strong => $this->resolveElement('bold')->apply($this->renderInlineNodes($node)).$this->restoreContext, + $node instanceof Emphasis => $this->resolveElement('italic')->apply($this->renderInlineNodes($node)).$this->restoreContext, + $node instanceof Strikethrough => $this->resolveElement('strikethrough')->apply($this->renderInlineNodes($node)).$this->restoreContext, + $node instanceof Code => $this->resolveElement('code')->apply($node->getLiteral()).$this->restoreContext, + $node instanceof Link => $this->resolveElement('link')->apply($this->renderInlineNodes($node)).$this->restoreContext.' '.$this->resolveElement('link-url')->apply('('.$node->getUrl().')').$this->restoreContext, + $node instanceof Newline => "\n", + default => $this->renderInlineNodes($node), // For nested structures + }; + } +} diff --git a/extern/Tui/Widget/ParentInterface.php b/extern/Tui/Widget/ParentInterface.php new file mode 100644 index 00000000..9196b525 --- /dev/null +++ b/extern/Tui/Widget/ParentInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Interface for widgets that have child widgets. + * + * This is a read-only interface for tree traversal. Use ContainerInterface + * when you need to add or remove children. + * + * @experimental + * + * @author Fabien Potencier + */ +interface ParentInterface +{ + /** + * Get all child widgets. + * + * @return AbstractWidget[] + */ + public function all(): array; +} diff --git a/extern/Tui/Widget/ProgressBarWidget.php b/extern/Tui/Widget/ProgressBarWidget.php new file mode 100644 index 00000000..951f04d7 --- /dev/null +++ b/extern/Tui/Widget/ProgressBarWidget.php @@ -0,0 +1,598 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Animated progress bar widget. + * + * Supports both determinate (with max steps) and indeterminate (no max) modes. + * Uses a format string with placeholders to render the bar. + * + * Built-in placeholders: %current%, %max%, %bar%, %percent%, %elapsed%, + * %remaining%, %estimated%, %memory%, %message%. + * + * The bar animates via the event loop, like the LoaderWidget spinner. + * + * @experimental + * + * @author Fabien Potencier + */ +class ProgressBarWidget extends AbstractWidget +{ + use ScheduledTickTrait; + + private const TICK_INTERVAL_MS = 100; + + private int $step = 0; + private int $startingStep = 0; + private ?int $max; + private int $stepWidth; + private float $percent = 0.0; + private int $startTime; + private bool $running = false; + + private int $barWidth = 28; + private string $barChar = '━'; + private string $emptyBarChar = '━'; + private string $progressChar = ''; + private string $format; + + /** @var array */ + private array $messages = []; + + /** @var array */ + private array $placeholderFormatters = []; + + /** @var array */ + private static array $defaultPlaceholderFormatters = []; + + public function __construct( + int $max = 0, + ?string $format = null, + ) { + $this->setMaxSteps($max); + $this->format = $format ?? ($max > 0 ? self::FORMAT_NORMAL : self::FORMAT_INDETERMINATE); + $this->startTime = time(); + } + + /** + * Normal format: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30%`. + */ + public const FORMAT_NORMAL = ' %current%/%max% [%bar%] %percent:3s%%'; + + /** + * Indeterminate format (no max): ` 42 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━]`. + */ + public const FORMAT_INDETERMINATE = ' %current% [%bar%]'; + + /** + * Verbose format: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30% 0:05`. + */ + public const FORMAT_VERBOSE = ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%'; + + /** + * Very verbose format: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30% 0:05/0:15`. + */ + public const FORMAT_VERY_VERBOSE = ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%'; + + /** + * Debug format with memory: ` 3/10 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 30% 0:05/0:15 12.0 MiB`. + */ + public const FORMAT_DEBUG = ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'; + + /** + * Verbose indeterminate format: ` 42 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 0:05`. + */ + public const FORMAT_VERBOSE_INDETERMINATE = ' %current% [%bar%] %elapsed:6s%'; + + /** + * Debug indeterminate format: ` 42 [━━━━━━━━━━━━━━━━━━━━━━━━━━━━] 0:05 12.0 MiB`. + */ + public const FORMAT_DEBUG_INDETERMINATE = ' %current% [%bar%] %elapsed:6s% %memory:6s%'; + + /** + * Start (or restart) the progress bar. + * + * @param int|null $max Maximum steps (0 for indeterminate), null to keep current + * @param int $startAt Starting step value + */ + public function start(?int $max = null, int $startAt = 0): void + { + $this->startTime = time(); + $this->step = $startAt; + $this->startingStep = $startAt; + $this->percent = 0.0; + $this->running = true; + + if (null !== $max) { + $this->setMaxSteps($max); + } + + if ($startAt > 0) { + $this->setProgress($startAt); + } + + $this->invalidate(); + $this->getContext()?->requestRender(); + $this->startScheduledTick(self::TICK_INTERVAL_MS / 1000); + } + + /** + * Advance the progress bar by a number of steps. + */ + public function advance(int $step = 1): void + { + $this->setProgress($this->step + $step); + } + + /** + * Set the current progress. + */ + public function setProgress(int $step): void + { + if (null !== $this->max && $step > $this->max) { + $this->max = $step; + $this->stepWidth = \strlen((string) $this->max); + } elseif ($step < 0) { + $step = 0; + } + + $this->step = $step; + + if (null === $this->max) { + $this->percent = 0.0; + } elseif (0 === $this->max) { + $this->percent = 1.0; + } else { + $this->percent = (float) $this->step / $this->max; + } + + $this->invalidate(); + $this->getContext()?->requestRender(); + } + + /** + * Finish the progress bar (jump to max). + */ + public function finish(): void + { + if (null === $this->max) { + $this->max = $this->step; + $this->stepWidth = \strlen((string) $this->max); + } + + $this->setProgress($this->max); + $this->running = false; + $this->clearScheduledTick(); + } + + /** + * Check if the progress bar is running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * Get the current step. + */ + public function getProgress(): int + { + return $this->step; + } + + /** + * Get the maximum number of steps (0 if indeterminate). + */ + public function getMaxSteps(): int + { + return $this->max ?? 0; + } + + /** + * Get the progress as a percentage (0.0 to 1.0). + */ + public function getProgressPercent(): float + { + return $this->percent; + } + + /** + * Get the start time as a Unix timestamp. + */ + public function getStartTime(): int + { + return $this->startTime; + } + + /** + * Get the estimated total time in seconds. + */ + public function getEstimated(): float + { + if (0 === $this->step || $this->step === $this->startingStep) { + return 0; + } + + return round((time() - $this->startTime) / ($this->step - $this->startingStep) * ($this->max ?? $this->step)); + } + + /** + * Get the estimated remaining time in seconds. + */ + public function getRemaining(): float + { + if (null === $this->max || 0 === $this->step || $this->step === $this->startingStep) { + return 0; + } + + return round((time() - $this->startTime) / ($this->step - $this->startingStep) * ($this->max - $this->step)); + } + + /** + * Get the bar offset (number of filled characters). + */ + public function getBarOffset(): int + { + if (null !== $this->max) { + return (int) floor($this->percent * $this->barWidth); + } + + return $this->step % $this->barWidth; + } + + /** + * Get the width reserved for the step number display. + */ + public function getStepWidth(): int + { + return $this->stepWidth; + } + + /** + * @return $this + */ + public function setFormat(string $format): static + { + $this->format = $format; + $this->invalidate(); + + return $this; + } + + /** + * Get the current format string. + */ + public function getFormat(): string + { + return $this->format; + } + + /** + * @return $this + */ + public function setBarWidth(int $width): static + { + $this->barWidth = max(1, $width); + $this->invalidate(); + + return $this; + } + + /** + * Get the bar width in characters. + */ + public function getBarWidth(): int + { + return $this->barWidth; + } + + /** + * Set the character used for the filled part of the bar. + * + * @return $this + */ + public function setBarCharacter(string $char): static + { + $this->barChar = $char; + $this->invalidate(); + + return $this; + } + + /** + * Get the character used for the filled part of the bar. + */ + public function getBarCharacter(): string + { + return $this->barChar; + } + + /** + * Set the character used for the empty part of the bar. + * + * @return $this + */ + public function setEmptyBarCharacter(string $char): static + { + $this->emptyBarChar = $char; + $this->invalidate(); + + return $this; + } + + /** + * Get the character used for the empty part of the bar. + */ + public function getEmptyBarCharacter(): string + { + return $this->emptyBarChar; + } + + /** + * Set the character displayed at the progress position. + * + * @return $this + */ + public function setProgressCharacter(string $char): static + { + $this->progressChar = $char; + $this->invalidate(); + + return $this; + } + + /** + * Get the character displayed at the progress position. + */ + public function getProgressCharacter(): string + { + return $this->progressChar; + } + + /** + * Set the maximum number of steps. + */ + public function setMaxSteps(int $max): void + { + if (0 === $max) { + $this->max = null; + $this->stepWidth = 4; + } else { + $this->max = max(0, $max); + $this->stepWidth = \strlen((string) $this->max); + } + + $this->invalidate(); + } + + /** + * Associate a named message for use in the format string via %message_name%. + * + * @return $this + */ + public function setMessage(string $message, string $name = 'message'): static + { + $this->messages[$name] = $message; + $this->invalidate(); + $this->getContext()?->requestRender(); + + return $this; + } + + /** + * Get a named message. + */ + public function getMessage(string $name = 'message'): ?string + { + return $this->messages[$name] ?? null; + } + + /** + * Set a placeholder formatter for this instance. + * + * @param \Closure(self): string $formatter + * + * @return $this + */ + public function setPlaceholderFormatter(string $name, \Closure $formatter): static + { + $this->placeholderFormatters[$name] = $formatter; + $this->invalidate(); + + return $this; + } + + /** + * Get a placeholder formatter (instance-level, then global). + * + * @return (\Closure(self): string)|null + */ + public function getPlaceholderFormatter(string $name): ?\Closure + { + return $this->placeholderFormatters[$name] ?? self::$defaultPlaceholderFormatters[$name] ?? null; + } + + /** + * Set a global placeholder formatter for all ProgressBarWidget instances. + * + * @param \Closure(self): string $formatter + */ + public static function setDefaultPlaceholderFormatter(string $name, \Closure $formatter): void + { + self::$defaultPlaceholderFormatters[$name] = $formatter; + } + + /** + * Tick the animation (for indeterminate mode bouncing). + */ + public function tick(): bool + { + if (!$this->running) { + return false; + } + + $this->invalidate(); + + return true; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + $line = $this->buildLine($columns); + + $styledLine = $this->applyElement('text', $line); + $visibleLen = AnsiUtils::visibleWidth($styledLine); + $rightFill = str_repeat(' ', max(0, $columns - $visibleLen)); + + return [$styledLine.$rightFill]; + } + + protected function onAttach(WidgetContext $context): void + { + if ($this->running) { + $this->resumeScheduledTick(); + } + } + + protected function onDetach(): void + { + $this->stopScheduledTick(); + } + + protected function onScheduledTick(): void + { + $this->tick(); + } + + protected function resolveScheduledTickContext(): ?WidgetContext + { + return $this->getContext(); + } + + private function buildLine(int $availableWidth): string + { + $format = $this->format; + + // First pass: resolve all placeholders to measure total width + $line = $this->replacePlaceholders($format); + + // If the line is too wide, shrink the bar to fit + $lineWidth = AnsiUtils::visibleWidth($line); + if ($lineWidth > $availableWidth) { + $newBarWidth = $this->barWidth - ($lineWidth - $availableWidth); + if ($newBarWidth >= 1) { + $savedBarWidth = $this->barWidth; + $this->barWidth = $newBarWidth; + $line = $this->replacePlaceholders($format); + $this->barWidth = $savedBarWidth; + } + } + + return $line; + } + + private function replacePlaceholders(string $format): string + { + return preg_replace_callback('{%([a-z\-_]+)(?::([^%]+))?%}i', function (array $matches): string { + $name = $matches[1]; + + $text = match ($name) { + 'bar' => $this->renderBar(), + 'elapsed' => self::formatTime(time() - $this->startTime), + 'remaining' => self::formatTime((int) $this->getRemaining()), + 'estimated' => self::formatTime((int) $this->getEstimated()), + 'memory' => self::formatMemory(memory_get_usage(true)), + 'current' => str_pad((string) $this->step, $this->stepWidth, ' ', \STR_PAD_LEFT), + 'max' => (string) ($this->max ?? 0), + 'percent' => (string) (int) floor($this->percent * 100), + default => null, + }; + + if (null === $text) { + $formatter = $this->getPlaceholderFormatter($name); + if (null !== $formatter) { + $text = $formatter($this); + } elseif (isset($this->messages[$name])) { + $text = $this->messages[$name]; + } else { + return $matches[0]; + } + } + + if (isset($matches[2])) { + $text = \sprintf('%'.$matches[2], $text); + } + + return $text; + }, $format) ?? $format; + } + + private function renderBar(): string + { + $completeBars = $this->getBarOffset(); + $styledComplete = $this->applyElement('bar-fill', str_repeat($this->barChar, $completeBars)); + + if ($completeBars < $this->barWidth) { + $progressCharLen = mb_strlen($this->progressChar); + $emptyBars = $this->barWidth - $completeBars - $progressCharLen; + $styledProgress = '' !== $this->progressChar ? $this->applyElement('bar-progress', $this->progressChar) : ''; + $styledEmpty = $this->applyElement('bar-empty', str_repeat($this->emptyBarChar, max(0, $emptyBars))); + + return $styledComplete.$styledProgress.$styledEmpty; + } + + return $styledComplete; + } + + private static function formatTime(int $secs): string + { + if ($secs < 0) { + $secs = 0; + } + + $hours = (int) ($secs / 3600); + $minutes = (int) (($secs % 3600) / 60); + $seconds = $secs % 60; + + if ($hours > 0) { + return \sprintf('%d:%02d:%02d', $hours, $minutes, $seconds); + } + + return \sprintf('%d:%02d', $minutes, $seconds); + } + + private static function formatMemory(int $memory): string + { + if ($memory >= 1024 * 1024 * 1024) { + return \sprintf('%.1f GiB', $memory / 1024 / 1024 / 1024); + } + + if ($memory >= 1024 * 1024) { + return \sprintf('%.1f MiB', $memory / 1024 / 1024); + } + + if ($memory >= 1024) { + return \sprintf('%d KiB', $memory / 1024); + } + + return \sprintf('%d B', $memory); + } +} diff --git a/extern/Tui/Widget/QuitableTrait.php b/extern/Tui/Widget/QuitableTrait.php new file mode 100644 index 00000000..2dd8bd0e --- /dev/null +++ b/extern/Tui/Widget/QuitableTrait.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Event\QuitEvent; + +/** + * Trait for widgets that support a quit action. + * + * Dispatches a {@see QuitEvent} when the quit key is pressed. + * If no listener is registered for QuitEvent (neither globally on the + * Tui nor locally on the widget), the default behavior is to stop the TUI. + * + * @experimental + * + * @author Fabien Potencier + */ +trait QuitableTrait +{ + /** + * Register a listener for the quit event on this widget. + * + * @param callable(QuitEvent): void $callback + * + * @return $this + */ + public function onQuit(callable $callback): static + { + return $this->on(QuitEvent::class, $callback); + } + + /** + * Dispatch the quit event. + * + * Call this from handleInput() when quit key is pressed. + * If no listener is registered for QuitEvent, stops the TUI. + */ + protected function dispatchQuit(): void + { + $context = $this->getContext(); + if (null === $context) { + return; + } + + $hasListeners = $context->getEventDispatcher()->hasListeners(QuitEvent::class) + || $this->hasListeners(QuitEvent::class); + + if ($hasListeners) { + $this->dispatch(new QuitEvent($this)); + } else { + // Default behavior: stop the TUI + $context->stop(); + } + } +} diff --git a/extern/Tui/Widget/ScheduledTickTrait.php b/extern/Tui/Widget/ScheduledTickTrait.php new file mode 100644 index 00000000..66557274 --- /dev/null +++ b/extern/Tui/Widget/ScheduledTickTrait.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Exception\InvalidArgumentException; + +/** + * Shared scheduling lifecycle for runtime objects driven by WidgetContext ticks. + * + * @experimental + * + * @author Fabien Potencier + */ +trait ScheduledTickTrait +{ + private ?string $scheduledTickId = null; + private ?float $scheduledTickInterval = null; + + abstract protected function resolveScheduledTickContext(): ?WidgetContext; + + abstract protected function onScheduledTick(): void; + + protected function startScheduledTick(float $intervalSeconds): void + { + if ($intervalSeconds <= 0.0) { + throw new InvalidArgumentException(\sprintf('Interval must be greater than 0, got %d.', $intervalSeconds)); + } + + if (null !== $this->scheduledTickId && null !== $this->scheduledTickInterval && abs($this->scheduledTickInterval - $intervalSeconds) < 0.000001) { + return; + } + + $this->stopScheduledTick(); + $this->scheduledTickInterval = $intervalSeconds; + + $context = $this->resolveScheduledTickContext(); + if (null === $context) { + return; + } + + $this->scheduledTickId = $context->scheduleTick( + function (): void { + $this->onScheduledTick(); + }, + $intervalSeconds, + ); + } + + protected function resumeScheduledTick(): void + { + if (null === $this->scheduledTickInterval) { + return; + } + + $this->startScheduledTick($this->scheduledTickInterval); + } + + protected function stopScheduledTick(): void + { + if (null === $this->scheduledTickId) { + return; + } + + $this->resolveScheduledTickContext()?->cancelTick($this->scheduledTickId); + $this->scheduledTickId = null; + } + + protected function clearScheduledTick(): void + { + $this->stopScheduledTick(); + $this->scheduledTickInterval = null; + } +} diff --git a/extern/Tui/Widget/SelectListWidget.php b/extern/Tui/Widget/SelectListWidget.php new file mode 100644 index 00000000..756fe6ae --- /dev/null +++ b/extern/Tui/Widget/SelectListWidget.php @@ -0,0 +1,355 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\SelectEvent; +use Symfony\Component\Tui\Event\SelectionChangeEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Interactive selection list with keyboard navigation. + * + * @experimental + * + * @author Fabien Potencier + */ +class SelectListWidget extends AbstractWidget implements FocusableInterface +{ + use FocusableTrait; + use KeybindingsTrait; + + /** @var array */ + private array $filteredItems; + + private int $selectedIndex = 0; + private bool $selected = false; + + /** + * @param array $items + */ + public function __construct( + private array $items, + private int $maxVisible = 5, + ?Keybindings $keybindings = null, + ) { + $this->filteredItems = $items; + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + } + + /** + * @param array $items + * + * @return $this + */ + public function setItems(array $items): static + { + $this->items = $items; + $this->filteredItems = $items; + $this->selectedIndex = 0; + $this->invalidate(); + + return $this; + } + + /** + * @return $this + */ + public function setFilter(string $filter): static + { + $filter = strtolower($filter); + + $filteredItems = array_values(array_filter( + $this->items, + static fn ($item) => str_starts_with(strtolower($item['value']), $filter), + )); + + if ($filteredItems !== $this->filteredItems) { + $this->filteredItems = $filteredItems; + $this->selectedIndex = 0; + $this->invalidate(); + } + + return $this; + } + + /** + * @return $this + */ + public function setSelectedIndex(int $index): static + { + $index = max(0, min($index, \count($this->filteredItems) - 1)); + if ($this->selectedIndex !== $index) { + $this->selectedIndex = $index; + $this->invalidate(); + } + + return $this; + } + + /** + * Get the currently selected item. + * + * @return array{value: string, label: string, description?: string}|null + */ + public function getSelectedItem(): ?array + { + return $this->filteredItems[$this->selectedIndex] ?? null; + } + + /** + * Check if an item was selected (Enter pressed) vs cancelled (Escape pressed). + */ + public function wasSelected(): bool + { + return $this->selected; + } + + /** + * @param callable(SelectEvent): void $callback + * + * @return $this + */ + public function onSelect(callable $callback): static + { + return $this->on(SelectEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): static + { + return $this->on(CancelEvent::class, $callback); + } + + /** + * @param callable(SelectionChangeEvent): void $callback + * + * @return $this + */ + public function onSelectionChange(callable $callback): static + { + return $this->on(SelectionChangeEvent::class, $callback); + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + $kb = $this->getKeybindings(); + + if ([] !== $this->filteredItems) { + // Up - wrap to bottom when at top + if ($kb->matches($data, 'select_up')) { + $this->selectedIndex = 0 === $this->selectedIndex ? \count($this->filteredItems) - 1 : $this->selectedIndex - 1; + $this->notifySelectionChange(); + + return; + } + + // Down - wrap to top when at bottom + if ($kb->matches($data, 'select_down')) { + $this->selectedIndex = $this->selectedIndex === \count($this->filteredItems) - 1 ? 0 : $this->selectedIndex + 1; + $this->notifySelectionChange(); + + return; + } + + if ($kb->matches($data, 'select_page_up') || $kb->matches($data, 'cursor_left')) { + $this->selectedIndex = max(0, $this->selectedIndex - $this->maxVisible); + $this->notifySelectionChange(); + + return; + } + + if ($kb->matches($data, 'select_page_down') || $kb->matches($data, 'cursor_right')) { + $this->selectedIndex = min(\count($this->filteredItems) - 1, $this->selectedIndex + $this->maxVisible); + $this->notifySelectionChange(); + + return; + } + + // Confirm selection + if ($kb->matches($data, 'select_confirm')) { + $this->confirmSelection(); + + return; + } + } + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->selected = false; + $this->dispatch(new CancelEvent($this)); + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + $lines = []; + + // No items match filter + if ([] === $this->filteredItems) { + $line = $this->applyElement('no-match', ' No matching items'); + $lines[] = $line; + + return $lines; + } + + // Calculate visible range with scrolling + $startIndex = max( + 0, + min( + $this->selectedIndex - (int) floor($this->maxVisible / 2), + \count($this->filteredItems) - $this->maxVisible, + ), + ); + $endIndex = min($startIndex + $this->maxVisible, \count($this->filteredItems)); + + // Compute max label width from visible items for alignment + $maxLabelWidth = 0; + for ($i = $startIndex; $i < $endIndex; ++$i) { + $maxLabelWidth = max($maxLabelWidth, AnsiUtils::visibleWidth($this->filteredItems[$i]['label'])); + } + $labelColumnWidth = min(30, $maxLabelWidth); + + // Render visible items + for ($i = $startIndex; $i < $endIndex; ++$i) { + $item = $this->filteredItems[$i]; + $isSelected = $i === $this->selectedIndex; + $description = isset($item['description']) ? $this->normalizeDescription($item['description']) : null; + $line = $this->renderItem($item, $isSelected, $description, $columns, $labelColumnWidth); + $lines[] = $line; + } + + // Add scroll indicator if needed + if ($startIndex > 0 || $endIndex < \count($this->filteredItems)) { + $scrollText = \sprintf(' (%d/%d)', $this->selectedIndex + 1, \count($this->filteredItems)); + $line = $this->applyElement('scroll-info', AnsiUtils::truncateToWidth($scrollText, $columns - 2, '')); + $lines[] = $line; + } + + return $lines; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'select_up' => [Key::UP], + 'select_down' => [Key::DOWN], + 'select_page_up' => [Key::PAGE_UP], + 'select_page_down' => [Key::PAGE_DOWN], + 'select_confirm' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + ]; + } + + /** + * @param array{value: string, label: string, description?: string} $item + */ + private function renderItem(array $item, bool $isSelected, ?string $description, int $columns, int $labelColumnWidth): string + { + $displayValue = $item['label']; + $alignedWidth = $labelColumnWidth + 2; + + if ($isSelected) { + $prefix = '→ '; + $selectedStyle = $this->resolveElement('selected'); + + if (null !== $description && $columns > 40) { + $maxValueColumns = min($labelColumnWidth, $columns - \strlen($prefix) - 4); + $truncatedValue = AnsiUtils::truncateToWidth($displayValue, $maxValueColumns, ''); + $spacing = str_repeat(' ', max(1, $alignedWidth - AnsiUtils::visibleWidth($truncatedValue))); + + $descriptionStart = \strlen($prefix) + AnsiUtils::visibleWidth($truncatedValue) + \strlen($spacing); + $remainingColumns = $columns - $descriptionStart - 2; + + if ($remainingColumns > 10) { + $truncatedDesc = AnsiUtils::truncateToWidth($description, $remainingColumns, ''); + + return $selectedStyle->apply("→ {$truncatedValue}{$spacing}{$truncatedDesc}"); + } + } + + $maxColumns = $columns - \strlen($prefix) - 2; + + return $selectedStyle->apply($prefix.AnsiUtils::truncateToWidth($displayValue, $maxColumns, '')); + } + + // Non-selected item + $prefix = ' '; + + if (null !== $description && $columns > 40) { + $maxValueColumns = min($labelColumnWidth, $columns - \strlen($prefix) - 4); + $truncatedValue = AnsiUtils::truncateToWidth($displayValue, $maxValueColumns, ''); + $spacing = str_repeat(' ', max(1, $alignedWidth - AnsiUtils::visibleWidth($truncatedValue))); + + $descriptionStart = \strlen($prefix) + AnsiUtils::visibleWidth($truncatedValue) + \strlen($spacing); + $remainingColumns = $columns - $descriptionStart - 2; + + if ($remainingColumns > 10) { + $truncatedDesc = AnsiUtils::truncateToWidth($description, $remainingColumns, ''); + $labelText = $this->applyElement('label', $truncatedValue); + $descText = $this->applyElement('description', $spacing.$truncatedDesc); + + return $prefix.$labelText.$descText; + } + } + + $maxColumns = $columns - \strlen($prefix) - 2; + + return $prefix.AnsiUtils::truncateToWidth($displayValue, $maxColumns, ''); + } + + private function normalizeDescription(string $description): string + { + // Convert multiline to single line + return trim(preg_replace('/[\r\n]+/', ' ', $description)); + } + + private function confirmSelection(): void + { + $this->selected = true; + $selectedItem = $this->filteredItems[$this->selectedIndex] ?? null; + if (null !== $selectedItem) { + $this->dispatch(new SelectEvent($this, $selectedItem)); + } + } + + private function notifySelectionChange(): void + { + $this->invalidate(); + $selectedItem = $this->filteredItems[$this->selectedIndex] ?? null; + if (null !== $selectedItem) { + $this->dispatch(new SelectionChangeEvent($this, $selectedItem)); + } + } +} diff --git a/extern/Tui/Widget/SettingItem.php b/extern/Tui/Widget/SettingItem.php new file mode 100644 index 00000000..95909934 --- /dev/null +++ b/extern/Tui/Widget/SettingItem.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Exception\LogicException; + +/** + * Represents a single item in a SettingsListWidget. + * + * @experimental + * + * @author Fabien Potencier + * + * @phpstan-type SubmenuFactory callable(string, callable(?string): void): (FocusableInterface&AbstractWidget) + */ +final class SettingItem +{ + private string $currentValue; + + /** + * @param list $values Predefined values for cycling (empty = no cycling) + * @param SubmenuFactory|null $submenu Factory for submenu widget + */ + public function __construct( + private readonly string $id, + private readonly string $label, + string $currentValue, + private readonly ?string $description = null, + /** @var list */ + private readonly array $values = [], + /** @var SubmenuFactory|null */ + private $submenu = null, + ) { + $this->currentValue = $currentValue; + } + + public function getId(): string + { + return $this->id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return list + */ + public function getValues(): array + { + return $this->values; + } + + public function getCurrentValue(): string + { + return $this->currentValue; + } + + public function setCurrentValue(string $value): void + { + $this->currentValue = $value; + } + + public function hasValues(): bool + { + return [] !== $this->values; + } + + public function hasSubmenu(): bool + { + return null !== $this->submenu; + } + + /** + * @return SubmenuFactory + */ + public function getSubmenu(): callable + { + if (null === $this->submenu) { + throw new LogicException('This setting item does not have a submenu.'); + } + + return $this->submenu; + } +} diff --git a/extern/Tui/Widget/SettingsListWidget.php b/extern/Tui/Widget/SettingsListWidget.php new file mode 100644 index 00000000..cb2b1b96 --- /dev/null +++ b/extern/Tui/Widget/SettingsListWidget.php @@ -0,0 +1,417 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Event\CancelEvent; +use Symfony\Component\Tui\Event\SelectEvent; +use Symfony\Component\Tui\Event\SettingChangeEvent; +use Symfony\Component\Tui\Input\Key; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; + +/** + * Settings panel with value cycling and submenus. + * + * @experimental + * + * @author Fabien Potencier + */ +class SettingsListWidget extends AbstractWidget implements FocusableInterface, ParentInterface +{ + use FocusableTrait; + use KeybindingsTrait; + + private int $selectedIndex = 0; + + // Submenu state + private (FocusableInterface&AbstractWidget)|null $activeSubmenu = null; + + /** @var list Listeners to remove from the global dispatcher on cleanup */ + private array $submenuListeners = []; + + /** + * @param list $items + */ + public function __construct( + private array $items, + private int $maxVisible = 10, + ?Keybindings $keybindings = null, + ) { + if (null !== $keybindings) { + $this->setKeybindings($keybindings); + } + } + + /** + * Update the value for a setting. + */ + public function updateValue(string $id, string $value): void + { + foreach ($this->items as $item) { + if ($item->getId() === $id) { + if ($item->getCurrentValue() !== $value) { + $item->setCurrentValue($value); + $this->invalidate(); + } + break; + } + } + } + + /** + * Get the current value for a setting. + */ + public function getValue(string $id): ?string + { + foreach ($this->items as $item) { + if ($item->getId() === $id) { + return $item->getCurrentValue(); + } + } + + return null; + } + + /** + * @param callable(SettingChangeEvent): void $callback + * + * @return $this + */ + public function onChange(callable $callback): static + { + return $this->on(SettingChangeEvent::class, $callback); + } + + /** + * @param callable(CancelEvent): void $callback + * + * @return $this + */ + public function onCancel(callable $callback): static + { + return $this->on(CancelEvent::class, $callback); + } + + public function all(): array + { + if (null !== $this->activeSubmenu) { + return [$this->activeSubmenu]; + } + + return []; + } + + public function handleInput(string $data): void + { + if (null !== $this->onInput && ($this->onInput)($data)) { + return; + } + + // If submenu is active, forward input to it + if (null !== $this->activeSubmenu) { + $this->activeSubmenu->handleInput($data); + $this->invalidate(); + + return; + } + + if ([] === $this->items) { + return; + } + + $kb = $this->getKeybindings(); + + // Navigation + if ($kb->matches($data, 'select_up')) { + $nextIndex = max(0, $this->selectedIndex - 1); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'select_down')) { + $nextIndex = min(\count($this->items) - 1, $this->selectedIndex + 1); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'select_page_up')) { + $nextIndex = max(0, $this->selectedIndex - $this->maxVisible); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + if ($kb->matches($data, 'select_page_down')) { + $nextIndex = min(\count($this->items) - 1, $this->selectedIndex + $this->maxVisible); + if ($this->selectedIndex !== $nextIndex) { + $this->selectedIndex = $nextIndex; + $this->invalidate(); + } + + return; + } + + // Activate (cycle value or open submenu) + if ($kb->matches($data, 'select_confirm') || ' ' === $data) { + $this->activateCurrentItem(); + + return; + } + + // Cycle value forward (Right arrow) + if ($kb->matches($data, 'cursor_right')) { + $this->cycleValue(1); + + return; + } + + // Cycle value backward (Left arrow) + if ($kb->matches($data, 'cursor_left')) { + $this->cycleValue(-1); + + return; + } + + // Cancel + if ($kb->matches($data, 'select_cancel')) { + $this->dispatch(new CancelEvent($this)); + } + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + $columns = $context->getColumns(); + + // If submenu is active, render it through the Renderer pipeline + // so its style (padding, border, background) is properly applied + if (null !== $this->activeSubmenu && null !== ($widgetContext = $this->getContext())) { + return $widgetContext->renderWidget($this->activeSubmenu, $context); + } + + $lines = []; + + // Calculate visible range + $startIndex = max( + 0, + min( + $this->selectedIndex - (int) floor($this->maxVisible / 2), + \count($this->items) - $this->maxVisible, + ), + ); + $endIndex = min($startIndex + $this->maxVisible, \count($this->items)); + + // Render items + for ($i = $startIndex; $i < $endIndex; ++$i) { + $item = $this->items[$i]; + $isSelected = $i === $this->selectedIndex; + + $line = $this->renderItem($item, $isSelected, $columns); + $lines[] = $line; + + // Add description for selected item + if ($isSelected && null !== $item->getDescription()) { + $descLine = ' '.$this->applyElement('description', $item->getDescription()); + $descLine = AnsiUtils::truncateToWidth($descLine, $columns); + $lines[] = $descLine; + } + } + + // Add hint + $hint = $this->applyElement('hint', ' ↑↓ Navigate Enter/Space Activate Esc Cancel'); + $hint = AnsiUtils::truncateToWidth($hint, $columns); + $lines[] = $hint; + + return $lines; + } + + protected function onDetach(): void + { + $this->removeSubmenuListeners(); + $this->activeSubmenu = null; + } + + /** + * @return array + */ + protected static function getDefaultKeybindings(): array + { + return [ + 'select_up' => [Key::UP], + 'select_down' => [Key::DOWN], + 'select_page_up' => [Key::PAGE_UP], + 'select_page_down' => [Key::PAGE_DOWN], + 'select_confirm' => [Key::ENTER], + 'select_cancel' => [Key::ESCAPE, 'ctrl+c'], + 'cursor_left' => [Key::LEFT, 'ctrl+b'], + 'cursor_right' => [Key::RIGHT, 'ctrl+f'], + ]; + } + + /** + * Cycle the current item's value forward or backward. + */ + private function cycleValue(int $direction): void + { + if (!isset($this->items[$this->selectedIndex])) { + return; + } + + $item = $this->items[$this->selectedIndex]; + + // Only cycle if item has predefined values + if (!$item->hasValues()) { + return; + } + + $values = $item->getValues(); + $valueCount = \count($values); + $currentIndex = array_search($item->getCurrentValue(), $values, true); + $currentIndex = false === $currentIndex ? 0 : (int) $currentIndex; + + // Calculate next index with wrapping + $nextIndex = ($currentIndex + $direction + $valueCount) % $valueCount; + $newValue = $values[$nextIndex]; + + $item->setCurrentValue($newValue); + $this->invalidate(); + $this->dispatch(new SettingChangeEvent($this, $item->getId(), $newValue)); + } + + private function renderItem(SettingItem $item, bool $isSelected, int $columns): string + { + $cursor = $isSelected ? '→ ' : ' '; + $label = $isSelected + ? $this->applyElement('label-selected', $item->getLabel()) + : $item->getLabel(); + $value = $isSelected + ? $this->applyElement('value-selected', $item->getCurrentValue()) + : $this->applyElement('value', $item->getCurrentValue()); + + // Calculate spacing + $labelWidth = AnsiUtils::visibleWidth($cursor.$label); + $valueWidth = AnsiUtils::visibleWidth($value); + $spacing = max(1, $columns - $labelWidth - $valueWidth - 2); + + $line = $cursor.$label.str_repeat(' ', $spacing).$value; + + return AnsiUtils::truncateToWidth($line, $columns); + } + + private function activateCurrentItem(): void + { + if (!isset($this->items[$this->selectedIndex])) { + return; + } + + $item = $this->items[$this->selectedIndex]; + + // If item has predefined values, cycle through them + if ($item->hasValues()) { + $values = $item->getValues(); + $currentIndex = array_search($item->getCurrentValue(), $values, true); + $nextIndex = (false === $currentIndex ? 0 : (int) $currentIndex + 1) % \count($values); + $newValue = $values[$nextIndex]; + + $item->setCurrentValue($newValue); + $this->invalidate(); + $this->dispatch(new SettingChangeEvent($this, $item->getId(), $newValue)); + + return; + } + + // If item has a submenu, open it + if ($item->hasSubmenu()) { + $onDone = function (?string $selectedValue) use ($item): void { + $this->removeSubmenuListeners(); + + if (null !== $this->activeSubmenu) { + $context = $this->getContext(); + if (null !== $context) { + $context->detachChild($this->activeSubmenu); + } + } + $this->activeSubmenu = null; + + if (null !== $selectedValue) { + $item->setCurrentValue($selectedValue); + $this->invalidate(); + $this->dispatch(new SettingChangeEvent($this, $item->getId(), $selectedValue)); + } else { + $this->invalidate(); + } + }; + + $this->activeSubmenu = ($item->getSubmenu())( + $item->getCurrentValue(), + $onDone, + ); + + $submenu = $this->activeSubmenu; + $context = $this->getContext(); + if (null !== $context) { + $context->attachChild($this, $submenu); + + // Wire submenu events: when the inner widget dispatches + // SelectEvent or CancelEvent, route to the onDone callback + $dispatcher = $context->getEventDispatcher(); + $selectListener = static function (SelectEvent $e) use ($submenu, $onDone): void { + if ($e->getTarget() === $submenu) { + $onDone($e->getValue()); + } + }; + $cancelListener = static function (CancelEvent $e) use ($submenu, $onDone): void { + if ($e->getTarget() === $submenu) { + $onDone(null); + } + }; + $dispatcher->addListener(SelectEvent::class, $selectListener); + $dispatcher->addListener(CancelEvent::class, $cancelListener); + $this->submenuListeners = [ + [SelectEvent::class, $selectListener], + [CancelEvent::class, $cancelListener], + ]; + } + $this->invalidate(); + } + } + + private function removeSubmenuListeners(): void + { + if ([] === $this->submenuListeners) { + return; + } + + $context = $this->getContext(); + if (null !== $context) { + $dispatcher = $context->getEventDispatcher(); + foreach ($this->submenuListeners as [$eventClass, $listener]) { + $dispatcher->removeListener($eventClass, $listener); + } + } + $this->submenuListeners = []; + } +} diff --git a/extern/Tui/Widget/TextWidget.php b/extern/Tui/Widget/TextWidget.php new file mode 100644 index 00000000..bff00737 --- /dev/null +++ b/extern/Tui/Widget/TextWidget.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\Tui\Ansi\AnsiUtils; +use Symfony\Component\Tui\Ansi\TextWrapper; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Widget\Figlet\FigletRenderer; + +/** + * Text component - displays text with word wrapping or truncation. + * + * When truncate is false (default), text wraps to multiple lines. + * When truncate is true, each line is truncated to fit the width with an ellipsis. + * + * When a FIGlet font is set via the Style system, the text is rendered as large + * ASCII art instead. Bundled fonts: big, small, slant, standard, mini. + * Custom fonts can be registered via the FontRegistry. + * + * Font can be set via stylesheet rules, CSS classes, or Tailwind utility classes: + * + * // Stylesheet rule + * $stylesheet->addRule('.title', new Style(font: 'big')); + * + * // Tailwind utility class + * $widget->addStyleClass('font-big'); + * + * // Template + * Hello + * + * @experimental + * + * @author Fabien Potencier + */ +class TextWidget extends AbstractWidget +{ + /** + * @param string $text Text content to display + * @param bool $truncate When true, truncate lines to fit width instead of wrapping + */ + public function __construct( + private string $text = '', + private bool $truncate = false, + ) { + } + + /** + * @return $this + */ + public function setText(string $text): static + { + $this->text = $text; + $this->invalidate(); + + return $this; + } + + /** + * Get the text content. + */ + public function getText(): string + { + return $this->text; + } + + /** + * @return string[] + */ + public function render(RenderContext $context): array + { + // Don't render anything if there's no actual text + if ('' === $this->text || '' === trim($this->text)) { + return []; + } + + $font = $context->getStyle()->getFont(); + + if (null !== $font) { + return $this->renderFiglet($context, $font); + } + + return $this->renderText($context); + } + + /** + * @return string[] + */ + private function renderText(RenderContext $context): array + { + // Replace tabs with 3 spaces + $normalizedText = str_replace("\t", ' ', $this->text); + + // Context already has inner dimensions (chrome subtracted by the Renderer) + $contentColumns = $context->getColumns(); + + // Either truncate or wrap based on mode + if ($this->truncate) { + $lines = explode("\n", $normalizedText); + $processedLines = []; + foreach ($lines as $line) { + $processedLines[] = AnsiUtils::truncateToWidth($line, $contentColumns); + } + } else { + $processedLines = TextWrapper::wrapTextWithAnsi($normalizedText, $contentColumns); + } + + return [] !== $processedLines ? $processedLines : ['']; + } + + /** + * @return string[] + */ + private function renderFiglet(RenderContext $context, string $fontName): array + { + $font = $context->getFontRegistry()->get($fontName); + $renderer = new FigletRenderer($font); + $lines = $renderer->render($this->text); + + // Truncate lines that exceed available width (ANSI-aware) + $truncated = []; + foreach ($lines as $line) { + if (AnsiUtils::visibleWidth($line) > $context->getColumns()) { + $truncated[] = AnsiUtils::truncateToWidth($line, $context->getColumns(), ''); + } else { + $truncated[] = $line; + } + } + + return $truncated; + } +} diff --git a/extern/Tui/Widget/Util/KillRing.php b/extern/Tui/Widget/Util/KillRing.php new file mode 100644 index 00000000..6e20e6f6 --- /dev/null +++ b/extern/Tui/Widget/Util/KillRing.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +/** + * Emacs-style kill ring for cut/paste operations. + * + * Consecutive kill operations are appended to the same entry. + * Yank/yank-pop cycles through the ring. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +class KillRing +{ + /** @var string[] */ + private array $entries = []; + private ?string $lastAction = null; + + /** + * @var array{start_line: int, start_col: int, end_line: int, end_col: int}|null + */ + private ?array $lastYank = null; + + public function __construct( + private int $maxEntries = 50, + ) { + } + + /** + * Add text to the kill ring. + * + * If the last action was also a kill, the text is appended/prepended + * to the most recent entry (consecutive kills accumulate). + */ + public function add(string $text, bool $prepend): void + { + if ('' === $text) { + return; + } + + if ('kill' === $this->lastAction && [] !== $this->entries) { + $lastIndex = \count($this->entries) - 1; + $current = $this->entries[$lastIndex]; + $this->entries[$lastIndex] = $prepend ? $text.$current : $current.$text; + } else { + $this->entries[] = $text; + if (\count($this->entries) > $this->maxEntries) { + array_shift($this->entries); + } + } + + $this->lastAction = 'kill'; + $this->lastYank = null; + } + + /** + * Get the most recent kill ring entry for yanking. + */ + public function peek(): ?string + { + if ([] === $this->entries) { + return null; + } + + return $this->entries[\count($this->entries) - 1]; + } + + /** + * Whether yank-pop is available (last action was yank and ring has > 1 entry). + */ + public function canYankPop(): bool + { + return 'yank' === $this->lastAction && \count($this->entries) > 1 && null !== $this->lastYank; + } + + /** + * Rotate the ring for yank-pop and return the new top entry. + * + * Moves the last entry to the front and returns the new last entry. + */ + public function rotate(): ?string + { + if (\count($this->entries) <= 1) { + return null; + } + + $lastEntry = array_pop($this->entries); + array_unshift($this->entries, $lastEntry); + + return $this->entries[\count($this->entries) - 1]; + } + + /** + * Record that a yank happened (for yank-pop tracking). + * + * @param array{start_line: int, start_col: int, end_line: int, end_col: int} $range + */ + public function recordYank(array $range): void + { + $this->lastYank = $range; + $this->lastAction = 'yank'; + } + + /** + * Get the range of the last yank (for deletion before yank-pop). + * + * @return array{start_line: int, start_col: int, end_line: int, end_col: int}|null + */ + public function getLastYankRange(): ?array + { + return $this->lastYank; + } + + /** + * Reset the action tracking (called after non-kill/yank operations). + */ + public function resetAction(): void + { + $this->lastAction = null; + } + + /** + * Reset both action and yank tracking (called after undo). + */ + public function resetAll(): void + { + $this->lastAction = null; + $this->lastYank = null; + } +} diff --git a/extern/Tui/Widget/Util/Line.php b/extern/Tui/Widget/Util/Line.php new file mode 100644 index 00000000..5c9e390f --- /dev/null +++ b/extern/Tui/Widget/Util/Line.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +/** + * Grapheme-aware single-line text buffer with cursor position. + * + * Encapsulates text content and a byte-offset cursor. Every editing method + * mutates internal state and returns whether the operation had an effect, + * letting the caller decide whether to invalidate, push undo snapshots, etc. + * + * Both InputWidget (single-line) and EditorWidget (multi-line, per-line) + * delegate grapheme-level operations here. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class Line +{ + private string $text; + private int $cursor; + + public function __construct(string $text = '', int $cursor = 0) + { + $this->text = $text; + $this->cursor = max(0, min($cursor, \strlen($text))); + } + + public function getText(): string + { + return $this->text; + } + + public function getCursor(): int + { + return $this->cursor; + } + + public function setText(string $text): void + { + $this->text = $text; + $this->cursor = min($this->cursor, \strlen($text)); + } + + public function setCursor(int $cursor): void + { + $this->cursor = max(0, min($cursor, \strlen($this->text))); + } + + /** + * Insert text at the cursor position. + */ + public function insert(string $insertion): void + { + $this->text = substr($this->text, 0, $this->cursor).$insertion.substr($this->text, $this->cursor); + $this->cursor += \strlen($insertion); + } + + /** + * Delete one grapheme backward (backspace). + */ + public function deleteCharBackward(): bool + { + if (0 === $this->cursor) { + return false; + } + + $beforeCursor = substr($this->text, 0, $this->cursor); + $graphemes = grapheme_str_split($beforeCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + $lastGrapheme = array_pop($graphemes); + $this->text = implode('', $graphemes).substr($this->text, $this->cursor); + $this->cursor -= \strlen($lastGrapheme); + + return true; + } + + /** + * Delete one grapheme forward (delete key). + */ + public function deleteCharForward(): bool + { + if ($this->cursor >= \strlen($this->text)) { + return false; + } + + $afterCursor = substr($this->text, $this->cursor); + $graphemes = grapheme_str_split($afterCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + $this->text = substr($this->text, 0, $this->cursor).substr($this->text, $this->cursor + \strlen($graphemes[0])); + + return true; + } + + /** + * Move one grapheme to the left. + */ + public function moveCursorLeft(): bool + { + if (0 === $this->cursor) { + return false; + } + + $beforeCursor = substr($this->text, 0, $this->cursor); + $graphemes = grapheme_str_split($beforeCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + /** @var string $lastGrapheme */ + $lastGrapheme = array_pop($graphemes); + $this->cursor -= \strlen($lastGrapheme); + + return true; + } + + /** + * Move one grapheme to the right. + */ + public function moveCursorRight(): bool + { + if ($this->cursor >= \strlen($this->text)) { + return false; + } + + $afterCursor = substr($this->text, $this->cursor); + $graphemes = grapheme_str_split($afterCursor); + + if (false === $graphemes || [] === $graphemes) { + return false; + } + + $this->cursor += \strlen($graphemes[0]); + + return true; + } + + /** + * Move cursor to the beginning of the line. + */ + public function moveCursorToStart(): bool + { + if (0 === $this->cursor) { + return false; + } + + $this->cursor = 0; + + return true; + } + + /** + * Move cursor to the end of the line. + */ + public function moveCursorToEnd(): bool + { + $end = \strlen($this->text); + if ($this->cursor === $end) { + return false; + } + + $this->cursor = $end; + + return true; + } + + /** + * Move cursor one word backward. + */ + public function moveWordBackward(): bool + { + if (0 === $this->cursor) { + return false; + } + + $newCursor = WordNavigator::skipWordBackward($this->text, $this->cursor); + if ($newCursor === $this->cursor) { + return false; + } + + $this->cursor = $newCursor; + + return true; + } + + /** + * Move cursor one word forward. + */ + public function moveWordForward(): bool + { + if ($this->cursor >= \strlen($this->text)) { + return false; + } + + $newCursor = WordNavigator::skipWordForward($this->text, $this->cursor); + if ($newCursor === $this->cursor) { + return false; + } + + $this->cursor = $newCursor; + + return true; + } + + /** + * Delete one word backward and return the deleted text. + */ + public function deleteWordBackward(): string + { + if (0 === $this->cursor) { + return ''; + } + + $deleteFrom = WordNavigator::skipWordBackward($this->text, $this->cursor); + $deletedText = substr($this->text, $deleteFrom, $this->cursor - $deleteFrom); + + $this->text = substr($this->text, 0, $deleteFrom).substr($this->text, $this->cursor); + $this->cursor = $deleteFrom; + + return $deletedText; + } + + /** + * Delete one word forward and return the deleted text. + */ + public function deleteWordForward(): string + { + if ($this->cursor >= \strlen($this->text)) { + return ''; + } + + $deleteTo = WordNavigator::skipWordForward($this->text, $this->cursor); + $deletedText = substr($this->text, $this->cursor, $deleteTo - $this->cursor); + + $this->text = substr($this->text, 0, $this->cursor).substr($this->text, $deleteTo); + + return $deletedText; + } + + /** + * Delete from cursor to end of line and return the deleted text. + */ + public function deleteToEnd(): string + { + $deletedText = substr($this->text, $this->cursor); + if ('' === $deletedText) { + return ''; + } + + $this->text = substr($this->text, 0, $this->cursor); + + return $deletedText; + } + + /** + * Delete from start of line to cursor and return the deleted text. + */ + public function deleteToStart(): string + { + $deletedText = substr($this->text, 0, $this->cursor); + if ('' === $deletedText) { + return ''; + } + + $this->text = substr($this->text, $this->cursor); + $this->cursor = 0; + + return $deletedText; + } +} diff --git a/extern/Tui/Widget/Util/StringUtils.php b/extern/Tui/Widget/Util/StringUtils.php new file mode 100644 index 00000000..f22b2d13 --- /dev/null +++ b/extern/Tui/Widget/Util/StringUtils.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +/** + * General-purpose string utilities for terminal input handling. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class StringUtils +{ + /** + * Check if the input data contains control characters (C0 controls + DEL). + * + * Only checks for ASCII control characters (0x00-0x1F and 0x7F). + * Does NOT check for C1 control characters (U+0080-U+009F) at the byte + * level, because bytes 0x80-0x9F are valid UTF-8 continuation bytes used + * in multi-byte characters like emojis (e.g. 😀 = \xF0\x9F\x98\x80). + */ + public static function hasControlChars(string $data): bool + { + for ($i = 0; $i < \strlen($data); ++$i) { + $code = \ord($data[$i]); + if ($code < 32 || 0x7F === $code) { + return true; + } + } + + return false; + } + + /** + * Sanitize a string by removing invalid UTF-8 byte sequences. + */ + public static function sanitizeUtf8(string $value): string + { + if ('' === $value || false !== preg_match('//u', $value)) { + return $value; + } + + $sanitized = @iconv('UTF-8', 'UTF-8//IGNORE', $value); + + return false === $sanitized ? '' : $sanitized; + } +} diff --git a/extern/Tui/Widget/Util/WordNavigator.php b/extern/Tui/Widget/Util/WordNavigator.php new file mode 100644 index 00000000..4af37621 --- /dev/null +++ b/extern/Tui/Widget/Util/WordNavigator.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget\Util; + +use Symfony\Component\Tui\Ansi\AnsiUtils; + +/** + * Grapheme-aware word navigation for text editing. + * + * Provides cursor movement logic: skip whitespace, then skip a punctuation run + * or a word run. Both InputWidget and EditorWidget delegate to this class for + * within-line word navigation. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class WordNavigator +{ + /** + * Returns the new cursor position after moving one word backward within + * the given text. The cursor is a byte offset. + * + * Algorithm: skip trailing whitespace, then skip a punctuation run or a + * word-character run. + */ + public static function skipWordBackward(string $text, int $cursor): int + { + if (0 === $cursor) { + return 0; + } + + $graphemes = grapheme_str_split(substr($text, 0, $cursor)); + if (false === $graphemes) { + return $cursor; + } + + $newCursor = $cursor; + + // Skip trailing whitespace + while ([] !== $graphemes && AnsiUtils::isWhitespace(end($graphemes))) { + $newCursor -= \strlen(array_pop($graphemes)); + } + + if ([] !== $graphemes) { + /** @var string $lastGrapheme */ + $lastGrapheme = end($graphemes); + if (AnsiUtils::isPunctuation($lastGrapheme)) { + // Skip punctuation run + while ([] !== $graphemes && AnsiUtils::isPunctuation(end($graphemes))) { + $newCursor -= \strlen(array_pop($graphemes)); + } + } else { + // Skip word run + while ([] !== $graphemes + && !AnsiUtils::isWhitespace(end($graphemes)) + && !AnsiUtils::isPunctuation(end($graphemes))) { + $newCursor -= \strlen(array_pop($graphemes)); + } + } + } + + return max(0, $newCursor); + } + + /** + * Returns the new cursor position after moving one word forward within + * the given text. The cursor is a byte offset. + * + * Algorithm: skip leading whitespace, then skip a punctuation run or a + * word-character run. + */ + public static function skipWordForward(string $text, int $cursor): int + { + $textLength = \strlen($text); + if ($cursor >= $textLength) { + return $cursor; + } + + $graphemes = grapheme_str_split(substr($text, $cursor)); + if (false === $graphemes) { + return $cursor; + } + + $newCursor = $cursor; + $index = 0; + $count = \count($graphemes); + + // Skip leading whitespace + while ($index < $count && AnsiUtils::isWhitespace($graphemes[$index])) { + $newCursor += \strlen($graphemes[$index]); + ++$index; + } + + if ($index < $count) { + if (AnsiUtils::isPunctuation($graphemes[$index])) { + // Skip punctuation run + while ($index < $count && AnsiUtils::isPunctuation($graphemes[$index])) { + $newCursor += \strlen($graphemes[$index]); + ++$index; + } + } else { + // Skip word run + while ($index < $count) { + $segment = $graphemes[$index]; + if (AnsiUtils::isWhitespace($segment) || AnsiUtils::isPunctuation($segment)) { + break; + } + $newCursor += \strlen($segment); + ++$index; + } + } + } + + return $newCursor; + } +} diff --git a/extern/Tui/Widget/VerticallyExpandableInterface.php b/extern/Tui/Widget/VerticallyExpandableInterface.php new file mode 100644 index 00000000..b3f37da7 --- /dev/null +++ b/extern/Tui/Widget/VerticallyExpandableInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +/** + * Interface for widgets that can expand to fill available vertical space. + * + * When a widget implements this interface and vertical expansion is enabled, + * it will expand to use available vertical space in its parent container. + * In vertical layouts, multiple expanded siblings share the space equally. + * In horizontal layouts, all children receive the full available height. + * + * @experimental + * + * @author Fabien Potencier + */ +interface VerticallyExpandableInterface +{ + /** + * Set whether the widget should expand to fill available height. + * + * @return $this + */ + public function expandVertically(bool $expand): static; + + /** + * Check if the widget should expand to fill available height. + */ + public function isVerticallyExpanded(): bool; +} diff --git a/extern/Tui/Widget/WidgetContext.php b/extern/Tui/Widget/WidgetContext.php new file mode 100644 index 00000000..c9c4652e --- /dev/null +++ b/extern/Tui/Widget/WidgetContext.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Event\AbstractEvent; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\RenderContext; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Style\Style; +use Symfony\Component\Tui\Terminal\TerminalInterface; +use Symfony\Component\Tui\Tui; + +/** + * Runtime context provided to widgets when attached to the tree. + * + * @experimental + * + * @author Fabien Potencier + */ +final class WidgetContext +{ + /** @var array */ + private array $tickIds = []; + + public function __construct( + private readonly Tui $tui, + private readonly Keybindings $keybindings, + private readonly TerminalInterface $terminal, + private readonly FocusManager $focusManager, + private readonly Renderer $renderer, + private readonly WidgetTree $widgetTree, + private readonly EventDispatcherInterface $eventDispatcher, + ) { + } + + public function keybindings(): Keybindings + { + return $this->keybindings; + } + + public function stop(): void + { + $this->tui->stop(); + } + + public function requestRender(bool $force = false): void + { + $this->tui->requestRender($force); + } + + public function dispatch(AbstractEvent $event): void + { + $this->eventDispatcher->dispatch($event); + $this->tui->requestRender(); + } + + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->eventDispatcher; + } + + public function resolveElement(AbstractWidget $widget, string $element): Style + { + return $this->renderer->getStyleSheet()->resolveElement($widget, $element); + } + + public function getTerminalColumns(): int + { + return $this->terminal->getColumns(); + } + + public function getTerminalRows(): int + { + return $this->terminal->getRows(); + } + + /** + * @internal + */ + public function getFocusManager(): FocusManager + { + return $this->focusManager; + } + + /** + * @return string[] + */ + public function renderWidget(AbstractWidget $widget, RenderContext $context): array + { + return $this->renderer->renderWidget($widget, $context); + } + + public function scheduleTick(callable $callback, float $intervalSeconds): string + { + $id = $this->tui->scheduleInterval($callback, $intervalSeconds); + $this->tickIds[$id] = $id; + + return $id; + } + + public function cancelTick(string $id): void + { + if (!isset($this->tickIds[$id])) { + return; + } + + $this->tui->cancelInterval($id); + unset($this->tickIds[$id]); + } + + /** + * @internal + */ + public function attachChild(AbstractWidget $parent, AbstractWidget $child): void + { + $this->widgetTree->attach($child, $parent); + } + + /** + * @internal + */ + public function detachChild(AbstractWidget $child): void + { + $this->widgetTree->detach($child); + } +} diff --git a/extern/Tui/Widget/WidgetTree.php b/extern/Tui/Widget/WidgetTree.php new file mode 100644 index 00000000..54214068 --- /dev/null +++ b/extern/Tui/Widget/WidgetTree.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Tui\Widget; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Tui\Focus\FocusManager; +use Symfony\Component\Tui\Input\Keybindings; +use Symfony\Component\Tui\Render\Renderer; +use Symfony\Component\Tui\Terminal\TerminalInterface; +use Symfony\Component\Tui\Tui; + +/** + * Internal widget tree manager. + * + * @experimental + * + * @internal + * + * @author Fabien Potencier + */ +final class WidgetTree +{ + private WidgetContext $context; + private readonly TerminalInterface $terminal; + private ?AbstractWidget $root = null; + + public function __construct( + Tui $tui, + Keybindings $keybindings, + FocusManager $focusManager, + Renderer $renderer, + TerminalInterface $terminal, + EventDispatcherInterface $eventDispatcher, + ) { + $this->terminal = $terminal; + $this->context = new WidgetContext( + $tui, + $keybindings, + $this->terminal, + $focusManager, + $renderer, + $this, + $eventDispatcher, + ); + } + + public function setRoot(AbstractWidget $root): void + { + if ($this->root === $root) { + return; + } + + if (null !== $this->root) { + $this->detach($this->root); + } + + $this->root = $root; + $this->attach($root, null); + } + + public function attach(AbstractWidget $widget, ?AbstractWidget $parent): void + { + $widget->attach($parent, $this->context); + + if ($widget instanceof ParentInterface) { + foreach ($widget->all() as $child) { + $this->attach($child, $widget); + } + } + } + + public function detach(AbstractWidget $widget): void + { + if ($widget instanceof ParentInterface) { + foreach ($widget->all() as $child) { + $this->detach($child); + } + } + + $cleanup = $widget->collectTerminalCleanupSequence(); + $widget->detach(); + + if ('' !== $cleanup) { + $this->terminal->write($cleanup); + } + } +} diff --git a/extern/Tui/composer.json b/extern/Tui/composer.json new file mode 100644 index 00000000..5ece6550 --- /dev/null +++ b/extern/Tui/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/tui", + "type": "library", + "description": "Provides a terminal UI framework for building rich, interactive CLI applications in PHP", + "keywords": ["tui", "terminal", "console", "cli"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.4", + "revolt/event-loop": "^1.0", + "symfony/event-dispatcher": "^8.0", + "symfony/string": "^8.0" + }, + "require-dev": { + "league/commonmark": "^2.9", + "tempest/highlight": "^2.16" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Tui\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/extern/Tui/phpstan.neon.dist b/extern/Tui/phpstan.neon.dist new file mode 100644 index 00000000..1d5823a9 --- /dev/null +++ b/extern/Tui/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: 7 + paths: + - src