├── .gitignore ├── phpunit.xml ├── src ├── IO │ ├── OutputStream.php │ ├── InputStream.php │ ├── BufferedOutput.php │ ├── ResourceOutputStream.php │ └── ResourceInputStream.php ├── Exception │ └── NotInteractiveTerminal.php ├── NonCanonicalReader.php ├── Terminal.php ├── InputCharacter.php └── UnixTerminal.php ├── .travis.yml ├── test ├── Exception │ └── NotInteractiveTerminalTest.php ├── IO │ ├── ResourceOutputStreamTest.php │ ├── ResourceInputStreamTest.php │ └── BufferedOutputTest.php ├── NonCanonicalReaderTest.php ├── InputCharacterTest.php └── UnixTerminalTest.php ├── README.md └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .idea 3 | /composer.lock 4 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./test 6 | 7 | 8 | 9 | ./src 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/IO/OutputStream.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface OutputStream 9 | { 10 | /** 11 | * Write the buffer to the stream 12 | */ 13 | public function write(string $buffer) : void; 14 | 15 | /** 16 | * Whether the stream is connected to an interactive terminal 17 | */ 18 | public function isInteractive() : bool; 19 | } 20 | -------------------------------------------------------------------------------- /src/IO/InputStream.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface InputStream 9 | { 10 | /** 11 | * Callback should be called with the number of bytes requested 12 | * when ready. 13 | */ 14 | public function read(int $numBytes, callable $callback) : void; 15 | 16 | /** 17 | * Whether the stream is connected to an interactive terminal 18 | */ 19 | public function isInteractive() : bool; 20 | } 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | 7 | install: 8 | - composer self-update 9 | - composer install 10 | 11 | before_script: 12 | - mkdir -p build/logs 13 | 14 | script: 15 | - ./vendor/bin/phpunit --coverage-clover ./build/logs/clover.xml 16 | - composer cs 17 | - composer static 18 | 19 | after_script: 20 | - bash <(curl -s https://codecov.io/bash) 21 | - wget https://scrutinizer-ci.com/ocular.phar 22 | - php ocular.phar code-coverage:upload --format=php-clover ./build/logs/clover.xml 23 | -------------------------------------------------------------------------------- /src/Exception/NotInteractiveTerminal.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class NotInteractiveTerminal extends \RuntimeException 9 | { 10 | public static function inputNotInteractive() : self 11 | { 12 | return new self('Input stream is not interactive (non TTY)'); 13 | } 14 | 15 | public static function outputNotInteractive() : self 16 | { 17 | return new self('Output stream is not interactive (non TTY)'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/Exception/NotInteractiveTerminalTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class NotInteractiveTerminalTest extends TestCase 12 | { 13 | public function testInputNotInteractive() : void 14 | { 15 | $e = NotInteractiveTerminal::inputNotInteractive(); 16 | 17 | self::assertEquals('Input stream is not interactive (non TTY)', $e->getMessage()); 18 | } 19 | 20 | public function testOutputNotInteractive() : void 21 | { 22 | $e = NotInteractiveTerminal::outputNotInteractive(); 23 | 24 | self::assertEquals('Output stream is not interactive (non TTY)', $e->getMessage()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/IO/BufferedOutput.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class BufferedOutput implements OutputStream 9 | { 10 | private $buffer = ''; 11 | 12 | public function write(string $buffer): void 13 | { 14 | $this->buffer .= $buffer; 15 | } 16 | 17 | public function fetch(bool $clean = true) : string 18 | { 19 | $buffer = $this->buffer; 20 | 21 | if ($clean) { 22 | $this->buffer = ''; 23 | } 24 | 25 | return $buffer; 26 | } 27 | 28 | public function __toString() : string 29 | { 30 | return $this->fetch(); 31 | } 32 | 33 | /** 34 | * Whether the stream is connected to an interactive terminal 35 | */ 36 | public function isInteractive() : bool 37 | { 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Terminal Utility

2 | 3 |

4 | Small utility to help provide a simple, consise API for terminal interaction 5 |

6 | 7 |

8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

21 | 22 | --- 23 | 24 | ## Install 25 | 26 | ```bash 27 | composer require php-school/terminal 28 | ``` 29 | 30 | ## TODO 31 | 32 | - [ ] Docs 33 | -------------------------------------------------------------------------------- /test/IO/ResourceOutputStreamTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ResourceOutputStreamTest extends TestCase 12 | { 13 | public function testNonStream() : void 14 | { 15 | $this->expectException(\InvalidArgumentException::class); 16 | $this->expectExceptionMessage('Expected a valid stream'); 17 | new ResourceOutputStream(42); 18 | } 19 | 20 | public function testNotWritable() : void 21 | { 22 | $this->expectException(\InvalidArgumentException::class); 23 | $this->expectExceptionMessage('Expected a writable stream'); 24 | new ResourceOutputStream(\STDIN); 25 | } 26 | 27 | public function testWrite() : void 28 | { 29 | $stream = fopen('php://memory', 'r+'); 30 | $outputStream = new ResourceOutputStream($stream); 31 | $outputStream->write('123456789'); 32 | 33 | rewind($stream); 34 | static::assertEquals('123456789', stream_get_contents($stream)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-school/terminal", 3 | "description": "A command line terminal utility in PHP", 4 | "keywords": ["cli", "console", "terminal", "phpschool", "php-school"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Michael Woodward", 9 | "email": "mikeymike.mw@gmail.com" 10 | }, 11 | { 12 | "name": "Aydin Hassan", 13 | "email": "aydin@hotmail.com" 14 | } 15 | ], 16 | "require": { 17 | "php" : ">=7.2" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "^7.1", 21 | "squizlabs/php_codesniffer": "^3.2", 22 | "phpstan/phpstan": "^0.9.2" 23 | }, 24 | "autoload" : { 25 | "psr-4" : { 26 | "PhpSchool\\Terminal\\": "src" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { "PhpSchool\\TerminalTest\\": "test" } 31 | }, 32 | "scripts" : { 33 | "cs" : [ 34 | "phpcs src --standard=PSR2", 35 | "phpcs test --standard=PSR2" 36 | ], 37 | "static" : [ 38 | "phpstan analyse src --level=7" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/IO/ResourceOutputStream.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class ResourceOutputStream implements OutputStream 14 | { 15 | /** 16 | * @var resource 17 | */ 18 | private $stream; 19 | 20 | public function __construct($stream = \STDOUT) 21 | { 22 | if (!is_resource($stream) || get_resource_type($stream) !== 'stream') { 23 | throw new \InvalidArgumentException('Expected a valid stream'); 24 | } 25 | 26 | $meta = stream_get_meta_data($stream); 27 | if (strpos($meta['mode'], 'r') !== false && strpos($meta['mode'], '+') === false) { 28 | throw new \InvalidArgumentException('Expected a writable stream'); 29 | } 30 | 31 | $this->stream = $stream; 32 | } 33 | 34 | public function write(string $buffer): void 35 | { 36 | fwrite($this->stream, $buffer); 37 | } 38 | 39 | /** 40 | * Whether the stream is connected to an interactive terminal 41 | */ 42 | public function isInteractive() : bool 43 | { 44 | return stream_isatty($this->stream); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/IO/ResourceInputStreamTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ResourceInputStreamTest extends TestCase 12 | { 13 | public function testNonStream() : void 14 | { 15 | $this->expectException(\InvalidArgumentException::class); 16 | $this->expectExceptionMessage('Expected a valid stream'); 17 | new ResourceInputStream(42); 18 | } 19 | 20 | public function testNotReadable() : void 21 | { 22 | $this->expectException(\InvalidArgumentException::class); 23 | $this->expectExceptionMessage('Expected a readable stream'); 24 | new ResourceInputStream(\STDOUT); 25 | } 26 | 27 | public function testRead() : void 28 | { 29 | $stream = fopen('php://memory', 'r+'); 30 | fwrite($stream, '1234'); 31 | rewind($stream); 32 | 33 | $inputStream = new ResourceInputStream($stream); 34 | 35 | $input = ''; 36 | $inputStream->read(4, function ($buffer) use (&$input) { 37 | $input .= $buffer; 38 | }); 39 | 40 | static::assertSame('1234', $input); 41 | 42 | fclose($stream); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/IO/BufferedOutputTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class BufferedOutputTest extends TestCase 12 | { 13 | public function testFetch() : void 14 | { 15 | $output = new BufferedOutput; 16 | $output->write('one'); 17 | 18 | static::assertEquals('one', $output->fetch()); 19 | } 20 | 21 | public function testFetchWithMultipleWrites() : void 22 | { 23 | $output = new BufferedOutput; 24 | $output->write('one'); 25 | $output->write('two'); 26 | 27 | static::assertEquals('onetwo', $output->fetch()); 28 | } 29 | 30 | public function testFetchCleansBufferByDefault() : void 31 | { 32 | $output = new BufferedOutput; 33 | $output->write('one'); 34 | 35 | static::assertEquals('one', $output->fetch()); 36 | static::assertEquals('', $output->fetch()); 37 | } 38 | 39 | public function testFetchWithoutCleaning() : void 40 | { 41 | $output = new BufferedOutput; 42 | $output->write('one'); 43 | 44 | static::assertEquals('one', $output->fetch(false)); 45 | 46 | $output->write('two'); 47 | 48 | static::assertEquals('onetwo', $output->fetch(false)); 49 | } 50 | 51 | public function testToString() : void 52 | { 53 | $output = new BufferedOutput; 54 | $output->write('one'); 55 | 56 | static::assertEquals('one', (string) $output); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/IO/ResourceInputStream.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class ResourceInputStream implements InputStream 14 | { 15 | /** 16 | * @var resource 17 | */ 18 | private $stream; 19 | 20 | /** 21 | * @var bool Original blocking state. 22 | */ 23 | private $blocking; 24 | 25 | public function __construct($stream = \STDIN) 26 | { 27 | if (!is_resource($stream) || get_resource_type($stream) !== 'stream') { 28 | throw new \InvalidArgumentException('Expected a valid stream'); 29 | } 30 | 31 | $meta = stream_get_meta_data($stream); 32 | if (strpos($meta['mode'], 'r') === false && strpos($meta['mode'], '+') === false) { 33 | throw new \InvalidArgumentException('Expected a readable stream'); 34 | } 35 | 36 | $this->blocking = $meta['blocked']; 37 | $this->stream = $stream; 38 | } 39 | 40 | /** 41 | * Restore the blocking state. 42 | */ 43 | public function __destruct() { 44 | stream_set_blocking($this->stream, $this->blocking); 45 | } 46 | 47 | public function read(int $numBytes, callable $callback) : void 48 | { 49 | $buffer = fread($this->stream, $numBytes); 50 | if (!empty($buffer)) { 51 | // Prevent blocking to handle pasted input. 52 | stream_set_blocking($this->stream, false); 53 | } else { 54 | // Re-enable blocking when input has been handled. 55 | stream_set_blocking($this->stream, true); 56 | } 57 | 58 | $callback($buffer); 59 | } 60 | 61 | /** 62 | * Whether the stream is connected to an interactive terminal 63 | */ 64 | public function isInteractive() : bool 65 | { 66 | return stream_isatty($this->stream); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/NonCanonicalReader.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class NonCanonicalReader 15 | { 16 | /** 17 | * @var Terminal 18 | */ 19 | private $terminal; 20 | 21 | /** 22 | * @var bool 23 | */ 24 | private $wasCanonicalModeEnabled; 25 | 26 | /** 27 | * Map of characters to controls. 28 | * Eg map 'w' to the up control. 29 | * 30 | * @var array 31 | */ 32 | private $mappings = []; 33 | 34 | public function __construct(Terminal $terminal) 35 | { 36 | $this->terminal = $terminal; 37 | $this->wasCanonicalModeEnabled = $terminal->isCanonicalMode(); 38 | $this->terminal->disableCanonicalMode(); 39 | } 40 | 41 | public function addControlMapping(string $character, string $mapToControl) : void 42 | { 43 | if (!InputCharacter::controlExists($mapToControl)) { 44 | throw new \InvalidArgumentException(sprintf('Control "%s" does not exist', $mapToControl)); 45 | } 46 | 47 | $this->mappings[$character] = $mapToControl; 48 | } 49 | 50 | public function addControlMappings(array $mappings) : void 51 | { 52 | foreach ($mappings as $character => $mapToControl) { 53 | $this->addControlMapping($character, $mapToControl); 54 | } 55 | } 56 | 57 | /** 58 | * This should be ran with the terminal canonical mode disabled. 59 | * 60 | * @return InputCharacter 61 | */ 62 | public function readCharacter() : InputCharacter 63 | { 64 | $char = $this->terminal->read(4); 65 | 66 | if (isset($this->mappings[$char])) { 67 | return InputCharacter::fromControlName($this->mappings[$char]); 68 | } 69 | 70 | return new InputCharacter($char); 71 | } 72 | 73 | public function __destruct() 74 | { 75 | if ($this->wasCanonicalModeEnabled) { 76 | $this->terminal->enableCanonicalMode(); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/NonCanonicalReaderTest.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class NonCanonicalReaderTest extends TestCase 14 | { 15 | public function testExceptionIsThrownIfMappingAddedForNonControlCharacter() : void 16 | { 17 | self::expectException(\InvalidArgumentException::class); 18 | self::expectExceptionMessage('Control "w" does not exist'); 19 | 20 | $terminal = $this->createMock(Terminal::class); 21 | $terminalReader = new NonCanonicalReader($terminal); 22 | $terminalReader->addControlMapping('p', 'w'); 23 | } 24 | 25 | public function testExceptionIsThrownIfMappingsAddedForNonControlCharacter() : void 26 | { 27 | self::expectException(\InvalidArgumentException::class); 28 | self::expectExceptionMessage('Control "w" does not exist'); 29 | 30 | $terminal = $this->createMock(Terminal::class); 31 | $terminalReader = new NonCanonicalReader($terminal); 32 | $terminalReader->addControlMappings(['p' => 'w']); 33 | } 34 | 35 | public function testCustomMappingToUpControl() : void 36 | { 37 | $terminal = $this->createMock(Terminal::class); 38 | $terminal 39 | ->expects($this->once()) 40 | ->method('read') 41 | ->with(4) 42 | ->willReturn('w'); 43 | 44 | $terminalReader = new NonCanonicalReader($terminal); 45 | $terminalReader->addControlMapping('w', InputCharacter::UP); 46 | 47 | $char = $terminalReader->readCharacter(); 48 | 49 | self::assertTrue($char->isControl()); 50 | self::assertEquals('UP', $char->getControl()); 51 | self::assertEquals("\033[A", $char->get()); 52 | } 53 | 54 | public function testReadNormalCharacter() : void 55 | { 56 | $terminal = $this->createMock(Terminal::class); 57 | $terminal 58 | ->expects($this->once()) 59 | ->method('read') 60 | ->with(4) 61 | ->willReturn('w'); 62 | 63 | $terminalReader = new NonCanonicalReader($terminal); 64 | 65 | $char = $terminalReader->readCharacter(); 66 | 67 | self::assertFalse($char->isControl()); 68 | self::assertEquals('w', $char->get()); 69 | } 70 | 71 | public function testReadControlCharacter() 72 | { 73 | $terminal = $this->createMock(Terminal::class); 74 | $terminal 75 | ->expects($this->once()) 76 | ->method('read') 77 | ->with(4) 78 | ->willReturn("\n"); 79 | 80 | $terminalReader = new NonCanonicalReader($terminal); 81 | 82 | $char = $terminalReader->readCharacter(); 83 | 84 | self::assertTrue($char->isControl()); 85 | self::assertEquals('ENTER', $char->getControl()); 86 | self::assertEquals("\n", $char->get()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Terminal.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Aydin Hassan 8 | */ 9 | interface Terminal 10 | { 11 | /** 12 | * Get the available width of the terminal 13 | */ 14 | public function getWidth() : int; 15 | 16 | /** 17 | * Get the available height of the terminal 18 | */ 19 | public function getHeight() : int; 20 | 21 | /** 22 | * Get the number of colours the terminal supports (1, 8, 256, true colours) 23 | */ 24 | public function getColourSupport() : int; 25 | 26 | /** 27 | * Disables echoing every character back to the terminal. This means 28 | * we do not have to clear the line when reading. 29 | */ 30 | public function disableEchoBack() : void; 31 | 32 | /** 33 | * Enable echoing back every character input to the terminal. 34 | */ 35 | public function enableEchoBack() : void; 36 | 37 | /** 38 | * Is echo back mode enabled 39 | */ 40 | public function isEchoBack() : bool; 41 | 42 | /** 43 | * Disable canonical input (allow each key press for reading, rather than the whole line) 44 | * 45 | * @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html 46 | */ 47 | public function disableCanonicalMode() : void; 48 | 49 | /** 50 | * Enable canonical input - read input by line 51 | * 52 | * @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html 53 | */ 54 | public function enableCanonicalMode() : void; 55 | 56 | /** 57 | * Is canonical mode enabled or not 58 | */ 59 | public function isCanonicalMode() : bool; 60 | 61 | /** 62 | * Check if the Input & Output streams are interactive. Eg - they are 63 | * connected to a terminal. 64 | * 65 | * @return bool 66 | */ 67 | public function isInteractive() : bool; 68 | 69 | /** 70 | * Restore the terminals original configuration 71 | */ 72 | public function restoreOriginalConfiguration() : void; 73 | 74 | /** 75 | * Test whether terminal supports colour output 76 | */ 77 | public function supportsColour() : bool; 78 | 79 | /** 80 | * Clear the terminal window 81 | */ 82 | public function clear() : void; 83 | 84 | /** 85 | * Clear the current cursors line 86 | */ 87 | public function clearLine() : void; 88 | 89 | /** 90 | * Erase screen from the current line down to the bottom of the screen 91 | */ 92 | public function clearDown() : void; 93 | 94 | /** 95 | * Clean the whole console without jumping the window 96 | */ 97 | public function clean() : void; 98 | 99 | /** 100 | * Enable cursor display 101 | */ 102 | public function enableCursor() : void; 103 | 104 | /** 105 | * Disable cursor display 106 | */ 107 | public function disableCursor() : void; 108 | 109 | /** 110 | * Move the cursor to the top left of the window 111 | */ 112 | public function moveCursorToTop() : void; 113 | 114 | /** 115 | * Move the cursor to the start of a specific row 116 | */ 117 | public function moveCursorToRow(int $rowNumber) : void; 118 | 119 | /** 120 | * Move the cursor to a specific column 121 | */ 122 | public function moveCursorToColumn(int $columnNumber) : void; 123 | 124 | /** 125 | * Read from the input stream 126 | */ 127 | public function read(int $bytes) : string; 128 | 129 | /** 130 | * Write to the output stream 131 | */ 132 | public function write(string $buffer) : void; 133 | } 134 | -------------------------------------------------------------------------------- /test/InputCharacterTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class InputCharacterTest extends TestCase 12 | { 13 | public function testWhenCharacterIsAControl() : void 14 | { 15 | $char = new InputCharacter("\n"); 16 | 17 | self::assertTrue($char->isControl()); 18 | self::assertTrue($char->isHandledControl()); 19 | self::assertFalse($char->isNotControl()); 20 | self::assertEquals('ENTER', $char->getControl()); 21 | self::assertEquals("\n", $char->get()); 22 | self::assertEquals("\n", $char->__toString()); 23 | } 24 | 25 | public function testWhenCharacterIsNotAControl() : void 26 | { 27 | $char = new InputCharacter('p'); 28 | 29 | self::assertFalse($char->isControl()); 30 | self::assertFalse($char->isHandledControl()); 31 | self::assertTrue($char->isNotControl()); 32 | self::assertEquals('p', $char->get()); 33 | self::assertEquals('p', $char->__toString()); 34 | } 35 | 36 | public function testExceptionIsThrownIfGetControlCalledWhenNotAControl() : void 37 | { 38 | self::expectException(\RuntimeException::class); 39 | self::expectExceptionMessage('Character "p" is not a control'); 40 | 41 | $char = new InputCharacter('p'); 42 | $char->getControl(); 43 | } 44 | 45 | public function testGetControls() : void 46 | { 47 | self::assertEquals( 48 | [ 49 | 'UP', 50 | 'DOWN', 51 | 'RIGHT', 52 | 'LEFT', 53 | 'CTRLA', 54 | 'CTRLB', 55 | 'CTRLE', 56 | 'CTRLF', 57 | 'BACKSPACE', 58 | 'CTRLW', 59 | 'ENTER', 60 | 'TAB', 61 | 'ESC', 62 | ], 63 | InputCharacter::getControls() 64 | ); 65 | } 66 | 67 | public function testFromControlNameThrowsExceptionIfControlDoesNotExist() : void 68 | { 69 | self::expectException(\InvalidArgumentException::class); 70 | self::expectExceptionMessage('Control "w" does not exist'); 71 | 72 | InputCharacter::fromControlName('w'); 73 | } 74 | 75 | public function testFromControlName() : void 76 | { 77 | $char = InputCharacter::fromControlName(InputCharacter::UP); 78 | 79 | self::assertTrue($char->isControl()); 80 | self::assertEquals('UP', $char->getControl()); 81 | self::assertEquals("\033[A", $char->get()); 82 | } 83 | 84 | public function testControlExists() : void 85 | { 86 | self::assertTrue(InputCharacter::controlExists(InputCharacter::UP)); 87 | self::assertFalse(InputCharacter::controlExists('w')); 88 | } 89 | 90 | public function testIsControlOnNotExplicitlyHandledControls() : void 91 | { 92 | $char = new InputCharacter("\016"); //ctrl + p (I think) 93 | 94 | self::assertTrue($char->isControl()); 95 | self::assertFalse($char->isHandledControl()); 96 | 97 | $char = new InputCharacter("\021"); //ctrl + u (I think) 98 | 99 | self::assertTrue($char->isControl()); 100 | self::assertFalse($char->isHandledControl()); 101 | } 102 | 103 | public function testUnicodeCharacter() : void 104 | { 105 | $char = new InputCharacter('ß'); 106 | 107 | self::assertFalse($char->isControl()); 108 | self::assertFalse($char->isHandledControl()); 109 | self::assertTrue($char->isNotControl()); 110 | self::assertEquals('ß', $char->get()); 111 | self::assertEquals('ß', $char->__toString()); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/InputCharacter.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class InputCharacter 11 | { 12 | /** 13 | * @var string 14 | */ 15 | private $data; 16 | 17 | public const UP = 'UP'; 18 | public const DOWN = 'DOWN'; 19 | public const RIGHT = 'RIGHT'; 20 | public const LEFT = 'LEFT'; 21 | public const CTRLA = 'CTRLA'; 22 | public const CTRLB = 'CTRLB'; 23 | public const CTRLE = 'CTRLE'; 24 | public const CTRLF = 'CTRLF'; 25 | public const BACKSPACE = 'BACKSPACE'; 26 | public const CTRLW = 'CTRLW'; 27 | public const ENTER = 'ENTER'; 28 | public const TAB = 'TAB'; 29 | public const ESC = 'ESC'; 30 | 31 | private static $controls = [ 32 | "\033[A" => self::UP, 33 | "\033[B" => self::DOWN, 34 | "\033[C" => self::RIGHT, 35 | "\033[D" => self::LEFT, 36 | "\033OA" => self::UP, 37 | "\033OB" => self::DOWN, 38 | "\033OC" => self::RIGHT, 39 | "\033OD" => self::LEFT, 40 | "\001" => self::CTRLA, 41 | "\002" => self::CTRLB, 42 | "\005" => self::CTRLE, 43 | "\006" => self::CTRLF, 44 | "\010" => self::BACKSPACE, 45 | "\177" => self::BACKSPACE, 46 | "\027" => self::CTRLW, 47 | "\n" => self::ENTER, 48 | "\t" => self::TAB, 49 | "\e" => self::ESC, 50 | ]; 51 | 52 | public function __construct(string $data) 53 | { 54 | $this->data = $data; 55 | } 56 | 57 | public function isHandledControl() : bool 58 | { 59 | return isset(static::$controls[$this->data]); 60 | } 61 | 62 | /** 63 | * Is this character a control sequence? 64 | */ 65 | public function isControl() : bool 66 | { 67 | return preg_match('/[\x00-\x1F\x7F]/', $this->data); 68 | } 69 | 70 | /** 71 | * Is this character a normal character? 72 | */ 73 | public function isNotControl() : bool 74 | { 75 | return ! $this->isControl(); 76 | } 77 | 78 | /** 79 | * Get the raw character or control sequence 80 | */ 81 | public function get() : string 82 | { 83 | return $this->data; 84 | } 85 | 86 | /** 87 | * Get the actual control name that this sequence represents. 88 | * One of the class constants. Eg. self::UP. 89 | * 90 | * Throws an exception if the character is not actually a control sequence 91 | */ 92 | public function getControl() : string 93 | { 94 | if (!isset(static::$controls[$this->data])) { 95 | throw new \RuntimeException(sprintf('Character "%s" is not a control', $this->data)); 96 | } 97 | 98 | return static::$controls[$this->data]; 99 | } 100 | 101 | /** 102 | * Get the raw character or control sequence 103 | */ 104 | public function __toString() : string 105 | { 106 | return $this->get(); 107 | } 108 | 109 | /** 110 | * Does the given control name exist? eg self::UP. 111 | */ 112 | public static function controlExists(string $controlName) : bool 113 | { 114 | return in_array($controlName, static::$controls, true); 115 | } 116 | 117 | /** 118 | * Get all of the available control names 119 | */ 120 | public static function getControls() : array 121 | { 122 | return array_values(array_unique(static::$controls)); 123 | } 124 | 125 | /** 126 | * Create a instance from a given control name. Throws an exception if the 127 | * control name does not exist. 128 | */ 129 | public static function fromControlName(string $controlName) : self 130 | { 131 | if (!static::controlExists($controlName)) { 132 | throw new \InvalidArgumentException(sprintf('Control "%s" does not exist', $controlName)); 133 | } 134 | 135 | return new static(array_search($controlName, static::$controls, true)); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/UnixTerminal.php: -------------------------------------------------------------------------------- 1 | 11 | * @author Aydin Hassan 12 | */ 13 | class UnixTerminal implements Terminal 14 | { 15 | /** 16 | * @var bool 17 | */ 18 | private $isCanonical; 19 | 20 | /** 21 | * Whether terminal echo back is enabled or not. 22 | * Eg. user key presses and the terminal immediately shows it. 23 | * 24 | * @var bool 25 | */ 26 | private $echoBack = true; 27 | 28 | /** 29 | * @var int 30 | */ 31 | private $width; 32 | 33 | /** 34 | * @var int 35 | */ 36 | private $height; 37 | 38 | /** 39 | * @var int; 40 | */ 41 | private $colourSupport; 42 | 43 | /** 44 | * @var string 45 | */ 46 | private $originalConfiguration; 47 | 48 | /** 49 | * @var InputStream 50 | */ 51 | private $input; 52 | 53 | /** 54 | * @var OutputStream 55 | */ 56 | private $output; 57 | 58 | public function __construct(InputStream $input, OutputStream $output) 59 | { 60 | $this->getOriginalConfiguration(); 61 | $this->getOriginalCanonicalMode(); 62 | $this->input = $input; 63 | $this->output = $output; 64 | } 65 | 66 | private function getOriginalCanonicalMode() : void 67 | { 68 | exec('stty -a', $output); 69 | $this->isCanonical = (strpos(implode("\n", $output), ' icanon') !== false); 70 | } 71 | 72 | public function getWidth() : int 73 | { 74 | return $this->width ?: $this->width = (int) exec('tput cols'); 75 | } 76 | 77 | public function getHeight() : int 78 | { 79 | return $this->height ?: $this->height = (int) exec('tput lines'); 80 | } 81 | 82 | public function getColourSupport() : int 83 | { 84 | return $this->colourSupport ?: $this->colourSupport = (int) exec('tput colors'); 85 | } 86 | 87 | private function getOriginalConfiguration() : string 88 | { 89 | return $this->originalConfiguration ?: $this->originalConfiguration = exec('stty -g'); 90 | } 91 | 92 | /** 93 | * Disables echoing every character back to the terminal. This means 94 | * we do not have to clear the line when reading. 95 | */ 96 | public function disableEchoBack() : void 97 | { 98 | exec('stty -echo'); 99 | $this->echoBack = false; 100 | } 101 | 102 | /** 103 | * Enable echoing back every character input to the terminal. 104 | */ 105 | public function enableEchoBack() : void 106 | { 107 | exec('stty echo'); 108 | $this->echoBack = true; 109 | } 110 | 111 | /** 112 | * Is echo back mode enabled 113 | */ 114 | public function isEchoBack() : bool 115 | { 116 | return $this->echoBack; 117 | } 118 | 119 | /** 120 | * Disable canonical input (allow each key press for reading, rather than the whole line) 121 | * 122 | * @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html 123 | */ 124 | public function disableCanonicalMode() : void 125 | { 126 | if ($this->isCanonical) { 127 | exec('stty -icanon'); 128 | $this->isCanonical = false; 129 | } 130 | } 131 | 132 | /** 133 | * Enable canonical input - read input by line 134 | * 135 | * @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html 136 | */ 137 | public function enableCanonicalMode() : void 138 | { 139 | if (!$this->isCanonical) { 140 | exec('stty icanon'); 141 | $this->isCanonical = true; 142 | } 143 | } 144 | 145 | /** 146 | * Is canonical mode enabled or not 147 | */ 148 | public function isCanonicalMode() : bool 149 | { 150 | return $this->isCanonical; 151 | } 152 | 153 | /** 154 | * Restore the original terminal configuration 155 | */ 156 | public function restoreOriginalConfiguration() : void 157 | { 158 | exec('stty ' . $this->getOriginalConfiguration()); 159 | } 160 | 161 | /** 162 | * Check if the Input & Output streams are interactive. Eg - they are 163 | * connected to a terminal. 164 | * 165 | * @return bool 166 | */ 167 | public function isInteractive() : bool 168 | { 169 | return $this->input->isInteractive() && $this->output->isInteractive(); 170 | } 171 | 172 | /** 173 | * Assert that both the Input & Output streams are interactive. Throw 174 | * `NotInteractiveTerminal` if not. 175 | */ 176 | public function mustBeInteractive() : void 177 | { 178 | if (!$this->input->isInteractive()) { 179 | throw NotInteractiveTerminal::inputNotInteractive(); 180 | } 181 | 182 | if (!$this->output->isInteractive()) { 183 | throw NotInteractiveTerminal::outputNotInteractive(); 184 | } 185 | } 186 | 187 | /** 188 | * @see https://github.com/symfony/Console/blob/master/Output/StreamOutput.php#L95-L102 189 | */ 190 | public function supportsColour() : bool 191 | { 192 | if (DIRECTORY_SEPARATOR === '\\') { 193 | return false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI') || 'xterm' === getenv('TERM'); 194 | } 195 | 196 | return $this->isInteractive(); 197 | } 198 | 199 | public function clear() : void 200 | { 201 | $this->output->write("\033[2J"); 202 | } 203 | 204 | public function clearLine() : void 205 | { 206 | $this->output->write("\033[2K"); 207 | } 208 | 209 | /** 210 | * Erase screen from the current line down to the bottom of the screen 211 | */ 212 | public function clearDown() : void 213 | { 214 | $this->output->write("\033[J"); 215 | } 216 | 217 | public function clean() : void 218 | { 219 | foreach (range(0, $this->getHeight()) as $rowNum) { 220 | $this->moveCursorToRow($rowNum); 221 | $this->clearLine(); 222 | } 223 | } 224 | 225 | public function enableCursor() : void 226 | { 227 | $this->output->write("\033[?25h"); 228 | } 229 | 230 | public function disableCursor() : void 231 | { 232 | $this->output->write("\033[?25l"); 233 | } 234 | 235 | public function moveCursorToTop() : void 236 | { 237 | $this->output->write("\033[H"); 238 | } 239 | 240 | public function moveCursorToRow(int $rowNumber) : void 241 | { 242 | $this->output->write(sprintf("\033[%d;0H", $rowNumber)); 243 | } 244 | 245 | public function moveCursorToColumn(int $column) : void 246 | { 247 | $this->output->write(sprintf("\033[%dC", $column)); 248 | } 249 | 250 | public function showSecondaryScreen() : void 251 | { 252 | $this->output->write("\033[?47h"); 253 | } 254 | 255 | public function showPrimaryScreen() : void 256 | { 257 | $this->output->write("\033[?47l"); 258 | } 259 | 260 | /** 261 | * Read bytes from the input stream 262 | */ 263 | public function read(int $bytes): string 264 | { 265 | $buffer = ''; 266 | $this->input->read($bytes, function ($data) use (&$buffer) { 267 | $buffer .= $data; 268 | }); 269 | return $buffer; 270 | } 271 | 272 | /** 273 | * Write to the output stream 274 | */ 275 | public function write(string $buffer): void 276 | { 277 | $this->output->write($buffer); 278 | } 279 | 280 | /** 281 | * Restore the original terminal configuration on shutdown. 282 | */ 283 | public function __destruct() 284 | { 285 | $this->restoreOriginalConfiguration(); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /test/UnixTerminalTest.php: -------------------------------------------------------------------------------- 1 | createMock(InputStream::class); 19 | $output = $this->createMock(OutputStream::class); 20 | 21 | $input 22 | ->expects($this->once()) 23 | ->method('isInteractive') 24 | ->willReturn(true); 25 | $output 26 | ->expects($this->once()) 27 | ->method('isInteractive') 28 | ->willReturn(true); 29 | 30 | $terminal = new UnixTerminal($input, $output); 31 | 32 | self::assertTrue($terminal->isInteractive()); 33 | } 34 | 35 | public function testIsInteractiveReturnsFalseIfInputNotTTY() : void 36 | { 37 | $input = $this->createMock(InputStream::class); 38 | $output = $this->createMock(OutputStream::class); 39 | 40 | $input 41 | ->expects($this->once()) 42 | ->method('isInteractive') 43 | ->willReturn(false); 44 | $output 45 | ->expects($this->any()) 46 | ->method('isInteractive') 47 | ->willReturn(true); 48 | 49 | $terminal = new UnixTerminal($input, $output); 50 | 51 | self::assertFalse($terminal->isInteractive()); 52 | } 53 | 54 | public function testIsInteractiveReturnsFalseIfOutputNotTTY() : void 55 | { 56 | $input = $this->createMock(InputStream::class); 57 | $output = $this->createMock(OutputStream::class); 58 | 59 | $input 60 | ->expects($this->once()) 61 | ->method('isInteractive') 62 | ->willReturn(true); 63 | $output 64 | ->expects($this->once()) 65 | ->method('isInteractive') 66 | ->willReturn(false); 67 | 68 | $terminal = new UnixTerminal($input, $output); 69 | 70 | self::assertFalse($terminal->isInteractive()); 71 | } 72 | 73 | public function testIsInteractiveReturnsFalseIfInputAndOutputNotTTYs() : void 74 | { 75 | $input = $this->createMock(InputStream::class); 76 | $output = $this->createMock(OutputStream::class); 77 | 78 | $input 79 | ->expects($this->once()) 80 | ->method('isInteractive') 81 | ->willReturn(false); 82 | $output 83 | ->expects($this->any()) 84 | ->method('isInteractive') 85 | ->willReturn(false); 86 | 87 | $terminal = new UnixTerminal($input, $output); 88 | 89 | self::assertFalse($terminal->isInteractive()); 90 | } 91 | 92 | public function testMustBeInteractiveThrowsExceptionIfInputNotTTY() : void 93 | { 94 | self::expectException(NotInteractiveTerminal::class); 95 | self::expectExceptionMessage('Input stream is not interactive (non TTY)'); 96 | 97 | $input = $this->createMock(InputStream::class); 98 | $output = $this->createMock(OutputStream::class); 99 | 100 | $input 101 | ->expects($this->once()) 102 | ->method('isInteractive') 103 | ->willReturn(false); 104 | 105 | $terminal = new UnixTerminal($input, $output); 106 | $terminal->mustBeInteractive(); 107 | } 108 | 109 | public function testMustBeInteractiveThrowsExceptionIfOutputNotTTY() : void 110 | { 111 | self::expectException(NotInteractiveTerminal::class); 112 | self::expectExceptionMessage('Output stream is not interactive (non TTY)'); 113 | 114 | $input = $this->createMock(InputStream::class); 115 | $output = $this->createMock(OutputStream::class); 116 | 117 | $input 118 | ->expects($this->once()) 119 | ->method('isInteractive') 120 | ->willReturn(true); 121 | 122 | $output 123 | ->expects($this->once()) 124 | ->method('isInteractive') 125 | ->willReturn(false); 126 | 127 | $terminal = new UnixTerminal($input, $output); 128 | $terminal->mustBeInteractive(); 129 | } 130 | 131 | public function testClear() : void 132 | { 133 | $input = $this->createMock(InputStream::class); 134 | $output = new BufferedOutput; 135 | 136 | $terminal = new UnixTerminal($input, $output); 137 | $terminal->clear(); 138 | 139 | self::assertEquals("\033[2J", $output->fetch()); 140 | } 141 | 142 | public function testClearLine() : void 143 | { 144 | $input = $this->createMock(InputStream::class); 145 | $output = new BufferedOutput; 146 | 147 | $terminal = new UnixTerminal($input, $output); 148 | $terminal->clearLine(); 149 | 150 | self::assertEquals("\033[2K", $output->fetch()); 151 | } 152 | 153 | public function testClearDown() : void 154 | { 155 | $input = $this->createMock(InputStream::class); 156 | $output = new BufferedOutput; 157 | 158 | $terminal = new UnixTerminal($input, $output); 159 | $terminal->clearDown(); 160 | 161 | self::assertEquals("\033[J", $output->fetch()); 162 | } 163 | 164 | public function testClean() : void 165 | { 166 | $input = $this->createMock(InputStream::class); 167 | $output = new BufferedOutput; 168 | 169 | $terminal = new UnixTerminal($input, $output); 170 | $rf = new \ReflectionObject($terminal); 171 | $rp = $rf->getProperty('width'); 172 | $rp->setAccessible(true); 173 | $rp->setValue($terminal, 23); 174 | $rp = $rf->getProperty('height'); 175 | $rp->setAccessible(true); 176 | $rp->setValue($terminal, 2); 177 | 178 | $terminal->clean(); 179 | 180 | self::assertEquals("\033[0;0H\033[2K\033[1;0H\033[2K\033[2;0H\033[2K", $output->fetch()); 181 | } 182 | 183 | public function testEnableCursor() : void 184 | { 185 | $input = $this->createMock(InputStream::class); 186 | $output = new BufferedOutput; 187 | 188 | $terminal = new UnixTerminal($input, $output); 189 | $terminal->enableCursor(); 190 | 191 | self::assertEquals("\033[?25h", $output->fetch()); 192 | } 193 | 194 | public function testDisableCursor() : void 195 | { 196 | $input = $this->createMock(InputStream::class); 197 | $output = new BufferedOutput; 198 | 199 | $terminal = new UnixTerminal($input, $output); 200 | $terminal->disableCursor(); 201 | 202 | self::assertEquals("\033[?25l", $output->fetch()); 203 | } 204 | 205 | public function testMoveCursorToTop() : void 206 | { 207 | $input = $this->createMock(InputStream::class); 208 | $output = new BufferedOutput; 209 | 210 | $terminal = new UnixTerminal($input, $output); 211 | $terminal->moveCursorToTop(); 212 | 213 | self::assertEquals("\033[H", $output->fetch()); 214 | } 215 | 216 | public function testMoveCursorToRow() : void 217 | { 218 | $input = $this->createMock(InputStream::class); 219 | $output = new BufferedOutput; 220 | 221 | $terminal = new UnixTerminal($input, $output); 222 | $terminal->moveCursorToRow(2); 223 | 224 | self::assertEquals("\033[2;0H", $output->fetch()); 225 | } 226 | 227 | public function testMoveCursorToColumn() : void 228 | { 229 | $input = $this->createMock(InputStream::class); 230 | $output = new BufferedOutput; 231 | 232 | $terminal = new UnixTerminal($input, $output); 233 | $terminal->moveCursorToColumn(10); 234 | 235 | self::assertEquals("\033[10C", $output->fetch()); 236 | } 237 | 238 | public function testShowAlternateScreen() : void 239 | { 240 | $input = $this->createMock(InputStream::class); 241 | $output = new BufferedOutput; 242 | 243 | $terminal = new UnixTerminal($input, $output); 244 | $terminal->showSecondaryScreen(); 245 | 246 | self::assertEquals("\033[?47h", $output->fetch()); 247 | } 248 | 249 | public function testShowMainScreen() : void 250 | { 251 | $input = $this->createMock(InputStream::class); 252 | $output = new BufferedOutput; 253 | 254 | $terminal = new UnixTerminal($input, $output); 255 | $terminal->showPrimaryScreen(); 256 | 257 | self::assertEquals("\033[?47l", $output->fetch()); 258 | } 259 | 260 | public function testRead() : void 261 | { 262 | $tempStream = fopen('php://temp', 'r+'); 263 | fwrite($tempStream, 'mystring'); 264 | rewind($tempStream); 265 | 266 | $input = new ResourceInputStream($tempStream); 267 | $output = $this->createMock(OutputStream::class); 268 | 269 | $terminal = new UnixTerminal($input, $output); 270 | 271 | self::assertEquals('myst', $terminal->read(4)); 272 | self::assertEquals('ring', $terminal->read(4)); 273 | 274 | fclose($tempStream); 275 | } 276 | 277 | public function testWriteForwardsToOutput() : void 278 | { 279 | $input = $this->createMock(InputStream::class); 280 | $output = new BufferedOutput; 281 | 282 | $terminal = new UnixTerminal($input, $output); 283 | $terminal->write('My awesome string'); 284 | 285 | self::assertEquals('My awesome string', $output->fetch()); 286 | } 287 | 288 | public function testGetColourSupport() : void 289 | { 290 | $input = $this->createMock(InputStream::class); 291 | $output = new BufferedOutput; 292 | 293 | $terminal = new UnixTerminal($input, $output); 294 | 295 | // Travis terminal supports 8 colours, but just in case 296 | // in ever changes I'll add the 256 colors possibility too 297 | self::assertTrue($terminal->getColourSupport() === 8 || $terminal->getColourSupport() === 256); 298 | } 299 | } 300 | --------------------------------------------------------------------------------