From eceacf34cdd2721e87e0e6539f154c2fbf20bf80 Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Mon, 1 Feb 2021 16:14:51 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 + SCRIPTING.md | 167 +++++++++++++++++ bin/faketerm | 29 +++ composer.json | 26 +++ src/FakeTerminal.php | 416 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 642 insertions(+) create mode 100644 .gitignore create mode 100644 SCRIPTING.md create mode 100755 bin/faketerm create mode 100644 composer.json create mode 100644 src/FakeTerminal.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4fc989 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/composer.lock +/vendor +*~ + diff --git a/SCRIPTING.md b/SCRIPTING.md new file mode 100644 index 0000000..4fc61e6 --- /dev/null +++ b/SCRIPTING.md @@ -0,0 +1,167 @@ +# FakeBash Scripting + +## Understanding the parser + +The parser is CSV-based, and as such does not care about unquoted spaces. +For that reason, the following two examples are the same: + + write "Hello World" + write H e l l o " " W o r l d + write "Hello " World + +You can use unicode with the syntax `u{CODE}`: + + write u{26c4} " A snowman!" + +### Conventions + +* Annotations are prefixed with an at-sign (`@`) +* Bookmarks are prefixed with a percent sign (`%`) +* Comments start with a hash sign (`#`) and cover the entire line + + +## Commands + +### annotate - Add an anotation + +Annotations can be added to point to any cell in the virtual terminal. + + annotate @useful-tip "This is a useful tip\nthat spans 2 lines" + delay 2000 + unannotate @useful-tip + +Annotations doesn't need to have a name and may have a position: + + annotate "This will annotate char 5 on line 1" 0 4 + +You can also use bookmarks for positioning the annotations: + + mark %where + ... + annotate "This will annotate the bookmark %where" at %where + +### clearannotate - Remove all annotations + +Removes all annontations in one go. + + clearannotate + +### delay - Wait for time to pass + +Only useful if you have `set fps` to a non-zero value, this command will +simulate time passing and render any frames needed. + + delay 100 + +### goto - Goto a bookmark + + mark %somewhere + write "Hello" + goto %somewhere + write "Goodbye" + +### log - Log debug info + +This command just sends output to the console for debugging. + + log "The parameter is " $1 + +### mark - Set a bookmark + + mark %here + +### moveto - Go to position or bookmark + + moveto %here + moveto 0 0 + +### savepng - Save the terminal as a png + + savepng output.png + +### set - Set options + +The `set` command is used to configure Faketerm. + +Terminal-related: + + set terminal 80x25 # Width and height of window + +Typing related: + + set typedelay 250 # Delay in ms when using the type command + set typejitter 100 # Random amount of +/- ms when using type + +Animation: + + set fps 30 # Enable rendering of frames + set video out.mp4 # Convert frames to a video/gif + +Annotations: + + set annotationsize 3 # Font size for annotations + + +### sub - Subroutine + +The `sub` command defines a subroutine, all the way up to the +`endsub` command. Subroutines can take indexed arguments. The +arguments are not checked, and empty arguments are replaced with +empty strings. + + sub hello + write "Hello, " $1 + endsub + + hello "World" + +### type - Simulate typing to the fake terminal + +This command works just like `write`, only it writes character by character. + + type "Hello World" + +### unannotate - Remove an annotation + +Removes an annotation + + unannotate @id + +### write - Write to the fake terminal + +Write output to the current cursor position of the virtual terminal. Extra +tags can be added as arguments, but be aware there are no closing tags. + + write "Hello World" + +Supported tags are: + + - Set output style, no parameters resets style. + - Move to the beginning of the current line + - Move the cursor + - Erase line or screen + - Move to the beginning of the next line + +### writefile - Write a file to the fake terminal + +This command writes the output of a file line by line to the fake terminal +with an optional delay between each line. + + writefile "command-output.txt" 100 + +## Write and Type tags + +The following can be used with the tag: + + sgr fg: + bg: + bold + underline + +The following with the tag: + + move [steps] + +The following with the tag: + + erase {screen|to-eol|to-bol|to-top|to-bottom} diff --git a/bin/faketerm b/bin/faketerm new file mode 100755 index 0000000..7f9b93f --- /dev/null +++ b/bin/faketerm @@ -0,0 +1,29 @@ +#!/usr/bin/env php +setOutputVideo($video); +} + +$ft->setOutput($output); +$ft->executeFile($input); + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1d9a263 --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "noccylabs/faketerm", + "description": "Script fake terminals into videos", + "type": "application", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Christopher Vagnetoft", + "email": "cvagnetoft@gmail.com" + } + ], + "autoload": { + "psr-4": { + "NoccyLabs\\FakeTerm\\": "src/" + } + }, + "repositories": [ + { + "type": "path", + "url": "../php-termbuf" + } + ], + "require": { + "noccylabs/termbuf": "@dev" + } +} diff --git a/src/FakeTerminal.php b/src/FakeTerminal.php new file mode 100644 index 0000000..00c9035 --- /dev/null +++ b/src/FakeTerminal.php @@ -0,0 +1,416 @@ +renderer = new GdRenderer(); + $this->dumper = new TerminalRenderer(); + + } + + public function setOutput(string $output) + { + $this->output = $output; + } + + public function setOutputVideo(string $outputVideo) + { + $this->outputVideo = $outputVideo; + } + + public function executeFile(string $filename) + { + $fp = fopen($filename, "r"); + $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()); + } + } + + 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 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 '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; + + default: + if (array_key_exists($cmd, $this->subs)) { + $this->callSub($cmd, $args); + return; + } + throw new \LogicException("No such command ${cmd}"); + } + + } + + public function writeFrame($name=null) + { + $this->renderer->render($this->term); + + if ($name) { + $this->renderer->writePng($name); + return; + } + + $file = sprintf($this->output, $this->frame++); + $this->renderer->writePng($file); + + //$this->dumper->render($this->term); + //printf("\e[0m\rwrite: \e[1m%s\e[0m", $file); + $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; + while (count($args)) { + $arg = array_shift($args); + if ($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])); + //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)); + } + } + + } else { + //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)); + } + } + } + } + + + } + + 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 'return': + $this->term->setCursor($this->term->getCursorPosition()[0] + 1, 0); + break; + + case 'move': + // move [steps] + // dir = { up, down, left, right } + + case 'erase': + // erase + // 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]:""; + } + } + $this->evaluate($data); + } + } + + 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); + } + +} \ No newline at end of file