From 579311237e8bcdbe2a8d397f63a9a1d7edc6ca6b Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Wed, 15 Oct 2025 22:18:39 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + composer.json | 20 ++++ composer.lock | 289 ++++++++++++++++++++++++++++++++++++++++++++++ src/Key.php | 9 ++ src/KeyEvent.php | 14 +++ src/KeyMapper.php | 123 ++++++++++++++++++++ src/Style.php | 64 ++++++++++ src/Terminal.php | 224 +++++++++++++++++++++++++++++++++++ src/Theme.php | 35 ++++++ 9 files changed, 779 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/Key.php create mode 100644 src/KeyEvent.php create mode 100644 src/KeyMapper.php create mode 100644 src/Style.php create mode 100644 src/Terminal.php create mode 100644 src/Theme.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1f185bd --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "noccylabs/react-term-io", + "description": "Interactive terminal I/O library for ReactPHP", + "type": "library", + "license": "GPL-2.0-or-later", + "autoload": { + "psr-4": { + "NoccyLabs\\React\\TermIo\\": "src/" + } + }, + "authors": [ + { + "name": "Christopher Vagnetoft", + "email": "labs@noccy.com" + } + ], + "require": { + "clue/term-react": "^1.4" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1599fec --- /dev/null +++ b/composer.lock @@ -0,0 +1,289 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "9319b87c1012a4273dfc71b03c8b01a7", + "packages": [ + { + "name": "clue/term-react", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-term.git", + "reference": "00f297dc597eaee2ebf98af8f27cca5d21d60fa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-term/zipball/00f297dc597eaee2ebf98af8f27cca5d21d60fa3", + "reference": "00f297dc597eaee2ebf98af8f27cca5d21d60fa3", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Term\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming terminal emulator, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-term", + "keywords": [ + "C0", + "CSI", + "ansi", + "apc", + "ascii", + "c1", + "control codes", + "dps", + "osc", + "pm", + "reactphp", + "streaming", + "terminal", + "vt100", + "xterm" + ], + "support": { + "issues": "https://github.com/clue/reactphp-term/issues", + "source": "https://github.com/clue/reactphp-term/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-01-30T10:22:09+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/src/Key.php b/src/Key.php new file mode 100644 index 0000000..664e3e7 --- /dev/null +++ b/src/Key.php @@ -0,0 +1,9 @@ + Key binds and actions */ + private array $binds = []; + /** @var array Defined chords indexed by the base key*/ + private array $chords = []; + /** @var bool If we are currently parsing a chord */ + private bool $inChord = false; + /** @var array The currently entered chord */ + private array $chord = []; + /** @var int When the chord was last updated, if delta too high flush before push */ + private int $putChord = 0; + + public function __construct(Terminal $terminal) + { + $terminal->on('key', $this->onKey(...)); + } + + public function bind(string $keyOrChord, string $action, bool $force = false): void + { + if (isset($this->binds[$keyOrChord]) && !$force) { + throw new \RuntimeException("Key or chord already bound: {$keyOrChord}"); + } + $this->binds[$keyOrChord] = $action; + $this->updateChords(); + } + + public function bindMultiple(array $keys, bool $force = false): void + { + foreach ($keys as $key => $action) { + $this->bind($key, $action, $force); + } + } + + public function unbind(string $keyOrChord): void + { + unset($this->binds[$keyOrChord]); + $this->updateChords(); + } + + public function findKeyForAction(string $action): ?string + { + return array_find_key($this->binds, fn($v) => $v === $action); + } + + private function updateChords(): void + { + $this->chords = []; + $chords = array_filter(array_keys($this->binds), fn($k) => str_contains($k, " ")); + foreach ($chords as $chord) { + $chordSplit = explode(" ", $chord); + $chordBase = reset($chordSplit); + if (!isset($this->chords[$chordBase])) { + $this->chords[$chordBase] = []; + } + $this->chords[$chordBase][] = $chordSplit; + } + } + + public function onKey(string $key, bool $printable = false): void + { + if (!$this->inChord) { + if ($this->isChord($key)) { + $this->inChord = true; + $this->chord = [ $key ]; + return; + } + } else { + $this->chord[] = $key; + $this->matchChord(); + return; + } + + if (isset($this->binds[$key])) { + $this->emit(self::ON_ACTION, [ $this->binds[$key] ]); + return; + } + + if ($printable) { + $this->emit(self::ON_PRINTABLE, [ $key ]); + } else { + $this->emit(self::ON_KEY, [ $key ]); + } + } + + private function matchChord(): void + { + $chord = array_values($this->chord); + $chords = $this->chords[$chord] ?? null; + + if ($chords === null) return; + + // FIXME + + $this->chord = []; + $this->inChord = false; + + } + + /** + * Returns true if the key is defined as a chord key + * + * @param string $key + * @return boolean + */ + public function isChord(string $key): bool + { + return false; + } + +} \ No newline at end of file diff --git a/src/Style.php b/src/Style.php new file mode 100644 index 0000000..16f8339 --- /dev/null +++ b/src/Style.php @@ -0,0 +1,64 @@ +toSgr() . $content; + } + + public function toSgr(): string + { + $sgr = []; + + if ($this->reset == true) $sgr[] = "0"; + if ($this->bold == true) $sgr[] = "1"; + if ($this->bold == false) $sgr[] = "22"; + if ($this->dim == true) $sgr[] = "2"; + if ($this->dim == false) $sgr[] = "22"; + if ($this->italic == true) $sgr[] = "3"; + if ($this->italic == false) $sgr[] = "23"; + if ($this->underline == true) $sgr[] = "4"; + if ($this->underline == false) $sgr[] = "24"; + if ($this->reverse == true) $sgr[] = "7"; + if ($this->reverse == false) $sgr[] = "27"; + + if ($this->fg !== null) { + if ($this->fg > 7) { + $sgr[] = 90 + $this->fg - 8; + } else { + $sgr[] = 30 + $this->fg; + } + } + + if ($this->bg !== null) { + if ($this->bg > 7) { + $sgr[] = 100 + $this->bg - 8; + } else { + $sgr[] = 40 + $this->bg; + } + } + + if (count($sgr) == 0) { + return ""; + } + return "\e[".join(";",$sgr)."m"; + } + +} + diff --git a/src/Terminal.php b/src/Terminal.php new file mode 100644 index 0000000..dfeb228 --- /dev/null +++ b/src/Terminal.php @@ -0,0 +1,224 @@ +savedStty = exec("stty -g"); + exec("stty raw -icanon time 0 -echo"); + } + $this->stdin = $stdin; + + $this->parser = new ControlCodeParser($this->stdin); + $this->parser->on("data", function ($data) { + $this->emit(self::ON_KEY, [ $data, true ]); + // echo "data: ".addcslashes($data,"\e")."\n"; + }); + $this->parser->on("csi", function ($data) { + $m = match ($data) { + "\e[A" => 'up', + "\e[B" => 'down', + "\e[D" => 'left', + "\e[C" => 'right', + "\e[H" => 'home', + "\e[F" => 'end', + "\e[5~" => 'pgup', + "\e[6~" => 'pgdn', + default => null, + }; + if ($m !== null) { + $this->emit(self::ON_KEY, [ $m ]); + } else { + echo "csi: ".addcslashes($data,"\e")."\n"; + } + }); + $this->parser->on("osc", function ($data) { + echo "osc: ".addcslashes($data,"\e")."\n"; + }); + $this->parser->on("apc", function ($data) { + echo "apc: ".addcslashes($data,"\e")."\n"; + }); + $this->parser->on("dps", function ($data) { + echo "dps: ".addcslashes($data,"\e")."\n"; + }); + $this->parser->on("pm", function ($data) { + echo "pm: ".addcslashes($data,"\e")."\n"; + }); + $this->parser->on("c0", function ($data) { + if ($data == "\x7F") { + $this->emit(self::ON_KEY, [ 'bs' ]); + } elseif ($data == "\n") { + $this->emit(self::ON_KEY, [ 'enter' ]); + } else { + $this->emit(self::ON_KEY, [ 'c-'.chr(ord($data) + 96) ]); + } + //echo "c0: ".addcslashes($data,"\e")." (".dechex(ord($data)).")\n"; + }); + $this->parser->on("c1", function ($data) { + if ($data == "\e\e") { + $this->emit(self::ON_KEY, [ 'esc' ]); + } elseif (strlen($data) == 2 && $data[0] == "\e") { + $this->emit(self::ON_KEY, [ 'm-' . $data[1] ]); + } else { + echo "c1: ".addcslashes($data,"\e")."\n"; + } + }); + + $stdout ??= new WritableResourceStream(STDOUT); + if ($stdout instanceof WritableResourceStream) { + echo "\e[?7l"; + } + $this->stdout = $stdout; + + pcntl_signal(SIGWINCH, function () { + $this->measure(); + Loop::futureTick(fn() => $this->emit("resize", [ $this->lines, $this->cols ])); + }); + $this->measure(); + + } + + private function measure(): void + { + $this->lines = intval(exec("tput lines")); + $this->cols = intval(exec("tput cols")); + + } + + public function shutdown(): void + { + $this->stdin = null; + + if ($this->savedStty) { + exec("stty {$this->savedStty}"); + } + + if ($this->stdout instanceof WritableResourceStream) { + echo "\e[?7h"; + echo "\e[?25h"; + if ($this->altBuffer) { + echo "\e[?1049l"; + } + } + + $this->stdout = null; + + if ($this->parser) { + $this->parser->removeAllListeners(); + $this->parser = null; + } + } + + public function setAlternateBuffer(bool $buffer): void + { + $this->altBuffer = $buffer; + echo "\e[?1049" . ($buffer ? "h" : "l"); + } + + public function enableBuffering(bool $buffering = true): void + { + $this->buffering = $buffering; + if ($buffering == false) { + $this->flush(); + } + } + + public function setCursorVisible(bool $visible): void + { + echo "\e[?25" . ($visible?"h":"l"); + } + + public function writeAt(int $line, int $column, string $data, ?Style $style = null): void + { + $l = $line+1; + $c = $column+1; + $data = "\e[{$l};{$c}H" . ($style ? $style($data) : $data); + $this->write($data); + } + + public function write(string $data, ?Style $style = null): void + { + if ($style) { + $data = $style($data); + } + if ($this->buffering) { + $this->buffer[] = $data; + } else { + if ($this->stdout) { + $this->stdout->write($data); + } else { + // echo $data; + } + } + } + + public function flush(): void + { + $this->stdout->write(join("",$this->buffer)); + $this->buffer = []; + } + + public function __destruct() + { + $this->shutdown(); + } + + /** + * Get the dimensions of the terminal window + * + * @return object{lines:int,cols:int} + */ + public function getSize(): object + { + return (object)[ + 'lines' => $this->lines, + 'cols' => $this->cols, + ]; + } + + public function clear(): void + { + $this->write("\e[0m\e[H\e[2J"); + } + +} diff --git a/src/Theme.php b/src/Theme.php new file mode 100644 index 0000000..a056fd1 --- /dev/null +++ b/src/Theme.php @@ -0,0 +1,35 @@ +defaults[$element] = $defaultStyle; + $this->styles[$element] = $defaultStyle; + } + + public function set(string $element, ?Style $style): void + { + if (!isset($this->defaults[$element])) { + throw new \InvalidArgumentException("The theme element {$element} is not defined"); + } + if ($style !== null) { + $this->styles[$element] = $style; + } else { + $this->styles[$element] = $this->defaults[$element]; + } + } + + public function get(String $element): ?Style + { + return $this->styles[$element] ?? null; + } + +} +