├── .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 |
--------------------------------------------------------------------------------