401 lines
15 KiB
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";
|
|
}
|
|
|
|
}
|