Initial commit

This commit is contained in:
2025-10-15 22:18:39 +02:00
commit 579311237e
9 changed files with 779 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/vendor/

20
composer.json Normal file
View File

@@ -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"
}
}

289
composer.lock generated Normal file
View File

@@ -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"
}

9
src/Key.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
namespace NoccyLabs\React\TermIo;
class Key
{
const ESCAPE = 'esc';
const ENTER = 'c-J';
}

14
src/KeyEvent.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace NoccyLabs\React\TermIo;
class KeyEvent
{
public function __construct(
public readonly string|Key $key,
public readonly bool $ctrl,
public readonly bool $alt,
)
{
}
}

123
src/KeyMapper.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
namespace NoccyLabs\React\TermIo;
use Evenement\EventEmitterInterface;
use Evenement\EventEmitterTrait;
class KeyMapper implements EventEmitterInterface
{
const ON_PRINTABLE = "printable";
const ON_ACTION = "action";
const ON_KEY = "key";
use EventEmitterTrait;
/** @var array<string,string> Key binds and actions */
private array $binds = [];
/** @var array<string,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<string> 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;
}
}

64
src/Style.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
namespace NoccyLabs\React\TermIo;
class Style
{
public function __construct(
public readonly ?int $fg = null,
public readonly ?int $bg = null,
public readonly ?bool $bold = null,
public readonly ?bool $dim = null,
public readonly ?bool $italic = null,
public readonly ?bool $underline = null,
public readonly ?bool $reverse = null,
public readonly bool $reset = false,
)
{
}
public function __invoke(string $content): string
{
return $this->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";
}
}

224
src/Terminal.php Normal file
View File

@@ -0,0 +1,224 @@
<?php
namespace NoccyLabs\React\TermIo;
use Clue\React\Term\ControlCodeParser;
use Evenement\EventEmitterTrait;
use Evenement\EventEmitterInterface;
use React\EventLoop\Loop;
use React\Stream\ReadableResourceStream;
use React\Stream\ReadableStreamInterface;
use React\Stream\WritableResourceStream;
use React\Stream\WritableStreamInterface;
class Terminal implements EventEmitterInterface
{
use EventEmitterTrait;
/** @var string ON_KEY Emitted when a key is pressed */
const ON_KEY = 'key';
/** @var string ON_RESIZE Emitted when the terminal is resized */
const ON_RESIZE = 'resize';
private ?ReadableStreamInterface $stdin;
private ?WritableStreamInterface $stdout;
private ?ControlCodeParser $parser;
private ?string $savedStty = null;
private bool $altBuffer = false;
private bool $buffering = false;
private array $buffer = [];
private int $lines = 0;
private int $cols = 0;
public function __construct(
?ReadableStreamInterface $stdin = null,
?WritableStreamInterface $stdout = null,
)
{
$stdin ??= new ReadableResourceStream(STDIN);
if ($stdin instanceof ReadableResourceStream) {
$this->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");
}
}

35
src/Theme.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace NoccyLabs\React\TermIo;
class Theme
{
private array $defaults = [];
private array $styles = [];
public function define(string $element, Style $defaultStyle): void
{
$this->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;
}
}