├── .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 | [](https://github.com/php-tui/term/actions/workflows/ci.yml)
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------