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); } }