jsonedit/src/Editor/Editor.php

401 lines
15 KiB
PHP

<?php
namespace NoccyLabs\JEdit\Editor;
use NoccyLabs\JEdit\List\TreeList;
use NoccyLabs\JEdit\Terminal\Terminal;
use NoccyLabs\JEdit\Tree\ArrayNode;
use NoccyLabs\JEdit\Tree\ObjectNode;
use NoccyLabs\JEdit\Tree\Tree;
use NoccyLabs\JEdit\Tree\ValueNode;
use Symfony\Component\Yaml\Yaml;
class Editor
{
private Tree $document;
private TreeList $list;
private int $currentRow = 0;
private int $scrollOffset = 0;
private ?string $filename = "untitled.json";
private ?string $shortfilename = "untitled.json";
public function __construct(private Terminal $term)
{
$this->document = new Tree();
// $this->document->load((object)[
// 'foo' => true,
// 'bar' => [
// 'a', 'b', 'c'
// ],
// 'what' => 42
// ]);
$this->list = new TreeList($this->document);
}
public function loadFile(string $filename): void
{
$this->filename = $filename;
$this->shortfilename = basename($filename);
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
switch ($ext) {
case 'json':
$doc = json_decode(file_get_contents($filename));
break;
case 'yml':
case 'yaml':
$doc = Yaml::parseFile($filename);
break;
default:
throw new \RuntimeException("Unable to read file of type {$ext}");
}
$this->document->load($doc);
$this->list->parseTree();
}
public function loadDocument(mixed $document): void
{
$this->document->load($document);
$this->list->parseTree();
}
private bool $running = true;
public function run()
{
$this->list->parseTree();
$this->redrawEditor();
while ($this->running) {
[$w,$h] = $this->term->getSize();
$read = $this->term->readKey();
switch ($read) {
case 'k{UP}':
$this->currentRow = max(0, $this->currentRow - 1);
$this->redrawEditor();
break;
case 'k{DOWN}':
$this->currentRow = min(count($this->list), $this->currentRow + 1);
$this->redrawEditor();
break;
case 'k{PGUP}':
$this->currentRow = max(0, $this->currentRow - ($h - 3));
$this->redrawEditor();
break;
case 'k{PGDN}':
$this->currentRow = min(count($this->list), $this->currentRow + ($h - 3));
$this->redrawEditor();
break;
case 'E':
//$coll = $this->list->findNearestCollection($this->currentRow);
$entry = $this->list->getEntryForIndex($this->currentRow);
$path = $this->list->getPathForIndex($this->currentRow);
$parentIndex = $this->list->getIndexForPath(dirname($path));
$parent = $this->list->getEntryForIndex($parentIndex);
if ($parent->node instanceof ObjectNode) {
$newVal = $this->ask("\e[33;1mnew key:\e[0m ", $entry->key);
if ($newVal !== null) {
$entry->key = $newVal;
$this->redrawEditor();
}
} else {
$this->term->setCursor(1, $h);
echo "\e[97;41mCan only edit keys on objects\e[K\e[0m";
}
break;
case 'e':
//$coll = $this->list->findNearestCollection($this->currentRow);
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof ValueNode) {
$val = json_encode($node->value, JSON_UNESCAPED_SLASHES);
$newVal = $this->ask("\e[33;1mnew value:\e[0m ", $val);
if ($newVal !== null) {
$val = json_decode($newVal);
$node->value = $val;
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
} else {
$this->term->setCursor(1, $h);
echo "\e[97;41mCan not edit array/object\e[K\e[0m";
}
break;
case '[':
break;
case 'D':
$node = $this->list->getNodeForIndex($this->currentRow);
$path = $this->list->getPathForIndex($this->currentRow);
$parentPath = dirname($path);
$deleteKey = basename($path);
$collNode = $this->list->getNodeForPath($parentPath);
if ($collNode instanceof ArrayNode) {
$collNode->removeIndex($deleteKey);
$this->list->parseTree();
$this->redrawEditor();
} elseif ($collNode instanceof ObjectNode) {
$collNode->unset($deleteKey);
$this->list->parseTree();
$this->redrawEditor();
} else {
$this->term->setCursor(1, $h);
echo "\e[97;41mCan only delete from object, array\e[K\e[0m";
}
break;
// FIXME make sure this clones; editing a clone updates original as well
// case 'd':
// $node = $this->list->getNodeForIndex($this->currentRow);
// $coll = $this->list->findNearestCollection($this->currentRow, true);
// if (!$coll)
// break;
// $collNode = $this->list->getNodeForIndex($coll);
// if ($collNode instanceof ArrayNode) {
// $collNode->append(clone $node);
// $this->list->parseTree();
// $this->redrawEditor();
// } else {
// $this->term->setCursor(1, $h);
// echo "\e[97;41mCan only duplicate in arrays, node={$this->currentRow}, coll={$coll}\e[K\e[0m";
// }
// break;
case 'i':
$coll = $this->list->findNearestCollection($this->currentRow);
$node = $this->list->getNodeForIndex($coll);
if ($node instanceof ObjectNode) {
$key = $this->ask("\e[97mkey:\e[0m ");
if ($key === null) {
$this->redrawInfoBar();
break;
}
}
$value = $this->ask("\e[97mvalue:\e[0m ");
if ($value !== null) {
$value = json_decode($value);
$valueNode = match (true) {
is_array($value) => new ArrayNode([]),
is_object($value) => new ObjectNode([]),
default => new ValueNode($value)
};
if ($node instanceof ArrayNode) {
$node->append($valueNode);
} elseif ($node instanceof ObjectNode) {
$node->set($key, $valueNode);
}
$this->list->parseTree();
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
break;
case 'Q':
case "\x03": // ctrl-w
$this->running = false;
break;
case "\x12": // ctrl-r
$readFrom = $this->ask("\e[33mRead from:\e[0m ", "");
$this->term->setCursor(1, $h);
if (!file_exists($readFrom)) {
echo "\e[97;41mFile does not exist\e[K\e[0m";
break;
}
if (!is_readable($readFrom)) {
echo "\e[97;41mFile not readable\e[K\e[0m";
break;
}
$ext = strtolower(pathinfo($readFrom, PATHINFO_EXTENSION));
switch ($ext) {
case 'json':
$doc = json_decode(file_get_contents($readFrom));
break;
case 'yml':
case 'yaml':
$doc = Yaml::parseFile($readFrom);
break;
default:
echo "\e[97;41mUnable to read format: {$ext}\e[K\e[0m";
break(2);
}
$this->filename = $readFrom;
$this->shortfilename = basename($readFrom);
$this->document->load($doc);
$this->currentRow = 0;
$this->list->parseTree();
$this->redrawEditor();
$this->term->setCursor(1, $h);
echo "\e[97;42mLoaded {$readFrom}\e[K\e[0m";
break;
case "\x17": // ctrl-w
$saveTo = $this->ask("\e[33mWrite to:\e[0m ", $this->filename);
$doc = $this->document->save();
$this->term->setCursor(1, $h);
$ext = strtolower(pathinfo($saveTo, PATHINFO_EXTENSION));
switch ($ext) {
case 'json':
file_put_contents($saveTo, json_encode($doc, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)."\n");
break;
case 'yml':
case 'yaml':
$doc = json_decode(json_encode($doc), true);
file_put_contents($saveTo, Yaml::dump($doc));
break;
default:
echo "\e[97;41mUnable to write format: {$ext}\e[K\e[0m";
}
echo "\e[97;42mWrote to {$saveTo}\e[K\e[0m";
break;
case null:
break;
default:
$this->term->setCursor(1, $h, true);
echo sprintf("%s %02x %03d", ctype_print($read)?$read:'.', ord($read), ord($read));
sleep(1);
}
}
// for ($n = 0; $n < 20; $n++) {
// $this->currentRow = $n;
// $this->redrawEditor();
// sleep(1);
// }
}
public function ask(string $prompt, $value = ''): ?string
{
$plainPrompt = preg_replace('<\e\[.+?m>', '', $prompt);
$promptLen = mb_strlen($plainPrompt);
[$w,$h] = $this->term->getSize();
$prompting = true;
$cursorPos = mb_strlen($value);
while ($prompting) {
$this->term->setCursor(1, $h);
echo $prompt."\e[0m\e[K".$value;
$this->term->setCursor($promptLen+$cursorPos+1, $h, true);
while (null === ($ch = $this->term->readKey())) {
usleep(10000);
}
if (mb_strlen($ch) == 1) {
if (ord($ch) < 32) {
if (ord($ch) == 13) {
$this->term->setCursor(1, $h);
echo "\e[0m\e[K";
return $value;
}
if (ord($ch) == 3) {
// ctrl-c
$this->term->setCursor(1, $h);
echo "\e[0m\e[K";
return null;
}
} elseif (ord($ch) == 127) {
if ($cursorPos > 0) {
$value = substr($value, 0, $cursorPos - 1) . substr($value, $cursorPos);
$cursorPos--;
}
} else {
$value = mb_substr($value, 0, $cursorPos) . $ch . mb_substr($value, $cursorPos);
$cursorPos++;
}
} else {
switch ($ch) {
case "k{LEFT}":
if ($cursorPos > 0)
$cursorPos--;
break;
case "k{RIGHT}":
if ($cursorPos < mb_strlen($value))
$cursorPos++;
break;
}
}
}
}
public function redrawEditor()
{
[$w,$h] = $this->term->getSize();
while ($this->currentRow < $this->scrollOffset) $this->scrollOffset--;
while ($this->currentRow > $h + $this->scrollOffset - 3) $this->scrollOffset++;
$path = $this->list->getPathForIndex($this->currentRow);
$node = $this->list->getNodeForIndex($this->currentRow);
$this->term->setCursor(1, $h-1);
echo "\e[44;37m\e[K\e[1m{$this->shortfilename}\e[22m#\e[3m{$path}\e[37;23m";
//$this->term->setCursor(1, $h);
echo " = ";
if ($node instanceof ValueNode) {
echo json_encode($node->value, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
} elseif ($node instanceof ArrayNode) {
echo "[…]";
} elseif ($node instanceof ObjectNode) {
echo "{…}";
}
echo "\e[0m";
$this->redrawInfoBar();
// $this->term->setCursor(1, $h);
// echo "\e[0;37m\e[K";
for ($n = 0; $n < $h - 2; $n++) {
$this->list->drawEntry($n + 1, $n + $this->scrollOffset, $w, ($n + $this->scrollOffset == $this->currentRow));
}
// $this->term->setCursor(1, 1);
// echo "\e[90;3m«Empty»\e[0m";
}
private function redrawInfoBar()
{
[$w,$h] = $this->term->getSize();
$keys = [
'e' => 'Edit',
'E' => 'Edit key',
'i' => 'Insert',
'D' => 'Delete',
'C' => 'Copy',
'P' => 'Paste',
'^C' => 'Exit',
];
$this->term->setCursor(1, $h);
echo "\e[0m\e[K";
foreach ($keys as $key=>$info)
echo " \e[1m{$key}\e[2m \e[3m{$info}\e[0m \e[90m\u{2502}\e[0m";
}
}