Added added third-party library 'php-school/terminal'

This commit is contained in:
Zi Xing 2022-04-16 19:06:04 -04:00
parent b633f16694
commit f8db35135e
11 changed files with 859 additions and 0 deletions

View file

@ -0,0 +1,19 @@
<?php
namespace PhpSchool\Terminal\Exception;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class NotInteractiveTerminal extends \RuntimeException
{
public static function inputNotInteractive() : self
{
return new self('Input stream is not interactive (non TTY)');
}
public static function outputNotInteractive() : self
{
return new self('Output stream is not interactive (non TTY)');
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace PhpSchool\Terminal\IO;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class BufferedOutput implements OutputStream
{
private $buffer = '';
public function write(string $buffer): void
{
$this->buffer .= $buffer;
}
public function fetch(bool $clean = true) : string
{
$buffer = $this->buffer;
if ($clean) {
$this->buffer = '';
}
return $buffer;
}
public function __toString() : string
{
return $this->fetch();
}
/**
* Whether the stream is connected to an interactive terminal
*/
public function isInteractive() : bool
{
return false;
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace PhpSchool\Terminal\IO;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
interface InputStream
{
/**
* Callback should be called with the number of bytes requested
* when ready.
*/
public function read(int $numBytes, callable $callback) : void;
/**
* Whether the stream is connected to an interactive terminal
*/
public function isInteractive() : bool;
}

View file

@ -0,0 +1,19 @@
<?php
namespace PhpSchool\Terminal\IO;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
interface OutputStream
{
/**
* Write the buffer to the stream
*/
public function write(string $buffer) : void;
/**
* Whether the stream is connected to an interactive terminal
*/
public function isInteractive() : bool;
}

View file

@ -0,0 +1,47 @@
<?php
namespace PhpSchool\Terminal\IO;
use function is_resource;
use function get_resource_type;
use function stream_get_meta_data;
use function strpos;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class ResourceInputStream implements InputStream
{
/**
* @var resource
*/
private $stream;
public function __construct($stream = STDIN)
{
if (!is_resource($stream) || get_resource_type($stream) !== 'stream') {
throw new \InvalidArgumentException('Expected a valid stream');
}
$meta = stream_get_meta_data($stream);
if (strpos($meta['mode'], 'r') === false && strpos($meta['mode'], '+') === false) {
throw new \InvalidArgumentException('Expected a readable stream');
}
$this->stream = $stream;
}
public function read(int $numBytes, callable $callback) : void
{
$buffer = fread($this->stream, $numBytes);
$callback($buffer);
}
/**
* Whether the stream is connected to an interactive terminal
*/
public function isInteractive() : bool
{
return posix_isatty($this->stream);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace PhpSchool\Terminal\IO;
use function is_resource;
use function get_resource_type;
use function stream_get_meta_data;
use function strpos;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class ResourceOutputStream implements OutputStream
{
/**
* @var resource
*/
private $stream;
public function __construct($stream = STDOUT)
{
if (!is_resource($stream) || get_resource_type($stream) !== 'stream') {
throw new \InvalidArgumentException('Expected a valid stream');
}
$meta = stream_get_meta_data($stream);
if (strpos($meta['mode'], 'r') !== false && strpos($meta['mode'], '+') === false) {
throw new \InvalidArgumentException('Expected a writable stream');
}
$this->stream = $stream;
}
public function write(string $buffer): void
{
fwrite($this->stream, $buffer);
}
/**
* Whether the stream is connected to an interactive terminal
*/
public function isInteractive() : bool
{
return posix_isatty($this->stream);
}
}

View file

@ -0,0 +1,137 @@
<?php
namespace PhpSchool\Terminal;
use function in_array;
/**
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class InputCharacter
{
/**
* @var string
*/
private $data;
public const UP = 'UP';
public const DOWN = 'DOWN';
public const RIGHT = 'RIGHT';
public const LEFT = 'LEFT';
public const CTRLA = 'CTRLA';
public const CTRLB = 'CTRLB';
public const CTRLE = 'CTRLE';
public const CTRLF = 'CTRLF';
public const BACKSPACE = 'BACKSPACE';
public const CTRLW = 'CTRLW';
public const ENTER = 'ENTER';
public const TAB = 'TAB';
public const ESC = 'ESC';
private static $controls = [
"\033[A" => self::UP,
"\033[B" => self::DOWN,
"\033[C" => self::RIGHT,
"\033[D" => self::LEFT,
"\033OA" => self::UP,
"\033OB" => self::DOWN,
"\033OC" => self::RIGHT,
"\033OD" => self::LEFT,
"\001" => self::CTRLA,
"\002" => self::CTRLB,
"\005" => self::CTRLE,
"\006" => self::CTRLF,
"\010" => self::BACKSPACE,
"\177" => self::BACKSPACE,
"\027" => self::CTRLW,
"\n" => self::ENTER,
"\t" => self::TAB,
"\e" => self::ESC,
];
public function __construct(string $data)
{
$this->data = $data;
}
public function isHandledControl() : bool
{
return isset(static::$controls[$this->data]);
}
/**
* Is this character a control sequence?
*/
public function isControl() : bool
{
return preg_match('/[\x00-\x1F\x7F]/', $this->data);
}
/**
* Is this character a normal character?
*/
public function isNotControl() : bool
{
return ! $this->isControl();
}
/**
* Get the raw character or control sequence
*/
public function get() : string
{
return $this->data;
}
/**
* Get the actual control name that this sequence represents.
* One of the class constants. Eg. self::UP.
*
* Throws an exception if the character is not actually a control sequence
*/
public function getControl() : string
{
if (!isset(static::$controls[$this->data])) {
throw new \RuntimeException(sprintf('Character "%s" is not a control', $this->data));
}
return static::$controls[$this->data];
}
/**
* Get the raw character or control sequence
*/
public function __toString() : string
{
return $this->get();
}
/**
* Does the given control name exist? eg self::UP.
*/
public static function controlExists(string $controlName) : bool
{
return in_array($controlName, static::$controls, true);
}
/**
* Get all of the available control names
*/
public static function getControls() : array
{
return array_values(array_unique(static::$controls));
}
/**
* Create a instance from a given control name. Throws an exception if the
* control name does not exist.
*/
public static function fromControlName(string $controlName) : self
{
if (!static::controlExists($controlName)) {
throw new \InvalidArgumentException(sprintf('Control "%s" does not exist', $controlName));
}
return new static(array_search($controlName, static::$controls, true));
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace PhpSchool\Terminal;
/**
* This class takes a terminal and disabled canonical mode. It reads the input
* and returns characters and control sequences as `InputCharacters` as soon
* as they are read - character by character.
*
* On destruct canonical mode will be enabled if it was when in it was constructed.
*
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class NonCanonicalReader
{
/**
* @var Terminal
*/
private $terminal;
/**
* @var bool
*/
private $wasCanonicalModeEnabled;
/**
* Map of characters to controls.
* Eg map 'w' to the up control.
*
* @var array
*/
private $mappings = [];
public function __construct(Terminal $terminal)
{
$this->terminal = $terminal;
$this->wasCanonicalModeEnabled = $terminal->isCanonicalMode();
$this->terminal->disableCanonicalMode();
}
public function addControlMapping(string $character, string $mapToControl) : void
{
if (!InputCharacter::controlExists($mapToControl)) {
throw new \InvalidArgumentException(sprintf('Control "%s" does not exist', $mapToControl));
}
$this->mappings[$character] = $mapToControl;
}
public function addControlMappings(array $mappings) : void
{
foreach ($mappings as $character => $mapToControl) {
$this->addControlMapping($character, $mapToControl);
}
}
/**
* This should be ran with the terminal canonical mode disabled.
*
* @return InputCharacter
*/
public function readCharacter() : InputCharacter
{
$char = $this->terminal->read(4);
if (isset($this->mappings[$char])) {
return InputCharacter::fromControlName($this->mappings[$char]);
}
return new InputCharacter($char);
}
public function __destruct()
{
if ($this->wasCanonicalModeEnabled) {
$this->terminal->enableCanonicalMode();
}
}
}

View file

@ -0,0 +1,32 @@
<h1 align="center">Terminal Utility</h1>
<p align="center">
Small utility to help provide a simple, consise API for terminal interaction
</p>
<p align="center">
<a href="https://travis-ci.org/php-school/terminal" title="Build Status" target="_blank">
<img src="https://img.shields.io/travis/php-school/terminal/master.svg?style=flat-square&label=Linux" />
</a
<a href="https://codecov.io/github/php-school/terminal" title="Coverage Status" target="_blank">
<img src="https://img.shields.io/codecov/c/github/php-school/terminal.svg?style=flat-square" />
</a>
<a href="https://scrutinizer-ci.com/g/php-school/terminal/" title="Scrutinizer Code Quality" target="_blank">
<img src="https://img.shields.io/scrutinizer/g/php-school/terminal.svg?style=flat-square" />
</a>
<a href="https://phpschool-team.slack.com/messages">
<img src="https://phpschool.herokuapp.com/badge.svg">
</a>
</p>
---
## Install
```bash
composer require php-school/terminal
```
## TODO
- [ ] Docs

View file

@ -0,0 +1,133 @@
<?php
namespace PhpSchool\Terminal;
/**
* @author Michael Woodward <mikeymike.mw@gmail.com>
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
interface Terminal
{
/**
* Get the available width of the terminal
*/
public function getWidth() : int;
/**
* Get the available height of the terminal
*/
public function getHeight() : int;
/**
* Get the number of colours the terminal supports (1, 8, 256, true colours)
*/
public function getColourSupport() : int;
/**
* Disables echoing every character back to the terminal. This means
* we do not have to clear the line when reading.
*/
public function disableEchoBack() : void;
/**
* Enable echoing back every character input to the terminal.
*/
public function enableEchoBack() : void;
/**
* Is echo back mode enabled
*/
public function isEchoBack() : bool;
/**
* Disable canonical input (allow each key press for reading, rather than the whole line)
*
* @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html
*/
public function disableCanonicalMode() : void;
/**
* Enable canonical input - read input by line
*
* @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html
*/
public function enableCanonicalMode() : void;
/**
* Is canonical mode enabled or not
*/
public function isCanonicalMode() : bool;
/**
* Check if the Input & Output streams are interactive. Eg - they are
* connected to a terminal.
*
* @return bool
*/
public function isInteractive() : bool;
/**
* Restore the terminals original configuration
*/
public function restoreOriginalConfiguration() : void;
/**
* Test whether terminal supports colour output
*/
public function supportsColour() : bool;
/**
* Clear the terminal window
*/
public function clear() : void;
/**
* Clear the current cursors line
*/
public function clearLine() : void;
/**
* Erase screen from the current line down to the bottom of the screen
*/
public function clearDown() : void;
/**
* Clean the whole console without jumping the window
*/
public function clean() : void;
/**
* Enable cursor display
*/
public function enableCursor() : void;
/**
* Disable cursor display
*/
public function disableCursor() : void;
/**
* Move the cursor to the top left of the window
*/
public function moveCursorToTop() : void;
/**
* Move the cursor to the start of a specific row
*/
public function moveCursorToRow(int $rowNumber) : void;
/**
* Move the cursor to a specific column
*/
public function moveCursorToColumn(int $columnNumber) : void;
/**
* Read from the input stream
*/
public function read(int $bytes) : string;
/**
* Write to the output stream
*/
public function write(string $buffer) : void;
}

View file

@ -0,0 +1,287 @@
<?php
namespace PhpSchool\Terminal;
use PhpSchool\Terminal\Exception\NotInteractiveTerminal;
use PhpSchool\Terminal\IO\InputStream;
use PhpSchool\Terminal\IO\OutputStream;
/**
* @author Michael Woodward <mikeymike.mw@gmail.com>
* @author Aydin Hassan <aydin@hotmail.co.uk>
*/
class UnixTerminal implements Terminal
{
/**
* @var bool
*/
private $isCanonical;
/**
* Whether terminal echo back is enabled or not.
* Eg. user key presses and the terminal immediately shows it.
*
* @var bool
*/
private $echoBack = true;
/**
* @var int
*/
private $width;
/**
* @var int
*/
private $height;
/**
* @var int;
*/
private $colourSupport;
/**
* @var string
*/
private $originalConfiguration;
/**
* @var InputStream
*/
private $input;
/**
* @var OutputStream
*/
private $output;
public function __construct(InputStream $input, OutputStream $output)
{
$this->getOriginalConfiguration();
$this->getOriginalCanonicalMode();
$this->input = $input;
$this->output = $output;
}
private function getOriginalCanonicalMode() : void
{
exec('stty -a', $output);
$this->isCanonical = (strpos(implode("\n", $output), ' icanon') !== false);
}
public function getWidth() : int
{
return $this->width ?: $this->width = (int) exec('tput cols');
}
public function getHeight() : int
{
return $this->height ?: $this->height = (int) exec('tput lines');
}
public function getColourSupport() : int
{
return $this->colourSupport ?: $this->colourSupport = (int) exec('tput colors');
}
private function getOriginalConfiguration() : string
{
return $this->originalConfiguration ?: $this->originalConfiguration = exec('stty -g');
}
/**
* Disables echoing every character back to the terminal. This means
* we do not have to clear the line when reading.
*/
public function disableEchoBack() : void
{
exec('stty -echo');
$this->echoBack = false;
}
/**
* Enable echoing back every character input to the terminal.
*/
public function enableEchoBack() : void
{
exec('stty echo');
$this->echoBack = true;
}
/**
* Is echo back mode enabled
*/
public function isEchoBack() : bool
{
return $this->echoBack;
}
/**
* Disable canonical input (allow each key press for reading, rather than the whole line)
*
* @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html
*/
public function disableCanonicalMode() : void
{
if ($this->isCanonical) {
exec('stty -icanon');
$this->isCanonical = false;
}
}
/**
* Enable canonical input - read input by line
*
* @see https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html
*/
public function enableCanonicalMode() : void
{
if (!$this->isCanonical) {
exec('stty icanon');
$this->isCanonical = true;
}
}
/**
* Is canonical mode enabled or not
*/
public function isCanonicalMode() : bool
{
return $this->isCanonical;
}
/**
* Restore the original terminal configuration
*/
public function restoreOriginalConfiguration() : void
{
exec('stty ' . $this->getOriginalConfiguration());
}
/**
* Check if the Input & Output streams are interactive. Eg - they are
* connected to a terminal.
*
* @return bool
*/
public function isInteractive() : bool
{
return $this->input->isInteractive() && $this->output->isInteractive();
}
/**
* Assert that both the Input & Output streams are interactive. Throw
* `NotInteractiveTerminal` if not.
*/
public function mustBeInteractive() : void
{
if (!$this->input->isInteractive()) {
throw NotInteractiveTerminal::inputNotInteractive();
}
if (!$this->output->isInteractive()) {
throw NotInteractiveTerminal::outputNotInteractive();
}
}
/**
* @see https://github.com/symfony/Console/blob/master/Output/StreamOutput.php#L95-L102
*/
public function supportsColour() : bool
{
if (DIRECTORY_SEPARATOR === '\\') {
return false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI') || 'xterm' === getenv('TERM');
}
return $this->isInteractive();
}
public function clear() : void
{
$this->output->write("\033[2J");
}
public function clearLine() : void
{
$this->output->write("\033[2K");
}
/**
* Erase screen from the current line down to the bottom of the screen
*/
public function clearDown() : void
{
$this->output->write("\033[J");
}
public function clean() : void
{
foreach (range(0, $this->getHeight()) as $rowNum) {
$this->moveCursorToRow($rowNum);
$this->clearLine();
}
}
public function enableCursor() : void
{
$this->output->write("\033[?25h");
}
public function disableCursor() : void
{
$this->output->write("\033[?25l");
}
public function moveCursorToTop() : void
{
$this->output->write("\033[H");
}
public function moveCursorToRow(int $rowNumber) : void
{
$this->output->write(sprintf("\033[%d;0H", $rowNumber));
}
public function moveCursorToColumn(int $column) : void
{
$this->output->write(sprintf("\033[%dC", $column));
}
public function showSecondaryScreen() : void
{
$this->output->write("\033[?47h");
}
public function showPrimaryScreen() : void
{
$this->output->write("\033[?47l");
}
/**
* Read bytes from the input stream
*/
public function read(int $bytes): string
{
$buffer = '';
$this->input->read($bytes, function ($data) use (&$buffer) {
$buffer .= $data;
});
return $buffer;
}
/**
* Write to the output stream
*/
public function write(string $buffer): void
{
$this->output->write($buffer);
}
/**
* Restore the original terminal configuration on shutdown.
*/
public function __destruct()
{
$this->restoreOriginalConfiguration();
}
}