├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpbench.json ├── phpstan.neon ├── phpunit.xml └── src ├── Action.php ├── Action ├── AlternateScreenEnable.php ├── Clear.php ├── CursorShow.php ├── EnableCursorBlinking.php ├── EnableLineWrap.php ├── EnableMouseCapture.php ├── MoveCursor.php ├── MoveCursorDown.php ├── MoveCursorLeft.php ├── MoveCursorNextLine.php ├── MoveCursorPrevLine.php ├── MoveCursorRight.php ├── MoveCursorToColumn.php ├── MoveCursorToRow.php ├── MoveCursorUp.php ├── PrintString.php ├── RequestCursorPosition.php ├── Reset.php ├── RestoreCursorPosition.php ├── SaveCursorPosition.php ├── ScrollDown.php ├── ScrollUp.php ├── SetBackgroundColor.php ├── SetCursorStyle.php ├── SetForegroundColor.php ├── SetModifier.php ├── SetRgbBackgroundColor.php ├── SetRgbForegroundColor.php └── SetTerminalTitle.php ├── Actions.php ├── AnsiParser.php ├── Attribute.php ├── ClearType.php ├── Colors.php ├── Colors256.php ├── CursorStyle.php ├── Event.php ├── Event ├── CharKeyEvent.php ├── CodedKeyEvent.php ├── CursorPositionEvent.php ├── FocusEvent.php ├── FunctionKeyEvent.php ├── KeyEvent.php ├── MouseEvent.php └── TerminalResizedEvent.php ├── EventParser.php ├── EventProvider.php ├── EventProvider ├── AggregateEventProvider.php ├── ArrayEventProvider.php ├── SignalEventProvider.php └── SyncTtyEventProvider.php ├── Focus.php ├── InformationProvider.php ├── InformationProvider ├── AggregateInformationProvider.php ├── ClosureInformationProvider.php ├── SizeFromEnvVarProvider.php └── SizeFromSttyProvider.php ├── KeyCode.php ├── KeyEventKind.php ├── KeyModifiers.php ├── MouseButton.php ├── MouseEventKind.php ├── Painter.php ├── Painter ├── AnsiPainter.php ├── ArrayPainter.php └── StringPainter.php ├── ParseError.php ├── ProcessResult.php ├── ProcessRunner.php ├── ProcessRunner ├── ClosureRunner.php └── ProcRunner.php ├── RawMode.php ├── RawMode ├── SttyRawMode.php └── TestRawMode.php ├── Reader.php ├── Reader ├── ArrayReader.php └── StreamReader.php ├── Terminal.php ├── TerminalInformation.php ├── TerminalInformation └── Size.php ├── Writer.php └── Writer ├── StreamWriter.php └── StringWriter.php /.gitattributes: -------------------------------------------------------------------------------- 1 | *.php diff=php 2 | /example export-ignore 3 | /tests export-ignore 4 | /script export-ignore 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'main' 8 | 9 | env: 10 | fail-fast: true 11 | TZ: "Europe/Paris" 12 | 13 | jobs: 14 | phpunit: 15 | name: "PHPUnit (${{ matrix.php-version }})" 16 | 17 | runs-on: "ubuntu-latest" 18 | 19 | strategy: 20 | matrix: 21 | php-version: 22 | - '8.1' 23 | - '8.2' 24 | 25 | steps: 26 | - 27 | name: "Checkout code" 28 | uses: "actions/checkout@v2" 29 | - 30 | name: "Install PHP" 31 | uses: "shivammathur/setup-php@v2" 32 | with: 33 | coverage: "none" 34 | extensions: "${{ env.REQUIRED_PHP_EXTENSIONS }}" 35 | php-version: "${{ matrix.php-version }}" 36 | tools: composer:v2 37 | 38 | - 39 | name: "Composer install" 40 | uses: "ramsey/composer-install@v1" 41 | with: 42 | composer-options: "--no-scripts" 43 | - 44 | name: "Run PHPUnit" 45 | run: "php -dzend.assertions=1 vendor/bin/phpunit" 46 | phpstan: 47 | name: "PHPStan (${{ matrix.php-version }})" 48 | 49 | runs-on: "ubuntu-latest" 50 | 51 | strategy: 52 | matrix: 53 | php-version: 54 | - '8.1' 55 | 56 | steps: 57 | - 58 | name: "Checkout code" 59 | uses: "actions/checkout@v2" 60 | - 61 | name: "Install PHP" 62 | uses: "shivammathur/setup-php@v2" 63 | with: 64 | coverage: "none" 65 | extensions: "${{ env.REQUIRED_PHP_EXTENSIONS }}" 66 | php-version: "${{ matrix.php-version }}" 67 | tools: composer:v2 68 | 69 | - 70 | name: "Composer install" 71 | uses: "ramsey/composer-install@v1" 72 | with: 73 | composer-options: "--no-scripts" 74 | - 75 | name: "Run PHPStan" 76 | run: "vendor/bin/phpstan analyse" 77 | php-cs-fixer: 78 | name: "PHP-CS-Fixer (${{ matrix.php-version }})" 79 | 80 | runs-on: "ubuntu-latest" 81 | 82 | strategy: 83 | matrix: 84 | php-version: 85 | - '8.1' 86 | steps: 87 | - 88 | name: "Checkout code" 89 | uses: "actions/checkout@v2" 90 | - 91 | name: "Install PHP" 92 | uses: "shivammathur/setup-php@v2" 93 | with: 94 | coverage: "none" 95 | extensions: "${{ env.REQUIRED_PHP_EXTENSIONS }}" 96 | php-version: "${{ matrix.php-version }}" 97 | tools: composer:v2 98 | 99 | - 100 | name: "Composer install" 101 | uses: "ramsey/composer-install@v1" 102 | with: 103 | composer-options: "--no-scripts" 104 | - 105 | name: "Run PHP-CS_Fixer" 106 | run: "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff" 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.phpunit.cache 4 | /.php-cs-fixer.cache 5 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in('src') 8 | ->in('tests') 9 | ; 10 | 11 | return (new Config()) 12 | ->setRiskyAllowed(true) 13 | ->setRules([ 14 | '@PSR2' => true, 15 | 'final_class' => true, 16 | 'no_unused_imports' => true, 17 | 'phpdoc_to_property_type' => true, 18 | 'no_superfluous_phpdoc_tags' => [ 19 | 'remove_inheritdoc' => true, 20 | 'allow_mixed' => true, 21 | ], 22 | 'class_attributes_separation' => [ 23 | 'elements' => [ 24 | 'const' => 'only_if_meta', 25 | 'property' => 'one', 26 | 'trait_import' => 'only_if_meta', 27 | ], 28 | ], 29 | 'ordered_class_elements' => true, 30 | 'no_empty_phpdoc' => true, 31 | 'phpdoc_trim' => true, 32 | 'array_syntax' => ['syntax' => 'short'], 33 | 'list_syntax' => ['syntax' => 'short'], 34 | 'void_return' => true, 35 | 'ordered_class_elements' => true, 36 | 'single_quote' => true, 37 | 'heredoc_indentation' => true, 38 | 'global_namespace_import' => true, 39 | 'no_trailing_whitespace' => true, 40 | 'no_whitespace_in_blank_line' => true, 41 | ]) 42 | ->setFinder($finder) 43 | ; 44 | 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | ## 0.3.4 5 | 6 | - Bug fix: disable mouse capture uses wrong ansi codes #9 7 | 8 | ## 0.3.3 9 | 10 | - Bug fix: use `stream_get_contents` and check for empty string #199 11 | 12 | ## 0.3.2 13 | 14 | - Bug fix: fix alternate output style from STTY #185 15 | 16 | ## 0.3.1 17 | 18 | - Bug fix: fix Stty size handler when no match 19 | 20 | ## 0.3.0 21 | 22 | - Moved `Size` class to it's own namespace and added int types 23 | 24 | ## 0.2.0 25 | 26 | - Support variadics on queue() and execute() #14 27 | - Add bounded int types to API 28 | - Removed the HTMLCanvas painter #2 29 | - Renamed LoadedEventProvider => ArrayEventProvider 30 | - Renamed InMemoryReder => ArrayReader 31 | - Renamed BufferWriter => StringWriter 32 | - Renamed BufferPainter => ArrayPainter 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Daniel Leech 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 furnished 10 | 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP Term 2 | ======== 3 | 4 | [![CI](https://github.com/php-tui/term/actions/workflows/ci.yml/badge.svg)](https://github.com/php-tui/term/actions/workflows/ci.yml) 5 | 6 |

7 | Term Logo 8 |

9 | 10 | Low-level terminal control library **heavily** inspired by 11 | [crossterm](https://github.com/crossterm-rs/crossterm). 12 | 13 | Table of Contents 14 | ----------------- 15 | 16 | - [Installation](#installation) 17 | - [Requiremens](#requirements) 18 | - [Usage](#usage) 19 | - [Actions](#actions) 20 | - [Events](#events) 21 | - [Terminal Size](#terminal-size) 22 | - [Raw Mode](#raw-mode) 23 | - [ANSI parsing](#parsing) 24 | - [Testing](#testing) 25 | - [Contributing](#contributing) 26 | 27 | Installation 28 | ------------ 29 | 30 | ``` 31 | $ composer require php-tui/term 32 | ``` 33 | 34 | Requirements 35 | ------------ 36 | 37 | I have only tested this library on Linux. It currently requires `stty` to 38 | enable the raw mode and detect the current window size. It should work on 39 | MacOS and WSL. 40 | 41 | Native **Windows** is currently not supported as I cannot test on Windows, the 42 | architecture should support Windows however, so if you'd like to make a start 43 | look at 44 | [crossterm](https://github.com/crossterm-rs/crossterm/blob/master/src/style/sys/windows.rs) 45 | for inspiration and start a PR. 46 | 47 | Usage 48 | ----- 49 | 50 | ### Actions 51 | 52 | You can send data to the terminal using _actions_. 53 | 54 | ```php 55 | queue(Actions::printString('Hello World')); 61 | 62 | // flush the queue to the terminal 63 | $terminal->flush(); 64 | 65 | // or you can execute it directly 66 | $terminal->execute(Actions::printString('Hello World')); 67 | ``` 68 | 69 | All actions are made available via. the `Actions` factory: 70 | 71 | | method | description | 72 | | --- | --- | 73 | | `Actions::requestCursorPosition` | Request the cursor position.

This will (hopefully) be returned by the terminal and will be provided
as an `PhpTui\Term\Event\CursorPositionEvent`. | 74 | | `Actions::alternateScreenEnable` | Enable the alternate screen.

Allows switching back to the users previous "screen" later. | 75 | | `Actions::alternateScreenDisable` | Disable the alternate screen | 76 | | `Actions::printString` | Echo a standard string to the terminal | 77 | | `Actions::cursorShow` | Show the cursor | 78 | | `Actions::cursorHide` | Hide the cursor | 79 | | `Actions::setRgbForegroundColor` | Set the foreground color using RGB | 80 | | `Actions::setRgbBackgroundColor` | Set the background color using RGB | 81 | | `Actions::setForegroundColor` | Set the foreground color to one of the ANSI base colors | 82 | | `Actions::setBackgroundColor` | Set the background color to one of the ANSI base colors | 83 | | `Actions::moveCursor` | Move the cursor to an absolute position.

The top left cell is 0,0. | 84 | | `Actions::reset` | Reset all modes (styles and colors) | 85 | | `Actions::bold` | Enable or disable the bold styling | 86 | | `Actions::dim` | Enable or disable the dim styling | 87 | | `Actions::italic` | Enable or disable the italic styling | 88 | | `Actions::underline` | Enable or disable the underline styling | 89 | | `Actions::slowBlink` | Enable or disable the slow blink styling | 90 | | `Actions::rapidBlink` | Enable or disable the rapid blink styling | 91 | | `Actions::reverse` | Enable or disable the reverse blink styling | 92 | | `Actions::hidden` | Enable or disable the hidden styling - useful for passwords. | 93 | | `Actions::strike` | Enable or disable the strike-through styling | 94 | | `Actions::clear` | Perform a clear operation.

The type of clear operation is given with the Enum for example

`Actions::clear(ClearType::All)`

Will clear the entire screen. | 95 | | `Actions::enableMouseCapture` | Enable mouse capture.

Once this action has been issued mouse events will be made available. | 96 | | `Actions::disableMouseCapture` | Disable mouse capture | 97 | | `Actions::scrollUp` | Scroll the terminal up the given number of rows | 98 | | `Actions::scrollDown` | Scroll the terminal down the given number of rows | 99 | | `Actions::setTitle` | Set the title of the terminal for the current process. | 100 | | `Actions::lineWrap` | Enable or disable line wrap | 101 | | `Actions::moveCursorNextLine` | Move the cursor down and to the start of the next line (or the given number of lines) | 102 | | `Actions::moveCursorPreviousLine` | Move the cursor up and to the start of the previous line (or the given number of lines) | 103 | | `Actions::moveCursorToColumn` | Move the cursor to the given column (0 based) | 104 | | `Actions::moveCursorToRow` | Move the cursor to the given row (0 based) | 105 | | `Actions::moveCursorUp` | Move cursor up 1 or the given number of rows. | 106 | | `Actions::moveCursorRight` | Move cursor right 1 or the given number of columns. | 107 | | `Actions::moveCursorDown` | Move cursor down 1 or the given number of rows. | 108 | | `Actions::moveCursorLeft` | Move cursor left 1 or the given number of columns. | 109 | | `Actions::saveCursorPosition` | Save the cursor position | 110 | | `Actions::restoreCursorPosition` | Restore the cursor position | 111 | | `Actions::enableCusorBlinking` | Enable cursor blinking | 112 | | `Actions::disableCursorBlinking` | Disable cursor blinking | 113 | | `Actions::setCursorStyle` | Set the cursor style | 114 | 115 | ### Events 116 | 117 | Term provides user events: 118 | 119 | ```php 120 | while (true) { 121 | while ($event = $terminal->events()->next()) { 122 | if ($event instanceof CodedKeyEvent) { 123 | if ($event->code === KeyCode::Esc) { 124 | // escape pressed 125 | } 126 | } 127 | if ($event instanceof CharKeyEvent) { 128 | if ($event->char === 'c' && $event->modifiers === KeyModifiers::CONTROL) { 129 | // ctrl-c pressed 130 | } 131 | } 132 | } 133 | usleep(10000); 134 | } 135 | ``` 136 | 137 | The events are as follows: 138 | 139 | - `PhpTui\Term\Event\CharKeyEvent`: Standard character key 140 | - `PhpTui\Term\Event\CodedKeyEvent`: Special key, e.g. escape, control, page 141 | up, arrow down, etc 142 | - `PhpTui\Term\Event\CursorPositionEvent`: as a response to 143 | `Actions::requestCursorPosition`. 144 | - `PhpTui\Term\Event\FocusEvent`: for when focus has been gained or lost 145 | - `PhpTui\Term\Event\FunctionKeyEvent`: when a function key is pressed 146 | - `PhpTui\Term\Event\MouseEvent`: When the 147 | `Actions::enableMouseCapture` has been called, provides mouse event 148 | information. 149 | - `PhpTui\Term\Event\TerminalResizedEvent`: The terminal was resized. 150 | 151 | ### Terminal Size 152 | 153 | You can request the terminal size: 154 | 155 | ```php 156 | info(Size::class); 161 | if (null !== $size) { 162 | echo $size->__toString() . "\n"; 163 | } else { 164 | echo 'Could not determine terminal size'."\n"; 165 | } 166 | ``` 167 | 168 | ### Raw Mode 169 | 170 | Raw mode disables all the default terminal behaviors and is what you typically 171 | want to enable when you want a fully interactive terminal. 172 | 173 | ```php 174 | enableRawMode(); 178 | $terminal->disableRawMode(); 179 | ``` 180 | 181 | Always be sure to disable raw mode as it will leave the terminal in a barely 182 | useable state otherwise! 183 | 184 | ### Parsing 185 | 186 | In addition Term provides a parser which can parse any escape code emitted by 187 | the actions. 188 | 189 | This is useful if you want to capture the output from a terminal application 190 | and convert it to a set of Actions which can then be redrawn in another medium 191 | (e.g. plain text or HTML). 192 | 193 | ```php 194 | use PhpTui\Term\AnsiParser; 195 | $actions = AnsiParser::parseString($rawAnsiOutput, true); 196 | ``` 197 | 198 | ## Testing 199 | 200 | The `Terminal` has testable versions of all it's dependencies: 201 | 202 | ```php 203 | execute( 223 | Actions::printString('Hello World'), 224 | Actions::setTitle('Terminal Title'), 225 | ); 226 | 227 | echo implode("\n", array_map( 228 | fn (Action $action) => $action->__toString(), 229 | $painter->actions() 230 | )). "\n"; 231 | ``` 232 | 233 | See the example `testable.php` in `examples/`. 234 | 235 | ## Contributing 236 | 237 | PRs for missing functionalities and improvements are charactr. 238 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-tui/term", 3 | "description": "comprehensive low level terminal control", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "PhpTui\\Term\\": "src/" 9 | } 10 | }, 11 | "autoload-dev": { 12 | "psr-4": { 13 | "PhpTui\\Term\\Tests\\": "tests/" 14 | } 15 | }, 16 | "authors": [ 17 | { 18 | "name": "Daniel Leech" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.1" 23 | }, 24 | "require-dev": { 25 | "friendsofphp/php-cs-fixer": "^3.34", 26 | "phpstan/phpstan": "^1.10", 27 | "phpunit/phpunit": "^10.4", 28 | "symfony/var-dumper": "^6.3" 29 | }, 30 | "scripts": { 31 | "phpstan": "./vendor/bin/phpstan --memory-limit=1G", 32 | "php-cs-fixer": "./vendor/bin/php-cs-fixer fix", 33 | "phpunit": "./vendor/bin/phpunit", 34 | "integrate": [ 35 | "@php-cs-fixer", 36 | "@phpstan", 37 | "@phpunit" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpbench.json: -------------------------------------------------------------------------------- 1 | { 2 | "runner.path": "tests/Benchmark", 3 | "$schema": "vendor/phpbench/phpbench/phpbench.schema.json", 4 | "runner.bootstrap": "vendor/autoload.php" 5 | } 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | excludePaths: 4 | - src/Widget/Canvas/Data 5 | paths: 6 | - src 7 | - tests 8 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Action.php: -------------------------------------------------------------------------------- 1 | enable ? 'true' : 'false'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/Clear.php: -------------------------------------------------------------------------------- 1 | clearType->name); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/CursorShow.php: -------------------------------------------------------------------------------- 1 | show ? 'true' : 'false'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/EnableCursorBlinking.php: -------------------------------------------------------------------------------- 1 | enable ? 'true' : 'false'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/EnableLineWrap.php: -------------------------------------------------------------------------------- 1 | enable ? 'true' : 'false'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Action/EnableMouseCapture.php: -------------------------------------------------------------------------------- 1 | enable ? 'true' : 'false'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/MoveCursor.php: -------------------------------------------------------------------------------- 1 | line, $this->col); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/MoveCursorDown.php: -------------------------------------------------------------------------------- 1 | lines); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorLeft.php: -------------------------------------------------------------------------------- 1 | cols); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorNextLine.php: -------------------------------------------------------------------------------- 1 | nbLines); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorPrevLine.php: -------------------------------------------------------------------------------- 1 | nbLines); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorRight.php: -------------------------------------------------------------------------------- 1 | cols); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorToColumn.php: -------------------------------------------------------------------------------- 1 | col); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorToRow.php: -------------------------------------------------------------------------------- 1 | row); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/MoveCursorUp.php: -------------------------------------------------------------------------------- 1 | lines); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Action/PrintString.php: -------------------------------------------------------------------------------- 1 | string); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/RequestCursorPosition.php: -------------------------------------------------------------------------------- 1 | rows); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Action/ScrollUp.php: -------------------------------------------------------------------------------- 1 | rows); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Action/SetBackgroundColor.php: -------------------------------------------------------------------------------- 1 | color->name); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Action/SetCursorStyle.php: -------------------------------------------------------------------------------- 1 | cursorStyle->name); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Action/SetForegroundColor.php: -------------------------------------------------------------------------------- 1 | color->name); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Action/SetModifier.php: -------------------------------------------------------------------------------- 1 | modifier->name, $this->enable ? 'on' : 'off'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Action/SetRgbBackgroundColor.php: -------------------------------------------------------------------------------- 1 | r, $this->g, $this->b); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/SetRgbForegroundColor.php: -------------------------------------------------------------------------------- 1 | r, $this->g, $this->b); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Action/SetTerminalTitle.php: -------------------------------------------------------------------------------- 1 | title); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Actions.php: -------------------------------------------------------------------------------- 1 | $r 96 | * @param int<0,255> $g 97 | * @param int<0,255> $b 98 | */ 99 | public static function setRgbForegroundColor(int $r, int $g, int $b): SetRgbForegroundColor 100 | { 101 | return new SetRgbForegroundColor($r, $g, $b); 102 | } 103 | 104 | /** 105 | * Set the background color using RGB 106 | * @param int<0,255> $r 107 | * @param int<0,255> $g 108 | * @param int<0,255> $b 109 | */ 110 | public static function setRgbBackgroundColor(int $r, int $g, int $b): SetRgbBackgroundColor 111 | { 112 | return new SetRgbBackgroundColor($r, $g, $b); 113 | } 114 | 115 | /** 116 | * Set the foreground color to one of the ANSI base colors 117 | */ 118 | public static function setForegroundColor(Colors $color): SetForegroundColor 119 | { 120 | return new SetForegroundColor($color); 121 | } 122 | 123 | /** 124 | * Set the background color to one of the ANSI base colors 125 | */ 126 | public static function setBackgroundColor(Colors $color): SetBackgroundColor 127 | { 128 | return new SetBackgroundColor($color); 129 | } 130 | 131 | /** 132 | * Move the cursor to an absolute position. 133 | * 134 | * The top left cell is 0,0. 135 | * 136 | * @param int<0,max> $line 137 | * @param int<0,max> $col 138 | */ 139 | public static function moveCursor(int $line, int $col): MoveCursor 140 | { 141 | return new MoveCursor($line, $col); 142 | } 143 | 144 | /** 145 | * Reset all modes (styles and colors) 146 | */ 147 | public static function reset(): Reset 148 | { 149 | return new Reset(); 150 | } 151 | 152 | /** 153 | * Enable or disable the bold styling 154 | */ 155 | public static function bold(bool $enable): SetModifier 156 | { 157 | return new SetModifier(Attribute::Bold, $enable); 158 | } 159 | 160 | /** 161 | * Enable or disable the dim styling 162 | */ 163 | public static function dim(bool $enable): SetModifier 164 | { 165 | return new SetModifier(Attribute::Dim, $enable); 166 | } 167 | 168 | /** 169 | * Enable or disable the italic styling 170 | */ 171 | public static function italic(bool $enable): SetModifier 172 | { 173 | return new SetModifier(Attribute::Italic, $enable); 174 | } 175 | 176 | /** 177 | * Enable or disable the underline styling 178 | */ 179 | public static function underline(bool $enable): SetModifier 180 | { 181 | return new SetModifier(Attribute::Underline, $enable); 182 | } 183 | 184 | /** 185 | * Enable or disable the slow blink styling 186 | */ 187 | public static function slowBlink(bool $enable): SetModifier 188 | { 189 | return new SetModifier(Attribute::SlowBlink, $enable); 190 | } 191 | 192 | /** 193 | * Enable or disable the rapid blink styling 194 | */ 195 | public static function rapidBlink(bool $enable): SetModifier 196 | { 197 | return new SetModifier(Attribute::RapidBlink, $enable); 198 | } 199 | 200 | /** 201 | * Enable or disable the reverse blink styling 202 | */ 203 | public static function reverse(bool $enable): SetModifier 204 | { 205 | return new SetModifier(Attribute::Reverse, $enable); 206 | } 207 | 208 | /** 209 | * Enable or disable the hidden styling - useful for passwords. 210 | */ 211 | public static function hidden(bool $enable): SetModifier 212 | { 213 | return new SetModifier(Attribute::Hidden, $enable); 214 | } 215 | 216 | /** 217 | * Enable or disable the strike-through styling 218 | */ 219 | public static function strike(bool $enable): SetModifier 220 | { 221 | return new SetModifier(Attribute::Strike, $enable); 222 | } 223 | 224 | /** 225 | * Perform a clear operation. 226 | * 227 | * The type of clear operation is given with the Enum for example 228 | * 229 | * `Actions::clear(ClearType::All)` 230 | * 231 | * Will clear the entire screen. 232 | */ 233 | public static function clear(ClearType $clearType): Clear 234 | { 235 | return new Clear($clearType); 236 | } 237 | 238 | /** 239 | * Enable mouse capture. 240 | * 241 | * Once this action has been issued mouse events will be made available. 242 | */ 243 | public static function enableMouseCapture(): EnableMouseCapture 244 | { 245 | return new EnableMouseCapture(true); 246 | } 247 | 248 | /** 249 | * Disable mouse capture 250 | */ 251 | public static function disableMouseCapture(): EnableMouseCapture 252 | { 253 | return new EnableMouseCapture(false); 254 | } 255 | 256 | /** 257 | * Scroll the terminal up the given number of rows 258 | * @param int<0,max> $rows 259 | */ 260 | public static function scrollUp(int $rows = 1): ScrollUp 261 | { 262 | return new ScrollUp($rows); 263 | } 264 | 265 | /** 266 | * Scroll the terminal down the given number of rows 267 | * @param int<0,max> $rows 268 | */ 269 | public static function scrollDown(int $rows = 1): ScrollDown 270 | { 271 | return new ScrollDown($rows); 272 | } 273 | 274 | /** 275 | * Set the title of the terminal for the current process. 276 | */ 277 | public static function setTitle(string $title): SetTerminalTitle 278 | { 279 | return new SetTerminalTitle($title); 280 | } 281 | 282 | /** 283 | * Enable or disable line wrap 284 | */ 285 | public static function lineWrap(bool $enable): EnableLineWrap 286 | { 287 | return new EnableLineWrap($enable); 288 | } 289 | 290 | /** 291 | * Move the cursor down and to the start of the next line (or the given number of lines) 292 | * @param int<0,max> $nbLines 293 | */ 294 | public static function moveCursorNextLine(int $nbLines = 1): MoveCursorNextLine 295 | { 296 | return new MoveCursorNextLine($nbLines); 297 | } 298 | 299 | /** 300 | * Move the cursor up and to the start of the previous line (or the given number of lines) 301 | * @param int<0,max> $nbLines 302 | */ 303 | public static function moveCursorPreviousLine(int $nbLines = 1): MoveCursorPrevLine 304 | { 305 | return new MoveCursorPrevLine($nbLines); 306 | } 307 | 308 | /** 309 | * Move the cursor to the given column (0 based) 310 | */ 311 | public static function moveCursorToColumn(int $col): MoveCursorToColumn 312 | { 313 | return new MoveCursorToColumn($col); 314 | } 315 | 316 | /** 317 | * Move the cursor to the given row (0 based) 318 | * @param int<0,max> $rows 319 | */ 320 | public static function moveCursorToRow(int $rows): MoveCursorToRow 321 | { 322 | return new MoveCursorToRow($rows); 323 | } 324 | 325 | /** 326 | * Move cursor up 1 or the given number of rows. 327 | * @param int<0,max> $rows 328 | */ 329 | public static function moveCursorUp(int $rows = 1): MoveCursorUp 330 | { 331 | return new MoveCursorUp($rows); 332 | } 333 | 334 | 335 | /** 336 | * Move cursor right 1 or the given number of columns. 337 | * @param int<0,max> $cols 338 | */ 339 | public static function moveCursorRight(int $cols): MoveCursorRight 340 | { 341 | return new MoveCursorRight($cols); 342 | } 343 | 344 | /** 345 | * Move cursor down 1 or the given number of rows. 346 | * @param int<0,max> $rows 347 | */ 348 | public static function moveCursorDown(int $rows): MoveCursorDown 349 | { 350 | return new MoveCursorDown($rows); 351 | } 352 | 353 | /** 354 | * Move cursor left 1 or the given number of columns. 355 | * @param int<0,max> $cols 356 | */ 357 | public static function moveCursorLeft(int $cols): MoveCursorLeft 358 | { 359 | return new MoveCursorLeft($cols); 360 | } 361 | 362 | /** 363 | * Save the cursor position 364 | */ 365 | public static function saveCursorPosition(): SaveCursorPosition 366 | { 367 | return new SaveCursorPosition(); 368 | } 369 | 370 | /** 371 | * Restore the cursor position 372 | */ 373 | public static function restoreCursorPosition(): RestoreCursorPosition 374 | { 375 | return new RestoreCursorPosition(); 376 | } 377 | 378 | /** 379 | * Enable cursor blinking 380 | */ 381 | public static function enableCusorBlinking(): EnableCursorBlinking 382 | { 383 | return new EnableCursorBlinking(true); 384 | } 385 | 386 | /** 387 | * Disable cursor blinking 388 | */ 389 | public static function disableCursorBlinking(): EnableCursorBlinking 390 | { 391 | return new EnableCursorBlinking(false); 392 | } 393 | 394 | /** 395 | * Set the cursor style 396 | */ 397 | public static function setCursorStyle(CursorStyle $cursorStyle): SetCursorStyle 398 | { 399 | return new SetCursorStyle($cursorStyle); 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/AnsiParser.php: -------------------------------------------------------------------------------- 1 | actions; 55 | $this->actions = []; 56 | $strings = []; 57 | 58 | // compress strings 59 | $newActions = []; 60 | foreach ($actions as $action) { 61 | if ($action instanceof PrintString) { 62 | $strings[] = $action; 63 | 64 | continue; 65 | } 66 | if ($strings) { 67 | $newActions[] = Actions::printString( 68 | implode('', array_map(static fn (PrintString $s): string => $s->string, $strings)) 69 | ); 70 | $strings = []; 71 | } 72 | $newActions[] = $action; 73 | } 74 | if ($strings) { 75 | $newActions[] = Actions::printString( 76 | implode('', array_map(static fn (PrintString $s): string => $s->string, $strings)) 77 | ); 78 | } 79 | 80 | return $newActions; 81 | } 82 | 83 | public function advance(string $line, bool $more): void 84 | { 85 | // split string into bytes 86 | $chars = mb_str_split($line); 87 | 88 | foreach ($chars as $index => $char) { 89 | $more = $index + 1 < strlen($line) || $more; 90 | 91 | $this->buffer[] = $char; 92 | 93 | try { 94 | $action = $this->parseAction($this->buffer, $more); 95 | } catch (ParseError $error) { 96 | if ($this->throw) { 97 | throw $error; 98 | } 99 | $this->buffer = []; 100 | 101 | continue; 102 | } 103 | if ($action === null) { 104 | continue; 105 | } 106 | $this->actions[] = $action; 107 | $this->buffer = []; 108 | } 109 | } 110 | 111 | /** 112 | * @return Action[] 113 | */ 114 | public static function parseString(string $output, bool $throw = false): array 115 | { 116 | $parser = new self($throw); 117 | $parser->advance($output, true); 118 | 119 | return $parser->drain(); 120 | } 121 | 122 | /** 123 | * @param string[] $buffer 124 | */ 125 | private function parseAction(array $buffer, bool $more): ?Action 126 | { 127 | return match ($buffer[0]) { 128 | "\x1B" => $this->parseEsc($buffer, $more), 129 | default => Actions::printString($buffer[0]) 130 | }; 131 | } 132 | 133 | /** 134 | * @param string[] $buffer 135 | */ 136 | private function parseEsc(array $buffer, bool $more): ?Action 137 | { 138 | if (count($buffer) === 1) { 139 | return null; 140 | } 141 | 142 | return match ($buffer[1]) { 143 | '[' => $this->parseCsi($buffer, $more), 144 | ']' => $this->parseOsc($buffer, $more), 145 | '7' => new SaveCursorPosition(), 146 | '8' => new RestoreCursorPosition(), 147 | default => Actions::printString($buffer[0]) 148 | }; 149 | } 150 | 151 | /** 152 | * @param string[] $buffer 153 | */ 154 | private function parseCsi(array $buffer, bool $more): ?Action 155 | { 156 | if (count($buffer) === 2) { 157 | return null; 158 | } 159 | 160 | return match ($buffer[2]) { 161 | '0','1','2','3','4','5','6','7','8','9' => $this->parseCsiSeq($buffer), 162 | '?' => $this->parsePrivateModes($buffer), 163 | 'J' => Actions::clear(ClearType::FromCursorDown), 164 | 'K' => Actions::clear(ClearType::UntilNewLine), 165 | 'S' => Actions::scrollUp(), 166 | 'T' => Actions::scrollDown(), 167 | default => throw ParseError::couldNotParseOffset($buffer, 2, 'Could not parse CSI sequence'), 168 | }; 169 | 170 | } 171 | 172 | /** 173 | * @param string[] $buffer 174 | */ 175 | private function parseCsiSeq(array $buffer): ?Action 176 | { 177 | // numbered escape code 178 | if (count($buffer) === 3) { 179 | return null; 180 | } 181 | 182 | $lastByte = $buffer[array_key_last($buffer)]; 183 | // the final byte of a CSI sequence can be in the range 64-126 184 | $ord = ord($lastByte); 185 | if ($ord < 64 || $ord > 126) { 186 | return null; 187 | } 188 | 189 | return match ($lastByte) { 190 | 'm' => $this->parseGraphicsMode($buffer), 191 | 'H' => $this->parseCursorPosition($buffer), 192 | 'J', 'K' => $this->parseClear($buffer), 193 | 'n' => Actions::requestCursorPosition(), 194 | 'E', 'F', 'G', 'd', 'A', 'C', 'B', 'D' => $this->parseCursorMovement($buffer), 195 | 'q' => $this->parseCursorStyle($buffer), 196 | 'S', 'T' => $this->parseScroll($buffer), 197 | default => throw ParseError::couldNotParseOffset( 198 | $buffer, 199 | intval(array_key_last($buffer)), 200 | 'Do not know how to parse CSI sequence' 201 | ), 202 | }; 203 | } 204 | 205 | /** 206 | * @param string[] $buffer 207 | */ 208 | private function parseGraphicsMode(array $buffer): Action 209 | { 210 | $string = implode('', array_slice($buffer, 2, -1)); 211 | $parts = explode(';', $string); 212 | 213 | // true colors 214 | if (count($parts) === 5) { 215 | $rgb = array_map(static fn (string $index): int => (int) $index, array_slice($parts, -3)); 216 | $rgb = array_map(static fn (int $byte): int => min(255, max(0, $byte)), $rgb); 217 | 218 | return match ($parts[0]) { 219 | '48' => Actions::setRgbBackgroundColor(...$rgb), 220 | '38' => Actions::setRgbForegroundColor(...$rgb), 221 | default => throw new ParseError(sprintf('Could not parse graphics mode: %s', json_encode(implode('', $buffer)))), 222 | }; 223 | } 224 | if (count($parts) === 3) { 225 | return match ($parts[0]) { 226 | '48' => Actions::setRgbBackgroundColor(...Colors256::indexToRgb((int) ($parts[2]))), 227 | '38' => Actions::setRgbForegroundColor(...Colors256::indexToRgb((int) ($parts[2]))), 228 | default => throw new ParseError(sprintf('Could not parse graphics mode: %s', json_encode(implode('', $buffer)))), 229 | }; 230 | } 231 | 232 | $code = (int) ($parts[0]); 233 | 234 | return match ($parts[0]) { 235 | '39' => Actions::setForegroundColor(Colors::Reset), 236 | '49' => Actions::setBackgroundColor(Colors::Reset), 237 | '1' => Actions::bold(true), 238 | '22' => Actions::bold(false), 239 | '2' => Actions::dim(true), 240 | '22' => Actions::dim(false), 241 | '3' => Actions::italic(true), 242 | '23' => Actions::italic(false), 243 | '4' => Actions::underline(true), 244 | '24' => Actions::underline(false), 245 | '5' => Actions::slowBlink(true), 246 | '25' => Actions::slowBlink(false), 247 | '7' => Actions::reverse(true), 248 | '27' => Actions::reverse(false), 249 | '8' => Actions::hidden(true), 250 | '28' => Actions::hidden(false), 251 | '9' => Actions::strike(true), 252 | '29' => Actions::strike(false), 253 | '0' => Actions::reset(), 254 | default => match (true) { 255 | str_starts_with($parts[0], '3') => Actions::setForegroundColor($this->inverseColorIndex($code, false)), 256 | str_starts_with($parts[0], '4') => Actions::setBackgroundColor($this->inverseColorIndex($code, true)), 257 | str_starts_with($parts[0], '9') => Actions::setForegroundColor($this->inverseColorIndex($code, false)), 258 | str_starts_with($parts[0], '10') => Actions::setBackgroundColor($this->inverseColorIndex($code, true)), 259 | default => throw new ParseError(sprintf('Could not parse graphics mode: %s', json_encode(implode('', $buffer)))), 260 | }, 261 | }; 262 | } 263 | 264 | /** 265 | * @param string[] $buffer 266 | */ 267 | private function parsePrivateModes(array $buffer): ?Action 268 | { 269 | $last = $buffer[array_key_last($buffer)]; 270 | if (count($buffer) === 3) { 271 | return null; 272 | } 273 | 274 | return match ($buffer[3]) { 275 | '2' => $this->parsePrivateModes2($buffer), 276 | '1' => $this->parsePrivateModes2($buffer), 277 | '7' => $this->parseLineWrap($buffer), 278 | default => throw ParseError::couldNotParseOffset($buffer, 3, 'Could not parse private mode'), 279 | }; 280 | } 281 | 282 | /** 283 | * @param string[] $buffer 284 | */ 285 | private function parsePrivateModes2(array $buffer): ?Action 286 | { 287 | if (count($buffer) === 4) { 288 | return null; 289 | } 290 | if (count($buffer) === 5) { 291 | return null; 292 | } 293 | 294 | return match ($buffer[4]) { 295 | '2' => match ($buffer[5]) { 296 | 'h' => new EnableCursorBlinking(true), 297 | 'l' => new EnableCursorBlinking(false), 298 | default => throw ParseError::couldNotParseOffset($buffer, 4, 'Could not parse cursor blinking mode'), 299 | }, 300 | '5' => match ($buffer[5]) { 301 | 'l' => Actions::cursorHide(), 302 | 'h' => Actions::cursorShow(), 303 | default => throw ParseError::couldNotParseBuffer($buffer), 304 | }, 305 | '0' => match ($buffer[5]) { 306 | '4' => (function () use ($buffer): ?AlternateScreenEnable { 307 | if (count($buffer) === 6 || count($buffer) === 7) { 308 | return null; 309 | } 310 | 311 | return match ($buffer[7]) { 312 | 'h' => Actions::alternateScreenEnable(), 313 | 'l' => Actions::alternateScreenDisable(), 314 | default => throw ParseError::couldNotParseOffset($buffer, 7), 315 | }; 316 | })(), 317 | default => throw ParseError::couldNotParseOffset($buffer, 5), 318 | }, 319 | default => throw ParseError::couldNotParseOffset($buffer, 4), 320 | }; 321 | } 322 | 323 | /** 324 | * @param string[] $buffer 325 | */ 326 | private function parseCursorPosition(array $buffer): Action 327 | { 328 | $string = implode('', array_slice($buffer, 2, -1)); 329 | $parts = explode(';', $string); 330 | if (count($parts) !== 2) { 331 | throw new ParseError(sprintf('Could not parse cursor position from: "%s"', $string)); 332 | } 333 | 334 | return Actions::moveCursor(max(0, (int) ($parts[0])), max(0, (int) ($parts[1]))); 335 | } 336 | 337 | /** 338 | * @param string[] $buffer 339 | */ 340 | private function parseClear(array $buffer): Action 341 | { 342 | array_shift($buffer); 343 | array_shift($buffer); 344 | $clear = implode('', $buffer); 345 | 346 | return new Clear(match ($clear) { 347 | '2J' => ClearType::All, 348 | '3J' => ClearType::Purge, 349 | 'J' => ClearType::FromCursorDown, 350 | '1J' => ClearType::FromCursorUp, 351 | '2K' => ClearType::CurrentLine, 352 | 'K' => ClearType::UntilNewLine, 353 | default => throw new ParseError(sprintf( 354 | 'Could not parse clear "%s"', 355 | $clear 356 | )), 357 | }); 358 | } 359 | 360 | private function inverseColorIndex(int $color, bool $background): Colors 361 | { 362 | $color -= $background ? 10 : 0; 363 | 364 | return match ($color) { 365 | 30 => Colors::Black, 366 | 31 => Colors::Red, 367 | 32 => Colors::Green, 368 | 33 => Colors::Yellow, 369 | 34 => Colors::Blue, 370 | 35 => Colors::Magenta, 371 | 36 => Colors::Cyan, 372 | 37 => Colors::Gray, 373 | 90 => Colors::DarkGray, 374 | 91 => Colors::LightRed, 375 | 92 => Colors::LightGreen, 376 | 93 => Colors::LightYellow, 377 | 94 => Colors::LightBlue, 378 | 95 => Colors::LightMagenta, 379 | 96 => Colors::LightCyan, 380 | 97 => Colors::White, 381 | default => throw new ParseError(sprintf('Do not know how to handle color: %s', $color)), 382 | }; 383 | } 384 | 385 | /** 386 | * @param string[] $buffer 387 | */ 388 | private function parseOsc(array $buffer, bool $more): ?Action 389 | { 390 | if (count($buffer) === 2) { 391 | return null; 392 | } 393 | 394 | return match ($buffer[2]) { 395 | '0' => $this->parseSetTitle($buffer), 396 | default => throw new ParseError(sprintf('Could not parse OSC sequence: %s', json_encode(implode('', $buffer)))), 397 | }; 398 | 399 | } 400 | 401 | /** 402 | * @param string[] $buffer 403 | */ 404 | private function parseSetTitle(array $buffer): ?Action 405 | { 406 | if (count($buffer) <= 3) { 407 | return null; 408 | } 409 | if ($buffer[3] !== ';') { 410 | throw ParseError::couldNotParseBuffer($buffer, sprintf('Expected ";" after 0 for set title command')); 411 | } 412 | $last = $buffer[array_key_last($buffer)]; 413 | if ($last === "\x07") { // BEL 414 | return new SetTerminalTitle(implode('', array_slice($buffer, 4, -1))); 415 | } 416 | return null; 417 | } 418 | 419 | /** 420 | * @param string[] $buffer 421 | */ 422 | private function parseLineWrap(array $buffer): ?Action 423 | { 424 | if (count($buffer) === 4) { 425 | return null; 426 | } 427 | $last = $buffer[array_key_last($buffer)]; 428 | return match($last) { 429 | 'h' => new EnableLineWrap(true), 430 | 'l' => new EnableLineWrap(false), 431 | default => throw ParseError::couldNotParseOffset($buffer, intval(array_key_last($buffer)), 'Could not parse line wrapping'), 432 | }; 433 | } 434 | 435 | /** 436 | * @param string[] $buffer 437 | */ 438 | private function parseCursorMovement(array $buffer): Action 439 | { 440 | $type = array_pop($buffer); 441 | $amount = intval(implode('', array_slice($buffer, 2))); 442 | 443 | return match($type) { 444 | 'E' => new MoveCursorNextLine($amount), 445 | 'F' => new MoveCursorPrevLine($amount), 446 | 'G' => new MoveCursorToColumn($amount - 1), 447 | 'd' => new MoveCursorToRow($amount - 1), 448 | 'A' => new MoveCursorUp($amount), 449 | 'C' => new MoveCursorRight($amount), 450 | 'B' => new MoveCursorDown($amount), 451 | 'D' => new MoveCursorLeft($amount), 452 | default => throw ParseError::couldNotParseBuffer($buffer, 'Could not parse cursor movement'), 453 | }; 454 | } 455 | 456 | /** 457 | * @param string[] $buffer 458 | */ 459 | private function parseCursorStyle(array $buffer): Action 460 | { 461 | $number = intval(trim(implode('', array_slice($buffer, 2, -1)))); 462 | return new SetCursorStyle(match($number) { 463 | 0 => CursorStyle::DefaultUserShape, 464 | 1 => CursorStyle::BlinkingBlock, 465 | 2 => CursorStyle::SteadyBlock, 466 | 3 => CursorStyle::BlinkingUnderScore, 467 | 4 => CursorStyle::SteadyUnderScore, 468 | 5 => CursorStyle::BlinkingBar, 469 | 6 => CursorStyle::SteadyBar, 470 | default => throw ParseError::couldNotParseBuffer($buffer, 'Could not parse cursor style'), 471 | }); 472 | } 473 | 474 | /** 475 | * @param string[] $buffer 476 | */ 477 | private function parseScroll(array $buffer): Action 478 | { 479 | $type = array_pop($buffer); 480 | $amount = intval(implode('', array_slice($buffer, 2))); 481 | 482 | return match($type) { 483 | 'S' => new ScrollUp($amount), 484 | 'T' => new ScrollDown($amount), 485 | default => throw ParseError::couldNotParseBuffer($buffer, 'Could not parse scroll'), 486 | }; 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /src/Attribute.php: -------------------------------------------------------------------------------- 1 | [0,0,0], 13 | 1 => [128,0,0], 14 | 2 => [0,128,0], 15 | 3 => [128,128,0], 16 | 4 => [0,0,128], 17 | 5 => [128,0,128], 18 | 6 => [0,128,128], 19 | 7 => [192,192,192], 20 | 8 => [128,128,128], 21 | 9 => [255,0,0], 22 | 10 => [0,255,0], 23 | 11 => [255,255,0], 24 | 12 => [0,0,255], 25 | 13 => [255,0,255], 26 | 14 => [0,255,255], 27 | 15 => [255,255,255], 28 | 16 => [0,0,0], 29 | 17 => [0,0,95], 30 | 18 => [0,0,135], 31 | 19 => [0,0,175], 32 | 20 => [0,0,215], 33 | 21 => [0,0,255], 34 | 22 => [0,95,0], 35 | 23 => [0,95,95], 36 | 24 => [0,95,135], 37 | 25 => [0,95,175], 38 | 26 => [0,95,215], 39 | 27 => [0,95,255], 40 | 28 => [0,135,0], 41 | 29 => [0,135,95], 42 | 30 => [0,135,135], 43 | 31 => [0,135,175], 44 | 32 => [0,135,215], 45 | 33 => [0,135,255], 46 | 34 => [0,175,0], 47 | 35 => [0,175,95], 48 | 36 => [0,175,135], 49 | 37 => [0,175,175], 50 | 38 => [0,175,215], 51 | 39 => [0,175,255], 52 | 40 => [0,215,0], 53 | 41 => [0,215,95], 54 | 42 => [0,215,135], 55 | 43 => [0,215,175], 56 | 44 => [0,215,215], 57 | 45 => [0,215,255], 58 | 46 => [0,255,0], 59 | 47 => [0,255,95], 60 | 48 => [0,255,135], 61 | 49 => [0,255,175], 62 | 50 => [0,255,215], 63 | 51 => [0,255,255], 64 | 52 => [95,0,0], 65 | 53 => [95,0,95], 66 | 54 => [95,0,135], 67 | 55 => [95,0,175], 68 | 56 => [95,0,215], 69 | 57 => [95,0,255], 70 | 58 => [95,95,0], 71 | 59 => [95,95,95], 72 | 60 => [95,95,135], 73 | 61 => [95,95,175], 74 | 62 => [95,95,215], 75 | 63 => [95,95,255], 76 | 64 => [95,135,0], 77 | 65 => [95,135,95], 78 | 66 => [95,135,135], 79 | 67 => [95,135,175], 80 | 68 => [95,135,215], 81 | 69 => [95,135,255], 82 | 70 => [95,175,0], 83 | 71 => [95,175,95], 84 | 72 => [95,175,135], 85 | 73 => [95,175,175], 86 | 74 => [95,175,215], 87 | 75 => [95,175,255], 88 | 76 => [95,215,0], 89 | 77 => [95,215,95], 90 | 78 => [95,215,135], 91 | 79 => [95,215,175], 92 | 80 => [95,215,215], 93 | 81 => [95,215,255], 94 | 82 => [95,255,0], 95 | 83 => [95,255,95], 96 | 84 => [95,255,135], 97 | 85 => [95,255,175], 98 | 86 => [95,255,215], 99 | 87 => [95,255,255], 100 | 88 => [135,0,0], 101 | 89 => [135,0,95], 102 | 90 => [135,0,135], 103 | 91 => [135,0,175], 104 | 92 => [135,0,215], 105 | 93 => [135,0,255], 106 | 94 => [135,95,0], 107 | 95 => [135,95,95], 108 | 96 => [135,95,135], 109 | 97 => [135,95,175], 110 | 98 => [135,95,215], 111 | 99 => [135,95,255], 112 | 100 => [135,135,0], 113 | 101 => [135,135,95], 114 | 102 => [135,135,135], 115 | 103 => [135,135,175], 116 | 104 => [135,135,215], 117 | 105 => [135,135,255], 118 | 106 => [135,175,0], 119 | 107 => [135,175,95], 120 | 108 => [135,175,135], 121 | 109 => [135,175,175], 122 | 110 => [135,175,215], 123 | 111 => [135,175,255], 124 | 112 => [135,215,0], 125 | 113 => [135,215,95], 126 | 114 => [135,215,135], 127 | 115 => [135,215,175], 128 | 116 => [135,215,215], 129 | 117 => [135,215,255], 130 | 118 => [135,255,0], 131 | 119 => [135,255,95], 132 | 120 => [135,255,135], 133 | 121 => [135,255,175], 134 | 122 => [135,255,215], 135 | 123 => [135,255,255], 136 | 124 => [175,0,0], 137 | 125 => [175,0,95], 138 | 126 => [175,0,135], 139 | 127 => [175,0,175], 140 | 128 => [175,0,215], 141 | 129 => [175,0,255], 142 | 130 => [175,95,0], 143 | 131 => [175,95,95], 144 | 132 => [175,95,135], 145 | 133 => [175,95,175], 146 | 134 => [175,95,215], 147 | 135 => [175,95,255], 148 | 136 => [175,135,0], 149 | 137 => [175,135,95], 150 | 138 => [175,135,135], 151 | 139 => [175,135,175], 152 | 140 => [175,135,215], 153 | 141 => [175,135,255], 154 | 142 => [175,175,0], 155 | 143 => [175,175,95], 156 | 144 => [175,175,135], 157 | 145 => [175,175,175], 158 | 146 => [175,175,215], 159 | 147 => [175,175,255], 160 | 148 => [175,215,0], 161 | 149 => [175,215,95], 162 | 150 => [175,215,135], 163 | 151 => [175,215,175], 164 | 152 => [175,215,215], 165 | 153 => [175,215,255], 166 | 154 => [175,255,0], 167 | 155 => [175,255,95], 168 | 156 => [175,255,135], 169 | 157 => [175,255,175], 170 | 158 => [175,255,215], 171 | 159 => [175,255,255], 172 | 160 => [215,0,0], 173 | 161 => [215,0,95], 174 | 162 => [215,0,135], 175 | 163 => [215,0,175], 176 | 164 => [215,0,215], 177 | 165 => [215,0,255], 178 | 166 => [215,95,0], 179 | 167 => [215,95,95], 180 | 168 => [215,95,135], 181 | 169 => [215,95,175], 182 | 170 => [215,95,215], 183 | 171 => [215,95,255], 184 | 172 => [215,135,0], 185 | 173 => [215,135,95], 186 | 174 => [215,135,135], 187 | 175 => [215,135,175], 188 | 176 => [215,135,215], 189 | 177 => [215,135,255], 190 | 178 => [215,175,0], 191 | 179 => [215,175,95], 192 | 180 => [215,175,135], 193 | 181 => [215,175,175], 194 | 182 => [215,175,215], 195 | 183 => [215,175,255], 196 | 184 => [215,215,0], 197 | 185 => [215,215,95], 198 | 186 => [215,215,135], 199 | 187 => [215,215,175], 200 | 188 => [215,215,215], 201 | 189 => [215,215,255], 202 | 190 => [215,255,0], 203 | 191 => [215,255,95], 204 | 192 => [215,255,135], 205 | 193 => [215,255,175], 206 | 194 => [215,255,215], 207 | 195 => [215,255,255], 208 | 196 => [255,0,0], 209 | 197 => [255,0,95], 210 | 198 => [255,0,135], 211 | 199 => [255,0,175], 212 | 200 => [255,0,215], 213 | 201 => [255,0,255], 214 | 202 => [255,95,0], 215 | 203 => [255,95,95], 216 | 204 => [255,95,135], 217 | 205 => [255,95,175], 218 | 206 => [255,95,215], 219 | 207 => [255,95,255], 220 | 208 => [255,135,0], 221 | 209 => [255,135,95], 222 | 210 => [255,135,135], 223 | 211 => [255,135,175], 224 | 212 => [255,135,215], 225 | 213 => [255,135,255], 226 | 214 => [255,175,0], 227 | 215 => [255,175,95], 228 | 216 => [255,175,135], 229 | 217 => [255,175,175], 230 | 218 => [255,175,215], 231 | 219 => [255,175,255], 232 | 220 => [255,215,0], 233 | 221 => [255,215,95], 234 | 222 => [255,215,135], 235 | 223 => [255,215,175], 236 | 224 => [255,215,215], 237 | 225 => [255,215,255], 238 | 226 => [255,255,0], 239 | 227 => [255,255,95], 240 | 228 => [255,255,135], 241 | 229 => [255,255,175], 242 | 230 => [255,255,215], 243 | 231 => [255,255,255], 244 | 232 => [8,8,8], 245 | 233 => [18,18,18], 246 | 234 => [28,28,28], 247 | 235 => [38,38,38], 248 | 236 => [48,48,48], 249 | 237 => [58,58,58], 250 | 238 => [68,68,68], 251 | 239 => [78,78,78], 252 | 240 => [88,88,88], 253 | 241 => [98,98,98], 254 | 242 => [108,108,108], 255 | 243 => [118,118,118], 256 | 244 => [128,128,128], 257 | 245 => [138,138,138], 258 | 246 => [148,148,148], 259 | 247 => [158,158,158], 260 | 248 => [168,168,168], 261 | 249 => [178,178,178], 262 | 250 => [188,188,188], 263 | 251 => [198,198,198], 264 | 252 => [208,208,208], 265 | 253 => [218,218,218], 266 | 254 => [228,228,228], 267 | 255 => [238,238,238], 268 | ]; 269 | 270 | /** 271 | * @return array{int<0,255>,int<0,255>,int<0,255>} 272 | */ 273 | public static function indexToRgb(int $index): array 274 | { 275 | if (!isset(self::COLOR_MAP[$index])) { 276 | throw new RuntimeException(sprintf( 277 | '256 Color index "%d" is invalid, it must be between 0 and 255', 278 | $index 279 | )); 280 | } 281 | 282 | return self::COLOR_MAP[$index]; 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/CursorStyle.php: -------------------------------------------------------------------------------- 1 | $modifiers 13 | */ 14 | private function __construct( 15 | public readonly string $char, 16 | public readonly int $modifiers 17 | ) { 18 | } 19 | 20 | public function __toString(): string 21 | { 22 | return sprintf( 23 | 'CharKeyEvent(char: %s, modifiers: %s)', 24 | $this->char, 25 | KeyModifiers::toString($this->modifiers) 26 | ); 27 | } 28 | 29 | /** 30 | * @param int-mask-of $modifiers 31 | */ 32 | public static function new(string $char, int $modifiers = KeyModifiers::NONE): self 33 | { 34 | return new self($char, $modifiers); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Event/CodedKeyEvent.php: -------------------------------------------------------------------------------- 1 | $modifiers 15 | */ 16 | private function __construct( 17 | public readonly KeyCode $code, 18 | public readonly int $modifiers, 19 | public readonly KeyEventKind $kind 20 | ) { 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return sprintf( 26 | 'CodedKeyEvent(code: %s, modifiers: %s, kind: %s)', 27 | $this->code->name, 28 | KeyModifiers::toString($this->modifiers), 29 | $this->kind->name, 30 | ); 31 | } 32 | 33 | /** 34 | * @param int-mask-of $modifiers 35 | */ 36 | public static function new(KeyCode $keyCode, int $modifiers = KeyModifiers::NONE, KeyEventKind $kind = KeyEventKind::Press): self 37 | { 38 | return new self($keyCode, $modifiers, $kind); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Event/CursorPositionEvent.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public readonly int $x, 16 | /** 17 | * @var int<0,max> 18 | */ 19 | public readonly int $y 20 | ) { 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return sprintf('CursorPosition(%d, %d)', $this->x, $this->y); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Event/FocusEvent.php: -------------------------------------------------------------------------------- 1 | focus->name); 19 | } 20 | 21 | public static function gained(): self 22 | { 23 | return new self(Focus::Gained); 24 | } 25 | 26 | public static function lost(): self 27 | { 28 | return new self(Focus::Lost); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Event/FunctionKeyEvent.php: -------------------------------------------------------------------------------- 1 | $modifiers 14 | */ 15 | private function __construct( 16 | /** 17 | * @var int<1,17> 18 | */ 19 | public readonly int $number, 20 | /** 21 | * @var int-mask-of 22 | */ 23 | public readonly int $modifiers, 24 | public readonly KeyEventKind $kind 25 | ) { 26 | } 27 | 28 | public function __toString(): string 29 | { 30 | return sprintf( 31 | 'FunctionKey(number: %s, modifier: %s, kind: %s)', 32 | $this->number, 33 | KeyModifiers::toString($this->modifiers), 34 | $this->kind->name 35 | ); 36 | } 37 | 38 | /** 39 | * @param int<1,17> $number 40 | * @param int-mask-of $modifiers 41 | */ 42 | public static function new(int $number, int $modifiers = KeyModifiers::NONE, KeyEventKind $kind = KeyEventKind::Press): self 43 | { 44 | return new self($number, $modifiers, $kind); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Event/KeyEvent.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public readonly int $column, 21 | /** 22 | * @var int<0,max> 23 | */ 24 | public readonly int $row, 25 | /** 26 | * @var int-mask-of 27 | */ 28 | public readonly int $modifiers 29 | ) { 30 | } 31 | 32 | public function __toString(): string 33 | { 34 | return sprintf( 35 | 'MouseEvent(kind: %s, button: %s, col: %d, row: %d, modifiers: %d)', 36 | $this->kind->name, 37 | $this->button->name, 38 | $this->column, 39 | $this->row, 40 | $this->modifiers 41 | ); 42 | } 43 | 44 | /** 45 | * @param int<0,max> $column 46 | * @param int<0,max> $row 47 | * @param int-mask-of $modifiers 48 | */ 49 | public static function new(MouseEventKind $kind, MouseButton $button, int $column, int $row, int $modifiers): self 50 | { 51 | return new self( 52 | kind: $kind, 53 | button: $button, 54 | column: $column, 55 | row: $row, 56 | modifiers: $modifiers 57 | ); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Event/TerminalResizedEvent.php: -------------------------------------------------------------------------------- 1 | events; 35 | $this->events = []; 36 | 37 | return $events; 38 | } 39 | 40 | public function advance(string $line, bool $more): void 41 | { 42 | // split string into bytes 43 | $bytes = str_split($line); 44 | 45 | foreach ($bytes as $index => $byte) { 46 | $more = $index + 1 < strlen($line) || $more; 47 | 48 | $this->buffer[] = $byte; 49 | 50 | try { 51 | $event = $this->parseEvent($this->buffer, $more); 52 | } catch (ParseError) { 53 | $this->buffer = []; 54 | 55 | continue; 56 | } 57 | if ($event === null) { 58 | continue; 59 | } 60 | $this->events[] = $event; 61 | $this->buffer = []; 62 | } 63 | } 64 | 65 | public static function new(): self 66 | { 67 | return new self(); 68 | } 69 | 70 | /** 71 | * @param string[] $buffer 72 | */ 73 | private function parseEvent(array $buffer, bool $inputAvailable): ?Event 74 | { 75 | if ($buffer === []) { 76 | return null; 77 | } 78 | 79 | return match ($buffer[0]) { 80 | "\x1B" => $this->parseEsc($buffer, $inputAvailable), 81 | "\x7F" => CodedKeyEvent::new(KeyCode::Backspace), 82 | "\r" => CodedKeyEvent::new(KeyCode::Enter), 83 | "\t" => CodedKeyEvent::new(KeyCode::Tab), 84 | default => $this->parseCtrlOrUtf8Char($buffer), 85 | }; 86 | } 87 | 88 | /** 89 | * @param string[] $buffer 90 | */ 91 | private function parseEsc(array $buffer, bool $inputAvailable): ?Event 92 | { 93 | if (count($buffer) === 1) { 94 | if ($inputAvailable) { 95 | // _could_ be an escape sequence 96 | return null; 97 | } 98 | 99 | return CodedKeyEvent::new(KeyCode::Esc); 100 | } 101 | 102 | return match ($buffer[1]) { 103 | '[' => $this->parseCsi($buffer), 104 | "\x1B" => CodedKeyEvent::new(KeyCode::Esc), 105 | 'O' => (function () use ($buffer): null|FunctionKeyEvent|CodedKeyEvent { 106 | if (count($buffer) === 2) { 107 | return null; 108 | } 109 | 110 | return match ($buffer[2]) { 111 | 'P' => FunctionKeyEvent::new(1), 112 | 'Q' => FunctionKeyEvent::new(2), 113 | 'R' => FunctionKeyEvent::new(3), 114 | 'S' => FunctionKeyEvent::new(4), 115 | 'H' => CodedKeyEvent::new(KeyCode::Home), 116 | 'F' => CodedKeyEvent::new(KeyCode::End), 117 | 'D' => CodedKeyEvent::new(KeyCode::Left), 118 | 'C' => CodedKeyEvent::new(KeyCode::Right), 119 | 'A' => CodedKeyEvent::new(KeyCode::Up), 120 | 'B' => CodedKeyEvent::new(KeyCode::Down), 121 | default => throw ParseError::couldNotParseOffset($buffer, 22), 122 | }; 123 | })(), 124 | default => $this->parseEvent(array_slice($buffer, 1), $inputAvailable), 125 | }; 126 | } 127 | 128 | /** 129 | * @param string[] $buffer 130 | */ 131 | private function parseCsi(array $buffer): ?Event 132 | { 133 | if (count($buffer) === 2) { 134 | return null; 135 | } 136 | 137 | return match ($buffer[2]) { 138 | 'D' => CodedKeyEvent::new(KeyCode::Left), 139 | 'C' => CodedKeyEvent::new(KeyCode::Right), 140 | 'A' => CodedKeyEvent::new(KeyCode::Up), 141 | 'B' => CodedKeyEvent::new(KeyCode::Down), 142 | 'H' => CodedKeyEvent::new(KeyCode::Home), 143 | 'F' => CodedKeyEvent::new(KeyCode::End), 144 | 'Z' => CodedKeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT, KeyEventKind::Press), 145 | 'M' => $this->parseCsiNormalMouse($buffer), 146 | '<' => $this->parseCsiSgrMouse($buffer), 147 | 'I' => FocusEvent::gained(), 148 | 'O' => FocusEvent::lost(), 149 | // https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-functional-keys 150 | 'P' => FunctionKeyEvent::new(1), 151 | 'Q' => FunctionKeyEvent::new(2), 152 | 'R' => FunctionKeyEvent::new(3), // this is omitted from crossterm 153 | 'S' => FunctionKeyEvent::new(4), 154 | ';' => $this->parseCsiModifierKeyCode($buffer), 155 | '0','1','2','3','4','5','6','7','8','9' => $this->parseCsiMore($buffer), 156 | default => throw ParseError::couldNotParseOffset($buffer, 2), 157 | }; 158 | } 159 | 160 | /** 161 | * @param string[] $buffer 162 | */ 163 | private function parseCsiMore(array $buffer): ?Event 164 | { 165 | // numbered escape code 166 | if (count($buffer) === 3) { 167 | return null; 168 | } 169 | 170 | $lastByte = $buffer[array_key_last($buffer)]; 171 | // the final byte of a CSI sequence can be in the range 64-126 172 | $ord = ord($lastByte); 173 | if ($ord < 64 || $ord > 126) { 174 | return null; 175 | } 176 | 177 | return match ($lastByte) { 178 | 'M' => $this->parseCsiRxvtMouse($buffer), 179 | '~' => $this->parseCsiSpecialKeyCode($buffer), 180 | 'R' => $this->parseCsiCursorPosition($buffer), 181 | default => $this->parseCsiModifierKeyCode($buffer), 182 | }; 183 | } 184 | 185 | /** 186 | * @param string[] $buffer 187 | */ 188 | private function parseCsiSpecialKeyCode(array $buffer): Event 189 | { 190 | $str = implode('', array_slice($buffer, 2, (int)array_key_last($buffer))); 191 | 192 | $split = array_map( 193 | fn (string $substr): int => $this->filterToInt($substr) ?? 0, 194 | explode(';', $str), 195 | ); 196 | $first = $split[array_key_first($split)]; 197 | 198 | $keycode = match ($first) { 199 | 1,7 => KeyCode::Home, 200 | 2 => KeyCode::Insert, 201 | 4,8 => KeyCode::End, 202 | 5 => KeyCode::PageUp, 203 | 6 => KeyCode::PageDown, 204 | 3 => KeyCode::Delete, 205 | default => null, 206 | }; 207 | if (null !== $keycode) { 208 | return CodedKeyEvent::new($keycode); 209 | } 210 | 211 | return match($first) { 212 | 11,12,13,14,15 => FunctionKeyEvent::new($first - 10), 213 | 17,18,19,20,21 => FunctionKeyEvent::new($first - 11), 214 | 23,24,25,26 => FunctionKeyEvent::new($first - 12), 215 | 28,29 => FunctionKeyEvent::new($first - 15), 216 | 31,32,33,34 => FunctionKeyEvent::new($first - 17), 217 | default => throw new ParseError( 218 | sprintf( 219 | 'Could not parse char "%s" in CSI event: %s', 220 | $first, 221 | json_encode(implode('', $buffer)) 222 | ) 223 | ), 224 | }; 225 | } 226 | 227 | /** 228 | * @param non-empty-array $buffer 229 | */ 230 | private function parseCtrlOrUtf8Char(array $buffer): ?Event 231 | { 232 | $char = $buffer[0]; 233 | $code = ord($char); 234 | 235 | // control key for alpha chars 236 | if ($code >= 1 && $code <= 26) { 237 | return CharKeyEvent::new(chr($code - 1 + ord('a')), KeyModifiers::CONTROL); 238 | } 239 | 240 | // control 4-7 !? 241 | if ($code >= 28 && $code <= 31) { 242 | return CharKeyEvent::new(chr( 243 | $code - ord("\x1C") + ord('4') 244 | ), KeyModifiers::CONTROL); 245 | } 246 | 247 | // control space 248 | if ($char === "\x0") { 249 | return CharKeyEvent::new(' ', KeyModifiers::CONTROL); 250 | } 251 | 252 | $char = implode('', $buffer); 253 | if (false === mb_check_encoding($char, 'utf-8')) { 254 | // this function either throws an exception or 255 | // returns NULL indicating we need to parse more bytes 256 | $this->parseUtf8($buffer); 257 | 258 | return null; 259 | } 260 | 261 | return $this->charToEvent($char); 262 | } 263 | 264 | private function charToEvent(string $char): Event 265 | { 266 | $modifiers = 0; 267 | $ord = ord($char); 268 | if ($ord >= 65 && $ord <= 90) { 269 | $modifiers = KeyModifiers::SHIFT; 270 | } 271 | 272 | return CharKeyEvent::new($char, $modifiers); 273 | } 274 | 275 | /** 276 | * @param string[] $buffer 277 | */ 278 | private function parseCsiModifierKeyCode(array $buffer): Event 279 | { 280 | $str = implode('', array_slice($buffer, 2, (int)array_key_last($buffer))); 281 | // split string into bytes 282 | $parts = explode(';', $str); 283 | 284 | [$modifiers, $kind] = (function () use ($parts): array { 285 | $modifierAndKindCode = $this->modifierAndKindParsed($parts); 286 | if (null !== $modifierAndKindCode) { 287 | return [ 288 | $this->parseModifiers($modifierAndKindCode[0]), 289 | $this->parseKeyEventKind($modifierAndKindCode[1]), 290 | ]; 291 | } 292 | 293 | // TODO: if buffer.len > 3 294 | 295 | return [KeyModifiers::NONE, KeyEventKind::Press]; 296 | })(); 297 | 298 | $key = $buffer[array_key_last($buffer)]; 299 | $codedKey = match ($key) { 300 | 'A' => KeyCode::Up, 301 | 'B' => KeyCode::Down, 302 | 'C' => KeyCode::Right, 303 | 'D' => KeyCode::Left, 304 | 'F' => KeyCode::End, 305 | 'H' => KeyCode::Home, 306 | default => null, 307 | }; 308 | if (null !== $codedKey) { 309 | return CodedKeyEvent::new($codedKey, $modifiers, $kind); 310 | } 311 | $fNumber = match ($key) { 312 | 'P' => 1, 313 | 'Q' => 2, 314 | 'R' => 3, 315 | 'S' => 4, 316 | default => null, 317 | }; 318 | if (null !== $fNumber) { 319 | return FunctionKeyEvent::new($fNumber, $modifiers, $kind); 320 | } 321 | 322 | throw new ParseError('Could not parse event'); 323 | } 324 | 325 | /** 326 | * @param string[] $parts 327 | * @return ?array{int,int} 328 | */ 329 | private function modifierAndKindParsed(array $parts): ?array 330 | { 331 | if (!isset($parts[1])) { 332 | throw new ParseError('Could not parse modifier'); 333 | } 334 | $parts = explode(':', $parts[1]); 335 | $modifierMask = $this->filterToInt($parts[0]); 336 | if (null === $modifierMask) { 337 | return null; 338 | } 339 | if (isset($parts[1])) { 340 | $kindCode = $this->filterToInt($parts[1]); 341 | if (null === $kindCode) { 342 | return null; 343 | } 344 | 345 | return [$modifierMask, $kindCode]; 346 | } 347 | 348 | return [$modifierMask, 1]; 349 | } 350 | 351 | private function filterToInt(string $substr): ?int 352 | { 353 | $str = array_reduce( 354 | str_split($substr), 355 | function (string $ac, string $char): string { 356 | if (false === is_numeric($char)) { 357 | return $ac; 358 | } 359 | 360 | return $ac . $char; 361 | }, 362 | '' 363 | ); 364 | if ($str === '') { 365 | return null; 366 | } 367 | 368 | return (int) $str; 369 | } 370 | 371 | /** 372 | * @return int-mask-of 373 | */ 374 | private function parseModifiers(int $mask): int 375 | { 376 | $modifierMask = max(0, $mask - 1); 377 | $modifiers = KeyModifiers::NONE; 378 | if (($modifierMask & 1) !== 0) { 379 | $modifiers |= KeyModifiers::SHIFT; 380 | } 381 | if (($modifierMask & 2) !== 0) { 382 | $modifiers |= KeyModifiers::ALT; 383 | } 384 | if (($modifierMask & 4) !== 0) { 385 | $modifiers |= KeyModifiers::CONTROL; 386 | } 387 | if (($modifierMask & 8) !== 0) { 388 | $modifiers |= KeyModifiers::SUPER; 389 | } 390 | if (($modifierMask & 16) !== 0) { 391 | $modifiers |= KeyModifiers::HYPER; 392 | } 393 | if (($modifierMask & 32) !== 0) { 394 | $modifiers |= KeyModifiers::META; 395 | } 396 | 397 | return $modifiers; 398 | } 399 | 400 | private function parseKeyEventKind(int $kind): KeyEventKind 401 | { 402 | return match ($kind) { 403 | 1 => KeyEventKind::Press, 404 | 2 => KeyEventKind::Repeat, 405 | 3 => KeyEventKind::Release, 406 | default => KeyEventKind::Press, 407 | }; 408 | } 409 | 410 | /** 411 | * @param string[] $buffer 412 | */ 413 | private function parseCsiNormalMouse(array $buffer): ?Event 414 | { 415 | if (count($buffer) < 6) { 416 | return null; 417 | } 418 | 419 | $cb = ord($buffer[3]) - 32; 420 | if ($cb < 0) { 421 | throw ParseError::couldNotParseOffset($buffer, 3, 'invalid button click value'); 422 | } 423 | [$kind, $modifiers, $button] = $this->parseCb($cb); 424 | 425 | // See http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking 426 | // The upper left character position on the terminal is denoted as 1,1. 427 | // Subtract 1 to keep it synced with cursor 428 | $cx = max(0, (ord($buffer[4]) - 32) - 1); 429 | $cy = max(0, (ord($buffer[5]) - 32) - 1); 430 | 431 | return MouseEvent::new($kind, $button, $cx, $cy, $modifiers); 432 | } 433 | 434 | /** 435 | * Cb is the byte of a mouse input that contains the button being used, the key modifiers being 436 | * held and whether the mouse is dragging or not. 437 | * 438 | * Bit layout of cb, from low to high: 439 | * 440 | * - button number 441 | * - button number 442 | * - shift 443 | * - meta (alt) 444 | * - control 445 | * - mouse is dragging 446 | * - button number 447 | * - button number 448 | * 449 | * @return array{MouseEventKind,int-mask-of,MouseButton} 450 | */ 451 | private function parseCb(int $cb): array 452 | { 453 | $buttonNumber = ($cb & 0b0000_0011) | (($cb & 0b1100_0000) >> 4); 454 | $dragging = ($cb & 0b0010_0000) === 0b0010_0000; 455 | 456 | [$kind, $button] = match ([$buttonNumber, $dragging]) { 457 | [0, false] => [MouseEventKind::Down, MouseButton::Left], 458 | [1, false] => [MouseEventKind::Down, MouseButton::Middle], 459 | [2, false] => [MouseEventKind::Down, MouseButton::Right], 460 | [0, true] => [MouseEventKind::Drag, MouseButton::Left], 461 | [1, true] => [MouseEventKind::Drag, MouseButton::Middle], 462 | [2, true] => [MouseEventKind::Drag, MouseButton::Right], 463 | [3, true], [4, true], [5, true] => [MouseEventKind::Moved, MouseButton::None], 464 | [4, false] => [MouseEventKind::ScrollUp, MouseButton::None], 465 | [5, false] => [MouseEventKind::ScrollDown, MouseButton::None], 466 | [6, false] => [MouseEventKind::ScrollLeft, MouseButton::None], 467 | [7, false] => [MouseEventKind::ScrollRight, MouseButton::None], 468 | default => throw new ParseError(sprintf( 469 | 'Could not parse mouse event: button number: %d, dragging: %s', 470 | $buttonNumber, 471 | $dragging ? 'true' : 'false' 472 | )) 473 | }; 474 | $modifiers = KeyModifiers::NONE; 475 | 476 | if (($cb & 0b0000_0100) === 0b0000_0100) { 477 | $modifiers |= KeyModifiers::SHIFT; 478 | } 479 | if (($cb & 0b0000_1000) === 0b0000_1000) { 480 | $modifiers |= KeyModifiers::ALT; 481 | } 482 | if (($cb & 0b0001_0000) === 0b0001_0000) { 483 | $modifiers |= KeyModifiers::CONTROL; 484 | } 485 | 486 | return [$kind, $modifiers, $button]; 487 | } 488 | 489 | /** 490 | * @param string[] $buffer 491 | */ 492 | private function parseCsiRxvtMouse(array $buffer): Event 493 | { 494 | $s = implode('', array_slice($buffer, 2, -1)); 495 | $split = explode(';', $s); 496 | if (!array_key_exists(2, $split)) { 497 | throw new ParseError(sprintf( 498 | 'Could not parse RXVT mouse seq: %s', 499 | $s 500 | )); 501 | } 502 | [$kind, $modifiers, $button] = $this->parseCb((int) ($split[0]) - 32); 503 | $cx = (int) ($split[1]) - 1; 504 | $cy = (int) ($split[2]) - 1; 505 | 506 | return MouseEvent::new( 507 | $kind, 508 | $button, 509 | max(0, $cx), 510 | max(0, $cy), 511 | $modifiers 512 | ); 513 | } 514 | 515 | /** 516 | * @param string[] $buffer 517 | */ 518 | private function parseCsiSgrMouse(array $buffer): ?Event 519 | { 520 | $lastChar = $buffer[array_key_last($buffer)]; 521 | if (!in_array($lastChar, ['m', 'M'], true)) { 522 | return null; 523 | } 524 | $s = implode('', array_slice($buffer, 3, -1)); 525 | $split = explode(';', $s); 526 | [$kind, $modifiers, $button] = $this->parseCb((int) ($split[0])); 527 | $cx = (int) ($split[1]) - 1; 528 | $cy = (int) ($split[2]) - 1; 529 | 530 | if ($lastChar === 'm') { 531 | $kind = match ($kind) { 532 | MouseEventKind::Down => MouseEventKind::Up, 533 | default => $kind, 534 | }; 535 | } 536 | 537 | return MouseEvent::new( 538 | $kind, 539 | $button, 540 | max(0, $cx), 541 | max(0, $cy), 542 | $modifiers 543 | ); 544 | } 545 | 546 | /** 547 | * @param string[] $buffer 548 | */ 549 | private function parseCsiCursorPosition(array $buffer): ?Event 550 | { 551 | $s = implode('', array_slice($buffer, 2, -1)); 552 | $split = explode(';', $s); 553 | if (count($split) !== 2) { 554 | return null; 555 | } 556 | 557 | return new CursorPositionEvent( 558 | max(0, (int) ($split[1]) - 1), 559 | max(0, (int) ($split[0]) - 1), 560 | ); 561 | } 562 | 563 | /** 564 | * @param non-empty-array $buffer 565 | */ 566 | private function parseUtf8(array $buffer): void 567 | { 568 | $firstByte = $buffer[0]; 569 | $ord = ord($firstByte); 570 | 571 | $requiredBytes = match(true) { 572 | ($ord <= 0x7F) => 1, 573 | ($ord >= 0xC0 && $ord <= 0xDF) => 2, 574 | ($ord >= 0xE0 && $ord <= 0xEF) => 3, 575 | ($ord >= 0xF0 && $ord <= 0xF7) => 4, 576 | default => throw new ParseError('Could not parse'), 577 | }; 578 | 579 | // NOTE: not sure why this is here... 580 | // https://github.com/crossterm-rs/crossterm/blob/08762b3ef4519e7f834453bf91e3fe36f4c63fe7/src/event/sys/unix/parse.rs#L845-L846 581 | // 582 | // More than 1 byte, check them for 10xxxxxx pattern 583 | if ($requiredBytes > 1 && count($buffer) > 1) { 584 | foreach (array_slice($buffer, 1) as $byte) { 585 | if ((ord($byte) & ~0b0011_1111) != 0b1000_0000) { 586 | throw new ParseError('Could not parse event'); 587 | } 588 | } 589 | } 590 | 591 | if (count($buffer) < $requiredBytes) { 592 | // all bytes look good so far, but we need more 593 | return; 594 | } 595 | 596 | throw new ParseError('Could not parse UTF-8'); 597 | } 598 | } 599 | -------------------------------------------------------------------------------- /src/EventProvider.php: -------------------------------------------------------------------------------- 1 | $providers 12 | */ 13 | public function __construct(private array $providers) 14 | { 15 | } 16 | 17 | public function next(): ?Event 18 | { 19 | foreach ($this->providers as $provider) { 20 | $next = $provider->next(); 21 | if (null !== $next) { 22 | return $next; 23 | } 24 | } 25 | 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/EventProvider/ArrayEventProvider.php: -------------------------------------------------------------------------------- 1 | $events 14 | */ 15 | public function __construct(private array $events) 16 | { 17 | } 18 | 19 | public static function fromEvents(?Event ...$events): self 20 | { 21 | return new self(array_values($events)); 22 | } 23 | 24 | public function next(): ?Event 25 | { 26 | return array_shift($this->events); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/EventProvider/SignalEventProvider.php: -------------------------------------------------------------------------------- 1 | buffer)) { 32 | return $event; 33 | } 34 | while (null !== $line = $this->reader->read()) { 35 | // TODO: pass true here if we read as much as we could as there 36 | // _could_ still be more in this case. 37 | $this->parser->advance($line, more: false); 38 | } 39 | foreach ($this->parser->drain() as $event) { 40 | $this->buffer[] = $event; 41 | } 42 | while ($event = array_shift($this->buffer)) { 43 | return $event; 44 | } 45 | 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Focus.php: -------------------------------------------------------------------------------- 1 | $classFqn 12 | * @return T 13 | */ 14 | public function for(string $classFqn): ?TerminalInformation; 15 | } 16 | -------------------------------------------------------------------------------- /src/InformationProvider/AggregateInformationProvider.php: -------------------------------------------------------------------------------- 1 | providers as $provider) { 22 | $information = $provider->for($classFqn); 23 | if (null !== $information) { 24 | return $information; 25 | } 26 | } 27 | 28 | return null; 29 | } 30 | 31 | /** 32 | * @param InformationProvider[] $providers 33 | */ 34 | public static function new(array $providers): self 35 | { 36 | return new self($providers); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/InformationProvider/ClosureInformationProvider.php: -------------------------------------------------------------------------------- 1 | ): (T|null) $closure 19 | */ 20 | private function __construct(private readonly Closure $closure) 21 | { 22 | } 23 | 24 | public function for(string $classFqn): ?TerminalInformation 25 | { 26 | return ($this->closure)($classFqn); 27 | } 28 | 29 | /** 30 | * @template T of TerminalInformation 31 | * @param Closure(class-string): (T|null) $closure 32 | */ 33 | public static function new(Closure $closure): self 34 | { 35 | return new self($closure); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/InformationProvider/SizeFromEnvVarProvider.php: -------------------------------------------------------------------------------- 1 | queryStty(); 30 | if (null === $out) { 31 | return null; 32 | } 33 | 34 | /** 35 | * @phpstan-ignore-next-line */ 36 | return $this->parse($out); 37 | 38 | } 39 | 40 | private function queryStty(): ?string 41 | { 42 | $result = $this->runner->run(['stty', '-a']); 43 | if ($result->exitCode !== 0) { 44 | return null; 45 | } 46 | 47 | return $result->stdout; 48 | } 49 | 50 | private function parse(string $out): ?Size 51 | { 52 | if (!preg_match('{rows ([0-9]+); columns ([0-9]+);}is', $out, $matches)) { 53 | if (!preg_match('{;.(\d+).rows;.(\d+).columns}i', $out, $matches)) { 54 | return null; 55 | } 56 | } 57 | 58 | return new Size(max(0, (int) ($matches[1])), max(0, (int) ($matches[2]))); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/KeyCode.php: -------------------------------------------------------------------------------- 1 | $modifierMask 19 | */ 20 | public static function toString(int $modifierMask): string 21 | { 22 | $modifiers = []; 23 | if ($modifierMask === self::NONE) { 24 | return 'none'; 25 | } 26 | if (($modifierMask & self::SHIFT) !== 0) { 27 | $modifiers[] = 'shift'; 28 | } 29 | if (($modifierMask & self::CONTROL) !== 0) { 30 | $modifiers[] = 'ctl'; 31 | } 32 | if (($modifierMask & self::ALT) !== 0) { 33 | $modifiers[] = 'alt'; 34 | } 35 | if (($modifierMask & self::SUPER) !== 0) { 36 | $modifiers[] = 'super'; 37 | } 38 | if (($modifierMask & self::HYPER) !== 0) { 39 | $modifiers[] = 'hyper'; 40 | } 41 | if (($modifierMask & self::META) !== 0) { 42 | $modifiers[] = 'meta'; 43 | } 44 | 45 | return implode(',', $modifiers); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/MouseButton.php: -------------------------------------------------------------------------------- 1 | drawCommand($action); 60 | } 61 | } 62 | 63 | private function drawCommand(Action $action): void 64 | { 65 | if ($action instanceof PrintString) { 66 | $this->writer->write($action->string); 67 | 68 | return; 69 | } 70 | if ($action instanceof SetForegroundColor && $action->color === Colors::Reset) { 71 | $this->writer->write($this->csi('39m')); 72 | 73 | return; 74 | } 75 | if ($action instanceof SetBackgroundColor && $action->color === Colors::Reset) { 76 | $this->writer->write($this->csi('49m')); 77 | 78 | return; 79 | } 80 | 81 | if ($action instanceof EnableMouseCapture) { 82 | $this->writer->write(implode('', array_map(fn (string $code): string => $this->csi($code), $action->enable ? [ 83 | // Normal tracking: Send mouse X & Y on button press and release 84 | '?1000h', 85 | // Button-event tracking: Report button motion events (dragging) 86 | '?1002h', 87 | // Any-event tracking: Report all motion events 88 | '?1003h', 89 | // RXVT mouse mode: Allows mouse coordinates of >223 90 | '?1015h', 91 | // SGR mouse mode: Allows mouse coordinates of >223, preferred over RXVT mode 92 | '?1006h', 93 | ] : [ 94 | // same as above but reversed 95 | '?1006l', 96 | '?1015l', 97 | '?1003l', 98 | '?1002l', 99 | '?1000l', 100 | ]))); 101 | 102 | return; 103 | } 104 | 105 | if ($action instanceof SaveCursorPosition) { 106 | $this->writer->write("\x1B7"); 107 | return; 108 | } 109 | if ($action instanceof RestoreCursorPosition) { 110 | $this->writer->write("\x1B8"); 111 | return; 112 | } 113 | 114 | if ($action instanceof SetTerminalTitle) { 115 | $this->writer->write($this->osc(sprintf("0;%s\x07", $action->title))); 116 | return; 117 | } 118 | 119 | if ($action instanceof SetCursorStyle) { 120 | $this->writer->write(sprintf("\x1b[%d q", match($action->cursorStyle) { 121 | CursorStyle::DefaultUserShape => 0, 122 | CursorStyle::BlinkingBlock => 1, 123 | CursorStyle::SteadyBlock => 2, 124 | CursorStyle::BlinkingUnderScore => 3, 125 | CursorStyle::SteadyUnderScore => 4, 126 | CursorStyle::BlinkingBar => 5, 127 | CursorStyle::SteadyBar => 6, 128 | 129 | })); 130 | return; 131 | } 132 | 133 | $this->writer->write($this->csi(match (true) { 134 | $action instanceof SetForegroundColor => sprintf('%dm', $this->colorIndex($action->color, false)), 135 | $action instanceof SetBackgroundColor => sprintf('%dm', $this->colorIndex($action->color, true)), 136 | $action instanceof SetRgbBackgroundColor => sprintf('48;2;%d;%d;%dm', $action->r, $action->g, $action->b), 137 | $action instanceof SetRgbForegroundColor => sprintf('38;2;%d;%d;%dm', $action->r, $action->g, $action->b), 138 | $action instanceof CursorShow => sprintf('?25%s', $action->show ? 'h' : 'l'), 139 | $action instanceof AlternateScreenEnable => sprintf('?1049%s', $action->enable ? 'h' : 'l'), 140 | $action instanceof MoveCursor => sprintf('%d;%dH', $action->line, $action->col), 141 | $action instanceof Reset => '0m', 142 | $action instanceof ScrollUp => sprintf('%dS', $action->rows), 143 | $action instanceof ScrollDown => sprintf('%dT', $action->rows), 144 | $action instanceof EnableLineWrap => $action->enable ? '?7h' : '?7l', 145 | $action instanceof Clear => match ($action->clearType) { 146 | ClearType::All => '2J', 147 | ClearType::Purge => '3J', 148 | ClearType::FromCursorDown => 'J', 149 | ClearType::FromCursorUp => '1J', 150 | ClearType::CurrentLine => '2K', 151 | ClearType::UntilNewLine => 'K', 152 | }, 153 | $action instanceof SetModifier => $action->enable ? 154 | sprintf('%dm', $this->modifierOnIndex($action->modifier)) : 155 | sprintf('%dm', $this->modifierOffIndex($action->modifier)), 156 | $action instanceof RequestCursorPosition => '6n', 157 | $action instanceof MoveCursorNextLine => sprintf('%dE', $action->nbLines), 158 | $action instanceof MoveCursorPrevLine => sprintf('%dF', $action->nbLines), 159 | $action instanceof MoveCursorToColumn => sprintf('%dG', $action->col + 1), 160 | $action instanceof MoveCursorToRow => sprintf('%dd', $action->row + 1), 161 | $action instanceof MoveCursorUp => sprintf('%dA', $action->lines), 162 | $action instanceof MoveCursorRight => sprintf('%dC', $action->cols), 163 | $action instanceof MoveCursorDown => sprintf('%dB', $action->lines), 164 | $action instanceof MoveCursorLeft => sprintf('%dD', $action->cols), 165 | $action instanceof EnableCursorBlinking => $action->enable ? '?12h' : '?12l', 166 | default => throw new RuntimeException(sprintf( 167 | 'Do not know how to handle action: %s', 168 | $action::class 169 | )) 170 | })); 171 | } 172 | 173 | private function colorIndex(Colors $termColor, bool $background): int 174 | { 175 | $offset = $background ? 10 : 0; 176 | 177 | return match ($termColor) { 178 | Colors::Black => 30, 179 | Colors::Red => 31, 180 | Colors::Green => 32, 181 | Colors::Yellow => 33, 182 | Colors::Blue => 34, 183 | Colors::Magenta => 35, 184 | Colors::Cyan => 36, 185 | Colors::Gray => 37, 186 | Colors::DarkGray => 90, 187 | Colors::LightRed => 91, 188 | Colors::LightGreen => 92, 189 | Colors::LightYellow => 93, 190 | Colors::LightBlue => 94, 191 | Colors::LightMagenta => 95, 192 | Colors::LightCyan => 96, 193 | Colors::White => 97, 194 | default => throw new RuntimeException(sprintf('Do not know how to handle color: %s', $termColor->name)), 195 | } + $offset; 196 | } 197 | 198 | private function modifierOnIndex(Attribute $modifier): int 199 | { 200 | return match($modifier) { 201 | Attribute::Reset => 0, 202 | Attribute::Bold => 1, 203 | Attribute::Dim => 2, 204 | Attribute::Italic => 3, 205 | Attribute::Underline => 4, 206 | Attribute::SlowBlink => 5, 207 | Attribute::RapidBlink => 6, 208 | Attribute::Hidden => 8, 209 | Attribute::Strike => 9, 210 | Attribute::Reverse => 7, 211 | }; 212 | } 213 | 214 | private function modifierOffIndex(Attribute $modifier): int 215 | { 216 | return match($modifier) { 217 | Attribute::Reset => 0, 218 | Attribute::Bold => 22, 219 | Attribute::Dim => 22, 220 | Attribute::Italic => 23, 221 | Attribute::Underline => 24, 222 | Attribute::SlowBlink => 25, 223 | // same code as disabling slow blink according to crossterm 224 | Attribute::RapidBlink => 25, 225 | Attribute::Hidden => 28, 226 | Attribute::Strike => 29, 227 | Attribute::Reverse => 27, 228 | }; 229 | } 230 | 231 | /** 232 | * Control sequence introducer 233 | */ 234 | private function csi(string $action): string 235 | { 236 | return sprintf("\x1B[%s", $action); 237 | } 238 | 239 | /** 240 | * Operating system command 241 | */ 242 | private function osc(string $action): string 243 | { 244 | return sprintf("\x1B]%s", $action); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/Painter/ArrayPainter.php: -------------------------------------------------------------------------------- 1 | actions = array_merge($this->actions, $actions); 31 | } 32 | /** 33 | * @return Action[] 34 | */ 35 | public function actions(): array 36 | { 37 | return $this->actions; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Painter/StringPainter.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | private array $grid = []; 17 | 18 | private int $cursorX = 0; 19 | 20 | private int $cursorY = 0; 21 | 22 | public function paint(array $actions): void 23 | { 24 | foreach ($actions as $action) { 25 | if ($action instanceof PrintString) { 26 | $this->printString($action); 27 | } 28 | if ($action instanceof MoveCursor) { 29 | $this->cursorX = $action->col - 1; 30 | $this->cursorY = $action->line - 1; 31 | } 32 | } 33 | } 34 | 35 | public function toString(): string 36 | { 37 | if ($this->grid === []) { 38 | return ''; 39 | } 40 | $maxX = max( 41 | 0, 42 | ...array_map( 43 | static fn (array $cells): mixed => max( 44 | array_keys($cells) 45 | ), 46 | $this->grid 47 | ) 48 | ); 49 | $lines = []; 50 | foreach ($this->grid as $line => &$cells) { 51 | for ($i = 0; $i <= $maxX; $i++) { 52 | if (!isset($cells[$i])) { 53 | $cells[$i] = ' '; 54 | } 55 | } 56 | ksort($cells); 57 | $lines[] = implode('', $cells); 58 | } 59 | 60 | return implode("\n", $lines); 61 | } 62 | 63 | private function printString(PrintString $action): void 64 | { 65 | foreach (mb_str_split($action->string) as $char) { 66 | $this->paintChar($this->cursorX, $this->cursorY, $char); 67 | $this->cursorX++; 68 | } 69 | } 70 | 71 | private function paintChar(int $x, int $y, string $char): void 72 | { 73 | if (!isset($this->grid[$y][$x])) { 74 | $this->grid[$y][$x] = ' '; 75 | } 76 | $this->grid[$y][$x] = $char; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ParseError.php: -------------------------------------------------------------------------------- 1 | closure)($command); 26 | } 27 | 28 | /** 29 | * @param Closure(string[]): ProcessResult $closure 30 | */ 31 | public static function new(Closure $closure): self 32 | { 33 | return new self($closure); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ProcessRunner/ProcRunner.php: -------------------------------------------------------------------------------- 1 | ['pipe', 'w'], 17 | 2 => ['pipe', 'w'], 18 | ]; 19 | $process = proc_open($command, $spec, $pipes, null, null, ['suppress_errors' => true]); 20 | if (!\is_resource($process)) { 21 | throw new RuntimeException(sprintf('Could not spawn process: "%s"', implode('", "', $command))); 22 | } 23 | 24 | $stdout = stream_get_contents($pipes[1]); 25 | if ($stdout === false) { 26 | throw new RuntimeException('Could not read from stdout stream'); 27 | } 28 | 29 | $stderr = stream_get_contents($pipes[2]); 30 | if ($stderr === false) { 31 | throw new RuntimeException('Could not read from stderr stream'); 32 | } 33 | 34 | fclose($pipes[1]); 35 | fclose($pipes[2]); 36 | $exitCode = proc_close($process); 37 | 38 | return new ProcessResult($exitCode, $stdout, $stderr); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/RawMode.php: -------------------------------------------------------------------------------- 1 | originalSettings) { 28 | return; 29 | } 30 | 31 | $result = $this->runner->run(['stty', '-g']); 32 | if ($result->exitCode !== 0) { 33 | throw new RuntimeException( 34 | 'Could not get stty settings' 35 | ); 36 | } 37 | 38 | $this->originalSettings = trim($result->stdout); 39 | 40 | $result = $this->runner->run(['stty', 'raw']); 41 | if ($result->exitCode !== 0) { 42 | throw new RuntimeException( 43 | 'Could not set raw mode' 44 | ); 45 | } 46 | $result = $this->runner->run(['stty', '-echo']); 47 | if ($result->exitCode !== 0) { 48 | throw new RuntimeException( 49 | 'Could not disable echo' 50 | ); 51 | } 52 | } 53 | 54 | public function disable(): void 55 | { 56 | if (null === $this->originalSettings) { 57 | return; 58 | } 59 | $result = $this->runner->run(['stty', $this->originalSettings]); 60 | if ($result->exitCode !== 0) { 61 | throw new RuntimeException(sprintf( 62 | 'Could not restore from raw mode: %s', 63 | $result->stderr 64 | )); 65 | } 66 | $this->originalSettings = null; 67 | } 68 | 69 | public function isEnabled(): bool 70 | { 71 | return $this->originalSettings !== null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/RawMode/TestRawMode.php: -------------------------------------------------------------------------------- 1 | enabled = true; 16 | } 17 | 18 | public function disable(): void 19 | { 20 | $this->enabled = false; 21 | } 22 | 23 | public function isEnabled(): bool 24 | { 25 | return $this->enabled; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Reader.php: -------------------------------------------------------------------------------- 1 | chunks)) { 24 | return $chunk; 25 | } 26 | 27 | return null; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Reader/StreamReader.php: -------------------------------------------------------------------------------- 1 | stream); 30 | if ('' === $bytes || false === $bytes) { 31 | return null; 32 | } 33 | 34 | return $bytes; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Terminal.php: -------------------------------------------------------------------------------- 1 | $classFqn 61 | * @return T|null 62 | */ 63 | public function info(string $classFqn): ?object 64 | { 65 | return $this->infoProvider->for($classFqn); 66 | } 67 | 68 | /** 69 | * Queue a painter action. 70 | */ 71 | public function queue(Action ...$actions): self 72 | { 73 | foreach ($actions as $action) { 74 | $this->queue[] = $action; 75 | } 76 | 77 | return $this; 78 | } 79 | 80 | public function events(): EventProvider 81 | { 82 | return $this->eventProvider; 83 | } 84 | 85 | public function enableRawMode(): void 86 | { 87 | $this->rawMode->enable(); 88 | } 89 | 90 | public function disableRawMode(): void 91 | { 92 | $this->rawMode->disable(); 93 | } 94 | 95 | public function flush(): self 96 | { 97 | $this->painter->paint($this->queue); 98 | $this->queue = []; 99 | 100 | return $this; 101 | } 102 | 103 | public function execute(Action ...$actions): void 104 | { 105 | foreach ($actions as $action) { 106 | $this->painter->paint([$action]); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/TerminalInformation.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public int $lines, 17 | /** 18 | * @var int<0,max> 19 | */ 20 | public int $cols 21 | ) { 22 | } 23 | 24 | public function __toString(): string 25 | { 26 | return sprintf('%dx%d', $this->cols, $this->lines); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Writer.php: -------------------------------------------------------------------------------- 1 | stream, substr($bytes, $written)); 32 | if ($fwritten === false) { 33 | return; 34 | } 35 | $written += $fwritten; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Writer/StringWriter.php: -------------------------------------------------------------------------------- 1 | buffer .= $bytes; 23 | } 24 | 25 | public function toString(): string 26 | { 27 | return $this->buffer; 28 | } 29 | } 30 | --------------------------------------------------------------------------------