php-faketerm/src/FakeTerminal.php

602 lines
19 KiB
PHP

<?php
namespace NoccyLabs\FakeTerm;
use NoccyLabs\TermBuf\Renderer\GdRenderer;
use NoccyLabs\TermBuf\Renderer\TerminalRenderer;
use NoccyLabs\TermBuf\TerminalBuffer;
class FakeTerminal
{
/** @var TerminalBuffer */
private $term;
/** @var GdRenderer */
private $renderer;
private $subs = [];
private $funcs = [];
private $execMode = null;
private $execTarget = null;
private $typeDelay = 100;
private $typeJitter = 0;
private $elapsedMs = 0;
private $fps = 0;
private $frame = 0;
private $frameDuration = 0;
private $startTime;
private $nextStat;
private $lastFrames;
private $output;
private $bookmarks = [];
private $outputVideo = null;
private $writtenFiles = [];
private $cursorBlinkRate = 0;
private $cursorStyle = GdRenderer::CURSOR_BLOCK;
public function __construct()
{
$this->renderer = new GdRenderer();
$this->dumper = new TerminalRenderer();
}
private function showStats()
{
if ($this->nextStat === null) {
$this->nextStat = microtime(true) + 2;
return;
} elseif ($this->nextStat > microtime(true)) {
return;
}
$this->nextStat = microtime(true) + 2;
$fps = ($this->frame - $this->lastFrames) / 2;
$this->lastFrames = $this->frame;
fprintf(STDERR, "rendering: f=%d fps=%.1f t=%.1d\n", $this->frame, $fps, microtime(true) - $this->startTime);
}
public function setOutput(string $output)
{
$this->output = $output;
}
public function setOutputVideo(string $outputVideo)
{
$this->outputVideo = $outputVideo;
}
public function loadModule(string $filename)
{
$funcs = include $filename;
foreach ($funcs as $name=>$func) {
$this->funcs[$name] = $func;
}
}
public function executeFile(string $filename)
{
$this->startTime = microtime(true);
$this->includeFile($filename);
echo "\r\e[K";
if ($this->outputVideo) {
fprintf(STDERR, "Converting %s → %s\n", $this->output, $this->outputVideo);
passthru(sprintf("ffmpeg -v error -r %d -i %s -y %s", $this->fps, $this->output, $this->outputVideo));
fprintf(STDERR, "Cleaning up %d files...\n", count($this->writtenFiles));
foreach ($this->writtenFiles as $fn) {
unlink($fn);
}
}
}
public function includeFile(string $filename)
{
$fp = fopen($filename, "r");
if (!$fp) return;
$lc = 0;
while (!feof($fp)) {
$lc++;
$ln = trim(fgets($fp));
if ("" == $ln || $ln[0] == "#") continue;
$lb = str_getcsv($ln, " ");
try {
$this->evaluate($lb);
}
catch (\Throwable $t) {
fprintf(STDERR, "Error executing %s line %d: %s\n", $filename, $lc, $t->getMessage());
}
}
fclose($fp);
}
public function evaluate(array $data)
{
if ($this->execMode == "sub") {
if ($data[0] == "endsub") {
$this->execMode = null;
$this->execTarget = null;
return;
}
$this->subs[$this->execTarget]->sub[] = $data;
return;
}
$args = $data;
$cmd = array_shift($args);
switch ($cmd) {
case 'set':
$prop = array_shift($args);
$value = array_shift($args);
$this->setProp($prop, $value);
break;
case 'include':
$file = array_shift($args);
$this->includeFile($file);
break;
case 'load':
$file = array_shift($args);
$this->loadModule($file);
break;
case 'sub':
$subname = array_shift($args);
$subargs = $args;
$this->subs[$subname] = (object)[
'sub' => [],
'args' => $subargs
];
$this->execMode = "sub";
$this->execTarget = $subname;
break;
case 'log':
echo "log: ";
echo join("", $args);
echo "\n";
break;
case 'write':
$this->writeTerm($args);
break;
case 'type':
$this->writeTerm($args, true);
break;
case 'writefile':
$file = file(array_shift($args));
$delay = intval(array_shift($args)??30);
foreach ($file as $line) {
$this->writeTerm([ $line ]);
$this->delayMs($delay);
}
break;
case 'delay':
$delay = intval(array_shift($args));
$this->delayMs($delay);
break;
case 'savepng':
$name = array_shift($args);
$this->writeFrame($name);
break;
case 'mark':
$id = array_shift($args);
$this->bookmarks[$id] = $this->term->getCursorPosition();
break;
case 'moveto':
$id = array_shift($args);
if (ctype_digit($id)) {
$line = $id;
$column = array_shift($args);
} else {
if (!array_key_exists($id, $this->bookmarks)) break;
[$line, $column] = $this->bookmarks[$id];
}
$this->term->setCursor($line, $column);
break;
case 'annotate':
$id = array_shift($args);
if ($id[0] != "@") {
$text = $id;
$id = uniqid();
} else {
$text = array_shift($args);
}
$line = array_shift($args);
$column = array_shift($args);
if ($line == "here" || $line == null) {
[$line,$column] = $this->term->getCursorPosition();
} elseif ($line == "at") {
$bmid = $column;
[$line,$column] = array_key_exists($bmid, $this->bookmarks) ? $this->bookmarks[$bmid] : [0,0];
}
$this->renderer->addAnnotation($id, $line, $column, $text);
break;
case 'unannotate':
$id = array_shift($args);
$this->renderer->removeAnnotation($id);
break;
case 'clearannotate':
$this->renderer->clearAnnotations();
break;
case 'annotatelength':
$id = array_shift($args);
$this->renderer->updateAnnotation($id, [ 'length' => intval(array_shift($args))]);
break;
case 'showkeys':
$this->renderer->setKeyOverlay($args);
break;
case 'cursor':
$op = array_shift($args);
switch ($op) {
case 'push':
// push cursor position
break;
case 'pop':
// pop cursor position
break;
case 'blink':
// set cursorBlinkRate
$this->cursorBlinkRate = intval(array_shift($args));
break;
case 'style':
$style = array_shift($args);
switch ($style) {
case 'none': $this->cursorStyle = GdRenderer::CURSOR_NONE; break;
case 'block': $this->cursorStyle = GdRenderer::CURSOR_BLOCK; break;
case 'solid': $this->cursorStyle = GdRenderer::CURSOR_SOLID; break;
}
break;
}
break;
case 'clear':
$what = array_shift($args);
$tw = $this->term->getColumns();
$th = $this->term->getLines();
[$cl,$cc] = $this->term->getCursorPosition();
$sa = $this->term->getAttributes();
switch ($what) {
case 'screen':
for ($l = 0; $l < $th; $l++) {
for ($c = 0; $c < $tw; $c++) {
$this->term->bufferSetRaw($l, $c, [ ' ', $sa ]);
}
}
break;
case 'line':
for ($c = 0; $c < $tw; $c++) {
$this->term->bufferSetRaw($cl, $c, [ ' ', $sa ]);
}
break;
case 'to-eol':
for ($c = $cc; $c < $tw; $c++) {
$this->term->bufferSetRaw($cl, $c, [ ' ', $sa ]);
}
break;
case 'to-bol':
for ($c = 0; $c <= $cc; $c++) {
$this->term->bufferSetRaw($cl, $c, [ ' ', $sa ]);
}
break;
case 'above':
for ($l = 0; $l < $cl; $l++) {
for ($c = 0; $c < $tw; $c++) {
$this->term->bufferSetRaw($l, $c, [ ' ', $sa ]);
}
}
break;
case 'to-top':
for ($l = 0; $l <= $cl; $l++) {
for ($c = 0; $c < $tw; $c++) {
$this->term->bufferSetRaw($l, $c, [ ' ', $sa ]);
}
}
break;
case 'below':
for ($l = $cl + 1; $l < $th; $l++) {
for ($c = 0; $c < $tw; $c++) {
$this->term->bufferSetRaw($l, $c, [ ' ', $sa ]);
}
}
break;
case 'to-bottom':
for ($l = $cl; $l < $th; $l++) {
for ($c = 0; $c < $tw; $c++) {
$this->term->bufferSetRaw($l, $c, [ ' ', $sa ]);
}
}
break;
default:
fprintf(STDERR, "error: Can't clear '%s'\n", $what);
}
break;
default:
if (array_key_exists($cmd, $this->subs)) {
$this->callSub($cmd, $args);
return;
}
if (array_key_exists($cmd, $this->funcs)) {
$this->callFunc($cmd, $args);
return;
}
throw new \LogicException("No such command ${cmd}");
}
}
public function writeFrame($name=null)
{
$ft = ($this->frame * $this->frameDuration);
if ($this->cursorBlinkRate > 0) {
//printf("\nft=%.1f r=%.1f\n", $ft, $this->cursorBlinkRate);
$visible = (floor($ft / $this->cursorBlinkRate) % 2) == 0;
if ($visible) {
$this->renderer->setCursorStyle($this->cursorStyle);
} else {
$this->renderer->setCursorStyle(GdRenderer::CURSOR_NONE);
}
} else {
$this->renderer->setCursorStyle($this->cursorStyle);
}
$this->renderer->render($this->term);
if ($name) {
$this->renderer->writePng($name);
return;
}
$file = sprintf($this->output, $this->frame++);
$this->renderer->writePng($file);
$this->showStats();
$this->writtenFiles[] = $file;
}
public function setFps(float $fps)
{
$this->fps = $fps;
if ($fps > 0) {
$this->frameDuration = 1000 / $fps;
}
}
public function delayMs(int $ms)
{
if ($this->fps == 0) return;
$this->elapsedMs += $ms;
$lastFrameMs = $this->frame * $this->frameDuration;
$nextFrameMs = $lastFrameMs + $this->frameDuration;
while ($this->elapsedMs >= $nextFrameMs) {
$this->writeFrame();
$nextFrameMs += $this->frameDuration;
}
}
public function writeTerm(array $args, bool $type=false)
{
//printf("write: %s\n", json_encode($args));
$buf = null;
$lastArg = null;
while (count($args)) {
$arg = array_shift($args);
if ($arg && $arg[0] == "<" && strlen($arg) > 1) {
if (substr($arg, -1, 1) == ">") {
$this->setupTerm(substr($arg, 1, -1));
continue;
}
$buf = [ $arg ];
while ($tail = array_shift($args)) {
$buf[] = $tail;
if (substr($tail, -1, 1) == ">") {
break;
}
}
$buf = join(" ", $buf);
$this->setupTerm(substr($buf, 1, -1));
$buf = null;
} elseif (preg_match('/u\{([0-9a-f]+)\}/', $arg, $match)) {
$arg = mb_chr(hexdec($match[1]));
$lastArg = $arg;
//printf("write: '%s'\n", $arg);
if (!$type) {
$this->term->write($arg);
} else {
for ($c = 0; $c < mb_strlen($arg); $c++) {
$this->term->write(mb_substr($arg, $c, 1));
$this->delayMs($this->typeDelay + rand(-$this->typeJitter,$this->typeJitter));
}
}
} elseif (preg_match('/\*([0-9]+)/', $arg, $match)) {
$repeat = intval($match[1]) - 1;
//printf("repeat: '%s' * %d\n", $lastArg, $repeat);
for ($n = 0; $n < $repeat; $n++) {
if (!$type) {
$this->term->write((string)$lastArg);
} else {
for ($c = 0; $c < mb_strlen($lastArg); $c++) {
$this->term->write(mb_substr($lastArg, $c, 1));
$this->delayMs($this->typeDelay + rand(-$this->typeJitter,$this->typeJitter));
}
}
}
} else {
//printf("write: '%s'\n", $arg);
$lastArg = $arg;
if (!$type) {
$this->term->write($arg);
} else {
for ($c = 0; $c < mb_strlen($arg); $c++) {
$this->term->write(mb_substr($arg, $c, 1));
$this->delayMs($this->typeDelay + rand(-$this->typeJitter,$this->typeJitter));
}
}
}
}
}
public function setupTerm(string $prop) {
$props = explode(" ", $prop);
$mode = array_shift($props);
switch ($mode) {
case 'home':
$this->term->setCursor($this->term->getCursorPosition()[0], 0);
break;
case 'br':
case 'return':
$this->term->setCursor($this->term->getCursorPosition()[0] + 1, 0);
break;
case 'move':
// move [steps] <dir>
// dir = { up, down, left, right }
case 'erase':
// erase <to>
// to = { screen, to-eol, to-bol, to-top, to-bottom }
break;
case 'sgr':
$sgr = [ 0 ];
while ($k = array_shift($props)) {
if (strpos($k, ":")!==false) {
[$k,$v] = explode(":", $k, 2);
} else {
$v = true;
}
switch ($k) {
case 'bold':
$sgr[] = 1;
break;
case 'fg':
$sgr[] = 30 + $this->colorToIndex($v);
break;
case 'bg':
$sgr[] = 40 + $this->colorToIndex($v);
break;
default:
fprintf(STDERR, "Unknown attribute: %s\n", $k);
}
}
// printf("sgr: %s\n", json_encode($sgr));
$this->term->setAttributes($sgr);
break;
}
}
private function colorToIndex(string $color):int {
switch ($color) {
case 'black': return 0;
case 'red': return 1;
case 'green': return 2;
case 'yellow': return 3;
case 'blue': return 4;
case 'magenta': return 5;
case 'cyan': return 6;
case 'white': return 7;
}
}
public function callSub(string $sub, array $args=[])
{
foreach ($this->subs[$sub]->sub as $data) {
foreach ($data as $k=>$v) {
if ($v[0] == '$' && strlen($v) > 1) {
$i = intval(substr($v, 1)) - 1;
$data[$k] = array_key_exists($i, $args)?$args[$i]:$v;
}
}
$this->evaluate($data);
}
}
public function callFunc(string $func, array $args=[])
{
call_user_func($this->funcs[$func], $this->term, ...$args);
}
public function setProp(string $prop, $value)
{
switch ($prop) {
case 'terminal':
[ $columns, $lines ] = explode("x", $value, 2);
$this->setTerminalSize(intval($columns), intval($lines));
break;
case 'colors':
$this->colors = intval($value);
break;
case 'typedelay':
$this->typeDelay = intval($value);
break;
case 'typejitter':
$this->typeJitter = intval($value);
break;
case 'fps':
$this->setFps(floatval($value));
break;
case 'annotationsize':
$this->annotationFontSize = intval($value);
break;
case 'video':
$this->outputVideo = $value;
break;
default:
throw new \LogicException("Bad property for set: ${prop}");
}
}
public function setTerminalSize(int $columns, int $rows)
{
$this->term = new TerminalBuffer($columns, $rows);
}
}