From 0a61c34d43c3f7d9e3dc36ad1c7bc11ca0d563bf Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Tue, 2 Feb 2021 18:09:29 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 + composer.json | 17 +++ src/Renderer/GdRenderer.php | 245 ++++++++++++++++++++++++++++++ src/Renderer/TerminalRenderer.php | 39 +++++ src/TerminalBuffer.php | 187 +++++++++++++++++++++++ 5 files changed, 492 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 src/Renderer/GdRenderer.php create mode 100644 src/Renderer/TerminalRenderer.php create mode 100644 src/TerminalBuffer.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f856c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/composer.lock +/vendor +*~ +/junk diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0bfb0c3 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "noccylabs/termbuf", + "description": "A virtual terminal buffer", + "type": "library", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Christopher Vagnetoft", + "email": "cvagnetoft@gmail.com" + } + ], + "autoload": { + "psr-4": { + "NoccyLabs\\TermBuf\\": "src/" + } + } +} \ No newline at end of file diff --git a/src/Renderer/GdRenderer.php b/src/Renderer/GdRenderer.php new file mode 100644 index 0000000..fae8348 --- /dev/null +++ b/src/Renderer/GdRenderer.php @@ -0,0 +1,245 @@ +getLines(); + $cols = $buffer->getColumns(); + + $cw = 8; + $ch = 17; + + $this->cellWidth = $cw; + $this->cellHeight = $ch; + + $this->gd = imagecreatetruecolor($cols * $cw, $lines * $ch); + + for ($line = 0; $line < $lines; $line++) { + for ($column = 0; $column < $cols; $column++) { + $this->renderChar($line*$ch, $column*$cw, $cw, $ch, $buffer->bufferGetRaw($line, $column)); + } + } + + [$cursorLine,$cursorColumn] = $buffer->getCursorPosition(); + $cursorX = $cursorColumn * $cw; + $cursorY = $cursorLine * $ch; + + imagerectangle($this->gd, $cursorX, $cursorY, $cursorX + $cw, $cursorY + $ch - 1, 0xFFFFFF); + + $this->drawAnnotations(); + $this->drawKeyOverlay(); + + } + + private function renderChar(int $y, int $x, int $w, int $h, array $raw) + { + [ $char, $attrs ] = $raw; + + $bold = false; + $underline = false; + $color = 0xFFFFFF; + $bgcolor = 0x000000; + + //echo "\rattr: " . json_encode($attrs); usleep(100000); + foreach ($attrs as $attr) { + $attr = intval($attr); + if ($attr === 0) { + $color = 0xFFFFFF; + $bgcolor = 0x000000; + $bold = false; + $underline = false; + } elseif ($attr == 1) { + $bold = true; + } elseif ($attr == 4) { + $underline = true; + } elseif ($attr == 22) { + $bold = false; + } elseif ($attr == 24) { + $underline = false; + } elseif ($attr >= 30 && $attr <= 39) { + $color = $this->palette[$attr - 30]; + } elseif ($attr >= 40 && $attr <= 49) { + $bgcolor = $this->palette[$attr - 40]; + } + } + //$attr = join(";", $attrs)??"0"; + //printf("\e[%d;%dH\e[%sm%s", $line + 1, $column + 1, $attr, $char); + imagefilledrectangle($this->gd, $x, $y, $x + $w, $y + $h, $bgcolor); + imagettftext($this->gd, 10, 0, $x, $y + 12, $color, $bold?$this->boldFont:$this->font, $char); + if ($bold) { + //imagettftext($this->gd, 10, 0, $x + 1, $y + 10, $color, $this->font, $char); + } + if ($underline) { + imageline($this->gd, $x, $y + $h - 2, $x + $w, $y + $h - 2, $color); + } + } + + public function writePng(string $filename) + { + imagepng($this->gd, $filename); + } + + private function drawAnnotations() + { + $fw = imagefontwidth($this->annotationFontSize); + $fh = imagefontheight($this->annotationFontSize); + $bg = 0xFFEEDD; + $bgs = 0x808080; + $bgf = 0xFF8800; + + foreach ($this->annotations as $annotation) { + [$line, $column, $text, $width] = $annotation; + $x = $this->cellWidth * $column; + $y = $this->cellHeight * ($line + 1) + 5; + $lines = explode("\\n", $text); + $lc = count($lines); + $lw = max(array_map("strlen", $lines)); + $w = 4 + ($fw * $lw); + $h = 2 + ($fh * $lc); + $aw = floor($this->cellWidth / 2); + for ($n = 0; $n < $aw; $n++) { + imageline($this->gd, $x + $n, $y - $n, $x + (2 * $aw) - $n, $y - $n, $bg); + } + imagerectangle($this->gd, $x+1, $y+1, $x + $w + 1, $y + $h + 1, $bgs); + imagefilledrectangle($this->gd, $x, $y, $x + $w, $y + $h, $bg); + $lp = 0; + foreach ($lines as $line) { + imagestring($this->gd, $this->annotationFontSize, $x + 2, $y + 1 + ($lp++ * $fh), $line, 0x0); + } + + if ($width > 0) { + imageline($this->gd, $x - 1, $y - 4, $x - 1, $y - 7, $bgf); + imageline($this->gd, $x + ($width * $this->cellWidth) + 1, $y - 4, $x + ($width * $this->cellWidth) + 1, $y - 7, $bgf); + imageline($this->gd, $x - 1, $y - 4, $x + ($width * $this->cellWidth) + 1, $y - 4, $bgf); + imageline($this->gd, $x - 1, $y - 5, $x + ($width * $this->cellWidth) + 1, $y - 5, $bgf); + } + } + } + + public function addAnnotation(string $id, int $line, int $column, string $text) + { + $this->annotations[$id] = [ $line, $column, $text, 0 ]; + } + + public function updateAnnotation(string $id, array $props) + { + if (!array_key_exists($id, $this->annotations)) { + return; + } + foreach ($props as $k=>$v) { + switch ($k) { + case 'length': + $this->annotations[$id][3] = $v; + break; + } + } + } + + public function removeAnnotation(string $id) + { + unset ($this->annotations[$id]); + } + + public function clearAnnotations() + { + $this->annotations = []; + } + + private function drawKeyOverlay() + { + + $x = 0; + $d = 5; + $y = imagesy($this->gd) - 32; + $h = 24; + $w = 32; + $ww = 48; + + foreach ($this->keyOverlay as $key) { + $x += $d; + $char = $this->translateKeyToGlyph($key); + if (mb_strlen($char) > 2) { + $this->drawRoundedRectangle($x, $y, $ww, $h, 3, 0xCCCCCC); + imagettftext($this->gd, 8, 0, $x + 5, $y + 16, 0x000000, $this->boldFont, $char); + $x += $ww; + } else { + $this->drawRoundedRectangle($x, $y, $w, $h, 3, 0xCCCCCC); + imagettftext($this->gd, 10, 0, $x + ((mb_strlen($char)==1)?12:8), $y + 16, 0x000000, $this->boldFont, $char); + $x += $w; + } + } + + } + + public function setKeyOverlay(array $keys) + { + $this->keyOverlay = $keys; + } + + private function drawRoundedRectangle(int $left, int $top, int $width, int $height, int $radius, $color) + { + + $right = $left + $width; + $bottom = $top + $height; + imagefilledrectangle($this->gd, $left + $radius, $top, $right - $radius, $bottom, $color); + imagefilledrectangle($this->gd, $left, $top + $radius, $right, $bottom - $radius, $color); + + imagefilledellipse($this->gd, $left + $radius, $top + $radius, $radius * 2, $radius * 2, $color); + imagefilledellipse($this->gd, $left + $radius, $bottom - $radius, $radius * 2, $radius * 2, $color); + imagefilledellipse($this->gd, $right - $radius, $top + $radius, $radius * 2, $radius * 2, $color); + imagefilledellipse($this->gd, $right - $radius, $bottom - $radius, $radius * 2, $radius * 2, $color); + + } + + private function translateKeyToGlyph(string $key) + { + switch ($key) { + case "up": + return "↑"; + case "down": + return "↓"; + case "left": + return "←"; + case "right": + return "→"; + case "backspace": + case "bs": + return mb_chr(0x232b); + default: + return $key; + } + } +} diff --git a/src/Renderer/TerminalRenderer.php b/src/Renderer/TerminalRenderer.php new file mode 100644 index 0000000..6873bfa --- /dev/null +++ b/src/Renderer/TerminalRenderer.php @@ -0,0 +1,39 @@ +getLines(); + $cols = $buffer->getColumns(); + + $this->clearScreen(); + for ($line = 0; $line < $lines; $line++) { + for ($column = 0; $column < $cols; $column++) { + $this->renderChar($line, $column, $buffer->bufferGetRaw($line, $column)); + } + } + + } + + private function clearScreen() + { + echo "\e[H\e[2J"; + } + + private function renderChar(int $line, int $column, array $raw) + { + [ $char, $attrs ] = $raw; + if ($char === ' ') return; + $attr = join(";", $attrs)??"0"; + printf("\e[%d;%dH\e[%sm%s", $line + 1, $column + 1, $attr, $char); + } + +} diff --git a/src/TerminalBuffer.php b/src/TerminalBuffer.php new file mode 100644 index 0000000..3fcc400 --- /dev/null +++ b/src/TerminalBuffer.php @@ -0,0 +1,187 @@ +columns = $columns; + $this->lines = $lines; + } + + public function getLines(): int + { + return $this->lines; + } + + public function getColumns(): int + { + return $this->columns; + } + + protected function translateBufferOffs(int $line, int $column): int + { + return ($line * $this->columns) + $column; + } + + public function bufferGetRaw(int $line, int $column): array + { + $offs = 'c'.$this->translateBufferOffs($line, $column); + if (array_key_exists($offs, $this->buffer)) { + return $this->buffer[$offs]; + } + return [ ' ', [ 0 ] ]; + } + + public function bufferSetRaw(int $line, int $column, array $raw) + { + $offs = 'c'.$this->translateBufferOffs($line, $column); + $this->buffer[$offs] = $raw; + } + + public function bufferSetChar(int $line, int $column, string $char, array $attr=null) + { + $this->bufferSetRaw($line, $column, [ + $char, + $attr??$this->textAttributes + ]); + } + + public function setAttributes(array $attr) + { + $this->textAttributes = $attr; + } + + public function getAttributes(): array + { + return $this->textAttributes; + } + + public function setCursor(int $line, int $column) + { + $this->cursorColumn = $column; + if ($line >= $this->lines) { + $scrollBy = $line - $this->lines + 1; + $this->scrollBuffer(-$scrollBy); + $line = $this->lines - 1; + } + $this->cursorLine = $line; + } + + public function getCursorPosition(): array + { + return [ $this->cursorLine, $this->cursorColumn ]; + } + + public function moveCursorColumns(int $columns) + { + $lines = 0; + while (($this->cursorColumn + $columns) > $this->columns) { + $lines++; + $columns -= $this->columns; + } + // TODO: This will always wrap, make it respect the wrap and scroll attributes + $this->setCursor($this->cursorLine + $lines, $this->cursorColumn + $columns); + } + + public function moveCursorRows(int $rows) + { + + } + + public function write(string $text) + { + //printf("write %d bytes\n", mb_strlen($text)); + for ($ch = 0; $ch < mb_strlen($text); $ch++) { + $c = mb_substr($text, $ch, 1); + if (mb_ord($c) >= 32) { + //printf("\r[%s] %d,%d\n", $c, $this->cursorLine, $this->cursorColumn); usleep(250000); + $this->bufferSetChar( $this->cursorLine, $this->cursorColumn, $c); + $this->moveCursorColumns(1); + } elseif ($c == chr(10)) { + //printf("\r[NL] %d,%d\n", $c, $this->cursorLine, $this->cursorColumn); usleep(250000); + $line = $this->cursorLine + 1; + $this->setCursor($line, 0); + } + } + } + + public function writeAnsi(string $text) + { + for ($ch = 0; $ch < mb_strlen($text); $ch++) { + $c = mb_substr($text, $ch, 1); + $cc = mb_substr($text, $ch, 2); + if ($cc == "\e[") { + $sb = null; $ch += 2; + while ($ch < mb_strlen($text)) { + $cp = mb_substr($text, $ch++, 1); + if (ctype_alpha($cp)) { $ch--; break; } + $sb .= $cp; + } + $attr = explode(";", $sb); + $this->setAttributes($attr); + } else { + $this->write($c); + } + } + } + + /** + * Scroll lines in the buffer, from $firstLine to $lastLine, moving every line by + * $scroll lines while adding empty lines at top or bottom. + */ + public function scrollLines(int $firstLine, int $lastLine, int $scroll = -1) + { + if ($scroll < 0) { + for ($n = 0; $n < abs($scroll); $n++) { + for ($line = $firstLine; $line < $lastLine; $line++) { + for ($col = 0; $col < $this->columns; $col++) { + $this->bufferSetRaw($line, $col, $this->bufferGetRaw($line + 1, $col)); + } + } + for ($col = 0; $col < $this->columns; $col++) { + $this->bufferSetRaw($lastLine, $col, [' ', []]); + } + } + } elseif ($scroll > 0) { + $first = $firstLine + $scroll; // add scroll to top + $last = $lastLine - $scroll; // remove from bottom + for ($line = $last - 1; $line >= $first; $line--) { + for ($col = 0; $col < $this->columns; $col++) { + $this->bufferSetRaw($line, $col, $this->bufferGetRaw($line - 1, $col)); + } + } + for ($line = $firstLine; $line <= $firstLine + $scroll; $line++) { + for ($col = 0; $col < $this->columns; $col++) { + $this->bufferSetRaw($line, $col, [' ', []]); + } + } + } + + } + + public function scrollBuffer(int $scroll) + { + $this->scrollLines(0, $this->lines - 1, $scroll); + } + +} \ No newline at end of file