react-shell/src/Shell.php

180 lines
4.7 KiB
PHP

<?php
namespace NoccyLabs\React\Shell;
use Evenement\EventEmitterInterface;
use Evenement\EventEmitterTrait;
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;
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(...));
// 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($this->prompt);
}
private function handleInput($v) {
switch ($v) {
case "\x03":
exit(0);
case "\n":
$buffer = str_getcsv($this->buffer, " ");
$this->buffer = '';
$this->cursorPos = 0;
$this->scrollOffset = 0;
$this->emit('input', [ $buffer ]);
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
case "\e[B": // down
break;
case "\e[H": // home
case "\e[F": // end
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 && 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;
}
}
$this->updatePrompt();
}
public function write($data)
{
$this->hidePrompt();
$this->ostream->write($data);
$this->redrawPrompt();
return true;
}
public function isWritable()
{
return true;
}
public function end($data = null)
{
// NOP
}
public function close()
{
// NOP
}
public function hidePrompt(): void
{
if (!$this->isPrompting) return;
$this->isPrompting = false;
$this->ostream->write("\r\e[K");
}
public function redrawPrompt(): void
{
$prompt = $this->emit("prompt");
$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[1m" . $this->prompt . "\e[22m";
$ostr = mb_substr($this->buffer, $this->scrollOffset) . " ";
$ostr = mb_substr($ostr, 0, $this->termWidth - $this->promptWidth);
$obuf .= $ostr . "\e[K\e[" . $pos . "G";
$this->ostream->write($obuf);
}
}