jsonedit/src/Editor/Editor.php

616 lines
20 KiB
PHP
Raw Normal View History

2024-10-01 18:46:03 +02:00
<?php
namespace NoccyLabs\JsonEdit\Editor;
use NoccyLabs\JsonEdit\List\TreeList;
use NoccyLabs\JsonEdit\Settings;
use NoccyLabs\JsonEdit\Terminal\Terminal;
use NoccyLabs\JsonEdit\Tree\ArrayNode;
use NoccyLabs\JsonEdit\Tree\ObjectNode;
use NoccyLabs\JsonEdit\Tree\Tree;
use NoccyLabs\JsonEdit\Tree\ValueNode;
2024-10-01 18:46:03 +02:00
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";
2024-10-01 22:51:58 +02:00
private bool $running = true;
2024-10-02 00:53:11 +02:00
private bool $modified = false;
2024-10-01 22:51:58 +02:00
/**
* Constructor
*
* @param Terminal $term
*/
2024-10-01 18:46:03 +02:00
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);
}
2024-10-01 22:51:58 +02:00
/**
* Read a file into the Tree and TreeList
*
* @param string $filename
* @return void
*/
2024-10-01 18:46:03 +02:00
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();
}
2024-10-01 22:51:58 +02:00
/**
* Load a document from array/object
*
* @param mixed $document
* @return void
*/
2024-10-01 18:46:03 +02:00
public function loadDocument(mixed $document): void
{
$this->document->load($document);
$this->list->parseTree();
}
2024-10-01 22:51:58 +02:00
/**
* Run the editor
*
* @return void
*/
2024-10-01 18:46:03 +02:00
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) - 1, $this->currentRow + 1);
2024-10-01 18:46:03 +02:00
$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':
2024-10-01 22:51:58 +02:00
$this->doEditKey();
2024-10-01 18:46:03 +02:00
break;
case 'e':
2024-10-01 22:51:58 +02:00
$this->doEditValue();
2024-10-01 18:46:03 +02:00
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();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
} elseif ($collNode instanceof ObjectNode) {
$collNode->unset($deleteKey);
$this->list->parseTree();
$this->redrawEditor();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
} 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;
2024-10-01 22:31:51 +02:00
case 'I':
$menu = new Menu($this->term, [
'value' => "Insert Value",
'object' => "Insert Object{}",
'array' => "Insert Array[]",
], 'Insert');
$this->redrawInfoBar([ '^C' => 'Cancel', '↑/↓' => 'Select option', 'Enter' => 'Accept' ]);
2024-10-01 22:31:51 +02:00
$sel = $menu->display(5, 2, 30, 0, "value");
$this->redrawEditor();
switch ($sel) {
case 'value':
$this->doInsertValue();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:31:51 +02:00
break;
case 'array':
$this->doInsertValue('[]');
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:31:51 +02:00
break;
case 'object':
$this->doInsertValue('{}');
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
break;
}
2024-10-01 22:31:51 +02:00
break;
2024-10-01 18:46:03 +02:00
2024-10-02 00:53:11 +02:00
case 'h':
$text = <<<EOT
Welcome to JSONEdit! The editor you have missed all this time without even knowing it!
To get started, press I (shift-i) and add something to the document. Use the arrow keys to get around. To cancel a prompt, close a menu or close a dialog, press ctrl-C. When you are happy with your work, press ctrl-W and enter a filename to write. You can also press ctrl-R and read in a new file, overwriting your masterpiece.
↑↓ Navigate values in document
i Insert a new value
I Insert value, array or object
e Edit selected value
E Edit selected key
D Delete selected key
^W Write to file
^R Read from file
^N New document with empty object
^C Cancel/Exit
This is beta software, if not alpha. It kinda works, but there will be issues. Feel free to help out with a patch, or by filing bug reports.
Known issues include:
* Editing long lines will blow up. Don't try to edit anything longer than the terminal is wide.
* There is no fullscreen editing, so verbatim blocks in twig will probably not work well either.
* Comments are not preserved.
* Files are overwritten without confirmation.
* There is no command mode, no search.
* Some things just don't work yet.
* Unhandled keys will appear in the bottom left of the screen with a delay.
* Folding is not yet implemented.
* There are crashes, and lock-ups. Data corruption is a possibility.
2024-10-02 00:53:11 +02:00
Go to https://dev.noccylabs.info/noccy/jsonedit to find the source code, issue tracker, and learn more about the project!
EOT;
$msg = new MessageBox($this->term, $text, "Help");
$this->redrawInfoBar([ '^C' => 'Close' ]);
$msg->display(10, 4, $w - 20, $h - 8);
2024-10-02 00:53:11 +02:00
$this->redrawEditor();
break;
2024-10-01 22:31:51 +02:00
case 'i':
$this->doInsertValue();
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 18:46:03 +02:00
break;
case 'Q':
2024-10-01 21:40:13 +02:00
case "\x03": // ctrl-w
2024-10-01 18:46:03 +02:00
$this->running = false;
break;
2024-10-02 00:53:11 +02:00
case "q":
Settings::$editorQuotedKeys = !Settings::$editorQuotedKeys;
$this->redrawEditor();
break;
case "\x0e": // ctrl-n
$this->document->load((object)[]);
$this->list->parseTree();
$this->filename = "untitled.json";
$this->shortfilename = "untitled.json";
$this->modified = false;
$this->redrawEditor();
break;
2024-10-01 21:40:13 +02:00
case "\x12": // ctrl-r
2024-10-01 22:51:58 +02:00
$this->doReadFile();
2024-10-01 21:40:13 +02:00
break;
case "\x17": // ctrl-w
2024-10-01 22:51:58 +02:00
$this->doWriteFile();
2024-10-01 21:40:13 +02:00
break;
2024-10-01 18:46:03 +02:00
case null:
break;
default:
$this->term->setCursor(1, $h, true);
2024-10-01 21:40:13 +02:00
echo sprintf("%s %02x %03d", ctype_print($read)?$read:'.', ord($read), ord($read));
2024-10-01 18:46:03 +02:00
sleep(1);
}
}
2024-10-02 00:53:11 +02:00
Settings::save(SETTINGS_FILE);
2024-10-01 18:46:03 +02:00
// for ($n = 0; $n < 20; $n++) {
// $this->currentRow = $n;
// $this->redrawEditor();
// sleep(1);
// }
}
2024-10-01 22:51:58 +02:00
/**
* Handler for read file command (ctrl-R)
*
* @return void
*/
private function doReadFile(): void
{
[$w,$h] = $this->term->getSize();
$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";
2024-10-01 23:01:26 +02:00
return;
2024-10-01 22:51:58 +02:00
}
if (!is_readable($readFrom)) {
echo "\e[97;41mFile not readable\e[K\e[0m";
2024-10-01 23:01:26 +02:00
return;
2024-10-01 22:51:58 +02:00
}
$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";
2024-10-01 23:01:26 +02:00
return;
2024-10-01 22:51:58 +02:00
}
$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";
}
/**
* Handler for the write file command (ctrl-W)
*
* @return void
*/
private function doWriteFile(): void
{
[$w,$h] = $this->term->getSize();
$saveTo = $this->ask("\e[33mWrite to:\e[0m ", $this->filename);
$doc = $this->document->save();
$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:
2024-10-02 00:53:11 +02:00
$this->term->setCursor(1, $h);
2024-10-01 22:51:58 +02:00
echo "\e[97;41mUnable to write format: {$ext}\e[K\e[0m";
}
2024-10-02 00:53:11 +02:00
$this->filename = $saveTo;
$this->shortfilename = basename($saveTo);
$this->modified = false;
$this->redrawEditor();
$this->term->setCursor(1, $h);
2024-10-01 22:51:58 +02:00
echo "\e[97;42mWrote to {$saveTo}\e[K\e[0m";
2024-10-02 00:53:11 +02:00
2024-10-01 22:51:58 +02:00
}
/**
* Edit selected key
*
* @return void
*/
private function doEditKey(): void
{
[$w,$h] = $this->term->getSize();
//$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[0;33mnew key:\e[0m ", $entry->key);
if (!empty($newVal)) {
2024-10-02 00:53:11 +02:00
$parent->node->rename($entry->key, $newVal);
2024-10-01 22:51:58 +02:00
$entry->key = $newVal;
2024-10-02 00:53:11 +02:00
$this->list->parseTree();
2024-10-01 22:51:58 +02:00
$this->redrawEditor();
}
} else {
$this->term->setCursor(1, $h);
echo "\e[97;41mCan only edit keys on objects\e[K\e[0m";
}
}
/**
* Edit selected value
*
* @return void
*/
private function doEditValue(): void
{
[$w,$h] = $this->term->getSize();
//$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);
// If the string decodes to null, but isn't 'null', treat it as a string
if ($val === null && $newVal !== 'null') {
$val = $newVal;
}
$node->value = $val;
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:51:58 +02:00
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
} else {
$this->term->setCursor(1, $h);
echo "\e[97;41mCan not edit array/object\e[K\e[0m";
}
}
/**
* Insert a new value
*
* @param mixed $value
* @return void
*/
2024-10-01 22:31:51 +02:00
private function doInsertValue(mixed $value = null): void
{
$coll = $this->list->findNearestCollection($this->currentRow);
$node = $this->list->getNodeForIndex($coll);
if ($node instanceof ObjectNode) {
$key = $this->ask("\e[0;33mkey:\e[0m ");
if (empty($key)) {
2024-10-01 22:31:51 +02:00
$this->redrawInfoBar();
return;
}
}
if ($value === null)
$value = $this->ask("\e[0;32mvalue:\e[0m ");
2024-10-01 22:31:51 +02:00
if ($value !== null) {
$newvalue = json_decode($value);
// If the string decodes to null, but isn't 'null', treat it as a string
if ($newvalue === null && $value !== 'null') {
$newvalue = $value;
}
$value = $newvalue;
2024-10-01 22:31:51 +02:00
$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);
}
2024-10-02 00:53:11 +02:00
$this->modified = true;
2024-10-01 22:31:51 +02:00
$this->list->parseTree();
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
}
2024-10-01 22:51:58 +02:00
/**
* Ask for input
*
* @param string $prompt
* @param string $value
* @return string|null
*/
2024-10-01 18:46:03 +02:00
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) {
2024-10-01 21:40:13 +02:00
// ctrl-c
2024-10-01 18:46:03 +02:00
$this->term->setCursor(1, $h);
echo "\e[0m\e[K";
return null;
2024-10-01 21:40:13 +02:00
}
2024-10-01 18:46:03 +02:00
} 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;
}
}
}
}
2024-10-01 22:51:58 +02:00
/**
* Redraw the editor
*
* @return void
*/
2024-10-01 18:46:03 +02:00
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);
2024-10-02 00:53:11 +02:00
$modified = $this->modified ? "\e[31m*\e[37m" : "";
2024-10-01 18:46:03 +02:00
$this->term->setCursor(1, $h-1);
2024-10-02 00:53:11 +02:00
echo "\e[40;37m\e[K{$modified}\e[1m{$this->shortfilename}\e[22m#\e[3m{$path}\e[37;23m";
2024-10-01 18:46:03 +02:00
//$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";
}
2024-10-01 22:51:58 +02:00
/**
* Redraw the info bar
*
* @return void
*/
private function redrawInfoBar(?array $keys = null)
2024-10-01 18:46:03 +02:00
{
[$w,$h] = $this->term->getSize();
if (!$keys) {
$keys = [
'h' => 'Help',
'e' => 'Edit',
'E' => 'Edit key',
'I' => 'Insert…',
//'i' => 'Ins value',
'D' => 'Delete',
//'C' => 'Copy',
//'P' => 'Paste',
'^N' => 'New',
'^R' => 'Read',
'^W' => 'Write',
'^C' => 'Exit',
];
}
2024-10-01 18:46:03 +02:00
$this->term->setCursor(1, $h);
echo "\e[0;40m\e[K";
2024-10-01 18:46:03 +02:00
foreach ($keys as $key=>$info)
echo "\e[37;40;2m\u{e0b6}\e[7;37m{$key} \e[22m {$info} \e[27m\u{e0b4}\e[0m";
//echo " \e[1m{$key}\e[2m \e[3m{$info}\e[0m \e[90m\u{2502}\e[0m";
2024-10-01 18:46:03 +02:00
}
}