├── src ├── Buffers │ ├── Proxy.php │ ├── PrintableBuffer.php │ ├── Buffer.php │ ├── AnsiBuffer.php │ └── CellBuffer.php ├── AnsiMatcher.php ├── AnsiMatch.php ├── ParsedAnsi.php ├── Output │ ├── StyleTracker.php │ └── CursorOptimizer.php ├── Cell.php ├── AnsiParser.php └── Screen.php ├── pint.json ├── LICENSE ├── composer.json ├── CHANGELOG.md ├── OPTIMIZATION.md ├── bin ├── test └── check-fixtures ├── PENDING_WRAP_DEEP_DIVE.md ├── CLAUDE.md ├── CROSS_TERMINAL_TESTING.md └── README.md /src/Buffers/Proxy.php: -------------------------------------------------------------------------------- 1 | items = $items; 15 | } 16 | 17 | public function __call($method, $parameters): void 18 | { 19 | foreach ($this->items as $item) { 20 | $item->{$method}(...$parameters); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "not_operator_with_successor_space": false, 5 | "heredoc_to_nowdoc": false, 6 | "phpdoc_summary": false, 7 | "concat_space": { 8 | "spacing": "one" 9 | }, 10 | "function_declaration": { 11 | "closure_fn_spacing": "none" 12 | }, 13 | "trailing_comma_in_multiline": false 14 | }, 15 | "xxx_header_comment": { 16 | "header": "@author Aaron Francis \n\n@link https://aaronfrancis.com\n@link https://x.com/aarondfrancis", 17 | "comment_type": "PHPDoc", 18 | "location": "after_declare_strict" 19 | } 20 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aaron Francis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/AnsiMatcher.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen; 11 | 12 | class AnsiMatcher 13 | { 14 | public static function split(string $content) 15 | { 16 | $parts = preg_split( 17 | static::regex(), $content, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY 18 | ); 19 | 20 | return array_map(function ($part) { 21 | return str_starts_with($part, "\e") ? new AnsiMatch($part) : $part; 22 | }, $parts); 23 | } 24 | 25 | /** 26 | * @link https://raw.githubusercontent.com/chalk/ansi-regex/refs/heads/main/fixtures/ansi-codes.js 27 | */ 28 | public static function regex(): string 29 | { 30 | return <<<12su78c] 35 | | 36 | \\#[34568] 37 | | 38 | \\([AB0-2] 39 | | 40 | \\)[AB0-2] 41 | | 42 | \\[[0-9;?]*[@-~] 43 | | 44 | [0356]n 45 | | 46 | \\].+?(?:\x07|\x1B\x5C|\x9C) # Valid string terminator sequences are BEL, ESC\, and 0x9c 47 | ) 48 | )/x 49 | EOT; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soloterm/screen", 3 | "description": "A terminal renderer written in pure PHP.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Aaron Francis", 9 | "email": "aarondfrancis@gmail.com" 10 | } 11 | ], 12 | "minimum-stability": "dev", 13 | "require": { 14 | "php": "^8.1", 15 | "soloterm/grapheme": "^v1.0.1", 16 | "ext-mbstring": "*" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^10.5|^11", 20 | "symfony/console": "^6.4|^7.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "SoloTerm\\Screen\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "SoloTerm\\Screen\\Tests\\": "tests/" 30 | }, 31 | "files": [ 32 | "tests/Support/helpers.php" 33 | ] 34 | }, 35 | "scripts": { 36 | "test": "php bin/test", 37 | "test:screenshots": "php bin/test --screenshots", 38 | "test:missing": "php bin/test --missing", 39 | "test:failures": "php bin/test -- --order-by=defects --stop-on-failure", 40 | "test:fixtures": "php bin/check-fixtures" 41 | }, 42 | "config": { 43 | "process-timeout": 0 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AnsiMatch.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen; 11 | 12 | class AnsiMatch implements \Stringable 13 | { 14 | public ?string $command; 15 | 16 | public ?string $params; 17 | 18 | public function __construct(public string $raw) 19 | { 20 | $pattern = << 24 | (10|11) 25 | ) 26 | ; 27 | (? 28 | \? 29 | ) 30 | | 31 | (? 32 | [ABCDEHIJKMNOSTZ=><12su78c] 33 | ) 34 | | 35 | \\[ 36 | (? 37 | [0-9;?]* 38 | ) 39 | (? 40 | [@-~] 41 | ) 42 | /x 43 | PATTERN; 44 | 45 | preg_match($pattern, $this->raw, $matches); 46 | 47 | $command = null; 48 | $params = null; 49 | 50 | foreach ($matches as $name => $value) { 51 | if (str_starts_with($name, 'command_')) { 52 | $command = $value; 53 | } 54 | 55 | if (str_starts_with($name, 'params_')) { 56 | $params = $value; 57 | } 58 | } 59 | 60 | $this->command = $command; 61 | $this->params = $params; 62 | } 63 | 64 | public function __toString(): string 65 | { 66 | return $this->raw; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ParsedAnsi.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen; 11 | 12 | /** 13 | * Lightweight ANSI escape sequence representation. 14 | * 15 | * Unlike AnsiMatch which uses regex to parse, this class extracts 16 | * command and params through direct string manipulation for better performance. 17 | */ 18 | class ParsedAnsi implements \Stringable 19 | { 20 | public ?string $command; 21 | 22 | public ?string $params; 23 | 24 | public function __construct(public string $raw) 25 | { 26 | $len = strlen($raw); 27 | 28 | // Minimum valid sequence is ESC + something (2 chars) 29 | if ($len < 2 || $raw[0] !== "\x1B") { 30 | $this->command = null; 31 | $this->params = null; 32 | 33 | return; 34 | } 35 | 36 | $second = $raw[1]; 37 | 38 | if ($second === '[') { 39 | // CSI sequence: ESC [ params command 40 | // Command is the last character, params is everything between 41 | if ($len > 2) { 42 | $this->command = $raw[$len - 1]; 43 | $this->params = $len > 3 ? substr($raw, 2, $len - 3) : ''; 44 | } else { 45 | $this->command = null; 46 | $this->params = null; 47 | } 48 | } elseif ($second === ']') { 49 | // OSC sequence: ESC ] num ; string terminator 50 | // Extract the number before semicolon as "command" 51 | $semicolonPos = strpos($raw, ';', 2); 52 | if ($semicolonPos !== false) { 53 | $this->command = substr($raw, 2, $semicolonPos - 2); 54 | $this->params = '?'; // OSC uses ? params format 55 | } else { 56 | $this->command = null; 57 | $this->params = null; 58 | } 59 | } else { 60 | // Simple escape: ESC command 61 | $this->command = $second; 62 | $this->params = ''; 63 | } 64 | } 65 | 66 | public function __toString(): string 67 | { 68 | return $this->raw; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - **Cross-terminal testing** - Visual testing now supports multiple terminals (iTerm and Ghostty) with terminal-specific fixture directories 13 | - **Fixture checking script** - New `bin/check-fixtures` script to verify all visual test fixtures are present 14 | 15 | ### Changed 16 | 17 | - **Rendering uses relative cursor positioning** - `Screen::output()` now uses DECSC/DECRC (save/restore cursor) with CUD (cursor down) instead of newlines between lines, avoiding "pending wrap" terminal inconsistencies and enabling correct rendering at any offset in a parent TUI 18 | 19 | ### Fixed 20 | 21 | - **Pending wrap terminal inconsistencies** - Different terminals handle full-width lines differently when using `\n`; new relative positioning approach eliminates this issue 22 | 23 | ## [1.1.1] - 2025-12-14 24 | 25 | ### Added 26 | 27 | - **Test runner script** - New `bin/test` script with `--screenshots` and `--missing` flags for easier fixture generation 28 | - **Automatic iTerm resizing** - Test runner automatically resizes iTerm to required dimensions (180x32) when generating fixtures 29 | - **PHP 8.5 support** - Added PHP 8.5 to the test matrix 30 | - **Enhanced README** - Added architecture diagram, differential rendering documentation, and comprehensive ANSI code reference 31 | 32 | ### Changed 33 | 34 | - Test fixtures now require consistent dimensions (180x32) to match CI environment 35 | - CI now fails on main branch if any test fixtures are missing 36 | 37 | ### Removed 38 | 39 | - Removed benchmark tests and their output from the test suite (cleaner test output) 40 | 41 | ## [1.1.0] - 2025-11-27 42 | 43 | ### Added 44 | 45 | - **Differential rendering support** - `Screen::output($sinceSeqNo)` now accepts an optional sequence number parameter to render only changed lines, providing significant performance improvements for frequently updating displays 46 | - **Sequence number tracking** - New `getSeqNo()` and `getLastRenderedSeqNo()` methods for tracking buffer modifications 47 | - **Cell-based buffer architecture** - New `Cell` class and `CellBuffer` for unified character + style storage with O(1) cell access 48 | - **`CellBuffer` features**: 49 | - Flat array indexing for efficient memory access 50 | - Dirty cell tracking for optimized differential rendering 51 | - Row hash caching for change detection 52 | - `renderRow()`, `renderDiff()`, and `renderDiffOptimized()` methods 53 | - **Cursor optimizer** - New `CursorOptimizer` class minimizes cursor movement escape sequences 54 | - **Style tracker** - New `StyleTracker` class for efficient style transition calculations 55 | - **State machine ANSI parser** - New `AnsiParser` class with `parseFast()` method, ~2.5x faster than regex-based parsing 56 | - **`Screen::toCellBuffer()`** - Convert dual-buffer content to unified CellBuffer for value-based comparisons 57 | 58 | ### Fixed 59 | 60 | - `CellBuffer::scrollUp()` now preserves buffer height when scrolling more lines than buffer contains 61 | - `CellBuffer::clear()` now clamps negative/out-of-range row/column values to prevent invalid array indices 62 | - `CellBuffer::getRowHash()` now handles variable-length extended color arrays (256-color mode uses 2 elements, RGB uses 4) 63 | - `CellBuffer::setCell()`, `writeChar()`, `writeContinuation()`, `clear()`, `clearLine()`, and `fill()` now properly invalidate row hash cache 64 | - `CellBuffer::insertLines()` and `deleteLines()` now properly shift `lineSeqNos` entries to maintain correct row tracking 65 | - `Screen::newlineWithScroll()` now marks all visible rows dirty when viewport scrolls, ensuring differential renderer includes shifted content 66 | 67 | ## [1.0.0] - 2024-XX-XX 68 | 69 | ### Added 70 | 71 | - Initial release 72 | - Pure PHP terminal renderer for ANSI escape sequences 73 | - Dual-buffer architecture (PrintableBuffer + AnsiBuffer) 74 | - Support for cursor movement, colors, styles, and screen clearing 75 | - Unicode and wide character support via Grapheme library 76 | - Visual testing system with screenshot comparison 77 | -------------------------------------------------------------------------------- /OPTIMIZATION.md: -------------------------------------------------------------------------------- 1 | # Screen Performance Optimizations 2 | 3 | This document summarizes the performance optimizations implemented in the Screen package. 4 | 5 | ## Overview 6 | 7 | These optimizations target terminal rendering performance, reducing both CPU usage and bytes written to the terminal. The key improvements enable differential rendering - only updating cells that actually changed between frames. 8 | 9 | ## Components 10 | 11 | ### 1. CellBuffer (`src/Buffers/CellBuffer.php`) 12 | 13 | A unified buffer that stores `Cell` objects in a flat array for O(1) access. 14 | 15 | **Features:** 16 | - Flat array indexing: `cells[y * width + x]` 17 | - Double-buffering for frame comparison via `swapBuffers()` 18 | - Dirty cell tracking for O(changed) change detection instead of O(all) 19 | - Value-based comparison via `getChangedCells()` 20 | 21 | **Key Methods:** 22 | - `swapBuffers()` - Swap current buffer to previous for next frame comparison 23 | - `getChangedCells()` - Get cells that differ from previous frame 24 | - `renderDiff()` - Render only changed cells with cursor positioning 25 | - `renderDiffOptimized()` - Render with optimized cursor/style tracking 26 | 27 | **Benchmark:** 81x faster differential rendering vs full Screen render 28 | 29 | ### 2. AnsiParser (`src/AnsiParser.php`) 30 | 31 | State machine ANSI escape sequence parser, replacing regex-based parsing. 32 | 33 | **Features:** 34 | - Uses `strpos()` to quickly find ESC characters 35 | - Inline byte range checks for performance 36 | - Returns lightweight `ParsedAnsi` objects 37 | 38 | **Benchmark:** 2.5x faster than regex-based `AnsiMatcher` 39 | 40 | ### 3. ParsedAnsi (`src/ParsedAnsi.php`) 41 | 42 | Lightweight ANSI match object without regex overhead. 43 | 44 | **Features:** 45 | - Parses command and params through string manipulation 46 | - Compatible interface with `AnsiMatch` 47 | - Minimal memory footprint 48 | 49 | ### 4. CursorOptimizer (`src/Output/CursorOptimizer.php`) 50 | 51 | Chooses optimal cursor movement sequences to minimize bytes written. 52 | 53 | **Strategies:** 54 | - `\e[H` for home position (3 bytes) 55 | - `\r` for column zero (1 byte vs 6+ for absolute) 56 | - `\n` for down-one-from-column-zero (1 byte) 57 | - Relative moves (`\e[C`, `\e[D`, `\e[A`, `\e[B`) when cheaper 58 | - Absolute positioning when optimal 59 | 60 | **Benchmark:** 67.5% byte savings on cursor movement 61 | 62 | ### 5. StyleTracker (`src/Output/StyleTracker.php`) 63 | 64 | Tracks terminal style state to emit minimal ANSI style codes. 65 | 66 | **Features:** 67 | - Tracks current style, foreground, background, extended colors 68 | - Only emits codes for attributes that actually changed 69 | - Handles 256-color and RGB extended colors 70 | - Uses efficient reset strategies when attributes are removed 71 | 72 | **Benchmark:** 68.6% byte savings on style sequences 73 | 74 | ### 6. Screen.toCellBuffer() (`src/Screen.php`) 75 | 76 | Converts Screen state to CellBuffer for value-based frame comparison. 77 | 78 | **Features:** 79 | - Extracts visible portion of screen to CellBuffer 80 | - Converts AnsiBuffer bitmasks to Cell-compatible style values 81 | - Enables integration with external differential renderers 82 | 83 | ## Usage 84 | 85 | ### Basic Differential Rendering 86 | 87 | ```php 88 | $buffer = new CellBuffer($width, $height); 89 | 90 | // Write frame 1 91 | $buffer->writeChar(0, 0, 'H'); 92 | $buffer->writeChar(0, 1, 'i'); 93 | $buffer->swapBuffers(); 94 | 95 | // Write frame 2 (only 'i' -> 'o' changed) 96 | $buffer->writeChar(0, 0, 'H'); 97 | $buffer->writeChar(0, 1, 'o'); 98 | 99 | // Get optimized diff output 100 | $output = $buffer->renderDiffOptimized(); 101 | // Only outputs cursor move + 'o', not full "Ho" 102 | ``` 103 | 104 | ### With Screen Integration 105 | 106 | ```php 107 | $screen = new Screen($width, $height); 108 | $screen->write("Hello World"); 109 | 110 | // Convert to CellBuffer for comparison 111 | $cellBuffer = $screen->toCellBuffer(); 112 | ``` 113 | 114 | ## Test Coverage 115 | 116 | All optimizations are covered by unit tests: 117 | 118 | - `tests/Unit/CellBufferTest.php` - 34 tests 119 | - `tests/Unit/AnsiParserTest.php` - Parser tests 120 | - `tests/Unit/CursorOptimizerTest.php` - 17 tests 121 | - `tests/Unit/StyleTrackerTest.php` - 16 tests 122 | 123 | -------------------------------------------------------------------------------- /src/Buffers/PrintableBuffer.php: -------------------------------------------------------------------------------- 1 | width = $width; 17 | 18 | return $this; 19 | } 20 | 21 | /** 22 | * Writes a string into the buffer at the specified row and starting column. 23 | * The string is split into "units" (either single characters or grapheme clusters), 24 | * and each unit is inserted into one or more cells based on its display width. 25 | * If a unit has width > 1, its first cell gets the unit, and the remaining cells are set to PHP null. 26 | * 27 | * If the text overflows the available width on that row, the function stops writing and returns 28 | * an array containing the number of columns advanced and a string of the remaining characters. 29 | * 30 | * @param int $row Row index (0-based). 31 | * @param int $col Starting column index (0-based). 32 | * @param string $text The text to write. 33 | * @return array [$advanceCursor, $remainder] 34 | * 35 | * @throws Exception if splitting into graphemes fails. 36 | */ 37 | public function writeString(int $row, int $col, string $text): array 38 | { 39 | // Determine the units to iterate over: if the text is ASCII-only, we can split by character, 40 | // otherwise we split into grapheme clusters. 41 | if (strlen($text) === mb_strlen($text)) { 42 | $units = str_split($text); 43 | } else { 44 | if (preg_match_all('/\X/u', $text, $matches) === false) { 45 | throw new Exception('Error splitting text into grapheme clusters.'); 46 | } 47 | 48 | $units = $matches[0]; 49 | } 50 | 51 | $currentCol = $col; 52 | $advanceCursor = 0; 53 | $totalUnits = count($units); 54 | 55 | // Ensure that the row is not sparse. 56 | // If the row already exists, fill any missing indices before the starting column with a space. 57 | // Otherwise, initialize the row and fill indices 0 through $col-1 with spaces. 58 | if (!isset($this->buffer[$row])) { 59 | $this->buffer[$row] = []; 60 | } 61 | 62 | for ($i = 0; $i < $col; $i++) { 63 | if (!array_key_exists($i, $this->buffer[$row])) { 64 | $this->buffer[$row][$i] = ' '; 65 | } 66 | } 67 | 68 | // Make sure we don't splice a wide character. 69 | if (array_key_exists($col, $this->buffer[$row]) && $this->buffer[$row][$col] === null) { 70 | for ($i = $col; $i >= 0; $i--) { 71 | // Replace null values with a space. 72 | if (!isset($this->buffer[$row][$i]) || $this->buffer[$row][$i] === null) { 73 | $this->buffer[$row][$i] = ' '; 74 | } else { 75 | // Also replace the first non-null value with a space, then exit. 76 | $this->buffer[$row][$i] = ' '; 77 | break; 78 | } 79 | } 80 | } 81 | 82 | for ($i = 0; $i < $totalUnits; $i++) { 83 | $unit = $units[$i]; 84 | 85 | // Check if the unit is a tab character. 86 | if ($unit === "\t") { 87 | // Calculate tab width as the number of spaces needed to reach the next tab stop. 88 | $unitWidth = 8 - ($currentCol % 8); 89 | } else { 90 | $unitWidth = Grapheme::wcwidth($unit); 91 | } 92 | 93 | // If adding this unit would overflow the available width, break out. 94 | if ($currentCol + $unitWidth > $this->width) { 95 | break; 96 | } 97 | 98 | // Write the unit into the first cell. 99 | $this->buffer[$row][$currentCol] = $unit; 100 | 101 | // Fill any additional columns that the unit occupies with PHP null. 102 | for ($j = 1; $j < $unitWidth; $j++) { 103 | if (($currentCol + $j) < $this->width) { 104 | $this->buffer[$row][$currentCol + $j] = null; 105 | } 106 | } 107 | 108 | $currentCol += $unitWidth; 109 | 110 | // Clear out any leftover continuation nulls 111 | if (array_key_exists($currentCol, $this->buffer[$row]) && $this->buffer[$row][$currentCol] === null) { 112 | $k = $currentCol; 113 | 114 | while (array_key_exists($k, $this->buffer[$row]) && $this->buffer[$row][$k] === null) { 115 | $this->buffer[$row][$k] = ' '; 116 | $k++; 117 | } 118 | } 119 | 120 | $advanceCursor += $unitWidth; 121 | } 122 | 123 | // The remainder is the unprocessed units joined back into a string. 124 | $remainder = implode('', array_slice($units, $i)); 125 | 126 | // Mark this row as modified for differential rendering 127 | $this->markLineDirty($row); 128 | 129 | return [$advanceCursor, $remainder]; 130 | } 131 | 132 | public function lines(): array 133 | { 134 | return array_map(fn($line) => implode('', $line), $this->buffer); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | &1', $output, $exitCode); 167 | 168 | return $exitCode === 0; 169 | } 170 | -------------------------------------------------------------------------------- /bin/check-fixtures: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 0) { 32 | echo "DIMENSION ERRORS FOUND:\n\n"; 33 | foreach ($dimensionErrors as $error) { 34 | echo " ✗ {$error}\n"; 35 | } 36 | echo "\n"; 37 | echo "Found " . count($dimensionErrors) . " fixture dimension error(s).\n"; 38 | echo "All fixtures must be generated at " . REQUIRED_WIDTH . "x" . REQUIRED_HEIGHT . ".\n"; 39 | echo "Resize your terminal and run: composer test:screenshots\n"; 40 | exit(1); 41 | } 42 | 43 | echo "✓ All {$dimensionsChecked} fixture(s) have correct dimensions.\n\n"; 44 | 45 | echo "Checking terminal fixture synchronization...\n\n"; 46 | 47 | // Check screenshot fixtures (tests/Fixtures/{iterm,ghostty}/...) 48 | $checked += checkFixtureDirectory( 49 | $fixturesDir . '/iterm', 50 | $fixturesDir . '/ghostty', 51 | 'iterm', 52 | 'ghostty', 53 | $errors 54 | ); 55 | 56 | // Check render fixtures (tests/Fixtures/Renders/{iterm,ghostty}/...) 57 | $checked += checkFixtureDirectory( 58 | $fixturesDir . '/Renders/iterm', 59 | $fixturesDir . '/Renders/ghostty', 60 | 'iterm', 61 | 'ghostty', 62 | $errors 63 | ); 64 | 65 | if (count($errors) > 0) { 66 | echo "ERRORS FOUND:\n\n"; 67 | foreach ($errors as $error) { 68 | echo " ✗ {$error}\n"; 69 | } 70 | echo "\n"; 71 | echo "Found " . count($errors) . " fixture validation error(s).\n"; 72 | echo "All visual tests must be run in both iTerm and Ghostty.\n"; 73 | echo "Fixtures must produce identical output in both terminals.\n"; 74 | exit(1); 75 | } 76 | 77 | if ($checked === 0) { 78 | echo "No terminal-specific fixtures found to validate.\n"; 79 | } else { 80 | echo "✓ All {$checked} fixture pair(s) validated successfully.\n"; 81 | } 82 | 83 | exit(0); 84 | 85 | /** 86 | * Check fixtures in a directory pair (e.g., iterm vs ghostty). 87 | * 88 | * @return int Number of fixture pairs checked 89 | */ 90 | function checkFixtureDirectory( 91 | string $dir1, 92 | string $dir2, 93 | string $name1, 94 | string $name2, 95 | array &$errors 96 | ): int { 97 | $checked = 0; 98 | 99 | // Check dir1 -> dir2 100 | if (is_dir($dir1)) { 101 | $files = findJsonFiles($dir1); 102 | foreach ($files as $file) { 103 | $relPath = substr($file, strlen($dir1) + 1); 104 | $otherFile = $dir2 . '/' . $relPath; 105 | 106 | if (!file_exists($otherFile)) { 107 | $errors[] = "Missing {$name2} fixture for: {$relPath}"; 108 | } elseif (!filesMatch($file, $otherFile)) { 109 | $errors[] = "Fixture mismatch between terminals for: {$relPath}"; 110 | } else { 111 | $checked++; 112 | } 113 | } 114 | } 115 | 116 | // Check dir2 -> dir1 (only for missing files, matches already checked) 117 | if (is_dir($dir2)) { 118 | $files = findJsonFiles($dir2); 119 | foreach ($files as $file) { 120 | $relPath = substr($file, strlen($dir2) + 1); 121 | $otherFile = $dir1 . '/' . $relPath; 122 | 123 | if (!file_exists($otherFile)) { 124 | $errors[] = "Missing {$name1} fixture for: {$relPath}"; 125 | } 126 | } 127 | } 128 | 129 | return $checked; 130 | } 131 | 132 | /** 133 | * Recursively find all JSON files in a directory. 134 | */ 135 | function findJsonFiles(string $dir): array 136 | { 137 | $files = []; 138 | 139 | if (!is_dir($dir)) { 140 | return $files; 141 | } 142 | 143 | $iterator = new RecursiveIteratorIterator( 144 | new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) 145 | ); 146 | 147 | foreach ($iterator as $file) { 148 | if ($file->isFile() && $file->getExtension() === 'json') { 149 | $files[] = $file->getPathname(); 150 | } 151 | } 152 | 153 | return $files; 154 | } 155 | 156 | /** 157 | * Check if two files have identical content. 158 | */ 159 | function filesMatch(string $file1, string $file2): bool 160 | { 161 | return file_get_contents($file1) === file_get_contents($file2); 162 | } 163 | 164 | /** 165 | * Check that all fixtures in a directory have the required dimensions. 166 | * 167 | * @return int Number of fixtures checked 168 | */ 169 | function checkFixtureDimensions(string $dir, array &$errors): int 170 | { 171 | $checked = 0; 172 | 173 | if (!is_dir($dir)) { 174 | return $checked; 175 | } 176 | 177 | $files = findJsonFiles($dir); 178 | foreach ($files as $file) { 179 | $data = json_decode(file_get_contents($file), true); 180 | 181 | if (!isset($data['width']) || !isset($data['height'])) { 182 | continue; 183 | } 184 | 185 | $checked++; 186 | 187 | if ($data['width'] !== REQUIRED_WIDTH || $data['height'] !== REQUIRED_HEIGHT) { 188 | $relPath = basename(dirname($dir)) . '/' . basename($dir) . '/' . substr($file, strlen($dir) + 1); 189 | $errors[] = "{$relPath}: {$data['width']}x{$data['height']} (expected " . REQUIRED_WIDTH . "x" . REQUIRED_HEIGHT . ")"; 190 | } 191 | } 192 | 193 | return $checked; 194 | } 195 | -------------------------------------------------------------------------------- /src/Output/StyleTracker.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen\Output; 11 | 12 | use SoloTerm\Screen\Cell; 13 | 14 | /** 15 | * Tracks current terminal style state to minimize style change sequences. 16 | * 17 | * Instead of always emitting full SGR sequences, this class: 18 | * - Tracks the current style state 19 | * - Only emits codes for attributes that actually changed 20 | * - Uses efficient reset strategies when attributes are removed 21 | */ 22 | class StyleTracker 23 | { 24 | /** 25 | * Current style bitmask (bold, italic, underline, etc.) 26 | */ 27 | protected int $style = 0; 28 | 29 | /** 30 | * Current foreground color (basic ANSI: 30-37, 90-97) 31 | */ 32 | protected ?int $fg = null; 33 | 34 | /** 35 | * Current background color (basic ANSI: 40-47, 100-107) 36 | */ 37 | protected ?int $bg = null; 38 | 39 | /** 40 | * Current extended foreground color [type, ...params] 41 | */ 42 | protected ?array $extFg = null; 43 | 44 | /** 45 | * Current extended background color [type, ...params] 46 | */ 47 | protected ?array $extBg = null; 48 | 49 | /** 50 | * Reset style tracking to default state. 51 | */ 52 | public function reset(): void 53 | { 54 | $this->style = 0; 55 | $this->fg = null; 56 | $this->bg = null; 57 | $this->extFg = null; 58 | $this->extBg = null; 59 | } 60 | 61 | /** 62 | * Check if we have any active styling. 63 | */ 64 | public function hasStyle(): bool 65 | { 66 | return $this->style !== 0 67 | || $this->fg !== null 68 | || $this->bg !== null 69 | || $this->extFg !== null 70 | || $this->extBg !== null; 71 | } 72 | 73 | /** 74 | * Generate the escape sequence to transition to the target cell's style. 75 | * 76 | * @param Cell $cell The target cell with desired style 77 | * @return string The escape sequence (empty if no change needed) 78 | */ 79 | public function transitionTo(Cell $cell): string 80 | { 81 | // Check if any change is needed 82 | if ($this->style === $cell->style 83 | && $this->fg === $cell->fg 84 | && $this->bg === $cell->bg 85 | && $this->extFg === $cell->extFg 86 | && $this->extBg === $cell->extBg) { 87 | return ''; 88 | } 89 | 90 | $codes = []; 91 | 92 | // Check if we need to reset (some styles were turned off or color type changed) 93 | $turnedOff = $this->style & ~$cell->style; 94 | $fgTypeChanged = ($this->extFg !== null && $cell->extFg === null) 95 | || ($this->fg !== null && $cell->extFg !== null); 96 | $bgTypeChanged = ($this->extBg !== null && $cell->extBg === null) 97 | || ($this->bg !== null && $cell->extBg !== null); 98 | $needsReset = $turnedOff !== 0 99 | || ($this->fg !== null && $cell->fg === null && $cell->extFg === null) 100 | || ($this->bg !== null && $cell->bg === null && $cell->extBg === null) 101 | || $fgTypeChanged 102 | || $bgTypeChanged; 103 | 104 | if ($needsReset) { 105 | // Reset and re-apply all current styles 106 | $codes[] = '0'; 107 | $this->style = 0; 108 | $this->fg = null; 109 | $this->bg = null; 110 | $this->extFg = null; 111 | $this->extBg = null; 112 | 113 | // Add all target styles 114 | $codes = array_merge($codes, $this->getStyleCodes($cell->style)); 115 | 116 | if ($cell->extFg !== null) { 117 | $codes[] = '38;' . implode(';', $cell->extFg); 118 | } elseif ($cell->fg !== null) { 119 | $codes[] = (string) $cell->fg; 120 | } 121 | 122 | if ($cell->extBg !== null) { 123 | $codes[] = '48;' . implode(';', $cell->extBg); 124 | } elseif ($cell->bg !== null) { 125 | $codes[] = (string) $cell->bg; 126 | } 127 | } else { 128 | // Incremental update - only add new styles 129 | 130 | // Add new style attributes 131 | $newStyles = $cell->style & ~$this->style; 132 | if ($newStyles !== 0) { 133 | $codes = array_merge($codes, $this->getStyleCodes($newStyles)); 134 | } 135 | 136 | // Handle foreground color 137 | if ($cell->fg !== $this->fg || $cell->extFg !== $this->extFg) { 138 | if ($cell->extFg !== null) { 139 | $codes[] = '38;' . implode(';', $cell->extFg); 140 | } elseif ($cell->fg !== null) { 141 | $codes[] = (string) $cell->fg; 142 | } 143 | } 144 | 145 | // Handle background color 146 | if ($cell->bg !== $this->bg || $cell->extBg !== $this->extBg) { 147 | if ($cell->extBg !== null) { 148 | $codes[] = '48;' . implode(';', $cell->extBg); 149 | } elseif ($cell->bg !== null) { 150 | $codes[] = (string) $cell->bg; 151 | } 152 | } 153 | } 154 | 155 | // Update tracked state 156 | $this->style = $cell->style; 157 | $this->fg = $cell->fg; 158 | $this->bg = $cell->bg; 159 | $this->extFg = $cell->extFg; 160 | $this->extBg = $cell->extBg; 161 | 162 | if (empty($codes)) { 163 | return ''; 164 | } 165 | 166 | return "\e[" . implode(';', $codes) . 'm'; 167 | } 168 | 169 | /** 170 | * Generate a reset sequence if we have any active styles. 171 | * 172 | * @return string ESC[0m if styles are active, empty otherwise 173 | */ 174 | public function resetIfNeeded(): string 175 | { 176 | if ($this->hasStyle()) { 177 | $this->reset(); 178 | 179 | return "\e[0m"; 180 | } 181 | 182 | return ''; 183 | } 184 | 185 | /** 186 | * Convert a style bitmask to an array of ANSI code strings. 187 | * 188 | * @param int $bitmask The style bitmask 189 | * @return array Array of ANSI code strings 190 | */ 191 | protected function getStyleCodes(int $bitmask): array 192 | { 193 | $codes = []; 194 | 195 | // Map bit positions to ANSI codes (1-9: bold, dim, italic, underline, blink, etc.) 196 | for ($code = 1; $code <= 9; $code++) { 197 | if ($bitmask & (1 << ($code - 1))) { 198 | $codes[] = (string) $code; 199 | } 200 | } 201 | 202 | return $codes; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Output/CursorOptimizer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen\Output; 11 | 12 | /** 13 | * Optimizes cursor movement sequences to minimize output bytes. 14 | * 15 | * Instead of always using absolute positioning (ESC[row;colH), 16 | * this class chooses the most efficient movement strategy: 17 | * - Carriage return (\r) for moving to column 0 18 | * - Newline (\n) for moving down one row 19 | * - Relative movements (ESC[nA/B/C/D) when cheaper 20 | * - Absolute positioning when it's the shortest option 21 | */ 22 | class CursorOptimizer 23 | { 24 | protected int $currentRow = 0; 25 | 26 | protected int $currentCol = 0; 27 | 28 | /** 29 | * Reset cursor tracking to origin. 30 | */ 31 | public function reset(): void 32 | { 33 | $this->currentRow = 0; 34 | $this->currentCol = 0; 35 | } 36 | 37 | /** 38 | * Get the current tracked cursor position. 39 | * 40 | * @return array{row: int, col: int} 41 | */ 42 | public function getPosition(): array 43 | { 44 | return [ 45 | 'row' => $this->currentRow, 46 | 'col' => $this->currentCol, 47 | ]; 48 | } 49 | 50 | /** 51 | * Generate the optimal escape sequence to move cursor to target position. 52 | * 53 | * @param int $row Target row (0-indexed) 54 | * @param int $col Target column (0-indexed) 55 | * @return string The escape sequence (may be empty if already at position) 56 | */ 57 | public function moveTo(int $row, int $col): string 58 | { 59 | // Already at target position 60 | if ($row === $this->currentRow && $col === $this->currentCol) { 61 | return ''; 62 | } 63 | 64 | // Home position: ESC[H (3 bytes) 65 | if ($row === 0 && $col === 0) { 66 | $this->currentRow = 0; 67 | $this->currentCol = 0; 68 | 69 | return "\e[H"; 70 | } 71 | 72 | // Same row, column 0: carriage return (1 byte) 73 | if ($row === $this->currentRow && $col === 0) { 74 | $this->currentCol = 0; 75 | 76 | return "\r"; 77 | } 78 | 79 | // Down one row, same column: newline (1 byte) - only if at col 0 or we handle it 80 | if ($row === $this->currentRow + 1 && $col === 0 && $this->currentCol === 0) { 81 | $this->currentRow++; 82 | 83 | return "\n"; 84 | } 85 | 86 | // Calculate costs for different strategies 87 | $dRow = $row - $this->currentRow; 88 | $dCol = $col - $this->currentCol; 89 | 90 | $relativeCost = $this->calculateRelativeCost($dRow, $dCol); 91 | $absoluteCost = $this->calculateAbsoluteCost($row, $col); 92 | 93 | // Also consider: CR + relative vertical + relative horizontal 94 | $crBasedCost = PHP_INT_MAX; 95 | if ($col < $this->currentCol || $dRow !== 0) { 96 | // Cost of CR + vertical move + horizontal move from col 0 97 | $crBasedCost = 1 + $this->calculateVerticalCost($dRow) + $this->calculateHorizontalCost($col); 98 | } 99 | 100 | $this->currentRow = $row; 101 | $this->currentCol = $col; 102 | 103 | // Choose the cheapest option 104 | if ($relativeCost <= $absoluteCost && $relativeCost <= $crBasedCost) { 105 | return $this->buildRelativeMove($dRow, $dCol); 106 | } elseif ($crBasedCost < $absoluteCost) { 107 | return $this->buildCrBasedMove($dRow, $col); 108 | } else { 109 | return $this->buildAbsoluteMove($row, $col); 110 | } 111 | } 112 | 113 | /** 114 | * Move cursor right by the width of a character just written. 115 | * Call this after outputting a character to keep tracking accurate. 116 | * 117 | * @param int $width Character width (1 for normal, 2 for wide chars) 118 | */ 119 | public function advance(int $width = 1): void 120 | { 121 | $this->currentCol += $width; 122 | } 123 | 124 | /** 125 | * Calculate the byte cost of relative cursor movement. 126 | */ 127 | protected function calculateRelativeCost(int $dRow, int $dCol): int 128 | { 129 | return $this->calculateVerticalCost($dRow) + $this->calculateHorizontalCost(abs($dCol)); 130 | } 131 | 132 | /** 133 | * Calculate the byte cost of vertical movement. 134 | */ 135 | protected function calculateVerticalCost(int $dRow): int 136 | { 137 | if ($dRow === 0) { 138 | return 0; 139 | } 140 | 141 | $n = abs($dRow); 142 | 143 | // ESC[A or ESC[B for n=1: 3 bytes 144 | // ESC[nA or ESC[nB for n>1: 3 + digits 145 | if ($n === 1) { 146 | return 3; 147 | } 148 | 149 | return 3 + strlen((string) $n); 150 | } 151 | 152 | /** 153 | * Calculate the byte cost of horizontal movement. 154 | */ 155 | protected function calculateHorizontalCost(int $distance): int 156 | { 157 | if ($distance === 0) { 158 | return 0; 159 | } 160 | 161 | // ESC[C or ESC[D for n=1: 3 bytes 162 | // ESC[nC or ESC[nD for n>1: 3 + digits 163 | if ($distance === 1) { 164 | return 3; 165 | } 166 | 167 | return 3 + strlen((string) $distance); 168 | } 169 | 170 | /** 171 | * Calculate the byte cost of absolute positioning. 172 | */ 173 | protected function calculateAbsoluteCost(int $row, int $col): int 174 | { 175 | // ESC[row;colH 176 | // Minimum: ESC[1;1H = 6 bytes 177 | // row and col are 1-indexed in ANSI 178 | return 4 + strlen((string) ($row + 1)) + strlen((string) ($col + 1)); 179 | } 180 | 181 | /** 182 | * Build the relative movement escape sequence. 183 | */ 184 | protected function buildRelativeMove(int $dRow, int $dCol): string 185 | { 186 | $result = ''; 187 | 188 | // Vertical movement 189 | if ($dRow !== 0) { 190 | $n = abs($dRow); 191 | $dir = $dRow > 0 ? 'B' : 'A'; 192 | $result .= $n === 1 ? "\e[{$dir}" : "\e[{$n}{$dir}"; 193 | } 194 | 195 | // Horizontal movement 196 | if ($dCol !== 0) { 197 | $n = abs($dCol); 198 | $dir = $dCol > 0 ? 'C' : 'D'; 199 | $result .= $n === 1 ? "\e[{$dir}" : "\e[{$n}{$dir}"; 200 | } 201 | 202 | return $result; 203 | } 204 | 205 | /** 206 | * Build a CR-based movement (carriage return + relative moves). 207 | */ 208 | protected function buildCrBasedMove(int $dRow, int $targetCol): string 209 | { 210 | $result = "\r"; 211 | 212 | // Vertical movement 213 | if ($dRow !== 0) { 214 | $n = abs($dRow); 215 | $dir = $dRow > 0 ? 'B' : 'A'; 216 | $result .= $n === 1 ? "\e[{$dir}" : "\e[{$n}{$dir}"; 217 | } 218 | 219 | // Horizontal movement from column 0 220 | if ($targetCol > 0) { 221 | $result .= $targetCol === 1 ? "\e[C" : "\e[{$targetCol}C"; 222 | } 223 | 224 | return $result; 225 | } 226 | 227 | /** 228 | * Build an absolute positioning escape sequence. 229 | */ 230 | protected function buildAbsoluteMove(int $row, int $col): string 231 | { 232 | // ANSI is 1-indexed 233 | return "\e[" . ($row + 1) . ';' . ($col + 1) . 'H'; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Buffers/Buffer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen\Buffers; 11 | 12 | use ArrayAccess; 13 | use ReturnTypeWillChange; 14 | 15 | class Buffer implements ArrayAccess 16 | { 17 | public array $buffer = []; 18 | 19 | protected mixed $valueForClearing = 0; 20 | 21 | /** 22 | * Tracks the sequence number when each line was last modified. 23 | * Used for differential rendering - only lines with seqNo > lastRendered need re-rendering. 24 | * 25 | * @var array 26 | */ 27 | protected array $lineSeqNos = []; 28 | 29 | /** 30 | * Reference to the parent screen's sequence counter. 31 | * Set via setSeqNoProvider() to avoid circular dependencies. 32 | * 33 | * @var callable|null 34 | */ 35 | protected $seqNoProvider = null; 36 | 37 | public function __construct(public int $max = 5000) 38 | { 39 | if (method_exists($this, 'initialize')) { 40 | $this->initialize(); 41 | } 42 | } 43 | 44 | /** 45 | * Set a callback that returns the current sequence number. 46 | */ 47 | public function setSeqNoProvider(callable $provider): static 48 | { 49 | $this->seqNoProvider = $provider; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * Mark a line as dirty (modified) with the current sequence number. 56 | */ 57 | public function markLineDirty(int $row): void 58 | { 59 | if ($this->seqNoProvider !== null) { 60 | $this->lineSeqNos[$row] = ($this->seqNoProvider)(); 61 | } 62 | } 63 | 64 | /** 65 | * Check if a line has changed since the given sequence number. 66 | */ 67 | public function lineChangedSince(int $row, int $seqNo): bool 68 | { 69 | return ($this->lineSeqNos[$row] ?? 0) > $seqNo; 70 | } 71 | 72 | /** 73 | * Get all rows that have changed since the given sequence number. 74 | * 75 | * @return array Row indices that have changed 76 | */ 77 | public function getChangedRows(int $sinceSeqNo): array 78 | { 79 | $changed = []; 80 | 81 | foreach ($this->lineSeqNos as $row => $rowSeqNo) { 82 | if ($rowSeqNo > $sinceSeqNo) { 83 | $changed[] = $row; 84 | } 85 | } 86 | 87 | sort($changed); 88 | 89 | return $changed; 90 | } 91 | 92 | /** 93 | * Get the maximum sequence number across all lines. 94 | */ 95 | public function getMaxSeqNo(): int 96 | { 97 | return empty($this->lineSeqNos) ? 0 : max($this->lineSeqNos); 98 | } 99 | 100 | public function getBuffer() 101 | { 102 | return $this->buffer; 103 | } 104 | 105 | public function clear( 106 | int $startRow = 0, 107 | int $startCol = 0, 108 | int $endRow = PHP_INT_MAX, 109 | int $endCol = PHP_INT_MAX 110 | ): void { 111 | // Short-circuit if we're clearing the whole buffer. 112 | if ($startRow === 0 && $startCol === 0 && $endRow === PHP_INT_MAX && $endCol === PHP_INT_MAX) { 113 | // Mark all existing rows as dirty before clearing 114 | foreach (array_keys($this->buffer) as $row) { 115 | $this->markLineDirty($row); 116 | } 117 | $this->buffer = []; 118 | 119 | return; 120 | } 121 | 122 | $endRow = min($endRow, count($this->buffer) - 1); 123 | 124 | for ($row = $startRow; $row <= $endRow; $row++) { 125 | if (!array_key_exists($row, $this->buffer)) { 126 | continue; 127 | } 128 | $cols = $this->normalizeClearColumns($row, $startRow, $startCol, $endRow, $endCol); 129 | 130 | $line = $this->buffer[$row]; 131 | $length = $this->rowMax($row); 132 | 133 | if ($cols[0] === 0 && $cols[1] === $length) { 134 | // Clearing an entire line. Benchmarked slightly 135 | // faster to just replace the entire row. 136 | $this->buffer[$row] = []; 137 | } elseif ($cols[0] > 0 && $cols[1] === $length) { 138 | // Clearing from cols[0] to the end of the line. 139 | // Chop off the end of the array. 140 | $this->buffer[$row] = array_slice($line, 0, $cols[0]); 141 | } else { 142 | // Clearing the middle of a row. Fill with the replacement value. 143 | $this->fill($this->valueForClearing, $row, $cols[0], $cols[1]); 144 | } 145 | 146 | // Mark this row as dirty 147 | $this->markLineDirty($row); 148 | } 149 | } 150 | 151 | public function expand($rows) 152 | { 153 | while (count($this->buffer) <= $rows) { 154 | $this->buffer[] = []; 155 | } 156 | } 157 | 158 | public function fill(mixed $value, int $row, int $startCol, int $endCol) 159 | { 160 | $this->expand($row); 161 | 162 | $line = $this->buffer[$row]; 163 | 164 | $this->buffer[$row] = array_replace( 165 | $line, array_fill_keys(range($startCol, $endCol), $value) 166 | ); 167 | 168 | $this->markLineDirty($row); 169 | $this->trim(); 170 | } 171 | 172 | public function rowMax($row) 173 | { 174 | return count($this->buffer[$row]) - 1; 175 | } 176 | 177 | public function trim() 178 | { 179 | // 95% chance of just doing nothing. 180 | if (rand(1, 100) <= 95) { 181 | return; 182 | } 183 | 184 | $excess = count($this->buffer) - $this->max; 185 | 186 | // Clear out old rows. Hopefully this helps save memory. 187 | // @link https://github.com/aarondfrancis/solo/issues/33 188 | if ($excess > 0) { 189 | $keys = array_keys($this->buffer); 190 | $remove = array_slice($keys, 0, $excess); 191 | $nulls = array_fill_keys($remove, []); 192 | 193 | $this->buffer = array_replace($this->buffer, $nulls); 194 | } 195 | } 196 | 197 | protected function normalizeClearColumns(int $currentRow, int $startRow, int $startCol, int $endRow, int $endCol) 198 | { 199 | if ($startRow === $endRow) { 200 | $cols = [$startCol, $endCol]; 201 | } elseif ($currentRow === $startRow) { 202 | $cols = [$startCol, PHP_INT_MAX]; 203 | } elseif ($currentRow === $endRow) { 204 | $cols = [0, $endCol]; 205 | } else { 206 | $cols = [0, PHP_INT_MAX]; 207 | } 208 | 209 | return [ 210 | max($cols[0], 0), 211 | min($cols[1], $this->rowMax($currentRow)), 212 | ]; 213 | } 214 | 215 | public function offsetExists(mixed $offset): bool 216 | { 217 | return isset($this->buffer[$offset]); 218 | } 219 | 220 | #[ReturnTypeWillChange] 221 | public function offsetGet(mixed $offset) 222 | { 223 | return $this->buffer[$offset] ?? null; 224 | } 225 | 226 | public function offsetSet(mixed $offset, mixed $value): void 227 | { 228 | if (is_null($offset)) { 229 | $offset = count($this->buffer); 230 | $this->buffer[] = $value; 231 | } else { 232 | $this->buffer[$offset] = $value; 233 | } 234 | 235 | $this->markLineDirty($offset); 236 | $this->trim(); 237 | } 238 | 239 | public function offsetUnset(mixed $offset): void 240 | { 241 | unset($this->buffer[$offset]); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /PENDING_WRAP_DEEP_DIVE.md: -------------------------------------------------------------------------------- 1 | # The Pending Wrap Problem: A Deep Dive into Terminal Rendering Differences 2 | 3 | When building terminal UI applications, you might assume that writing text to a terminal is straightforward. Write characters, move to the next line, repeat. But lurking beneath this simplicity is a subtle behavior called **pending wrap state** that can cause your carefully crafted TUI to render completely differently across terminals. 4 | 5 | This post documents what we learned while fixing a rendering bug where content appeared at column 81 in Ghostty but rendered correctly in iTerm2. 6 | 7 | ## The Bug 8 | 9 | We have a `Screen` class that acts as a virtual terminal buffer. You write content to it, and it tracks characters and ANSI styles in a grid. When you call `output()`, it produces a string you can print to the real terminal. 10 | 11 | The bug manifested when writing exactly 80 characters (the terminal width) followed by more content: 12 | 13 | ```php 14 | $screen = new Screen(80, 5); 15 | $screen->write(str_repeat('.', 80) . 'yo 80'); 16 | echo $screen->output(); 17 | ``` 18 | 19 | **Expected output:** 20 | ``` 21 | ................................................................................ 22 | yo 80 23 | ``` 24 | 25 | **Actual output in Ghostty:** 26 | ``` 27 | ................................................................................yo 80 28 | ↑ 29 | (at column 81!) 30 | ``` 31 | 32 | The `yo 80` text appeared at column 81 on the first line instead of wrapping to line 2. 33 | 34 | ## What is Pending Wrap State? 35 | 36 | When a terminal has auto-wrap mode enabled (DECAWM, which is the default), it needs to handle what happens when you print a character in the rightmost column. There are two possible behaviors: 37 | 38 | ### Behavior A: Immediate Wrap (iTerm2) 39 | After printing the 80th character, immediately move the cursor to row 2, column 1. 40 | 41 | ### Behavior B: Pending Wrap (Ghostty, and most standards-compliant terminals) 42 | After printing the 80th character, keep the cursor at column 80 but set an internal "pending wrap" flag. The actual wrap to the next line only happens when the *next printable character* arrives. 43 | 44 | The pending wrap behavior exists because it allows the cursor to remain at the last column for operations like backspace or cursor movement without accidentally wrapping. It's the more "correct" VT100 behavior. 45 | 46 | ## Why This Caused Our Bug 47 | 48 | Our original `Screen::outputFull()` method joined lines with newlines: 49 | 50 | ```php 51 | return implode(PHP_EOL, $outputLines); 52 | ``` 53 | 54 | Here's what happens with 80 dots followed by a newline: 55 | 56 | **In iTerm2 (immediate wrap):** 57 | 1. Print 80 dots → cursor wraps to row 2, col 1 58 | 2. Receive `\n` → cursor moves to row 3, col 1 59 | 3. Print next line starting at row 3 60 | 61 | **In Ghostty (pending wrap):** 62 | 1. Print 80 dots → cursor at row 1, col 80, pending wrap flag SET 63 | 2. Receive `\n` → pending wrap flag CLEARED, cursor moves down one row but STAYS at col 80 64 | 3. Print next line starting at row 2, col 80 ← **Bug!** 65 | 66 | The newline character (`\n`, LF) clears the pending wrap state and performs a line feed, but it doesn't perform a carriage return. The cursor stays at whatever column it was in. 67 | 68 | ## Failed Solution #1: CR+LF 69 | 70 | Our first attempt was to use `\r\n` (carriage return + line feed) instead of just `\n`: 71 | 72 | ```php 73 | return implode("\r\n", $outputLines); 74 | ``` 75 | 76 | The carriage return (`\r`) moves the cursor to column 1, clearing any pending wrap state. Then `\n` moves down a row. This works for the pending wrap issue! 77 | 78 | **But there's a problem:** The `Screen` component is designed to be composable. You might render a `Screen` inside a popup that's positioned at column 10. Using `\r` is *absolute* positioning—it always goes to column 1 of the terminal, not column 1 of your popup. This breaks composition. 79 | 80 | ## The Solution: Save/Restore with Relative Movement 81 | 82 | The fix is to avoid newlines entirely for line transitions. Instead, we: 83 | 84 | 1. Save the cursor position at the start (this becomes our "origin") 85 | 2. For each line, restore to the origin and move down N rows using relative cursor movement 86 | 3. Never use `\n` between lines 87 | 88 | ```php 89 | protected function outputFull(array $ansi, array $printable): string 90 | { 91 | $parts = []; 92 | 93 | // Save the caller's cursor position as the Screen origin (DECSC) 94 | $parts[] = "\0337"; 95 | 96 | foreach ($printable as $lineIndex => $line) { 97 | $visibleRow = $lineIndex - $this->linesOffScreen + 1; 98 | 99 | if ($visibleRow < 1 || $visibleRow > $this->height) { 100 | continue; 101 | } 102 | 103 | // Restore to origin (DECRC) 104 | $parts[] = "\0338"; 105 | 106 | // Move down to this line's row using CUD (cursor down) 107 | if ($visibleRow > 1) { 108 | $parts[] = "\033[" . ($visibleRow - 1) . "B"; 109 | } 110 | 111 | // Render the line content 112 | $parts[] = $this->renderLine($lineIndex, $line, $ansi[$lineIndex] ?? []); 113 | } 114 | 115 | return implode('', $parts); 116 | } 117 | ``` 118 | 119 | ### Why This Works 120 | 121 | 1. **Pending wrap becomes irrelevant.** We never use `\n` to move between lines, so it doesn't matter whether the terminal has pending wrap set or not. Each line starts fresh from the saved origin. 122 | 123 | 2. **Positioning is relative.** The origin is wherever the caller positioned the cursor before calling `output()`. If that's inside a popup at column 10, all lines render relative to that position. 124 | 125 | 3. **DECRC clears pending wrap.** As a side effect, restoring the cursor position also clears any pending wrap state from the previous line, giving us a clean slate. 126 | 127 | ### The Escape Sequences 128 | 129 | - `ESC 7` (DECSC): Save cursor position and attributes 130 | - `ESC 8` (DECRC): Restore cursor position and attributes 131 | - `CSI n B` (CUD): Move cursor down n rows (relative) 132 | 133 | ## Testing Across Terminals 134 | 135 | This experience highlighted the importance of testing TUI applications across multiple terminals. What works in one terminal may break in another due to subtle differences in VT100 interpretation. 136 | 137 | We updated our test infrastructure to: 138 | 139 | 1. **Require visual tests in both iTerm2 and Ghostty** 140 | 2. **Store fixtures per-terminal** in `tests/Fixtures/{iterm,ghostty}/...` 141 | 3. **Validate in CI that both fixture sets exist and match** 142 | 143 | If the fixtures don't match between terminals, it indicates a rendering difference that needs investigation. 144 | 145 | ## Key Takeaways 146 | 147 | 1. **Pending wrap state is real** and differs between terminals. Ghostty follows the VT100 spec more strictly than iTerm2. 148 | 149 | 2. **Newlines don't reset column position.** `\n` performs a line feed (move down), not a carriage return (move to column 1). 150 | 151 | 3. **Avoid absolute positioning in composable components.** Using `\r` or `CSI H` (cursor position) breaks when your component is rendered at an offset. 152 | 153 | 4. **Save/restore cursor with relative movement** is a robust pattern for multi-line output that works regardless of pending wrap state and supports composition. 154 | 155 | 5. **Test across multiple terminals.** If you only test in iTerm2, you might ship code that's broken in half your users' terminals. 156 | 157 | ## References 158 | 159 | - [VT100 User Guide - Cursor Movement](https://vt100.net/docs/vt100-ug/chapter3.html) 160 | - [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 161 | - [ECMA-48 (ANSI escape codes standard)](https://www.ecma-international.org/publications-and-standards/standards/ecma-48/) 162 | -------------------------------------------------------------------------------- /src/Cell.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen; 11 | 12 | /** 13 | * Represents a single cell in the terminal buffer. 14 | * 15 | * Combines the printable character with its ANSI styling attributes 16 | * into a single object for efficient comparison and rendering. 17 | */ 18 | class Cell 19 | { 20 | /** 21 | * Create a new Cell. 22 | * 23 | * @param string $char The printable character (or null for wide char continuation) 24 | * @param int $style Bitmask of active ANSI decoration codes (bold, italic, etc.) 25 | * @param int|null $fg Foreground color code (30-37, 90-97) or null for default 26 | * @param int|null $bg Background color code (40-47, 100-107) or null for default 27 | * @param array|null $extFg Extended foreground color [type, ...params] for 256/RGB colors 28 | * @param array|null $extBg Extended background color [type, ...params] for 256/RGB colors 29 | */ 30 | public function __construct( 31 | public string $char = ' ', 32 | public int $style = 0, 33 | public ?int $fg = null, 34 | public ?int $bg = null, 35 | public ?array $extFg = null, 36 | public ?array $extBg = null, 37 | ) {} 38 | 39 | /** 40 | * Check if this cell is visually equal to another cell. 41 | * 42 | * Used for differential rendering to detect changes. 43 | */ 44 | public function equals(Cell $other): bool 45 | { 46 | return $this->char === $other->char 47 | && $this->style === $other->style 48 | && $this->fg === $other->fg 49 | && $this->bg === $other->bg 50 | && $this->extFg === $other->extFg 51 | && $this->extBg === $other->extBg; 52 | } 53 | 54 | /** 55 | * Create a blank cell (space with no styling). 56 | */ 57 | public static function blank(): self 58 | { 59 | return new self(' ', 0, null, null, null, null); 60 | } 61 | 62 | /** 63 | * Create a continuation cell for wide characters. 64 | * These cells take up space but render as empty. 65 | */ 66 | public static function continuation(): self 67 | { 68 | $cell = new self; 69 | $cell->char = ''; 70 | 71 | return $cell; 72 | } 73 | 74 | /** 75 | * Check if this is a continuation cell (part of a wide character). 76 | */ 77 | public function isContinuation(): bool 78 | { 79 | return $this->char === ''; 80 | } 81 | 82 | /** 83 | * Check if this cell has any styling applied. 84 | */ 85 | public function hasStyle(): bool 86 | { 87 | return $this->style !== 0 88 | || $this->fg !== null 89 | || $this->bg !== null 90 | || $this->extFg !== null 91 | || $this->extBg !== null; 92 | } 93 | 94 | /** 95 | * Clone this cell with a different character. 96 | */ 97 | public function withChar(string $char): self 98 | { 99 | $cell = clone $this; 100 | $cell->char = $char; 101 | 102 | return $cell; 103 | } 104 | 105 | /** 106 | * Clone this cell with different styling. 107 | */ 108 | public function withStyle(int $style, ?int $fg = null, ?int $bg = null, ?array $extFg = null, ?array $extBg = null): self 109 | { 110 | $cell = clone $this; 111 | $cell->style = $style; 112 | $cell->fg = $fg; 113 | $cell->bg = $bg; 114 | $cell->extFg = $extFg; 115 | $cell->extBg = $extBg; 116 | 117 | return $cell; 118 | } 119 | 120 | /** 121 | * Get the ANSI escape sequence to transition from another cell's style to this cell's style. 122 | * 123 | * @param Cell|null $previous The previous cell's style (null = reset state) 124 | * @return string The ANSI escape sequence (empty string if no change needed) 125 | */ 126 | public function getStyleTransition(?Cell $previous = null): string 127 | { 128 | // If no previous, we're at the start of a line - always emit full style 129 | if ($previous === null) { 130 | return $this->getFullStyleSequence(); 131 | } 132 | 133 | // If styles are identical, no transition needed 134 | if ($this->style === $previous->style 135 | && $this->fg === $previous->fg 136 | && $this->bg === $previous->bg 137 | && $this->extFg === $previous->extFg 138 | && $this->extBg === $previous->extBg) { 139 | return ''; 140 | } 141 | 142 | // Build transition codes 143 | $codes = []; 144 | 145 | // Check if we need to turn off any styles 146 | $turnedOff = $previous->style & ~$this->style; 147 | if ($turnedOff !== 0) { 148 | // For simplicity, if any style was turned off, we reset and re-apply 149 | // This could be optimized to use specific reset codes (22, 23, 24, etc.) 150 | return "\e[0m" . $this->getFullStyleSequence(); 151 | } 152 | 153 | // Add any new style codes 154 | $newStyles = $this->style & ~$previous->style; 155 | if ($newStyles !== 0) { 156 | $codes = array_merge($codes, $this->getStyleCodesFromBitmask($newStyles)); 157 | } 158 | 159 | // Handle foreground color changes 160 | if ($this->fg !== $previous->fg || $this->extFg !== $previous->extFg) { 161 | if ($this->extFg !== null) { 162 | $codes[] = '38;' . implode(';', $this->extFg); 163 | } elseif ($this->fg !== null) { 164 | $codes[] = (string) $this->fg; 165 | } elseif ($previous->fg !== null || $previous->extFg !== null) { 166 | $codes[] = '39'; // Reset foreground 167 | } 168 | } 169 | 170 | // Handle background color changes 171 | if ($this->bg !== $previous->bg || $this->extBg !== $previous->extBg) { 172 | if ($this->extBg !== null) { 173 | $codes[] = '48;' . implode(';', $this->extBg); 174 | } elseif ($this->bg !== null) { 175 | $codes[] = (string) $this->bg; 176 | } elseif ($previous->bg !== null || $previous->extBg !== null) { 177 | $codes[] = '49'; // Reset background 178 | } 179 | } 180 | 181 | if (empty($codes)) { 182 | return ''; 183 | } 184 | 185 | return "\e[" . implode(';', $codes) . 'm'; 186 | } 187 | 188 | /** 189 | * Get the full ANSI sequence to apply this cell's style from a reset state. 190 | */ 191 | protected function getFullStyleSequence(): string 192 | { 193 | if (!$this->hasStyle()) { 194 | return ''; 195 | } 196 | 197 | $codes = $this->getStyleCodesFromBitmask($this->style); 198 | 199 | if ($this->extFg !== null) { 200 | $codes[] = '38;' . implode(';', $this->extFg); 201 | } elseif ($this->fg !== null) { 202 | $codes[] = (string) $this->fg; 203 | } 204 | 205 | if ($this->extBg !== null) { 206 | $codes[] = '48;' . implode(';', $this->extBg); 207 | } elseif ($this->bg !== null) { 208 | $codes[] = (string) $this->bg; 209 | } 210 | 211 | if (empty($codes)) { 212 | return ''; 213 | } 214 | 215 | return "\e[" . implode(';', $codes) . 'm'; 216 | } 217 | 218 | /** 219 | * Convert a style bitmask to an array of ANSI code strings. 220 | */ 221 | protected function getStyleCodesFromBitmask(int $bitmask): array 222 | { 223 | $codes = []; 224 | 225 | // Map bit positions to ANSI codes 226 | // These correspond to codes 1-9 (bold, dim, italic, underline, blink, etc.) 227 | for ($code = 1; $code <= 9; $code++) { 228 | if ($bitmask & (1 << ($code - 1))) { 229 | $codes[] = (string) $code; 230 | } 231 | } 232 | 233 | return $codes; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Screen is a pure PHP terminal renderer that interprets ANSI escape sequences and maintains a virtual terminal buffer. It was originally created to solve a specific problem in Solo for Laravel: preventing ANSI escape codes from "breaking out" of visual containers in a TUI with multiple panels. 8 | 9 | Unlike a full terminal emulator, Screen focuses on rendering: it interprets ANSI codes (cursor movement, colors, styles, screen clearing), maintains a virtual representation, and generates the final visual output. It does not handle input, interactive sessions, or process management. 10 | 11 | **Key characteristic**: This is a library meant to be integrated into PHP applications, not a standalone terminal. 12 | 13 | ## Common Commands 14 | 15 | ```bash 16 | # Run all tests 17 | composer test 18 | 19 | # Run all tests with visual screenshot comparison (requires iTerm + ImageMagick) 20 | ENABLE_SCREENSHOT_TESTING=1 composer test 21 | 22 | # Run tests with screenshots only for missing fixtures 23 | ENABLE_SCREENSHOT_TESTING=2 composer test 24 | 25 | # Run a specific test 26 | vendor/bin/phpunit --filter=test_name 27 | 28 | # Run a specific test file 29 | vendor/bin/phpunit tests/Unit/ScreenTest.php 30 | ``` 31 | 32 | ## High-Level Architecture 33 | 34 | ### The Dual-Buffer System 35 | 36 | Screen uses a **dual-buffer architecture** to separate concerns: 37 | 38 | 1. **PrintableBuffer**: Stores visible characters and handles grapheme cluster logic 39 | - Manages multi-byte characters (Unicode, emoji, CJK) 40 | - Calculates display widths using the Grapheme library 41 | - Wide characters occupy multiple cells (first cell contains char, remaining are `null`) 42 | - Handles tab expansion (8-character tab stops) 43 | 44 | 2. **AnsiBuffer**: Stores styling information as bitmasks 45 | - Uses bitwise operations for efficient style combination/manipulation 46 | - Each ANSI code (bold, underline, colors) gets a unique bit 47 | - Extended colors (256-color, RGB) stored separately as arrays 48 | - Cell values: either an integer (bitmask) or `[bitmask, extFg, extBg]` 49 | 50 | The **Proxy** class coordinates writes to both buffers simultaneously, ensuring the character buffer and style buffer stay in sync. 51 | 52 | ### Three Parsing Paths 53 | 54 | Screen has evolved to include multiple parsing strategies for different performance characteristics: 55 | 56 | 1. **AnsiMatcher** (legacy): Regex-based ANSI parsing 57 | 2. **AnsiParser**: State machine parser that processes character-by-character without regex 58 | - `parse()`: Returns `AnsiMatch` objects (backward compatible) 59 | - `parseFast()`: Returns `ParsedAnsi` objects (faster, used by Screen) 60 | 3. **ParsedAnsi**: Lightweight parsed representation with direct property access 61 | 62 | The state machine parser (`AnsiParser::parseFast()`) is the current production path used by `Screen::write()`. 63 | 64 | ### Rendering Pipeline 65 | 66 | When `Screen::write()` is called: 67 | 68 | 1. Content is preprocessed (backspace → cursor left, CR → column 0) 69 | 2. `AnsiParser::parseFast()` splits into printable text and ANSI sequences 70 | 3. For each part: 71 | - **Printable text**: Written to PrintableBuffer (handles grapheme width calculation, line wrapping) 72 | - **ANSI sequence**: Handled by `handleAnsiCode()` which updates cursor position or active styles 73 | 4. Both buffers track changes via sequence numbers for differential rendering 74 | 75 | When `Screen::output()` is called: 76 | 77 | 1. **Full rendering** (default): All lines joined by newlines 78 | 2. **Differential rendering** (`output($sinceSeqNo)`): Only changed lines with cursor positioning 79 | - Each buffer tracks which lines were modified via sequence numbers 80 | - PrintableBuffer provides changed row list 81 | - Only those rows are compressed and rendered 82 | - Each line prefixed with `\033[row;1H` for positioning 83 | 84 | ### Cell-Based Architecture (Alternative Path) 85 | 86 | Screen has two rendering approaches: 87 | 88 | 1. **Original**: Dual buffers (PrintableBuffer + AnsiBuffer) with separate compression 89 | 2. **CellBuffer**: Unified buffer storing `Cell` objects 90 | - Each Cell contains: `char`, `style`, `fg`, `bg`, `extFg`, `extBg` 91 | - Flat array indexing: `cells[$y * $width + $x]` for O(1) access 92 | - Supports dirty cell tracking for optimized differential rendering 93 | - Includes `CursorOptimizer` to minimize escape sequence bytes 94 | - Used via `Screen::toCellBuffer()` for value-based comparisons 95 | 96 | The CellBuffer approach enables more sophisticated differential rendering by comparing actual cell content rather than just tracking writes. 97 | 98 | ### Differential Rendering 99 | 100 | Screen implements performance optimizations for frequent updates: 101 | 102 | 1. **Sequence Number Tracking**: Each write increments a monotonic sequence number 103 | - Buffers track `lineSeqNos[$row]` = sequence when row was modified 104 | - `output($sinceSeqNo)` only renders rows changed since that sequence 105 | - Returns ANSI with cursor positioning for each changed line 106 | 107 | 2. **CellBuffer Differential Rendering**: 108 | - Maintains previous frame in `previousCells` array 109 | - Tracks dirty cells in `dirtyCells` for O(dirty) instead of O(all cells) 110 | - `getChangedCells()` returns only modified cells with positions 111 | - `renderDiffOptimized()` uses CursorOptimizer + StyleTracker for minimal output 112 | 113 | ### Coordinate System Details 114 | 115 | Screen maintains cursor position across two coordinate spaces: 116 | 117 | - **User space**: 0-based row/column (internal tracking) 118 | - **ANSI space**: 1-based row/column (for escape sequences) 119 | - **Scrolling**: `linesOffScreen` tracks how many lines have scrolled off the top 120 | - Cursor row calculations: `cursorRow - linesOffScreen` = visible row 121 | - Scrolling increments `linesOffScreen` and shifts buffer indices 122 | 123 | ### Visual Testing System 124 | 125 | The test suite includes an innovative screenshot-based comparison system built from focused components: 126 | 127 | - `ComparesVisually` trait — Test API facade (~200 lines) 128 | - `VisualTestConfig` — Environment detection, dimensions, test modes 129 | - `ScreenshotSession` — Capture and compare via Swift helper 130 | - `VisualFixtureStore` — Fixture I/O and checksums 131 | - `capture-window.swift` — Native screenshot capture using `CGWindowListCreateImage` 132 | 133 | **How it works:** 134 | 1. **Capture real terminal output**: Renders content in iTerm/Ghostty, captures via `CGWindowListCreateImage` 135 | 2. **Capture emulated output**: Renders same content through Screen, captures screenshot 136 | 3. **Pixel comparison**: Uses ImageMagick to verify identical visual output 137 | 138 | This ensures Screen's rendering matches actual terminal behavior for complex scenarios (multi-byte chars, ANSI formatting, cursor movements, scrolling). 139 | 140 | **Fixture fallback**: Tests can run without screenshots using pre-generated fixtures (JSON files with expected output), making CI/CD possible without iTerm + ImageMagick. 141 | 142 | ### ANSI Code Handling 143 | 144 | Screen handles these ANSI sequence categories: 145 | 146 | - **CSI sequences**: `ESC [ params command` (cursor movement, colors, clearing) 147 | - **Simple escapes**: `ESC command` (save/restore cursor) 148 | - **OSC sequences**: `ESC ] ... terminator` (title, color queries) 149 | - **Character sets**: `ESC ( X`, `ESC ) X`, `ESC # X` (mostly ignored) 150 | 151 | Key implementations: 152 | - **SGR (colors/styles)**: Codes parsed and applied to AnsiBuffer bitmask 153 | - **Cursor movement**: Updates `cursorRow`/`cursorCol` with boundary checking 154 | - **Screen clearing**: Calls `buffers->clear()` with row/column ranges 155 | - **Scrolling**: Modifies `linesOffScreen` and shifts buffer content 156 | - **Query responses**: `respondToQueriesVia()` callback for cursor position, colors 157 | 158 | ### Memory Management 159 | 160 | - Buffers have a max row limit (default 5000) 161 | - `trim()` method runs randomly (5% chance) to avoid constant overhead 162 | - When excess rows accumulate, oldest rows are cleared 163 | - This prevents unbounded memory growth in long-running processes 164 | -------------------------------------------------------------------------------- /src/AnsiParser.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen; 11 | 12 | /** 13 | * State machine-based ANSI escape sequence parser. 14 | * 15 | * This parser processes input character-by-character without using regex, 16 | * which provides better performance for large inputs with many ANSI sequences. 17 | * 18 | * Based on the VT100/VT500 state machine, simplified for common sequences: 19 | * - CSI sequences: ESC [ params command (e.g., ESC[31m, ESC[2;1H) 20 | * - Simple escapes: ESC command (e.g., ESC 7, ESC 8) 21 | * - OSC sequences: ESC ] ... terminator (e.g., ESC]0;title BEL) 22 | */ 23 | class AnsiParser 24 | { 25 | /** 26 | * Lookup table for simple escape commands (ESC + single char). 27 | * Using array for O(1) lookup instead of in_array(). 28 | */ 29 | protected static array $simpleEscapes = [ 30 | '7' => true, '8' => true, 'c' => true, 'D' => true, 'E' => true, 31 | 'H' => true, 'M' => true, 'N' => true, 'O' => true, 'Z' => true, 32 | '=' => true, '>' => true, '<' => true, '1' => true, '2' => true, 33 | 's' => true, 'u' => true, 34 | ]; 35 | 36 | /** 37 | * Parse input and return an array of tokens (strings and AnsiMatch objects). 38 | * 39 | * This method is a drop-in replacement for AnsiMatcher::split(). 40 | * Optimized version with inlined checks for better performance. 41 | * 42 | * @param string $input The input string to parse 43 | * @return array Array of text strings and ANSI match objects 44 | */ 45 | public static function parse(string $input): array 46 | { 47 | if ($input === '') { 48 | return []; 49 | } 50 | 51 | $tokens = []; 52 | $textStart = 0; 53 | $len = strlen($input); 54 | $i = 0; 55 | 56 | while ($i < $len) { 57 | // Fast path: scan for ESC character 58 | $escPos = strpos($input, "\x1B", $i); 59 | 60 | if ($escPos === false) { 61 | // No more escape sequences - rest is text 62 | if ($i < $len) { 63 | $tokens[] = substr($input, $textStart); 64 | } 65 | break; 66 | } 67 | 68 | // Add text before this escape sequence 69 | if ($escPos > $textStart) { 70 | $tokens[] = substr($input, $textStart, $escPos - $textStart); 71 | } 72 | 73 | // Parse the escape sequence 74 | $escStart = $escPos; 75 | $i = $escPos + 1; 76 | 77 | if ($i >= $len) { 78 | // Incomplete escape at end 79 | $tokens[] = "\x1B"; 80 | break; 81 | } 82 | 83 | $nextChar = $input[$i]; 84 | 85 | if ($nextChar === '[') { 86 | // CSI sequence: ESC [ params final 87 | $i++; 88 | $paramStart = $i; 89 | 90 | // Collect parameter bytes (0x30-0x3F: 0-9, ;, ?, <, =, >) 91 | while ($i < $len) { 92 | $ord = ord($input[$i]); 93 | if ($ord >= 0x30 && $ord <= 0x3F) { 94 | $i++; 95 | } else { 96 | break; 97 | } 98 | } 99 | 100 | // Skip intermediate bytes (0x20-0x2F) 101 | while ($i < $len) { 102 | $ord = ord($input[$i]); 103 | if ($ord >= 0x20 && $ord <= 0x2F) { 104 | $i++; 105 | } else { 106 | break; 107 | } 108 | } 109 | 110 | // Expect final byte (0x40-0x7E) 111 | if ($i < $len) { 112 | $ord = ord($input[$i]); 113 | if ($ord >= 0x40 && $ord <= 0x7E) { 114 | $i++; 115 | $tokens[] = new AnsiMatch(substr($input, $escStart, $i - $escStart)); 116 | $textStart = $i; 117 | 118 | continue; 119 | } 120 | } 121 | 122 | // Invalid CSI - treat as text 123 | $tokens[] = substr($input, $escStart, $i - $escStart); 124 | $textStart = $i; 125 | 126 | } elseif ($nextChar === ']') { 127 | // OSC sequence: ESC ] ... terminator 128 | $i++; 129 | 130 | // Find terminator: BEL (\x07), ST (\x9C), or ESC\ 131 | while ($i < $len) { 132 | $char = $input[$i]; 133 | if ($char === "\x07" || $char === "\x9C") { 134 | $i++; 135 | break; 136 | } elseif ($char === "\x1B" && $i + 1 < $len && $input[$i + 1] === '\\') { 137 | $i += 2; 138 | break; 139 | } 140 | $i++; 141 | } 142 | 143 | $tokens[] = new AnsiMatch(substr($input, $escStart, $i - $escStart)); 144 | $textStart = $i; 145 | 146 | } elseif ($nextChar === '(' || $nextChar === ')' || $nextChar === '#') { 147 | // Character set or line attribute: ESC ( X, ESC ) X, ESC # X 148 | $i++; 149 | if ($i < $len) { 150 | $i++; 151 | $tokens[] = new AnsiMatch(substr($input, $escStart, $i - $escStart)); 152 | $textStart = $i; 153 | } else { 154 | // Incomplete 155 | $tokens[] = substr($input, $escStart, $i - $escStart); 156 | $textStart = $i; 157 | } 158 | 159 | } elseif (isset(self::$simpleEscapes[$nextChar])) { 160 | // Simple escape command: ESC 7, ESC 8, etc. 161 | $i++; 162 | $tokens[] = new AnsiMatch(substr($input, $escStart, $i - $escStart)); 163 | $textStart = $i; 164 | 165 | } else { 166 | // Unknown escape - treat ESC as text, continue from next char 167 | $tokens[] = "\x1B"; 168 | $textStart = $i; 169 | } 170 | } 171 | 172 | return $tokens; 173 | } 174 | 175 | /** 176 | * Parse input using lightweight ParsedAnsi objects instead of AnsiMatch. 177 | * 178 | * This is faster because ParsedAnsi doesn't use regex for parsing. 179 | * Use this when you don't need full AnsiMatch compatibility. 180 | * 181 | * @param string $input The input string to parse 182 | * @return array Array of text strings and parsed ANSI objects 183 | */ 184 | public static function parseFast(string $input): array 185 | { 186 | if ($input === '') { 187 | return []; 188 | } 189 | 190 | $tokens = []; 191 | $textStart = 0; 192 | $len = strlen($input); 193 | $i = 0; 194 | 195 | while ($i < $len) { 196 | // Fast path: scan for ESC character 197 | $escPos = strpos($input, "\x1B", $i); 198 | 199 | if ($escPos === false) { 200 | // No more escape sequences - rest is text 201 | if ($i < $len) { 202 | $tokens[] = substr($input, $textStart); 203 | } 204 | break; 205 | } 206 | 207 | // Add text before this escape sequence 208 | if ($escPos > $textStart) { 209 | $tokens[] = substr($input, $textStart, $escPos - $textStart); 210 | } 211 | 212 | // Parse the escape sequence 213 | $escStart = $escPos; 214 | $i = $escPos + 1; 215 | 216 | if ($i >= $len) { 217 | // Incomplete escape at end 218 | $tokens[] = "\x1B"; 219 | break; 220 | } 221 | 222 | $nextChar = $input[$i]; 223 | 224 | if ($nextChar === '[') { 225 | // CSI sequence: ESC [ params final 226 | $i++; 227 | 228 | // Collect parameter bytes (0x30-0x3F: 0-9, ;, ?, <, =, >) 229 | while ($i < $len) { 230 | $ord = ord($input[$i]); 231 | if ($ord >= 0x30 && $ord <= 0x3F) { 232 | $i++; 233 | } else { 234 | break; 235 | } 236 | } 237 | 238 | // Skip intermediate bytes (0x20-0x2F) 239 | while ($i < $len) { 240 | $ord = ord($input[$i]); 241 | if ($ord >= 0x20 && $ord <= 0x2F) { 242 | $i++; 243 | } else { 244 | break; 245 | } 246 | } 247 | 248 | // Expect final byte (0x40-0x7E) 249 | if ($i < $len) { 250 | $ord = ord($input[$i]); 251 | if ($ord >= 0x40 && $ord <= 0x7E) { 252 | $i++; 253 | $tokens[] = new ParsedAnsi(substr($input, $escStart, $i - $escStart)); 254 | $textStart = $i; 255 | 256 | continue; 257 | } 258 | } 259 | 260 | // Invalid CSI - treat as text 261 | $tokens[] = substr($input, $escStart, $i - $escStart); 262 | $textStart = $i; 263 | 264 | } elseif ($nextChar === ']') { 265 | // OSC sequence: ESC ] ... terminator 266 | $i++; 267 | 268 | // Find terminator: BEL (\x07), ST (\x9C), or ESC\ 269 | while ($i < $len) { 270 | $char = $input[$i]; 271 | if ($char === "\x07" || $char === "\x9C") { 272 | $i++; 273 | break; 274 | } elseif ($char === "\x1B" && $i + 1 < $len && $input[$i + 1] === '\\') { 275 | $i += 2; 276 | break; 277 | } 278 | $i++; 279 | } 280 | 281 | $tokens[] = new ParsedAnsi(substr($input, $escStart, $i - $escStart)); 282 | $textStart = $i; 283 | 284 | } elseif ($nextChar === '(' || $nextChar === ')' || $nextChar === '#') { 285 | // Character set or line attribute: ESC ( X, ESC ) X, ESC # X 286 | $i++; 287 | if ($i < $len) { 288 | $i++; 289 | $tokens[] = new ParsedAnsi(substr($input, $escStart, $i - $escStart)); 290 | $textStart = $i; 291 | } else { 292 | // Incomplete 293 | $tokens[] = substr($input, $escStart, $i - $escStart); 294 | $textStart = $i; 295 | } 296 | 297 | } elseif (isset(self::$simpleEscapes[$nextChar])) { 298 | // Simple escape command: ESC 7, ESC 8, etc. 299 | $i++; 300 | $tokens[] = new ParsedAnsi(substr($input, $escStart, $i - $escStart)); 301 | $textStart = $i; 302 | 303 | } else { 304 | // Unknown escape - treat ESC as text, continue from next char 305 | $tokens[] = "\x1B"; 306 | $textStart = $i; 307 | } 308 | } 309 | 310 | return $tokens; 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /CROSS_TERMINAL_TESTING.md: -------------------------------------------------------------------------------- 1 | # Cross-Terminal Visual Testing: Ensuring Your TUI Looks Right Everywhere 2 | 3 | Building terminal UI applications presents a unique testing challenge: how do you verify that your output *looks* correct? Unit tests can assert that you're generating the right escape sequences, but they can't tell you if those sequences render properly across different terminal emulators. 4 | 5 | This post documents the visual testing strategy we built for the Screen library, which ensures consistent rendering across iTerm2 and Ghostty. 6 | 7 | ## The Problem 8 | 9 | Terminal emulators interpret ANSI escape sequences with subtle differences. A sequence that renders perfectly in iTerm2 might break in Ghostty, Alacritty, or the default macOS Terminal. We discovered this firsthand when a "pending wrap state" difference caused content to appear 80 columns offset in Ghostty while working fine in iTerm2. 10 | 11 | We needed a testing strategy that would: 12 | 13 | 1. **Catch visual regressions** when code changes 14 | 2. **Verify rendering across multiple terminals** before release 15 | 3. **Run automatically in CI** to prevent broken code from merging 16 | 4. **Be practical for developers** to generate and update fixtures 17 | 18 | ## The Solution: Per-Terminal Fixture Testing 19 | 20 | Our approach stores expected output fixtures separately for each supported terminal. Tests compare actual output against these fixtures, and CI validates that fixtures exist for all terminals and produce identical results. 21 | 22 | ### Directory Structure 23 | 24 | ``` 25 | tests/Fixtures/ 26 | ├── iterm/ 27 | │ └── Unit/ 28 | │ └── ScreenTest/ 29 | │ ├── test_basic_output_1.json 30 | │ └── test_colors_1.json 31 | ├── ghostty/ 32 | │ └── Unit/ 33 | │ └── ScreenTest/ 34 | │ ├── test_basic_output_1.json 35 | │ └── test_colors_1.json 36 | └── Renders/ 37 | ├── iterm/ 38 | │ └── Unit/ 39 | │ └── VtailTest/ 40 | │ └── visual_positioning_in_popup.json 41 | └── ghostty/ 42 | └── Unit/ 43 | └── VtailTest/ 44 | └── visual_positioning_in_popup.json 45 | ``` 46 | 47 | Each terminal has its own fixture directory. When tests run, they automatically detect which terminal they're running in and use the appropriate fixtures. 48 | 49 | ## Architecture 50 | 51 | The visual testing system is built from several focused components: 52 | 53 | ``` 54 | ComparesVisually (trait) - Test API facade 55 | ├── VisualTestConfig - Environment detection, dimensions, modes 56 | ├── TerminalEnvironment - Terminal control, resize, output buffering 57 | ├── ScreenshotSession - Capture and compare screenshots 58 | │ └── capture-window - Swift binary using CGWindowListCreateImage 59 | ├── VisualFixtureStore - Fixture I/O, checksums, paths 60 | └── InteractiveFixturePrompter - User prompts for fixture creation 61 | ``` 62 | 63 | ### Terminal Detection 64 | 65 | The `VisualTestConfig` class detects the current terminal by checking the `TERM_PROGRAM` environment variable: 66 | 67 | ```php 68 | private static function detectTerminal(): ?string 69 | { 70 | $termProgram = getenv('TERM_PROGRAM'); 71 | 72 | if ($termProgram === 'iTerm.app') { 73 | return 'iterm'; 74 | } 75 | 76 | if ($termProgram === 'ghostty') { 77 | return 'ghostty'; 78 | } 79 | 80 | return null; 81 | } 82 | ``` 83 | 84 | There's no override mechanism—tests must run in the actual terminal. This ensures fixtures are genuinely generated and validated in each environment, not faked. 85 | 86 | ## Two Types of Visual Tests 87 | 88 | ### 1. Screenshot Comparison Tests 89 | 90 | These tests render content to the real terminal, take a screenshot, then compare it against a reference screenshot. They use the `assertTerminalMatch()` method: 91 | 92 | ```php 93 | #[Test] 94 | public function colors_render_correctly() 95 | { 96 | $this->assertTerminalMatch("\e[31mRed\e[0m \e[32mGreen\e[0m \e[34mBlue\e[0m"); 97 | } 98 | ``` 99 | 100 | The test: 101 | 1. Renders the content to the terminal 102 | 2. Captures a screenshot using `CGWindowListCreateImage` (via our Swift helper) 103 | 3. Renders the same content through our Screen emulator 104 | 4. Captures another screenshot 105 | 5. Compares the screenshots pixel-by-pixel using ImageMagick 106 | 107 | If there's no fixture yet, it prompts the developer to visually confirm the output looks correct before saving. 108 | 109 | ### 2. Rendered Output Tests 110 | 111 | These tests verify that generated ANSI output produces the expected visual result. They use `appearsToRenderCorrectly()`: 112 | 113 | ```php 114 | #[Test] 115 | public function visual_positioning_in_popup() 116 | { 117 | $screen = new Screen(60, 5); 118 | $screen->write("Line 1\nLine 2\nLine 3"); 119 | 120 | // Build a popup frame around the screen output 121 | $rendered = "\e[H\e[2J"; 122 | $rendered .= "\e[3;8H┌─ Popup ─────┐"; 123 | // ... more popup chrome ... 124 | $rendered .= $screen->output(); 125 | // ... closing border ... 126 | 127 | $this->appearsToRenderCorrectly($rendered); 128 | } 129 | ``` 130 | 131 | When no fixture exists, the test: 132 | 1. Clears the terminal and renders the output 133 | 2. Prompts the developer: "Does the output look correct? [Y/n]" 134 | 3. If confirmed, saves the raw output string as a fixture 135 | 136 | On subsequent runs, it compares the output string against the saved fixture. 137 | 138 | ## Screenshot Capture 139 | 140 | Screenshot capture uses a custom Swift tool that leverages macOS's `CGWindowListCreateImage` API: 141 | 142 | ```swift 143 | // From tests/Support/bin/capture-window.swift 144 | func captureWindow(windowId: CGWindowID, outputPath: String, cropTop: Int = 0) throws { 145 | guard let image = CGWindowListCreateImage( 146 | .null, 147 | .optionIncludingWindow, 148 | windowId, 149 | [.boundsIgnoreFraming, .nominalResolution] 150 | ) else { 151 | throw CaptureError(message: "Failed to capture window") 152 | } 153 | // ... crop title bar and save as PNG 154 | } 155 | ``` 156 | 157 | This approach is more robust than the `screencapture` CLI because: 158 | 159 | - **No window activation required** — captures the window directly by ID 160 | - **No settle delays** — the API captures immediately 161 | - **No intermediate files** — crops in memory before saving 162 | - **Single operation** — finds terminal window and captures in one call 163 | 164 | The Swift tool is compiled on first use and cached as a binary for fast execution. 165 | 166 | ## The Test Runner 167 | 168 | The `bin/test` script handles the complexity of running visual tests: 169 | 170 | ```bash 171 | # Run tests without visual testing (CI-safe, fast) 172 | composer test 173 | 174 | # Run all tests with screenshot generation 175 | composer test:screenshots 176 | 177 | # Generate only missing fixtures 178 | composer test:missing 179 | 180 | # Pass filters to PHPUnit 181 | composer test:screenshots -- --filter=positioning 182 | ``` 183 | 184 | ### Terminal-Specific Behavior 185 | 186 | **For iTerm2**, the test runner automatically resizes the terminal to the required dimensions (180x32) via AppleScript: 187 | 188 | ```php 189 | private function resizeIterm(): bool 190 | { 191 | $script = sprintf( 192 | 'tell application "iTerm2" 193 | tell current session of current window 194 | set columns to %d 195 | set rows to %d 196 | end tell 197 | end tell', 198 | $this->config->requiredColumns, 199 | $this->config->requiredLines 200 | ); 201 | 202 | exec('osascript -e ' . escapeshellarg($script)); 203 | return true; 204 | } 205 | ``` 206 | 207 | **For Ghostty**, which doesn't support direct row/column control, the runner resizes via window bounds and prompts if dimensions don't match. 208 | 209 | ## CI Validation 210 | 211 | The GitHub Actions workflow runs on every push and PR: 212 | 213 | ```yaml 214 | - name: Execute tests 215 | env: 216 | LINES: 32 217 | COLUMNS: 180 218 | run: | 219 | vendor/bin/phpunit 2>&1 | tee phpunit-output.txt 220 | # Fail if fixtures are missing on main branch 221 | if [ "${{ github.ref }}" = "refs/heads/main" ]; then 222 | if grep -q "Fixture with correct content does not exist" phpunit-output.txt; then 223 | echo "::error::Missing fixtures on main branch" 224 | exit 1 225 | fi 226 | fi 227 | ``` 228 | 229 | More importantly, CI validates that **both terminals have fixtures and they match**: 230 | 231 | ```yaml 232 | - name: Validate terminal fixtures match 233 | run: | 234 | # Find all iterm fixtures and verify ghostty equivalents exist and match 235 | find tests/Fixtures/iterm -name "*.json" -type f | while read iterm_file; do 236 | ghostty_file="${iterm_file/iterm/ghostty}" 237 | 238 | if [ ! -f "$ghostty_file" ]; then 239 | echo "::error::Missing Ghostty fixture for: $iterm_file" 240 | exit 1 241 | fi 242 | 243 | if ! diff -q "$iterm_file" "$ghostty_file" > /dev/null; then 244 | echo "::error::Fixture mismatch between terminals: $iterm_file" 245 | exit 1 246 | fi 247 | done 248 | ``` 249 | 250 | This ensures: 251 | 252 | 1. **Every fixture exists for both terminals** — you can't merge code that was only tested in one terminal 253 | 2. **Fixtures are identical** — if they differ, it indicates a rendering difference that needs investigation 254 | 255 | ## The Developer Workflow 256 | 257 | Here's how a developer adds a new visual test: 258 | 259 | ### Step 1: Write the Test 260 | 261 | ```php 262 | #[Test] 263 | public function my_new_visual_feature() 264 | { 265 | $screen = new Screen(80, 10); 266 | $screen->write("Some content..."); 267 | 268 | $this->appearsToRenderCorrectly($screen->output()); 269 | } 270 | ``` 271 | 272 | ### Step 2: Generate Fixture in iTerm2 273 | 274 | ```bash 275 | # Open iTerm2 276 | composer test:screenshots -- --filter=my_new_visual_feature 277 | ``` 278 | 279 | The test runs, displays the output, and asks for confirmation. If it looks right, press `y` to save the fixture. 280 | 281 | ### Step 3: Generate Fixture in Ghostty 282 | 283 | ```bash 284 | # Open Ghostty 285 | composer test:screenshots -- --filter=my_new_visual_feature 286 | ``` 287 | 288 | Same process—the test prompts for confirmation and saves the Ghostty fixture. 289 | 290 | ### Step 4: Verify Fixtures Match 291 | 292 | ```bash 293 | diff tests/Fixtures/Renders/iterm/Unit/MyTest/my_new_visual_feature.json \ 294 | tests/Fixtures/Renders/ghostty/Unit/MyTest/my_new_visual_feature.json 295 | ``` 296 | 297 | If they differ, investigate why! This usually indicates a terminal compatibility issue that needs fixing. 298 | 299 | ### Step 5: Commit Both Fixtures 300 | 301 | ```bash 302 | git add tests/Fixtures/ 303 | git commit -m "Add visual test for new feature" 304 | ``` 305 | 306 | CI will verify both fixtures exist and match. 307 | 308 | ## Handling Fixture Mismatches 309 | 310 | When iTerm2 and Ghostty fixtures don't match, it usually means one of: 311 | 312 | 1. **A real rendering bug** — Your code produces different output in different terminals. This is what we're trying to catch! Fix the code. 313 | 314 | 2. **Terminal-specific escape sequence support** — Some sequences aren't supported identically. You may need to use a more portable approach. 315 | 316 | 3. **Test timing issues** — If tests involve animations or timing, results may vary. Make tests deterministic. 317 | 318 | The goal is that **identical code produces identical output** in all supported terminals. If it doesn't, that's a bug. 319 | 320 | ## Benefits of This Approach 321 | 322 | 1. **Catches real bugs** — We caught the pending wrap issue because fixtures differed between terminals. 323 | 324 | 2. **Prevents regressions** — Changes that break rendering fail the fixture comparison immediately. 325 | 326 | 3. **Documents expected behavior** — Fixtures serve as executable documentation of what output should look like. 327 | 328 | 4. **Enforces cross-terminal testing** — CI fails if you only test in one terminal. 329 | 330 | 5. **Practical for developers** — The interactive fixture generation makes it easy to add new visual tests. 331 | 332 | ## Trade-offs 333 | 334 | 1. **Requires macOS** — Screenshot testing uses `CGWindowListCreateImage`, a macOS-specific API. 335 | 336 | 2. **Manual fixture generation** — Developers must run tests in each terminal and confirm output visually. 337 | 338 | 3. **Fixture churn** — Any output change requires regenerating fixtures in both terminals. 339 | 340 | 4. **Binary fixtures** — Screenshot fixtures are images, which don't diff well in code review. 341 | 342 | For a TUI library where visual correctness is critical, these trade-offs are worth it. 343 | 344 | ## Conclusion 345 | 346 | Visual testing for terminal applications requires thinking beyond traditional unit tests. By combining per-terminal fixtures, interactive fixture generation, and CI validation that fixtures exist and match across terminals, we can catch rendering bugs before they reach users. 347 | 348 | The key insight is that **if your output differs between terminals, that's probably a bug**. Our testing strategy surfaces these differences early, forcing us to find portable solutions that work everywhere. 349 | 350 | Your terminal application's users are running iTerm2, Ghostty, Alacritty, Kitty, WezTerm, and dozens of other emulators. Testing in just one of them isn't enough. 351 | -------------------------------------------------------------------------------- /src/Buffers/AnsiBuffer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen\Buffers; 11 | 12 | use InvalidArgumentException; 13 | use RuntimeException; 14 | 15 | class AnsiBuffer extends Buffer 16 | { 17 | protected mixed $valueForClearing = 0; 18 | 19 | /** 20 | * The active integer bitmask representing current standard ANSI 21 | * states like bold, underline, 8-color FG/BG, etc. 22 | */ 23 | protected int $active = 0; 24 | 25 | /** 26 | * Extended active color states: 27 | * [ 'type' => '256', 'color' => <0–255> ] or 28 | * [ 'type' => 'rgb', 'r' => <0–255>, 'g' => <0–255>, 'b' => <0–255> ] 29 | */ 30 | protected ?array $extendedForeground = null; 31 | 32 | protected ?array $extendedBackground = null; 33 | 34 | /** 35 | * The ANSI codes that control text decoration, 36 | * like underline, bold, italic, etc. 37 | * 38 | * @var array 39 | */ 40 | protected readonly array $decoration; 41 | 42 | /** 43 | * The ANSI codes that control decoration resets. 44 | * 45 | * @var array 46 | */ 47 | protected readonly array $resets; 48 | 49 | /** 50 | * The ANSI codes that control text foreground colors. 51 | * 52 | * @var array 53 | */ 54 | protected readonly array $foreground; 55 | 56 | /** 57 | * The ANSI codes that control text background colors. 58 | * 59 | * @var array 60 | */ 61 | protected readonly array $background; 62 | 63 | /** 64 | * The keys are ANSI SGR codes and the values are arbitrary bit values 65 | * initiated in the constructor allowing for efficient combination 66 | * and manipulation using bitwise operations. 67 | * 68 | * @link https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 69 | * 70 | * @var array 71 | */ 72 | protected readonly array $codes; 73 | 74 | /** 75 | * Each key is an ANSI code that turns a certain decoration on, while 76 | * the value is an ANSI code that turns that decoration off. 77 | * 78 | * @var int[] 79 | */ 80 | protected array $decorationResets = [ 81 | 1 => 22, // 22 does indeed turn off bold & dim. 82 | 2 => 22, // 22 does indeed turn off bold & dim. 83 | 3 => 23, 84 | 4 => 24, 85 | 5 => 25, 86 | 7 => 27, 87 | 8 => 28, 88 | 9 => 29, 89 | ]; 90 | 91 | public function initialize() 92 | { 93 | if (PHP_INT_SIZE < 8) { 94 | throw new RuntimeException(static::class . ' requires a 64-bit PHP environment.'); 95 | } 96 | 97 | // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 98 | $this->decoration = range(1, 9); 99 | $this->resets = range(22, 29); 100 | // Standard and bright. 101 | $this->foreground = [...range(30, 39), ...range(90, 97)]; 102 | $this->background = [...range(40, 49), ...range(100, 107)]; 103 | 104 | $supported = [ 105 | 0, // Reset all styles 106 | ...$this->decoration, 107 | ...$this->resets, 108 | ...$this->foreground, 109 | ...$this->background, 110 | ]; 111 | 112 | $this->codes = array_reduce($supported, function ($carry, $code) { 113 | // Every code gets a unique bit value, via left shift. 114 | $carry[$code] = 1 << count($carry); 115 | 116 | return $carry; 117 | }, []); 118 | } 119 | 120 | /** 121 | * The "cellValue" determines what we actually store in the Buffer: 122 | * - If no extended FG or BG, store just an int (the $this->active bitmask). 123 | * - If extended FG or BG is set, store an array with bits + extFg + extBg. 124 | */ 125 | protected function cellValue(): int|array 126 | { 127 | if (is_null($this->extendedForeground) && is_null($this->extendedBackground)) { 128 | return $this->active; 129 | } 130 | 131 | // Use conventional placement to avoid having named keys, since 132 | // it could be copied many tens of thousands of times. 133 | return [ 134 | $this->active, $this->extendedForeground, $this->extendedBackground, 135 | ]; 136 | } 137 | 138 | public function fillBufferWithActiveFlags(int $row, int $startCol, int $endCol): void 139 | { 140 | $this->fill($this->cellValue(), $row, $startCol, $endCol); 141 | } 142 | 143 | public function getActive(): int 144 | { 145 | return $this->active; 146 | } 147 | 148 | public function getActiveAsAnsi(): string 149 | { 150 | return $this->ansiStringFromBits($this->active); 151 | } 152 | 153 | public function getActiveBackground(): array|int 154 | { 155 | // If we have an extended background (256-color or RGB), that counts as "active" 156 | if ($this->extendedBackground !== null) { 157 | return $this->extendedBackground; 158 | } 159 | 160 | // Build a bitmask that represents all possible background codes. 161 | // Then see if any of those bits are set in $this->active. 162 | $backgroundBitmask = 0; 163 | foreach ($this->background as $code) { 164 | $backgroundBitmask |= $this->codes[$code]; 165 | } 166 | 167 | // If any background bits are set, this expression will be non-zero. 168 | return $this->active & $backgroundBitmask; 169 | } 170 | 171 | public function compressedAnsiBuffer(): array 172 | { 173 | $lines = $this->buffer; 174 | 175 | return array_map(function ($line) { 176 | // We reset the previous cell on every single line, because we're not guaranteed that the 177 | // whole buffer is going to be printed to the screen. Imagine the first line has some 178 | // color information, like set foreground to blue. Then there are 300 lines of text. 179 | 180 | // In Solo, only ~50 lines are going to be shown. If the first line (the one that contains 181 | // the color info) is not shown, then the next line will have the wrong color. We need 182 | // to reset the color on every line, even if it's the same as the previous line. 183 | 184 | // Conventional placement: bits, extfg, extbg. 185 | $previousCell = [0, null, null]; 186 | 187 | return array_filter(array_map(function ($cell) use (&$previousCell) { 188 | if (is_int($cell)) { 189 | $cell = [$cell, null, null]; 190 | } 191 | 192 | $uniqueBits = $cell[0] & ~$previousCell[0]; 193 | $turnedOffBits = $previousCell[0] & ~$cell[0]; 194 | 195 | $resetCodes = []; 196 | $turnedOffCodes = $this->ansiCodesFromBits($turnedOffBits); 197 | 198 | foreach ($turnedOffCodes as $code) { 199 | if ($this->codeInRange($code, $this->foreground)) { 200 | // If a foreground code was removed, then use code 39 to reset. 201 | $resetCodes[] = 39; 202 | } elseif ($this->codeInRange($code, $this->background)) { 203 | // If a background code was removed, then use code 49 to reset. 204 | $resetCodes[] = 49; 205 | } elseif ($this->codeInRange($code, $this->decoration) && isset($this->decorationResets[$code])) { 206 | // If a decoration code turned off, apply its reset 207 | $resetCodes[] = $this->decorationResets[$code]; 208 | } 209 | } 210 | 211 | $uniqueCodes = $this->ansiCodesFromBits($uniqueBits); 212 | 213 | // Extended foreground changed 214 | if ($previousCell[1] !== $cell[1]) { 215 | if ($previousCell[1] !== null && $cell[1] === null) { 216 | $resetCodes[] = 39; 217 | } elseif ($cell[1] !== null) { 218 | $uniqueCodes[] = $this->buildExtendedColorCode(38, $cell[1]); 219 | } 220 | } 221 | 222 | // Extended background changed 223 | if ($previousCell[2] !== $cell[2]) { 224 | if ($previousCell[2] !== null && $cell[2] === null) { 225 | $resetCodes[] = 49; 226 | } elseif ($cell[2] !== null) { 227 | $uniqueCodes[] = $this->buildExtendedColorCode(48, $cell[2]); 228 | } 229 | } 230 | 231 | $previousCell = $cell; 232 | 233 | return $this->ansiStringFromCodes(array_unique(array_merge($resetCodes, $uniqueCodes))); 234 | }, $line)); 235 | }, $lines); 236 | } 237 | 238 | public function ansiCodesFromBits(int $bits): array 239 | { 240 | // Short-circuit 241 | if ($bits === 0) { 242 | return []; 243 | } 244 | 245 | $active = []; 246 | 247 | foreach ($this->codes as $code => $bit) { 248 | // Because the bits grow in ascending powers of 2, 249 | // if $bit > $bits, we can break early. 250 | if ($bit > $bits) { 251 | break; 252 | } 253 | 254 | if (($bits & $bit) === $bit) { 255 | $active[] = $code; 256 | } 257 | } 258 | 259 | sort($active); 260 | 261 | return $active; 262 | } 263 | 264 | public function ansiStringFromCodes(array $codes): string 265 | { 266 | return count($codes) ? ("\e[" . implode(';', $codes) . 'm') : ''; 267 | } 268 | 269 | public function ansiStringFromBits(int $bits): string 270 | { 271 | // Basic codes from the bitmask 272 | $codes = $this->ansiCodesFromBits($bits); 273 | 274 | // If we have an extended FG, add that 275 | if ($this->extendedForeground !== null) { 276 | $codes[] = $this->buildExtendedColorCode(38, $this->extendedForeground); 277 | } 278 | // If we have an extended BG, add that 279 | if ($this->extendedBackground !== null) { 280 | $codes[] = $this->buildExtendedColorCode(48, $this->extendedBackground); 281 | } 282 | 283 | return $this->ansiStringFromCodes($codes); 284 | } 285 | 286 | public function addAnsiCodes(int ...$codes) 287 | { 288 | for ($i = 0; $i < count($codes); $i++) { 289 | $code = $codes[$i]; 290 | 291 | // Extended color codes are multipart 292 | // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797#256-colors 293 | if ($code === 38 || $code === 48) { 294 | // Code 2 = RGB colors 295 | // Code 5 = 256 colors 296 | $type = $codes[$i + 1] ?? null; 297 | 298 | if ($type === 2 || $type === 5) { 299 | // A 256 color type requires 1 additional code, an RGB type requires 3. 300 | $take = $type === 5 ? 1 : 3; 301 | 302 | // Take the type code too 303 | $take++; 304 | 305 | $slice = array_slice($codes, $i + 1, $take); 306 | 307 | if (count($slice) < $take) { 308 | // Not enough codes... just move on 309 | continue; 310 | } 311 | 312 | $this->setExtendedColor($code, $slice); 313 | 314 | $i += $take; 315 | } 316 | 317 | continue; 318 | } 319 | 320 | // Otherwise treat it as a normal code 321 | $this->addAnsiCode($code); 322 | } 323 | } 324 | 325 | public function addAnsiCode(int $code) 326 | { 327 | if (!array_key_exists($code, $this->codes)) { 328 | return; 329 | // throw new InvalidArgumentException("Invalid ANSI code: $code"); 330 | } 331 | 332 | // Reset all styles. 333 | if ($code === 0) { 334 | $this->resetBitRange(0, 64); 335 | $this->extendedForeground = null; 336 | $this->extendedBackground = null; 337 | } 338 | 339 | // If we're adding a new foreground color, zero out the old ones. 340 | if ($this->codeInRange($code, $this->foreground)) { 341 | $this->resetForeground(); 342 | } 343 | 344 | // Same for backgrounds. 345 | if ($this->codeInRange($code, $this->background)) { 346 | $this->resetBackground(); 347 | } 348 | 349 | // If we're adding a decoration, we need to unset the 350 | // code that disables that specific decoration. 351 | if ($this->codeInRange($code, $this->decoration) && isset($this->decorationResets[$code])) { 352 | $bitToUnset = $this->decorationResets[$code] ?? null; 353 | if (isset($this->codes[$bitToUnset])) { 354 | $this->active &= ~$this->codes[$bitToUnset]; 355 | } 356 | } 357 | 358 | // If we're unsetting a decoration, we need to remove 359 | // the code that enables that decoration. 360 | if ($this->codeInRange($code, $this->resets)) { 361 | $unset = 0; 362 | foreach ($this->decorationResets as $decoration => $reset) { 363 | if ($code === $reset) { 364 | $unset |= $this->codes[$decoration]; 365 | } 366 | } 367 | $this->active &= ~$unset; 368 | } 369 | 370 | $this->active |= $this->codes[$code]; 371 | } 372 | 373 | protected function setExtendedColor(int $baseCode, array $color) 374 | { 375 | if ($baseCode === 38) { 376 | $this->resetForeground(); 377 | $this->extendedForeground = $color; 378 | } elseif ($baseCode === 48) { 379 | $this->resetBackground(); 380 | $this->extendedBackground = $color; 381 | } 382 | } 383 | 384 | protected function buildExtendedColorCode(int $base, array $color): string 385 | { 386 | return implode(';', [$base, ...$color]); 387 | } 388 | 389 | protected function resetForeground() 390 | { 391 | $this->resetCodes($this->foreground); 392 | $this->extendedForeground = null; 393 | } 394 | 395 | protected function resetBackground() 396 | { 397 | $this->resetCodes($this->background); 398 | $this->extendedBackground = null; 399 | } 400 | 401 | protected function codeInRange(int $code, array $range) 402 | { 403 | // O(1) lookup vs in_array which is O(n) 404 | return isset(array_flip($range)[$code]); 405 | } 406 | 407 | protected function resetCodes($codes) 408 | { 409 | foreach ($codes as $code) { 410 | if (isset($this->codes[$code])) { 411 | $this->active &= ~$this->codes[$code]; 412 | } 413 | } 414 | } 415 | 416 | protected function resetBitRange(int $start, int $end): void 417 | { 418 | // Validate bit positions 419 | if ($start < 0 || $end < 0) { 420 | throw new InvalidArgumentException('Bit positions must be non-negative.'); 421 | } 422 | 423 | if ($start > $end) { 424 | throw new InvalidArgumentException('Start bit must be less than or equal to end bit.'); 425 | } 426 | 427 | $totalBits = 64; 428 | 429 | // Adjust end if it exceeds the total bits 430 | $end = min($end, $totalBits - 1); 431 | 432 | // Calculate the number of bits to clear 433 | $length = $end - $start + 1; 434 | 435 | // Handle cases where the length equals the integer size 436 | if ($length >= $totalBits) { 437 | // Clear all bits 438 | $this->active = 0; 439 | 440 | return; 441 | } 442 | 443 | // Create a mask with 1s outside the range and 0s within the range 444 | $mask = ~(((1 << $length) - 1) << $start); 445 | 446 | // Apply the mask to clear the specified bit range 447 | $this->active &= $mask; 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solo Screen 2 | 3 | Screen is a terminal renderer written in pure PHP. It powers [Solo for Laravel](https://github.com/soloterm/solo) and 4 | can be used to build rich text-based user interfaces in any PHP application. 5 | 6 | > [!NOTE] 7 | > Screen is a library intended to be integrated into PHP applications. It is not a standalone terminal application. 8 | 9 | ## About terminal renderers 10 | 11 | A terminal renderer processes text and ANSI escape sequences to create a virtual representation of terminal output. 12 | Unlike a full terminal emulator, Screen focuses specifically on correctly interpreting and rendering text content with 13 | formatting rather than handling input, interactive sessions, or process management. 14 | 15 | Terminal renderers interpret escape sequences to: 16 | 17 | - Track cursor position 18 | - Apply text colors and styles (bold, underline, etc.) 19 | - Manage screen content 20 | - Handle special character sets 21 | - Generate a final rendered output 22 | 23 | Screen implements this functionality in pure PHP, allowing developers to build terminal user interfaces without relying 24 | on external dependencies or native code. 25 | 26 | ## Why this exists 27 | 28 | Screen was originally created to solve a specific problem in [Solo for Laravel](https://github.com/soloterm/solo). 29 | 30 | Solo provides a TUI (Text User Interface) that runs multiple processes simultaneously in separate panels, similar to 31 | tmux. However, when these processes output ANSI escape codes for cursor movement and screen manipulation, they could 32 | potentially "break out" of their visual containers and interfere with other parts of the interface. 33 | 34 | To solve this problem, Screen creates a virtual terminal buffer where: 35 | 36 | 1. All ANSI operations (cursor movements, color changes, screen clears) are safely interpreted within an isolated 37 | environment 38 | 2. The final rendered state is captured after all operations are processed 39 | 3. Only the final visual output is displayed to the user's terminal 40 | 41 | This approach provides complete control over how terminal output is rendered, ensuring that complex ANSI operations stay 42 | contained within their designated areas. While initially built for Solo, Screen has evolved into a standalone library 43 | that can be used in any PHP application requiring terminal rendering. 44 | 45 | ## Features 46 | 47 | - **Pure PHP Implementation**: Only one dependency ([Grapheme](https://github.com/soloterm/grapheme), another Solo 48 | library) 49 | - **Comprehensive ANSI Support**: Handles cursor positioning, text styling, and screen manipulation 50 | - **Unicode/Multibyte Support**: Properly handles UTF-8 characters including emojis and wide characters 51 | - **Buffer Management**: Maintains separate buffers for text content and styling 52 | - **Character Width Handling**: Correctly calculates display width for CJK and other double-width characters 53 | - **Scrolling**: Support for vertical scrolling with proper content management 54 | - **Relative Positioning**: Output can be rendered at any position in a parent TUI without interference 55 | 56 | ## Installation 57 | 58 | Install via Composer: 59 | 60 | ```shell 61 | composer require soloterm/screen 62 | ``` 63 | 64 | ## Requirements 65 | 66 | - PHP 8.1 or higher 67 | - mbstring extension 68 | 69 | ## Basic usage 70 | 71 | Here's a simple example of using Screen: 72 | 73 | ```php 74 | use SoloTerm\Screen\Screen; 75 | 76 | // Create a screen with dimensions (columns, rows) 77 | $screen = new Screen(80, 24); 78 | 79 | // Write text and ANSI escape sequences 80 | $screen->write("Hello, \e[1;32mWorld!\e[0m"); 81 | 82 | // Move cursor and add more text 83 | $screen->write("\e[5;10HPositioned text"); 84 | 85 | // Get the rendered content 86 | echo $screen->output(); 87 | ``` 88 | 89 | ## Core concepts 90 | 91 | Screen operates with several key components: 92 | 93 | ### Screen 94 | 95 | The main class that coordinates all functionality. It takes care of cursor positioning, content writing, and rendering 96 | the final output. 97 | 98 | ```php 99 | $screen = new Screen(80, 24); // width, height 100 | $screen->write("Text and ANSI codes"); 101 | ``` 102 | 103 | ### Buffers 104 | 105 | Screen uses multiple buffer types to track content and styling: 106 | 107 | - **PrintableBuffer**: Stores visible characters and handles width calculations 108 | - **AnsiBuffer**: Tracks styling information (colors, bold, underline, etc.) 109 | 110 | ### ANSI processing 111 | 112 | Screen correctly interprets ANSI escape sequences for: 113 | 114 | - Cursor movement (up, down, left, right, absolute positioning) 115 | - Text styling (colors, bold, italic, underline) 116 | - Screen clearing and line manipulation 117 | - Scrolling 118 | 119 | ## Architecture 120 | 121 | Screen uses a dual-buffer architecture to separate content from styling: 122 | 123 | ``` 124 | ┌──────────────────────────────────────────────────────────────────┐ 125 | │ Screen │ 126 | │ │ 127 | │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ 128 | │ │ AnsiParser │───▶│ Proxy │───▶│ Buffers │ │ 129 | │ │ │ │ │ │ │ │ 130 | │ │ Splits into │ │ Coordinates │ │ ┌───────────────┐ │ │ 131 | │ │ text + ANSI │ │ writes to │ │ │PrintableBuffer│ │ │ 132 | │ └─────────────┘ │ both buffers│ │ │ (characters) │ │ │ 133 | │ └─────────────┘ │ ├───────────────┤ │ │ 134 | │ │ │ AnsiBuffer │ │ │ 135 | │ │ │ (styles) │ │ │ 136 | │ │ └───────────────┘ │ │ 137 | │ └──────────┬──────────┘ │ 138 | │ │ │ 139 | │ ▼ │ 140 | │ ┌─────────────────────┐ │ 141 | │ │ output() │ │ 142 | │ │ Combines buffers │ │ 143 | │ │ into final ANSI │ │ 144 | │ └─────────────────────┘ │ 145 | └──────────────────────────────────────────────────────────────────┘ 146 | ``` 147 | 148 | - **AnsiParser**: A state machine that splits input into printable text and ANSI escape sequences 149 | - **Proxy**: Coordinates writes to both buffers simultaneously, keeping them in sync 150 | - **PrintableBuffer**: Stores visible characters, handles grapheme clusters and wide character width calculations 151 | - **AnsiBuffer**: Stores styling as efficient bitmasks, with support for 256-color and RGB 152 | 153 | This separation allows Screen to efficiently track what changed and optimize rendering. 154 | 155 | ## Advanced features 156 | 157 | ### Rendering at arbitrary positions (popup windows, panels) 158 | 159 | When building TUIs with multiple Screen instances rendered at different positions (like popup windows or panels), you 160 | need to handle cursor positioning carefully. Screen's `output()` method uses **relative cursor positioning** to avoid 161 | the "pending wrap state" problem that causes rendering issues across different terminals. 162 | 163 | #### The pending wrap problem 164 | 165 | When a line is filled to exactly the terminal width, terminals enter a "pending wrap" state. The behavior of `\n` and 166 | `\r` in this state varies between terminal emulators: 167 | 168 | - **iTerm2**: A newline after a full line moves down one row 169 | - **Ghostty**: May move down two rows or position content incorrectly 170 | 171 | This inconsistency can cause content to appear offset by an entire screen width in some terminals. 172 | 173 | #### How Screen solves this 174 | 175 | Screen's `output()` method uses DEC save/restore cursor (DECSC/DECRC) with cursor down (CUD) sequences instead of 176 | newlines: 177 | 178 | ``` 179 | ESC 7 Save cursor position (origin point) 180 | [line 1 content] 181 | ESC 8 Restore to origin 182 | ESC [1B Move down 1 row 183 | [line 2 content] 184 | ESC 8 Restore to origin 185 | ESC [2B Move down 2 rows 186 | [line 3 content] 187 | ... 188 | ``` 189 | 190 | This approach: 191 | - **Avoids pending wrap entirely** — no `\n` characters between lines means wrap state doesn't matter 192 | - **Uses relative positioning** — output renders correctly at any cursor position in a parent TUI 193 | - **Works consistently** — same behavior in iTerm2, Ghostty, and other terminals 194 | 195 | #### Rendering a Screen at a specific position 196 | 197 | To render a Screen at a specific position in your TUI: 198 | 199 | ```php 200 | use SoloTerm\Screen\Screen; 201 | 202 | // Create a screen for a popup/panel 203 | $popup = new Screen(40, 10); 204 | $popup->write("Popup content here..."); 205 | 206 | // Position the parent terminal's cursor where you want the popup 207 | echo "\e[5;20H"; // Move to row 5, column 20 208 | 209 | // Render the popup - it will use relative positioning from this point 210 | echo $popup->output(); 211 | ``` 212 | 213 | The Screen will render its content starting from wherever the cursor is positioned, with each line placed relative to 214 | that origin point. 215 | 216 | ### Differential rendering 217 | 218 | For high-performance applications like TUIs with frequent updates, Screen supports differential rendering that only 219 | outputs changed lines: 220 | 221 | ```php 222 | $screen = new Screen(80, 24); 223 | $screen->write("Initial content"); 224 | 225 | // Get the full output and capture the sequence number 226 | $output = $screen->output(); 227 | $seqNo = $screen->getLastRenderedSeqNo(); 228 | 229 | // ... later, after some updates ... 230 | $screen->write("\e[5;1HUpdated line"); 231 | 232 | // Only get the changed lines (with cursor positioning) 233 | $diff = $screen->output($seqNo); 234 | ``` 235 | 236 | This is particularly useful when building interfaces that update at high frame rates (e.g., 40 FPS) where 237 | re-rendering the entire screen would be wasteful. 238 | 239 | ### Cursor positioning 240 | 241 | ```php 242 | // Move cursor to position (row 5, column 10) 243 | $screen->write("\e[5;10H"); 244 | 245 | // Move cursor up 3 lines 246 | $screen->write("\e[3A"); 247 | 248 | // Save and restore cursor position 249 | $screen->write("\e7"); // Save 250 | $screen->write("More text"); 251 | $screen->write("\e8"); // Restore 252 | ``` 253 | 254 | ### Text styling 255 | 256 | ```php 257 | // Bold red text 258 | $screen->write("\e[1;31mImportant message\e[0m"); 259 | 260 | // Background colors 261 | $screen->write("\e[44mBlue background\e[0m"); 262 | 263 | // 256-color support 264 | $screen->write("\e[38;5;208mOrange text\e[0m"); 265 | 266 | // RGB colors 267 | $screen->write("\e[38;2;255;100;0mCustom color\e[0m"); 268 | ``` 269 | 270 | ### Screen manipulation 271 | 272 | ```php 273 | // Clear screen 274 | $screen->write("\e[2J"); 275 | 276 | // Clear from cursor to end of line 277 | $screen->write("\e[0K"); 278 | 279 | // Insert lines 280 | $screen->write("\e[2L"); 281 | 282 | // Scroll up 283 | $screen->write("\e[2S"); 284 | ``` 285 | 286 | ## Supported ANSI codes 287 | 288 | Screen supports a comprehensive set of ANSI escape sequences: 289 | 290 | ### Cursor movement (CSI sequences) 291 | 292 | | Code | Name | Description | 293 | |------|------|-------------| 294 | | `ESC[nA` | CUU | Cursor up n lines | 295 | | `ESC[nB` | CUD | Cursor down n lines | 296 | | `ESC[nC` | CUF | Cursor forward n columns | 297 | | `ESC[nD` | CUB | Cursor backward n columns | 298 | | `ESC[nE` | CNL | Cursor to beginning of line, n lines down | 299 | | `ESC[nF` | CPL | Cursor to beginning of line, n lines up | 300 | | `ESC[nG` | CHA | Cursor to column n | 301 | | `ESC[n;mH` | CUP | Cursor to row n, column m | 302 | | `ESC[nI` | CHT | Cursor forward n tab stops | 303 | | `ESC7` | DECSC | Save cursor position | 304 | | `ESC8` | DECRC | Restore cursor position | 305 | 306 | ### Erase functions 307 | 308 | | Code | Name | Description | 309 | |------|------|-------------| 310 | | `ESC[0J` | ED | Erase from cursor to end of screen | 311 | | `ESC[1J` | ED | Erase from start of screen to cursor | 312 | | `ESC[2J` | ED | Erase entire screen | 313 | | `ESC[0K` | EL | Erase from cursor to end of line | 314 | | `ESC[1K` | EL | Erase from start of line to cursor | 315 | | `ESC[2K` | EL | Erase entire line | 316 | 317 | ### Scrolling 318 | 319 | | Code | Name | Description | 320 | |------|------|-------------| 321 | | `ESC[nS` | SU | Scroll up n lines | 322 | | `ESC[nT` | SD | Scroll down n lines | 323 | | `ESC[nL` | IL | Insert n lines at cursor | 324 | 325 | ### Text styling (SGR - Select Graphic Rendition) 326 | 327 | | Code | Description | 328 | |------|-------------| 329 | | `0` | Reset all attributes | 330 | | `1` | Bold | 331 | | `2` | Dim | 332 | | `3` | Italic | 333 | | `4` | Underline | 334 | | `5` | Blink | 335 | | `7` | Reverse video | 336 | | `8` | Hidden | 337 | | `9` | Strikethrough | 338 | | `22` | Normal intensity (not bold/dim) | 339 | | `23` | Not italic | 340 | | `24` | Not underlined | 341 | | `25` | Not blinking | 342 | | `27` | Not reversed | 343 | | `28` | Not hidden | 344 | | `29` | Not strikethrough | 345 | | `30-37` | Foreground color (standard) | 346 | | `38;5;n` | Foreground color (256-color) | 347 | | `38;2;r;g;b` | Foreground color (RGB) | 348 | | `39` | Default foreground | 349 | | `40-47` | Background color (standard) | 350 | | `48;5;n` | Background color (256-color) | 351 | | `48;2;r;g;b` | Background color (RGB) | 352 | | `49` | Default background | 353 | | `90-97` | Foreground color (bright) | 354 | | `100-107` | Background color (bright) | 355 | 356 | ## Custom integrations 357 | 358 | You can respond to terminal queries by setting a callback: 359 | 360 | ```php 361 | $screen->respondToQueriesVia(function($response) { 362 | // Process response (like cursor position) 363 | echo $response; 364 | }); 365 | ``` 366 | 367 | > [!NOTE] 368 | > This is still a work in progress. We need some more tests / use cases here. 369 | 370 | ## Example: building a simple UI 371 | 372 | ```php 373 | use SoloTerm\Screen\Screen; 374 | 375 | $screen = new Screen(80, 24); 376 | 377 | // Draw a border 378 | $screen->write("┌" . str_repeat("─", 78) . "┐\n"); 379 | for ($i = 0; $i < 22; $i++) { 380 | $screen->write("│" . str_repeat(" ", 78) . "│\n"); 381 | } 382 | $screen->write("└" . str_repeat("─", 78) . "┘"); 383 | 384 | // Add a title 385 | $screen->write("\e[1;30H\e[1;36mMy Application\e[0m"); 386 | 387 | // Add some content 388 | $screen->write("\e[5;5HWelcome to the application!"); 389 | $screen->write("\e[7;5HPress 'q' to quit."); 390 | 391 | // Render 392 | echo $screen->output(); 393 | ``` 394 | 395 | ## Handling unicode and wide characters 396 | 397 | Screen properly handles Unicode characters including emoji and CJK characters that take up multiple columns: 398 | 399 | ```php 400 | $screen->write("Regular text: Hello"); 401 | $screen->write("\nWide characters: 你好世界"); 402 | $screen->write("\nEmoji: 🚀 👨‍👩‍👧‍👦 🌍"); 403 | ``` 404 | 405 | ## Testing 406 | 407 | Screen includes a comprehensive test suite with visual comparison testing that validates output against real terminal 408 | behavior. 409 | 410 | ```shell 411 | composer test 412 | ``` 413 | 414 | ### Available test commands 415 | 416 | | Command | Description | 417 | |---------|-------------| 418 | | `composer test` | Run tests without screenshot generation | 419 | | `composer test:screenshots` | Generate all fixtures (requires iTerm or Ghostty) | 420 | | `composer test:missing` | Generate only missing or out-of-sync fixtures | 421 | | `composer test:failures` | Re-run failed tests first, stop on first failure | 422 | | `composer test:fixtures` | Validate fixture integrity across terminals | 423 | 424 | You can pass additional PHPUnit options using `--`: 425 | 426 | ```shell 427 | # Run only emoji tests with screenshots 428 | composer test:screenshots -- --filter="emoji" 429 | 430 | # Generate missing fixtures for a specific test class 431 | composer test:missing -- --filter="MultibyteTest" 432 | ``` 433 | 434 | ### Visual testing 435 | 436 | Screen employs screenshot-based testing that compares rendered output against real terminal behavior. The system 437 | supports both **iTerm2** and **Ghostty** terminals to ensure cross-terminal compatibility. 438 | 439 | How it works: 440 | 441 | 1. The test renders content in a real terminal (iTerm2 or Ghostty) 442 | 2. It captures a screenshot using macOS's `CGWindowListCreateImage` API 443 | 3. It runs the same content through the Screen renderer 444 | 4. It captures another screenshot 445 | 5. It compares the screenshots pixel-by-pixel using ImageMagick 446 | 447 | This ensures Screen's rendering accurately matches real terminal behavior for: 448 | 449 | - Multi-byte characters and emoji 450 | - Complex ANSI formatting 451 | - Cursor movements and positioning 452 | - Scrolling behavior 453 | - Line wrapping 454 | - Terminal-specific edge cases (like pending wrap state) 455 | 456 | ### Requirements for visual testing 457 | 458 | - macOS 459 | - iTerm2 or Ghostty terminal 460 | - ImageMagick (`brew install imagemagick`) 461 | 462 | The test runner will automatically resize your terminal window to the required dimensions (180x32) for iTerm. For 463 | Ghostty, you'll be prompted to resize manually. 464 | 465 | ### Fixture structure 466 | 467 | Fixtures are stored per-terminal to account for rendering differences: 468 | 469 | ``` 470 | tests/Fixtures/ 471 | ├── iterm/ # iTerm2-specific fixtures 472 | │ └── Unit/ 473 | │ └── TestClass/ 474 | │ └── test_name.json 475 | └── ghostty/ # Ghostty-specific fixtures 476 | └── Unit/ 477 | └── ... 478 | ``` 479 | 480 | When running tests without screenshot generation, the system uses stored fixtures for comparison, making tests fast and 481 | suitable for CI/CD pipelines. In CI (where no terminal is available), iTerm fixtures are used since we validate that 482 | iTerm and Ghostty fixtures are identical. 483 | 484 | ## Contributing 485 | 486 | Contributions are welcome! Please feel free to submit a pull request. 487 | 488 | ## License 489 | 490 | The MIT License (MIT). 491 | 492 | ## Support 493 | 494 | This is free! If you want to support me: 495 | 496 | - Check out my courses: 497 | - [Database School](https://databaseschool.com) 498 | - [Screencasting](https://screencasting.com) 499 | - Help spread the word about things I make 500 | 501 | ## Related Projects 502 | 503 | Screen is part of the SoloTerm ecosystem of Laravel and PHP development tools: 504 | 505 | - [Solo](https://github.com/soloterm/solo) - All-in-one Laravel command for local development 506 | - [Dumps](https://github.com/soloterm/dumps) - Laravel command to intercept dumps 507 | - [Grapheme](https://github.com/soloterm/grapheme) - Unicode grapheme width calculator 508 | - [Notify](https://github.com/soloterm/notify) - PHP package for desktop notifications via OSC escape sequences 509 | - [Notify Laravel](https://github.com/soloterm/notify-laravel) - Laravel integration for soloterm/notify 510 | - [TNotify](https://github.com/soloterm/tnotify) - Standalone, cross-platform CLI for desktop notifications 511 | - [VTail](https://github.com/soloterm/vtail) - Vendor-aware tail for Laravel logs 512 | 513 | ## Credits 514 | 515 | Solo Screen was developed by Aaron Francis. If you like it, please let me know! 516 | 517 | - Twitter: https://twitter.com/aarondfrancis 518 | - Website: https://aaronfrancis.com 519 | - YouTube: https://youtube.com/@aarondfrancis 520 | - GitHub: https://github.com/aarondfrancis 521 | -------------------------------------------------------------------------------- /src/Buffers/CellBuffer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen\Buffers; 11 | 12 | use SoloTerm\Screen\Cell; 13 | use SoloTerm\Screen\Output\CursorOptimizer; 14 | use SoloTerm\Screen\Output\StyleTracker; 15 | 16 | /** 17 | * A unified buffer that stores Cell objects in a flat array. 18 | * 19 | * This combines the functionality of PrintableBuffer and AnsiBuffer 20 | * into a single data structure for more efficient access and comparison. 21 | * 22 | * Uses flat array indexing: $cells[$y * $width + $x] for O(1) access. 23 | */ 24 | class CellBuffer 25 | { 26 | /** 27 | * Flat array of Cell objects (current frame). 28 | * 29 | * @var Cell[] 30 | */ 31 | protected array $cells = []; 32 | 33 | /** 34 | * Flat array of Cell objects (previous frame for diff). 35 | * 36 | * @var Cell[] 37 | */ 38 | protected array $previousCells = []; 39 | 40 | /** 41 | * Whether we have a previous frame to compare against. 42 | */ 43 | protected bool $hasPreviousFrame = false; 44 | 45 | /** 46 | * Tracks cell indices that have been modified since last swap. 47 | * Using index as key for O(1) lookup and dedup. 48 | * 49 | * @var array 50 | */ 51 | protected array $dirtyCells = []; 52 | 53 | /** 54 | * Buffer width in columns. 55 | */ 56 | protected int $width; 57 | 58 | /** 59 | * Buffer height in rows (may grow dynamically). 60 | */ 61 | protected int $height; 62 | 63 | /** 64 | * Maximum number of rows to retain (for memory management). 65 | */ 66 | protected int $maxRows; 67 | 68 | /** 69 | * Number of rows that have scrolled off the top. 70 | */ 71 | protected int $scrollOffset = 0; 72 | 73 | /** 74 | * Tracks the sequence number when each line was last modified. 75 | * 76 | * @var array 77 | */ 78 | protected array $lineSeqNos = []; 79 | 80 | /** 81 | * Callback to get current sequence number. 82 | * 83 | * @var callable|null 84 | */ 85 | protected $seqNoProvider = null; 86 | 87 | /** 88 | * The current ANSI styling state (applied to new writes). 89 | */ 90 | protected Cell $currentStyle; 91 | 92 | public function __construct(int $width, int $height, int $maxRows = 5000) 93 | { 94 | $this->width = $width; 95 | $this->height = $height; 96 | $this->maxRows = $maxRows; 97 | $this->currentStyle = Cell::blank(); 98 | 99 | // Initialize buffer with blank cells 100 | $this->initializeRows(0, $height); 101 | } 102 | 103 | /** 104 | * Initialize rows with blank cells. 105 | */ 106 | protected function initializeRows(int $startRow, int $endRow): void 107 | { 108 | for ($y = $startRow; $y < $endRow; $y++) { 109 | for ($x = 0; $x < $this->width; $x++) { 110 | $this->cells[$y * $this->width + $x] = Cell::blank(); 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * Set a callback that returns the current sequence number. 117 | */ 118 | public function setSeqNoProvider(callable $provider): static 119 | { 120 | $this->seqNoProvider = $provider; 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Mark a line as dirty (modified) with the current sequence number. 127 | */ 128 | public function markLineDirty(int $row): void 129 | { 130 | if ($this->seqNoProvider !== null) { 131 | $this->lineSeqNos[$row] = ($this->seqNoProvider)(); 132 | } 133 | } 134 | 135 | /** 136 | * Get all rows that have changed since the given sequence number. 137 | * 138 | * @return array Row indices that have changed 139 | */ 140 | public function getChangedRows(int $sinceSeqNo): array 141 | { 142 | $changed = []; 143 | 144 | foreach ($this->lineSeqNos as $row => $rowSeqNo) { 145 | if ($rowSeqNo > $sinceSeqNo) { 146 | $changed[] = $row; 147 | } 148 | } 149 | 150 | sort($changed); 151 | 152 | return $changed; 153 | } 154 | 155 | /** 156 | * Get a cell at the specified position. 157 | */ 158 | public function getCell(int $row, int $col): Cell 159 | { 160 | $index = $row * $this->width + $col; 161 | 162 | return $this->cells[$index] ?? Cell::blank(); 163 | } 164 | 165 | /** 166 | * Set a cell at the specified position. 167 | */ 168 | public function setCell(int $row, int $col, Cell $cell): void 169 | { 170 | $this->ensureRow($row); 171 | $index = $row * $this->width + $col; 172 | $this->cells[$index] = $cell; 173 | $this->dirtyCells[$index] = true; 174 | $this->invalidateRowHash($row); 175 | $this->markLineDirty($row); 176 | } 177 | 178 | /** 179 | * Write a character at the specified position with current styling. 180 | */ 181 | public function writeChar(int $row, int $col, string $char): void 182 | { 183 | $this->ensureRow($row); 184 | 185 | $cell = new Cell( 186 | $char, 187 | $this->currentStyle->style, 188 | $this->currentStyle->fg, 189 | $this->currentStyle->bg, 190 | $this->currentStyle->extFg, 191 | $this->currentStyle->extBg 192 | ); 193 | 194 | $index = $row * $this->width + $col; 195 | $this->cells[$index] = $cell; 196 | $this->dirtyCells[$index] = true; 197 | $this->invalidateRowHash($row); 198 | $this->markLineDirty($row); 199 | } 200 | 201 | /** 202 | * Write a continuation cell (for wide characters). 203 | */ 204 | public function writeContinuation(int $row, int $col): void 205 | { 206 | $this->ensureRow($row); 207 | 208 | $cell = Cell::continuation(); 209 | $cell->style = $this->currentStyle->style; 210 | $cell->fg = $this->currentStyle->fg; 211 | $cell->bg = $this->currentStyle->bg; 212 | $cell->extFg = $this->currentStyle->extFg; 213 | $cell->extBg = $this->currentStyle->extBg; 214 | 215 | $index = $row * $this->width + $col; 216 | $this->cells[$index] = $cell; 217 | $this->dirtyCells[$index] = true; 218 | $this->invalidateRowHash($row); 219 | $this->markLineDirty($row); 220 | } 221 | 222 | /** 223 | * Clear a region of the buffer. 224 | */ 225 | public function clear( 226 | int $startRow = 0, 227 | int $startCol = 0, 228 | int $endRow = PHP_INT_MAX, 229 | int $endCol = PHP_INT_MAX 230 | ): void { 231 | // Clamp all values to valid ranges 232 | $startRow = max($startRow, 0); 233 | $startCol = max($startCol, 0); 234 | $endRow = min($endRow, $this->height - 1); 235 | $endCol = min($endCol, $this->width - 1); 236 | 237 | for ($row = $startRow; $row <= $endRow; $row++) { 238 | if (!$this->rowExists($row)) { 239 | continue; 240 | } 241 | 242 | // Compute column range for this row 243 | $colStart = max(($row === $startRow) ? $startCol : 0, 0); 244 | $colEnd = min(($row === $endRow) ? $endCol : $this->width - 1, $this->width - 1); 245 | 246 | // Skip row if column range is invalid 247 | if ($colStart > $colEnd) { 248 | continue; 249 | } 250 | 251 | for ($col = $colStart; $col <= $colEnd; $col++) { 252 | $index = $row * $this->width + $col; 253 | $this->cells[$index] = Cell::blank(); 254 | $this->dirtyCells[$index] = true; 255 | } 256 | 257 | $this->invalidateRowHash($row); 258 | $this->markLineDirty($row); 259 | } 260 | } 261 | 262 | /** 263 | * Clear an entire line. 264 | */ 265 | public function clearLine(int $row): void 266 | { 267 | if (!$this->rowExists($row)) { 268 | return; 269 | } 270 | 271 | for ($col = 0; $col < $this->width; $col++) { 272 | $index = $row * $this->width + $col; 273 | $this->cells[$index] = Cell::blank(); 274 | $this->dirtyCells[$index] = true; 275 | } 276 | 277 | $this->invalidateRowHash($row); 278 | $this->markLineDirty($row); 279 | } 280 | 281 | /** 282 | * Fill a region with a character using current styling. 283 | */ 284 | public function fill(string $char, int $row, int $startCol, int $endCol): void 285 | { 286 | $this->ensureRow($row); 287 | 288 | $cell = new Cell( 289 | $char, 290 | $this->currentStyle->style, 291 | $this->currentStyle->fg, 292 | $this->currentStyle->bg, 293 | $this->currentStyle->extFg, 294 | $this->currentStyle->extBg 295 | ); 296 | 297 | for ($col = $startCol; $col <= $endCol; $col++) { 298 | $index = $row * $this->width + $col; 299 | $this->cells[$index] = clone $cell; 300 | $this->dirtyCells[$index] = true; 301 | } 302 | 303 | $this->invalidateRowHash($row); 304 | $this->markLineDirty($row); 305 | } 306 | 307 | /** 308 | * Set the current ANSI styling state. 309 | */ 310 | public function setStyle(int $style, ?int $fg, ?int $bg, ?array $extFg, ?array $extBg): void 311 | { 312 | $this->currentStyle = new Cell(' ', $style, $fg, $bg, $extFg, $extBg); 313 | } 314 | 315 | /** 316 | * Get the current styling state. 317 | */ 318 | public function getCurrentStyle(): Cell 319 | { 320 | return $this->currentStyle; 321 | } 322 | 323 | /** 324 | * Reset styling to defaults. 325 | */ 326 | public function resetStyle(): void 327 | { 328 | $this->currentStyle = Cell::blank(); 329 | } 330 | 331 | /** 332 | * Ensure a row exists in the buffer, expanding if necessary. 333 | */ 334 | public function ensureRow(int $row): void 335 | { 336 | while ($this->height <= $row) { 337 | $this->initializeRows($this->height, $this->height + 1); 338 | $this->height++; 339 | } 340 | 341 | $this->trim(); 342 | } 343 | 344 | /** 345 | * Check if a row exists in the buffer. 346 | */ 347 | public function rowExists(int $row): bool 348 | { 349 | return $row >= 0 && $row < $this->height; 350 | } 351 | 352 | /** 353 | * Get a row as an array of Cells. 354 | * 355 | * @return Cell[] 356 | */ 357 | public function getRow(int $row): array 358 | { 359 | if (!$this->rowExists($row)) { 360 | return array_fill(0, $this->width, Cell::blank()); 361 | } 362 | 363 | $cells = []; 364 | $baseIndex = $row * $this->width; 365 | 366 | for ($col = 0; $col < $this->width; $col++) { 367 | $cells[] = $this->cells[$baseIndex + $col] ?? Cell::blank(); 368 | } 369 | 370 | return $cells; 371 | } 372 | 373 | /** 374 | * Cached row hashes for O(1) row comparison. 375 | * 376 | * @var array 377 | */ 378 | protected array $rowHashes = []; 379 | 380 | /** 381 | * Check if a row is identical to the same row in another CellBuffer. 382 | * 383 | * Uses cached row hashes for O(1) comparison of unchanged rows. 384 | */ 385 | public function rowEquals(int $row, CellBuffer $other): bool 386 | { 387 | // Different widths means rows can't be equal 388 | if ($this->width !== $other->getWidth()) { 389 | return false; 390 | } 391 | 392 | // Compare row hashes (O(1) for cached hashes) 393 | return $this->getRowHash($row) === $other->getRowHash($row); 394 | } 395 | 396 | /** 397 | * Get a hash representing the content of a row. 398 | * 399 | * Uses a fast polynomial rolling hash for efficient comparison. 400 | * The hash is cached for efficient repeated comparisons. 401 | */ 402 | public function getRowHash(int $row): int 403 | { 404 | if (isset($this->rowHashes[$row])) { 405 | return $this->rowHashes[$row]; 406 | } 407 | 408 | // Use polynomial rolling hash - much faster than string concat + MD5 409 | $baseIndex = $row * $this->width; 410 | $hash = 0; 411 | 412 | for ($col = 0; $col < $this->width; $col++) { 413 | $cell = $this->cells[$baseIndex + $col] ?? Cell::blank(); 414 | 415 | // Hash the character (using ord for single-byte, crc32 for multi-byte) 416 | $charHash = strlen($cell->char) === 1 ? ord($cell->char) : crc32($cell->char); 417 | 418 | // Combine all cell properties into hash using prime multiplier 419 | $hash = (($hash * 31) + $charHash) & 0x7FFFFFFF; 420 | $hash = (($hash * 31) + $cell->style) & 0x7FFFFFFF; 421 | $hash = (($hash * 31) + ($cell->fg ?? -1)) & 0x7FFFFFFF; 422 | $hash = (($hash * 31) + ($cell->bg ?? -1)) & 0x7FFFFFFF; 423 | 424 | // Handle extended colors (256-color: [5, index], RGB: [2, r, g, b]) 425 | if (is_array($cell->extFg)) { 426 | foreach ($cell->extFg as $value) { 427 | $hash = (($hash * 31) + $value) & 0x7FFFFFFF; 428 | } 429 | } 430 | if (is_array($cell->extBg)) { 431 | foreach ($cell->extBg as $value) { 432 | $hash = (($hash * 31) + $value) & 0x7FFFFFFF; 433 | } 434 | } 435 | } 436 | 437 | $this->rowHashes[$row] = $hash; 438 | 439 | return $this->rowHashes[$row]; 440 | } 441 | 442 | /** 443 | * Invalidate the cached hash for a row. 444 | * 445 | * Called automatically when cells in the row are modified. 446 | */ 447 | protected function invalidateRowHash(int $row): void 448 | { 449 | unset($this->rowHashes[$row]); 450 | } 451 | 452 | /** 453 | * Get the raw cells array slice for a row. 454 | * 455 | * This provides direct access to the underlying array for efficient comparison. 456 | * 457 | * @return Cell[] 458 | */ 459 | public function getRowSlice(int $row): array 460 | { 461 | $baseIndex = $row * $this->width; 462 | 463 | return array_slice($this->cells, $baseIndex, $this->width); 464 | } 465 | 466 | /** 467 | * Render a row to a string with ANSI codes. 468 | */ 469 | public function renderRow(int $row): string 470 | { 471 | $cells = $this->getRow($row); 472 | $output = ''; 473 | $previousCell = null; 474 | 475 | foreach ($cells as $cell) { 476 | // Skip continuation cells (they don't render anything) 477 | if ($cell->isContinuation()) { 478 | $previousCell = $cell; 479 | 480 | continue; 481 | } 482 | 483 | $output .= $cell->getStyleTransition($previousCell); 484 | $output .= $cell->char; 485 | $previousCell = $cell; 486 | } 487 | 488 | return $output; 489 | } 490 | 491 | /** 492 | * Render the entire buffer to a string. 493 | */ 494 | public function render(): string 495 | { 496 | $lines = []; 497 | 498 | for ($row = 0; $row < $this->height; $row++) { 499 | $lines[] = $this->renderRow($row); 500 | } 501 | 502 | return implode(PHP_EOL, $lines); 503 | } 504 | 505 | /** 506 | * Swap the current buffer to the previous buffer for diff comparison. 507 | * Call this after rendering a frame. 508 | */ 509 | public function swapBuffers(): void 510 | { 511 | $this->previousCells = $this->cells; 512 | $this->hasPreviousFrame = true; 513 | $this->dirtyCells = []; 514 | } 515 | 516 | /** 517 | * Check if we have a previous frame to compare against. 518 | */ 519 | public function hasPreviousFrame(): bool 520 | { 521 | return $this->hasPreviousFrame; 522 | } 523 | 524 | /** 525 | * Get all cells that have changed since the last swap. 526 | * 527 | * Uses dirty cell tracking for O(dirty cells) instead of O(all cells). 528 | * 529 | * @return array Array of changed cells with positions 530 | */ 531 | public function getChangedCells(): array 532 | { 533 | if (!$this->hasPreviousFrame) { 534 | // No previous frame - all cells are "changed" 535 | $changed = []; 536 | for ($row = 0; $row < $this->height; $row++) { 537 | for ($col = 0; $col < $this->width; $col++) { 538 | $index = $row * $this->width + $col; 539 | $changed[] = [ 540 | 'row' => $row, 541 | 'col' => $col, 542 | 'cell' => $this->cells[$index] ?? Cell::blank(), 543 | ]; 544 | } 545 | } 546 | 547 | return $changed; 548 | } 549 | 550 | // Only check cells that were actually modified since last swap 551 | $changed = []; 552 | 553 | foreach ($this->dirtyCells as $index => $_) { 554 | $currentCell = $this->cells[$index] ?? Cell::blank(); 555 | $previousCell = $this->previousCells[$index] ?? Cell::blank(); 556 | 557 | // Only report if actually different (cell might be written with same value) 558 | if (!$currentCell->equals($previousCell)) { 559 | $row = intdiv($index, $this->width); 560 | $col = $index % $this->width; 561 | $changed[] = [ 562 | 'row' => $row, 563 | 'col' => $col, 564 | 'cell' => $currentCell, 565 | ]; 566 | } 567 | } 568 | 569 | // Sort by row then column for consistent output order 570 | usort($changed, function ($a, $b) { 571 | if ($a['row'] !== $b['row']) { 572 | return $a['row'] - $b['row']; 573 | } 574 | 575 | return $a['col'] - $b['col']; 576 | }); 577 | 578 | return $changed; 579 | } 580 | 581 | /** 582 | * Render only the changed cells with cursor positioning. 583 | * 584 | * Returns ANSI escape sequences that move the cursor and update only changed cells. 585 | * This is more efficient than re-rendering the entire screen. 586 | * 587 | * @param int $baseRow The base row offset in the terminal (for embedding in larger displays) 588 | * @param int $baseCol The base column offset in the terminal 589 | * @return string ANSI output for differential update 590 | */ 591 | public function renderDiff(int $baseRow = 0, int $baseCol = 0): string 592 | { 593 | $changedCells = $this->getChangedCells(); 594 | 595 | if (empty($changedCells)) { 596 | return ''; 597 | } 598 | 599 | $output = ''; 600 | $lastRow = -1; 601 | $lastCol = -1; 602 | $previousCell = null; 603 | 604 | foreach ($changedCells as $change) { 605 | $row = $change['row']; 606 | $col = $change['col']; 607 | $cell = $change['cell']; 608 | 609 | // Skip continuation cells 610 | if ($cell->isContinuation()) { 611 | continue; 612 | } 613 | 614 | // Position cursor if not already at the right place 615 | $targetRow = $baseRow + $row + 1; // ANSI is 1-indexed 616 | $targetCol = $baseCol + $col + 1; 617 | 618 | if ($lastRow !== $row || $lastCol !== $col) { 619 | $output .= "\e[{$targetRow};{$targetCol}H"; 620 | } 621 | 622 | // Add style transition and character 623 | $output .= $cell->getStyleTransition($previousCell); 624 | $output .= $cell->char; 625 | 626 | $previousCell = $cell; 627 | $lastRow = $row; 628 | $lastCol = $col + 1; // We moved one column right 629 | } 630 | 631 | // Reset styles at the end 632 | if ($previousCell !== null && $previousCell->hasStyle()) { 633 | $output .= "\e[0m"; 634 | } 635 | 636 | return $output; 637 | } 638 | 639 | /** 640 | * Render only the changed cells with optimized cursor movement and style tracking. 641 | * 642 | * This method uses CursorOptimizer and StyleTracker to minimize the output size 643 | * by choosing efficient cursor movements and avoiding redundant style codes. 644 | * 645 | * @param int $baseRow The base row offset in the terminal (for embedding in larger displays) 646 | * @param int $baseCol The base column offset in the terminal 647 | * @return string Optimized ANSI output for differential update 648 | */ 649 | public function renderDiffOptimized(int $baseRow = 0, int $baseCol = 0): string 650 | { 651 | $changedCells = $this->getChangedCells(); 652 | 653 | if (empty($changedCells)) { 654 | return ''; 655 | } 656 | 657 | $cursor = new CursorOptimizer; 658 | $style = new StyleTracker; 659 | $parts = []; 660 | 661 | foreach ($changedCells as $change) { 662 | $row = $change['row']; 663 | $col = $change['col']; 664 | $cell = $change['cell']; 665 | 666 | // Skip continuation cells 667 | if ($cell->isContinuation()) { 668 | continue; 669 | } 670 | 671 | // Calculate target position (with base offset, 0-indexed for optimizer) 672 | $targetRow = $baseRow + $row; 673 | $targetCol = $baseCol + $col; 674 | 675 | // Get optimized cursor movement 676 | $parts[] = $cursor->moveTo($targetRow, $targetCol); 677 | 678 | // Get optimized style transition 679 | $parts[] = $style->transitionTo($cell); 680 | 681 | // Output the character 682 | $parts[] = $cell->char; 683 | 684 | // Track cursor position after character 685 | $cursor->advance(1); 686 | } 687 | 688 | // Reset styles at the end if needed 689 | $parts[] = $style->resetIfNeeded(); 690 | 691 | return implode('', $parts); 692 | } 693 | 694 | /** 695 | * Get rows that have any changed cells. 696 | * 697 | * More efficient than getChangedCells() when you only need row-level granularity. 698 | * Uses dirty cell tracking for fast lookup. 699 | * 700 | * @return array Row indices with changes 701 | */ 702 | public function getChangedRowIndices(): array 703 | { 704 | if (!$this->hasPreviousFrame) { 705 | return range(0, $this->height - 1); 706 | } 707 | 708 | $changedRows = []; 709 | 710 | foreach ($this->dirtyCells as $index => $_) { 711 | $currentCell = $this->cells[$index] ?? Cell::blank(); 712 | $previousCell = $this->previousCells[$index] ?? Cell::blank(); 713 | 714 | if (!$currentCell->equals($previousCell)) { 715 | $row = intdiv($index, $this->width); 716 | $changedRows[$row] = true; 717 | } 718 | } 719 | 720 | $result = array_keys($changedRows); 721 | sort($result); 722 | 723 | return $result; 724 | } 725 | 726 | /** 727 | * Get the buffer dimensions. 728 | * 729 | * @return array{width: int, height: int} 730 | */ 731 | public function getDimensions(): array 732 | { 733 | return [ 734 | 'width' => $this->width, 735 | 'height' => $this->height, 736 | ]; 737 | } 738 | 739 | /** 740 | * Get buffer width. 741 | */ 742 | public function getWidth(): int 743 | { 744 | return $this->width; 745 | } 746 | 747 | /** 748 | * Get buffer height. 749 | */ 750 | public function getHeight(): int 751 | { 752 | return $this->height; 753 | } 754 | 755 | /** 756 | * Trim old rows if buffer exceeds max size. 757 | */ 758 | protected function trim(): void 759 | { 760 | // 95% chance of skipping (same as original Buffer) 761 | if (rand(1, 100) <= 95) { 762 | return; 763 | } 764 | 765 | $excess = $this->height - $this->maxRows; 766 | 767 | if ($excess > 0) { 768 | // Remove oldest rows 769 | array_splice($this->cells, 0, $excess * $this->width); 770 | $this->height -= $excess; 771 | $this->scrollOffset += $excess; 772 | 773 | // Update line sequence numbers 774 | $newLineSeqNos = []; 775 | foreach ($this->lineSeqNos as $row => $seqNo) { 776 | if ($row >= $excess) { 777 | $newLineSeqNos[$row - $excess] = $seqNo; 778 | } 779 | } 780 | $this->lineSeqNos = $newLineSeqNos; 781 | } 782 | } 783 | 784 | /** 785 | * Insert blank lines at the specified row. 786 | */ 787 | public function insertLines(int $atRow, int $count): void 788 | { 789 | $this->ensureRow($atRow); 790 | 791 | // Create blank cells for new lines 792 | $newCells = []; 793 | for ($i = 0; $i < $count * $this->width; $i++) { 794 | $newCells[] = Cell::blank(); 795 | } 796 | 797 | // Insert at the correct position 798 | $insertIndex = $atRow * $this->width; 799 | array_splice($this->cells, $insertIndex, 0, $newCells); 800 | 801 | $this->height += $count; 802 | 803 | // Shift lineSeqNos entries for rows >= $atRow up by $count. 804 | // Iterate in descending order to avoid overwriting entries. 805 | $keys = array_keys($this->lineSeqNos); 806 | rsort($keys, SORT_NUMERIC); 807 | foreach ($keys as $row) { 808 | if ($row >= $atRow) { 809 | $this->lineSeqNos[$row + $count] = $this->lineSeqNos[$row]; 810 | unset($this->lineSeqNos[$row]); 811 | } 812 | } 813 | 814 | // Mark all affected rows as dirty 815 | for ($row = $atRow; $row < $this->height; $row++) { 816 | $this->markLineDirty($row); 817 | } 818 | 819 | $this->trim(); 820 | } 821 | 822 | /** 823 | * Delete lines at the specified row. 824 | */ 825 | public function deleteLines(int $atRow, int $count): void 826 | { 827 | if (!$this->rowExists($atRow)) { 828 | return; 829 | } 830 | 831 | $count = min($count, $this->height - $atRow); 832 | $deleteIndex = $atRow * $this->width; 833 | $deleteCount = $count * $this->width; 834 | 835 | array_splice($this->cells, $deleteIndex, $deleteCount); 836 | $this->height -= $count; 837 | 838 | // Update lineSeqNos: remove entries for deleted rows and shift 839 | // entries for rows >= $atRow + $count down by $count. 840 | $newLineSeqNos = []; 841 | foreach ($this->lineSeqNos as $row => $seqNo) { 842 | if ($row < $atRow) { 843 | // Rows before deletion point are unchanged 844 | $newLineSeqNos[$row] = $seqNo; 845 | } elseif ($row >= $atRow + $count) { 846 | // Rows after deleted region shift down by $count 847 | $newLineSeqNos[$row - $count] = $seqNo; 848 | } 849 | // Rows in the deleted range [$atRow, $atRow + $count) are discarded 850 | } 851 | $this->lineSeqNos = $newLineSeqNos; 852 | 853 | // Mark all affected rows as dirty 854 | for ($row = $atRow; $row < $this->height; $row++) { 855 | $this->markLineDirty($row); 856 | } 857 | } 858 | 859 | /** 860 | * Scroll the buffer up by inserting lines at the bottom. 861 | */ 862 | public function scrollUp(int $lines = 1): void 863 | { 864 | // Capture original height before deletion 865 | $origHeight = $this->height; 866 | 867 | // Delete from top 868 | $this->deleteLines(0, $lines); 869 | 870 | // Insert blank rows at bottom to restore original height 871 | while ($this->height < $origHeight) { 872 | $this->ensureRow($this->height); 873 | } 874 | } 875 | 876 | /** 877 | * Scroll the buffer down by inserting lines at the top. 878 | */ 879 | public function scrollDown(int $lines = 1): void 880 | { 881 | $this->insertLines(0, $lines); 882 | 883 | // Trim excess from bottom if needed 884 | if ($this->height > $this->maxRows) { 885 | $excess = $this->height - $this->maxRows; 886 | $this->deleteLines($this->height - $excess, $excess); 887 | } 888 | } 889 | } 890 | -------------------------------------------------------------------------------- /src/Screen.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @link https://aaronfrancis.com 7 | * @link https://x.com/aarondfrancis 8 | */ 9 | 10 | namespace SoloTerm\Screen; 11 | 12 | use Closure; 13 | use Exception; 14 | use SoloTerm\Screen\Buffers\AnsiBuffer; 15 | use SoloTerm\Screen\Buffers\Buffer; 16 | use SoloTerm\Screen\Buffers\CellBuffer; 17 | use SoloTerm\Screen\Buffers\PrintableBuffer; 18 | use SoloTerm\Screen\Buffers\Proxy; 19 | use Stringable; 20 | 21 | class Screen 22 | { 23 | public AnsiBuffer $ansi; 24 | 25 | public PrintableBuffer $printable; 26 | 27 | public Proxy $buffers; 28 | 29 | public int $cursorRow = 0; 30 | 31 | public int $cursorCol = 0; 32 | 33 | public int $linesOffScreen = 0; 34 | 35 | public int $width; 36 | 37 | public int $height; 38 | 39 | protected ?Closure $respondVia = null; 40 | 41 | protected array $stashedCursor = []; 42 | 43 | /** 44 | * Monotonically increasing sequence number. Incremented on each buffer modification. 45 | * Used for differential rendering to track which lines have changed. 46 | */ 47 | protected int $seqNo = 0; 48 | 49 | /** 50 | * The sequence number at which output() was last called. 51 | * Used to determine which lines need re-rendering. 52 | */ 53 | protected int $lastRenderedSeqNo = 0; 54 | 55 | public function __construct(int $width, int $height) 56 | { 57 | $this->width = $width; 58 | $this->height = $height; 59 | 60 | $this->ansi = new AnsiBuffer; 61 | $this->printable = (new PrintableBuffer)->setWidth($width); 62 | $this->buffers = new Proxy([ 63 | $this->ansi, 64 | $this->printable 65 | ]); 66 | 67 | // Wire up sequence number provider to both buffers 68 | $seqNoProvider = fn() => ++$this->seqNo; 69 | $this->ansi->setSeqNoProvider($seqNoProvider); 70 | $this->printable->setSeqNoProvider($seqNoProvider); 71 | } 72 | 73 | /** 74 | * Get the current sequence number. 75 | */ 76 | public function getSeqNo(): int 77 | { 78 | return $this->seqNo; 79 | } 80 | 81 | /** 82 | * Get the sequence number at which output() was last called. 83 | */ 84 | public function getLastRenderedSeqNo(): int 85 | { 86 | return $this->lastRenderedSeqNo; 87 | } 88 | 89 | public function respondToQueriesVia(Closure $closure): static 90 | { 91 | $this->respondVia = $closure; 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Generate output string from the screen buffer. 98 | * 99 | * When $sinceSeqNo is null (default), outputs all lines joined by newlines. 100 | * This is the original behavior for full screen rendering. 101 | * 102 | * When $sinceSeqNo is provided, only outputs lines that have changed since 103 | * that sequence number, with cursor positioning for each changed line. 104 | * This enables differential rendering for significant performance gains. 105 | * 106 | * @param int|null $sinceSeqNo Only render lines changed after this sequence number 107 | * @return string The rendered output 108 | */ 109 | public function output(?int $sinceSeqNo = null): string 110 | { 111 | // Update last rendered sequence number 112 | $this->lastRenderedSeqNo = $this->seqNo; 113 | 114 | // Differential rendering mode - optimized path 115 | if ($sinceSeqNo !== null) { 116 | return $this->outputDifferential($sinceSeqNo); 117 | } 118 | 119 | // Full rendering mode (original behavior) 120 | $ansi = $this->ansi->compressedAnsiBuffer(); 121 | $printable = $this->printable->getBuffer(); 122 | 123 | return $this->outputFull($ansi, $printable); 124 | } 125 | 126 | /** 127 | * Render all lines using relative cursor positioning. 128 | * 129 | * Uses DECSC/DECRC (save/restore cursor) combined with CUD (cursor down) 130 | * to position each line relative to where the caller placed the cursor. 131 | * This approach: 132 | * - Avoids "pending wrap" issues (different terminals handle full-width 133 | * lines differently when using \n) 134 | * - Uses relative positioning so Screen output can be rendered at any 135 | * offset in a parent TUI (unlike \r which always goes to column 1) 136 | * - Never uses \n between lines, so wrap semantics don't matter 137 | */ 138 | protected function outputFull(array $ansi, array $printable): string 139 | { 140 | $parts = []; 141 | 142 | // Save the caller's current cursor position as the Screen origin. 143 | // DECSC (DEC Save Cursor) - ESC 7 144 | $parts[] = "\0337"; 145 | 146 | foreach ($printable as $lineIndex => $line) { 147 | $visibleRow = $lineIndex - $this->linesOffScreen + 1; 148 | 149 | if ($visibleRow < 1 || $visibleRow > $this->height) { 150 | continue; 151 | } 152 | 153 | // Restore to origin (top-left of this Screen in the parent TUI). 154 | // DECRC (DEC Restore Cursor) - ESC 8 155 | $parts[] = "\0338"; 156 | 157 | // Move down to this line's row (relative) from the origin. 158 | // visibleRow is 1-based, so visibleRow=1 is the origin row (no movement needed). 159 | if ($visibleRow > 1) { 160 | $parts[] = "\033[" . ($visibleRow - 1) . 'B'; // CUD (cursor down) 161 | } 162 | 163 | // Render the line content. No newline afterwards. 164 | $parts[] = $this->renderLine($lineIndex, $line, $ansi[$lineIndex] ?? []); 165 | } 166 | 167 | return implode('', $parts); 168 | } 169 | 170 | /** 171 | * Render only lines that changed since the given sequence number. 172 | * Each line is prefixed with a cursor positioning escape sequence. 173 | * 174 | * This method is optimized to only process changed rows, avoiding 175 | * the O(rows × cols) cost of processing the entire buffer. 176 | */ 177 | protected function outputDifferential(int $sinceSeqNo): string 178 | { 179 | // Get changed rows from the printable buffer (which tracks all writes) 180 | $changedRows = $this->printable->getChangedRows($sinceSeqNo); 181 | 182 | // Early return if nothing changed 183 | if (empty($changedRows)) { 184 | return ''; 185 | } 186 | 187 | $parts = []; 188 | $printable = $this->printable->getBuffer(); 189 | 190 | // Only compute ANSI for changed rows 191 | foreach ($changedRows as $lineIndex) { 192 | // Skip rows that don't exist in the buffer 193 | if (!isset($printable[$lineIndex])) { 194 | continue; 195 | } 196 | 197 | // Calculate visible row (1-based, accounting for scroll offset) 198 | $visibleRow = $lineIndex - $this->linesOffScreen + 1; 199 | 200 | // Skip rows that are scrolled off screen 201 | if ($visibleRow < 1 || $visibleRow > $this->height) { 202 | continue; 203 | } 204 | 205 | $line = $printable[$lineIndex]; 206 | 207 | // Compute compressed ANSI only for this specific line 208 | $ansiForLine = $this->compressAnsiForLine($lineIndex); 209 | 210 | // Position cursor at start of this line, then render 211 | $parts[] = "\033[{$visibleRow};1H"; 212 | $parts[] = $this->renderLine($lineIndex, $line, $ansiForLine); 213 | // Clear to end of line to handle shortened content 214 | $parts[] = "\033[K"; 215 | } 216 | 217 | return implode('', $parts); 218 | } 219 | 220 | /** 221 | * Compute compressed ANSI codes for a single line. 222 | * This is an optimized version of compressedAnsiBuffer() for one row. 223 | */ 224 | protected function compressAnsiForLine(int $lineIndex): array 225 | { 226 | $line = $this->ansi->buffer[$lineIndex] ?? []; 227 | 228 | if (empty($line)) { 229 | return []; 230 | } 231 | 232 | // Reset on each line for safety (in case previous lines aren't visible) 233 | $previousCell = [0, null, null]; 234 | 235 | return array_filter(array_map(function ($cell) use (&$previousCell) { 236 | if (is_int($cell)) { 237 | $cell = [$cell, null, null]; 238 | } 239 | 240 | $uniqueBits = $cell[0] & ~$previousCell[0]; 241 | $turnedOffBits = $previousCell[0] & ~$cell[0]; 242 | 243 | $resetCodes = []; 244 | $turnedOffCodes = $this->ansi->ansiCodesFromBits($turnedOffBits); 245 | 246 | foreach ($turnedOffCodes as $code) { 247 | if ($code >= 30 && $code <= 39 || $code >= 90 && $code <= 97) { 248 | $resetCodes[] = 39; 249 | } elseif ($code >= 40 && $code <= 49 || $code >= 100 && $code <= 107) { 250 | $resetCodes[] = 49; 251 | } elseif ($code >= 1 && $code <= 9) { 252 | // Map decoration codes to their resets 253 | $decorationResets = [1 => 22, 2 => 22, 3 => 23, 4 => 24, 5 => 25, 7 => 27, 8 => 28, 9 => 29]; 254 | if (isset($decorationResets[$code])) { 255 | $resetCodes[] = $decorationResets[$code]; 256 | } 257 | } 258 | } 259 | 260 | $uniqueCodes = $this->ansi->ansiCodesFromBits($uniqueBits); 261 | 262 | // Extended foreground changed 263 | if ($previousCell[1] !== $cell[1]) { 264 | if ($previousCell[1] !== null && $cell[1] === null) { 265 | $resetCodes[] = 39; 266 | } elseif ($cell[1] !== null) { 267 | $uniqueCodes[] = implode(';', [38, ...$cell[1]]); 268 | } 269 | } 270 | 271 | // Extended background changed 272 | if ($previousCell[2] !== $cell[2]) { 273 | if ($previousCell[2] !== null && $cell[2] === null) { 274 | $resetCodes[] = 49; 275 | } elseif ($cell[2] !== null) { 276 | $uniqueCodes[] = implode(';', [48, ...$cell[2]]); 277 | } 278 | } 279 | 280 | $previousCell = $cell; 281 | 282 | $allCodes = array_unique(array_merge($resetCodes, $uniqueCodes)); 283 | 284 | return count($allCodes) ? ("\e[" . implode(';', $allCodes) . 'm') : ''; 285 | }, $line)); 286 | } 287 | 288 | /** 289 | * Render a single line by merging printable characters with ANSI codes. 290 | */ 291 | protected function renderLine(int $lineIndex, array $line, array $ansiForLine): string 292 | { 293 | $lineStr = ''; 294 | 295 | for ($col = 0; $col < count($line); $col++) { 296 | $lineStr .= ($ansiForLine[$col] ?? '') . $line[$col]; 297 | } 298 | 299 | return $lineStr; 300 | } 301 | 302 | public function write(string $content): static 303 | { 304 | // Backspace character gets replaced with "move one column backwards." 305 | // Carriage returns get replaced with a code to move to column 0. 306 | $content = str_replace( 307 | search: ["\x08", "\r"], 308 | replace: ["\e[D", "\e[G"], 309 | subject: $content 310 | ); 311 | 312 | // Split the line by ANSI codes using the fast state machine parser. 313 | // Each item in the resulting array will be a set of printable characters 314 | // or a ParsedAnsi object. 315 | $parts = AnsiParser::parseFast($content); 316 | 317 | foreach ($parts as $part) { 318 | if ($part instanceof Stringable) { 319 | // ParsedAnsi or AnsiMatch object 320 | if ($part->command) { 321 | $this->handleAnsiCode($part); 322 | } 323 | } else { 324 | if ($part === '') { 325 | continue; 326 | } 327 | 328 | $lines = explode(PHP_EOL, $part); 329 | $linesCount = count($lines); 330 | 331 | foreach ($lines as $index => $line) { 332 | $this->handlePrintableCharacters($line); 333 | 334 | if ($index < $linesCount - 1) { 335 | $this->newlineWithScroll(); 336 | } 337 | } 338 | } 339 | } 340 | 341 | return $this; 342 | } 343 | 344 | public function writeln(string $content): void 345 | { 346 | if ($this->cursorCol === 0) { 347 | $this->write("$content\n"); 348 | } else { 349 | $this->write("\n$content\n"); 350 | } 351 | } 352 | 353 | /** 354 | * Handle an ANSI escape code. 355 | * 356 | * @param AnsiMatch|ParsedAnsi $ansi The parsed ANSI sequence 357 | */ 358 | protected function handleAnsiCode(AnsiMatch|ParsedAnsi $ansi) 359 | { 360 | $command = $ansi->command; 361 | $param = $ansi->params; 362 | 363 | // Some commands have a default of zero and some have a default of one. Just 364 | // make both options and decide within the body of the if statement. 365 | // We could do a match here but it doesn't seem worth it. 366 | $paramDefaultZero = ($param !== '' && is_numeric($param)) ? intval($param) : 0; 367 | $paramDefaultOne = ($param !== '' && is_numeric($param)) ? intval($param) : 1; 368 | 369 | if ($command === 'A') { 370 | // Cursor up 371 | $this->moveCursorRow(relative: -$paramDefaultOne); 372 | 373 | } elseif ($command === 'B') { 374 | // Cursor down 375 | $this->moveCursorRow(relative: $paramDefaultOne); 376 | 377 | } elseif ($command === 'C') { 378 | // Cursor forward 379 | $this->moveCursorCol(relative: $paramDefaultOne); 380 | 381 | } elseif ($command === 'D') { 382 | // Cursor backward 383 | $this->moveCursorCol(relative: -$paramDefaultOne); 384 | 385 | } elseif ($command === 'E') { 386 | // Cursor to beginning of line, a number of lines down 387 | $this->moveCursorRow(relative: $paramDefaultOne); 388 | $this->moveCursorCol(absolute: 0); 389 | 390 | } elseif ($command === 'F') { 391 | // Cursor to beginning of line, a number of lines up 392 | $this->moveCursorRow(relative: -$paramDefaultOne); 393 | $this->moveCursorCol(absolute: 0); 394 | 395 | } elseif ($command === 'G') { 396 | // Cursor to column #, accounting for one-based indexing. 397 | $this->moveCursorCol($paramDefaultOne - 1); 398 | 399 | } elseif ($command === 'H') { 400 | $this->handleAbsoluteMove($ansi->params); 401 | 402 | } elseif ($command === 'I') { 403 | $this->handleTabulationMove($paramDefaultOne); 404 | 405 | } elseif ($command === 'J') { 406 | $this->handleEraseDisplay($paramDefaultZero); 407 | 408 | } elseif ($command === 'K') { 409 | $this->handleEraseInLine($paramDefaultZero); 410 | 411 | } elseif ($command === 'L') { 412 | $this->handleInsertLines($paramDefaultOne); 413 | 414 | } elseif ($command === 'S') { 415 | $this->handleScrollUp($paramDefaultOne); 416 | 417 | } elseif ($command === 'T') { 418 | $this->handleScrollDown($paramDefaultOne); 419 | 420 | } elseif ($command === 'l' || $command === 'h') { 421 | // Show/hide cursor. We simply ignore these. 422 | 423 | } elseif ($command === 'm') { 424 | // Colors / graphics mode 425 | $this->handleSGR($param); 426 | 427 | } elseif ($command === '7') { 428 | $this->saveCursor(); 429 | 430 | } elseif ($command === '8') { 431 | $this->restoreCursor(); 432 | 433 | } elseif ($param === '?' && in_array($command, ['10', '11'])) { 434 | // Ask for the foreground or background color. 435 | $this->handleQueryCode($command, $param); 436 | 437 | } elseif ($command === 'n' && $param === '6') { 438 | // Ask for the cursor position. 439 | $this->handleQueryCode($command, $param); 440 | } 441 | 442 | // @TODO Unhandled ansi command. Throw an error? Log it? 443 | } 444 | 445 | protected function newlineWithScroll() 446 | { 447 | if (($this->cursorRow - $this->linesOffScreen) >= $this->height - 1) { 448 | $this->linesOffScreen++; 449 | // Mark all visible rows dirty since their visual positions changed 450 | $this->markVisibleRowsDirty(); 451 | } 452 | 453 | $this->moveCursorRow(relative: 1); 454 | $this->moveCursorCol(absolute: 0); 455 | } 456 | 457 | protected function handlePrintableCharacters(string $text): void 458 | { 459 | if ($text === '') { 460 | return; 461 | } 462 | 463 | $this->printable->expand($this->cursorRow); 464 | 465 | [$advance, $remainder] = $this->printable->writeString($this->cursorRow, $this->cursorCol, $text); 466 | 467 | $this->ansi->fillBufferWithActiveFlags($this->cursorRow, $this->cursorCol, $this->cursorCol + $advance - 1); 468 | 469 | $this->cursorCol += $advance; 470 | 471 | // If there's overflow (i.e. text that didn't fit on this line), 472 | // move to a new line and recursively handle it. 473 | if ($remainder !== '') { 474 | $this->newlineWithScroll(); 475 | $this->handlePrintableCharacters($remainder); 476 | } 477 | } 478 | 479 | public function saveCursor() 480 | { 481 | $this->stashedCursor = [ 482 | $this->cursorCol, 483 | $this->cursorRow - $this->linesOffScreen 484 | ]; 485 | } 486 | 487 | public function restoreCursor() 488 | { 489 | if ($this->stashedCursor) { 490 | [$col, $row] = $this->stashedCursor; 491 | $this->moveCursorCol(absolute: $col); 492 | $this->moveCursorRow(absolute: $row); 493 | $this->stashedCursor = []; 494 | } 495 | } 496 | 497 | public function moveCursorCol(?int $absolute = null, ?int $relative = null) 498 | { 499 | $this->ensureCursorParams($absolute, $relative); 500 | 501 | // Inside this method, position is zero-based. 502 | 503 | $max = $this->width; 504 | $min = 0; 505 | 506 | $position = $this->cursorCol; 507 | 508 | if (!is_null($absolute)) { 509 | $position = $absolute; 510 | } 511 | 512 | if (!is_null($relative)) { 513 | // Relative movements cannot put the cursor at the very end, only absolute 514 | // movements can. Not sure why, but I verified the behavior manually. 515 | $max -= 1; 516 | $position += $relative; 517 | } 518 | 519 | $position = min($position, $max); 520 | $position = max($min, $position); 521 | 522 | $this->cursorCol = $position; 523 | } 524 | 525 | public function moveCursorRow(?int $absolute = null, ?int $relative = null) 526 | { 527 | $this->ensureCursorParams($absolute, $relative); 528 | 529 | $max = $this->height + $this->linesOffScreen - 1; 530 | $min = $this->linesOffScreen; 531 | 532 | $position = $this->cursorRow; 533 | 534 | if (!is_null($absolute)) { 535 | $position = $absolute + $this->linesOffScreen; 536 | } 537 | 538 | if (!is_null($relative)) { 539 | $position += $relative; 540 | } 541 | 542 | $position = min($position, $max); 543 | $position = max($min, $position); 544 | 545 | $this->cursorRow = $position; 546 | 547 | $this->printable->expand($this->cursorRow); 548 | } 549 | 550 | protected function moveCursor(string $direction, ?int $absolute = null, ?int $relative = null): void 551 | { 552 | $this->ensureCursorParams($absolute, $relative); 553 | 554 | $property = $direction === 'x' ? 'cursorCol' : 'cursorRow'; 555 | $max = $direction === 'x' ? $this->width : ($this->height + $this->linesOffScreen); 556 | $min = $direction === 'x' ? 0 : $this->linesOffScreen; 557 | 558 | if (!is_null($absolute)) { 559 | $this->{$property} = $absolute; 560 | } 561 | 562 | if (!is_null($relative)) { 563 | $this->{$property} += $relative; 564 | } 565 | 566 | $this->{$property} = min( 567 | max($this->{$property}, $min), 568 | $max - 1 569 | ); 570 | } 571 | 572 | protected function ensureCursorParams($absolute, $relative): void 573 | { 574 | if (!is_null($absolute) && !is_null($relative)) { 575 | throw new Exception('Use either relative or absolute, but not both.'); 576 | } 577 | 578 | if (is_null($absolute) && is_null($relative)) { 579 | throw new Exception('Relative and absolute cannot both be blank.'); 580 | } 581 | } 582 | 583 | /** 584 | * Handle SGR (Select Graphic Rendition) ANSI codes for colors and styles. 585 | */ 586 | protected function handleSGR(string $params): void 587 | { 588 | // Support multiple codes, like \e[30;41m 589 | $codes = array_map(intval(...), explode(';', $params)); 590 | 591 | $this->ansi->addAnsiCodes(...$codes); 592 | } 593 | 594 | protected function handleTabulationMove(int $tabs) 595 | { 596 | $tabStop = 8; 597 | 598 | // If current column isn't at a tab stop, move to the next one. 599 | $remainder = $this->cursorCol % $tabStop; 600 | if ($remainder !== 0) { 601 | $this->cursorCol += ($tabStop - $remainder); 602 | $tabs--; // one tab stop consumed 603 | } 604 | 605 | // For any remaining tabs, move by full tab stops. 606 | if ($tabs > 0) { 607 | $this->cursorCol += $tabs * $tabStop; 608 | } 609 | } 610 | 611 | protected function handleAbsoluteMove(string $params) 612 | { 613 | if ($params !== '') { 614 | [$row, $col] = explode(';', $params); 615 | $row = $row === '' ? 1 : intval($row); 616 | $col = $col === '' ? 1 : intval($col); 617 | } else { 618 | $row = 1; 619 | $col = 1; 620 | } 621 | 622 | // ANSI codes are 1-based, while our system is 0-based. 623 | $this->moveCursorRow(absolute: --$row); 624 | $this->moveCursorCol(absolute: --$col); 625 | } 626 | 627 | protected function handleEraseDisplay(int $param): void 628 | { 629 | if ($param === 0) { 630 | // \e[0J - Erase from cursor until end of screen 631 | $this->buffers->clear( 632 | startRow: $this->cursorRow, 633 | startCol: $this->cursorCol 634 | ); 635 | } elseif ($param === 1) { 636 | // \e[1J - Erase from cursor until beginning of screen 637 | $this->buffers->clear( 638 | startRow: $this->linesOffScreen, 639 | endRow: $this->cursorRow, 640 | endCol: $this->cursorCol 641 | ); 642 | } elseif ($param === 2) { 643 | // \e[2J - Erase entire screen 644 | $this->buffers->clear( 645 | startRow: $this->linesOffScreen, 646 | endRow: $this->linesOffScreen + $this->height, 647 | ); 648 | } 649 | } 650 | 651 | protected function handleInsertLines(int $lines): void 652 | { 653 | $allowed = $this->height - ($this->cursorRow - $this->linesOffScreen); 654 | $afterCursor = $lines + count($this->printable->buffer) - $this->cursorRow; 655 | 656 | $chop = $afterCursor - $allowed; 657 | 658 | // Ensure the buffer has enough rows so that $this->cursorRow is defined. 659 | if (!isset($this->printable->buffer[$this->cursorRow])) { 660 | $this->printable->expand($this->cursorRow); 661 | } 662 | 663 | if (!isset($this->ansi->buffer[$this->cursorRow])) { 664 | $this->ansi->expand($this->cursorRow); 665 | } 666 | 667 | // Create an array of $lines empty arrays. 668 | $newLines = array_fill(0, $lines, []); 669 | 670 | // Insert the new lines at the cursor row index. 671 | // array_splice will insert these new arrays and push the existing rows down. 672 | array_splice($this->printable->buffer, $this->cursorRow, 0, $newLines); 673 | array_splice($this->ansi->buffer, $this->cursorRow, 0, $newLines); 674 | 675 | if ($chop > 0) { 676 | array_splice($this->printable->buffer, -$chop); 677 | array_splice($this->ansi->buffer, -$chop); 678 | } 679 | 680 | // Mark all visible rows as dirty since insert/scroll affects them all 681 | $this->markVisibleRowsDirty(); 682 | } 683 | 684 | /** 685 | * Mark all rows in the visible area as dirty. 686 | * Used after scroll/insert operations that shift content. 687 | */ 688 | protected function markVisibleRowsDirty(): void 689 | { 690 | $startRow = $this->linesOffScreen; 691 | $endRow = $this->linesOffScreen + $this->height; 692 | 693 | for ($row = $startRow; $row < $endRow; $row++) { 694 | $this->printable->markLineDirty($row); 695 | } 696 | } 697 | 698 | protected function handleScrollDown(int $param): void 699 | { 700 | $stash = $this->cursorRow; 701 | 702 | $this->cursorRow = $this->linesOffScreen; 703 | 704 | $this->handleInsertLines($param); 705 | 706 | $this->cursorRow = $stash; 707 | } 708 | 709 | protected function handleScrollUp(int $param): void 710 | { 711 | $stash = $this->cursorRow; 712 | 713 | $this->printable->expand($this->height); 714 | 715 | $this->cursorRow = count($this->printable->buffer) + $param - 1; 716 | 717 | $this->handleInsertLines($param); 718 | 719 | $this->linesOffScreen += $param; 720 | 721 | $this->cursorRow = $stash + $param; 722 | } 723 | 724 | protected function handleEraseInLine(int $param): void 725 | { 726 | if ($param === 0) { 727 | // \e[0K - Erase from cursor to end of line 728 | $this->buffers->clear( 729 | startRow: $this->cursorRow, 730 | startCol: $this->cursorCol, 731 | endRow: $this->cursorRow 732 | ); 733 | 734 | $background = $this->ansi->getActiveBackground(); 735 | 736 | if ($background !== 0) { 737 | $this->printable->fill(' ', $this->cursorRow, $this->cursorCol, $this->width - 1); 738 | $this->ansi->fill($background, $this->cursorRow, $this->cursorCol, $this->width - 1); 739 | } 740 | } elseif ($param == 1) { 741 | // \e[1K - Erase start of line to the cursor 742 | $this->buffers->clear( 743 | startRow: $this->cursorRow, 744 | endRow: $this->cursorRow, 745 | endCol: $this->cursorCol 746 | ); 747 | } elseif ($param === 2) { 748 | // \e[2K - Erase the entire line 749 | $this->buffers->clear( 750 | startRow: $this->cursorRow, 751 | endRow: $this->cursorRow 752 | ); 753 | } 754 | } 755 | 756 | protected function handleQueryCode(string $command, string $param): void 757 | { 758 | if (!is_callable($this->respondVia)) { 759 | return; 760 | } 761 | 762 | $response = match ($param . $command) { 763 | // Foreground color 764 | // @TODO not hardcode this, somehow 765 | '?10' => "\e]10;rgb:0000/0000/0000 \e \\", 766 | // Background 767 | '?11' => "\e]11;rgb:FFFF/FFFF/FFFF \e \\", 768 | // Cursor 769 | '6n' => "\e[" . ($this->cursorRow + 1) . ';' . ($this->cursorCol + 1) . 'R', 770 | default => null, 771 | }; 772 | 773 | if ($response) { 774 | call_user_func($this->respondVia, $response); 775 | } 776 | } 777 | 778 | /** 779 | * Convert the visible portion of the screen to a CellBuffer. 780 | * 781 | * This enables value-based comparison between frames for 782 | * differential rendering, comparing actual cell content 783 | * rather than just tracking which cells were written. 784 | * 785 | * @param CellBuffer|null $targetBuffer Optional existing buffer to write into 786 | * @return CellBuffer The buffer containing the visible screen content 787 | */ 788 | public function toCellBuffer(?CellBuffer $targetBuffer = null): CellBuffer 789 | { 790 | $buffer = $targetBuffer ?? new CellBuffer($this->width, $this->height); 791 | $printable = $this->printable->getBuffer(); 792 | 793 | // Only convert the visible portion of the screen 794 | for ($row = 0; $row < $this->height; $row++) { 795 | $bufferRow = $row + $this->linesOffScreen; 796 | 797 | $printableLine = $printable[$bufferRow] ?? []; 798 | $ansiLine = $this->ansi->buffer[$bufferRow] ?? []; 799 | 800 | for ($col = 0; $col < $this->width; $col++) { 801 | $char = $printableLine[$col] ?? ' '; 802 | 803 | // Get raw ANSI cell data 804 | $ansiCell = $ansiLine[$col] ?? 0; 805 | 806 | // Parse the ANSI cell 807 | if (is_int($ansiCell)) { 808 | $bits = $ansiCell; 809 | $extFg = null; 810 | $extBg = null; 811 | } else { 812 | $bits = $ansiCell[0] ?? 0; 813 | $extFg = $ansiCell[1] ?? null; 814 | $extBg = $ansiCell[2] ?? null; 815 | } 816 | 817 | // Convert to Cell - extract style, fg, bg from the bitmask 818 | [$style, $fg, $bg] = $this->extractStyleFromBits($bits); 819 | 820 | $cell = new Cell($char, $style, $fg, $bg, $extFg, $extBg); 821 | $buffer->setCell($row, $col, $cell); 822 | } 823 | } 824 | 825 | return $buffer; 826 | } 827 | 828 | /** 829 | * Extract Cell-compatible style, foreground, and background from AnsiBuffer bitmask. 830 | * 831 | * @param int $bits The AnsiBuffer bitmask 832 | * @return array{int, int|null, int|null} [style, fg, bg] 833 | */ 834 | protected function extractStyleFromBits(int $bits): array 835 | { 836 | // The AnsiBuffer assigns bits to codes in the order they're added to $supported: 837 | // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 22-29, 30-39, 40-49, 90-97, 100-107 838 | // Each gets a sequential bit (1, 2, 4, 8, 16, ...) 839 | 840 | $style = 0; 841 | $fg = null; 842 | $bg = null; 843 | 844 | // Build the code-to-bit mapping (matching AnsiBuffer::initialize) 845 | $supported = [ 846 | 0, // bit 0 (value 1) 847 | 1, 2, 3, 4, 5, 6, 7, 8, 9, // bits 1-9 (values 2, 4, 8, ...) 848 | 22, 23, 24, 25, 26, 27, 28, 29, // decoration resets 849 | 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, // foreground 850 | 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, // background 851 | 90, 91, 92, 93, 94, 95, 96, 97, // bright foreground 852 | 100, 101, 102, 103, 104, 105, 106, 107, // bright background 853 | ]; 854 | 855 | $codesBits = []; 856 | foreach ($supported as $i => $code) { 857 | $codesBits[$code] = 1 << $i; 858 | } 859 | 860 | // Extract decoration style (codes 1-9 -> Cell style bits 0-8) 861 | for ($code = 1; $code <= 9; $code++) { 862 | if (isset($codesBits[$code]) && ($bits & $codesBits[$code])) { 863 | $style |= (1 << ($code - 1)); 864 | } 865 | } 866 | 867 | // Extract foreground color (30-37, 90-97) 868 | foreach ([...range(30, 37), ...range(90, 97)] as $code) { 869 | if (isset($codesBits[$code]) && ($bits & $codesBits[$code])) { 870 | $fg = $code; 871 | break; 872 | } 873 | } 874 | 875 | // Extract background color (40-47, 100-107) 876 | foreach ([...range(40, 47), ...range(100, 107)] as $code) { 877 | if (isset($codesBits[$code]) && ($bits & $codesBits[$code])) { 878 | $bg = $code; 879 | break; 880 | } 881 | } 882 | 883 | return [$style, $fg, $bg]; 884 | } 885 | } 886 | --------------------------------------------------------------------------------