255 lines
7.8 KiB
PHP
255 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace NoccyLabs\React\Shell;
|
|
|
|
use Evenement\EventEmitterInterface;
|
|
use Evenement\EventEmitterTrait;
|
|
use PgSql\Lob;
|
|
use React\EventLoop\Loop;
|
|
use React\Stream\ReadableResourceStream;
|
|
use React\Stream\ReadableStreamInterface;
|
|
use React\Stream\WritableResourceStream;
|
|
use React\Stream\WritableStreamInterface;
|
|
|
|
class Shell implements WritableStreamInterface, EventEmitterInterface
|
|
{
|
|
use EventEmitterTrait;
|
|
|
|
private ReadableStreamInterface $istream;
|
|
|
|
private WritableStreamInterface $ostream;
|
|
|
|
private string $oldStty;
|
|
|
|
private bool $isPrompting = false;
|
|
|
|
private string $buffer = '';
|
|
|
|
private int $cursorPos = 0;
|
|
|
|
private int $termWidth = 0;
|
|
|
|
private int $scrollOffset = 0;
|
|
|
|
private string $prompt = '';
|
|
|
|
private int $promptWidth = 0;
|
|
|
|
private ?string $promptStyle = "2";
|
|
|
|
private ?string $inputStyle = "1";
|
|
|
|
private array $history = [];
|
|
|
|
private ?int $historyIndex = null;
|
|
|
|
private ?string $historyCache = null;
|
|
|
|
private int $maxHistory = 100;
|
|
|
|
public function __construct(?ReadableStreamInterface $input=null, ?WritableStreamInterface $output=null)
|
|
{
|
|
$this->istream = $input ?? new ReadableResourceStream(STDIN);
|
|
$this->ostream = $output ?? new WritableResourceStream(STDOUT);
|
|
|
|
$this->istream->on('data', $this->handleInput(...));
|
|
|
|
Loop::futureTick($this->redrawPrompt(...));
|
|
|
|
Loop::addSignal(SIGINT, function () {
|
|
Loop::futureTick(function () {
|
|
if (count($this->listeners("abort")) == 0)
|
|
$this->end("Received ^C\n");
|
|
else
|
|
$this->emit("abort", [ $this ]);
|
|
});
|
|
});
|
|
// Loop::addSignal(SIGWINCH, function () {
|
|
// Loop::futureTick($this->redrawPrompt(...));
|
|
// });
|
|
|
|
// Save terminal settings and disable inupt buffering
|
|
$this->oldStty = exec("stty -g");
|
|
exec("stty -icanon min 1 time 0 -echo");
|
|
}
|
|
|
|
public function __destruct()
|
|
{
|
|
// Restore the terminal to regular buffered mode
|
|
exec("stty {$this->oldStty}");
|
|
}
|
|
|
|
public function setPrompt(string $prompt): void
|
|
{
|
|
$this->prompt = $prompt;
|
|
$this->promptWidth = mb_strlen(preg_replace('<(\e\[[0-9;]+?m)>m', '', $this->prompt));
|
|
}
|
|
|
|
private function handleInput($v) {
|
|
|
|
switch ($v) {
|
|
|
|
case "\x03":
|
|
if (count($this->listeners("abort")) == 0)
|
|
$this->end("Received ^C\n");
|
|
else
|
|
$this->emit("abort", [ $this ]);
|
|
|
|
case "\n":
|
|
// Update the history
|
|
array_unshift($this->history, $this->buffer);
|
|
while (count($this->history) > $this->maxHistory) array_pop($this->history);
|
|
$this->historyIndex = 0;
|
|
// Parse and empty the buffer
|
|
$buffer = str_getcsv(trim($this->buffer), " ");
|
|
$this->buffer = '';
|
|
$this->cursorPos = 0;
|
|
$this->scrollOffset = 0;
|
|
$this->ostream->write("\n");
|
|
if ($buffer[0] !== NULL) {
|
|
$this->emit('input', [ $buffer, $this ]);
|
|
} else {
|
|
$this->redrawPrompt();
|
|
}
|
|
break;
|
|
|
|
case "\x7F": // backspace
|
|
if ($this->cursorPos < 1) return;
|
|
$this->buffer = mb_substr($this->buffer, 0, $this->cursorPos - 1) .
|
|
mb_substr($this->buffer, $this->cursorPos);
|
|
$this->cursorPos--;
|
|
break;
|
|
|
|
case "\e[3~": // delete
|
|
$this->buffer = mb_substr($this->buffer, 0, $this->cursorPos) .
|
|
mb_substr($this->buffer, $this->cursorPos + 1);
|
|
break;
|
|
|
|
case "\e[A": // up
|
|
if ($this->historyIndex === null && count($this->history) > 0) {
|
|
$this->historyCache = $this->buffer;
|
|
$this->historyIndex = 0;
|
|
$this->buffer = $this->history[$this->historyIndex]??'';
|
|
} elseif ($this->historyIndex < count($this->history) - 1) {
|
|
$this->historyIndex++;
|
|
$this->buffer = $this->history[$this->historyIndex]??'';
|
|
}
|
|
$this->cursorPos = mb_strlen($this->buffer);
|
|
break;
|
|
case "\e[B": // down
|
|
if ($this->historyIndex === null) {
|
|
break;
|
|
} elseif ($this->historyIndex === 0) {
|
|
$this->historyIndex = null;
|
|
$this->buffer = $this->historyCache;
|
|
$this->historyCache = null;
|
|
} else {
|
|
$this->historyIndex--;
|
|
$this->buffer = $this->history[$this->historyIndex]??'';
|
|
}
|
|
$this->cursorPos = mb_strlen($this->buffer);
|
|
break;
|
|
|
|
case "\e[H": // home
|
|
$this->cursorPos = 0;
|
|
break;
|
|
case "\e[F": // end
|
|
$this->cursorPos = mb_strlen($this->buffer);
|
|
break;
|
|
|
|
case "\e[D": // left
|
|
if ($this->cursorPos < 1) return;
|
|
$this->cursorPos--;
|
|
break;
|
|
|
|
case "\e[C": // right
|
|
if ($this->cursorPos >= mb_strlen($this->buffer)) return;
|
|
$this->cursorPos++;
|
|
break;
|
|
|
|
default:
|
|
if (mb_strlen($v) == 1 && mb_ord($v) >= 32) {
|
|
$this->buffer = mb_substr($this->buffer, 0, $this->cursorPos) .
|
|
$v .
|
|
mb_substr($this->buffer, $this->cursorPos);
|
|
$this->cursorPos++;
|
|
} else {
|
|
$this->ostream->write("\e[7m".substr($v,2)."\e[27m");
|
|
return;
|
|
}
|
|
}
|
|
if ($this->cursorPos < $this->scrollOffset) {
|
|
$this->scrollOffset = $this->cursorPos;
|
|
}
|
|
$availWidth = $this->termWidth - $this->promptWidth;
|
|
if ($this->cursorPos - $this->scrollOffset >= $availWidth) {
|
|
$this->scrollOffset = abs($availWidth - $this->cursorPos) + 2;
|
|
}
|
|
$this->updatePrompt();
|
|
}
|
|
|
|
|
|
public function write($data)
|
|
{
|
|
$this->hidePrompt();
|
|
$this->ostream->write($data);
|
|
Loop::futureTick($this->redrawPrompt(...));
|
|
return true;
|
|
}
|
|
|
|
public function isWritable()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function end($data = null)
|
|
{
|
|
$this->hidePrompt();
|
|
$this->ostream->write($data);
|
|
$this->emit('end', [ $this ]);
|
|
Loop::futureTick(fn() => exit(1));
|
|
}
|
|
|
|
public function close()
|
|
{
|
|
$this->hidePrompt();
|
|
$this->emit('end', [ $this ]);
|
|
Loop::futureTick(fn() => exit(0));
|
|
}
|
|
|
|
|
|
|
|
public function hidePrompt(): void
|
|
{
|
|
if (!$this->isPrompting) return;
|
|
|
|
$this->isPrompting = false;
|
|
|
|
$this->ostream->write("\r\e[K");
|
|
}
|
|
|
|
public function redrawPrompt(): void
|
|
{
|
|
$this->emit("prompt", [ $this ]);
|
|
|
|
$this->termWidth = intval(exec("tput cols"));
|
|
$this->isPrompting = true;
|
|
|
|
$this->updatePrompt();
|
|
}
|
|
|
|
private function updatePrompt(): void
|
|
{
|
|
if (!$this->isPrompting) return;
|
|
|
|
$pos = $this->promptWidth + $this->cursorPos - $this->scrollOffset + 1;
|
|
|
|
$obuf = "\r" . "\e[{$this->promptStyle}m" . $this->prompt . "\e[0m";
|
|
$ostr = mb_substr($this->buffer, $this->scrollOffset) . " ";
|
|
$ostr = mb_substr($ostr, 0, $this->termWidth - $this->promptWidth);
|
|
$obuf .= "\e[{$this->inputStyle}m" . $ostr . "\e[K\e[0m\e[" . $pos . "G";
|
|
|
|
$this->ostream->write($obuf);
|
|
}
|
|
}
|