jsonedit/src/Editor/Editor.php

859 lines
30 KiB
PHP

<?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\CollapsibleNode;
use NoccyLabs\JsonEdit\Tree\ObjectNode;
use NoccyLabs\JsonEdit\Tree\Tree;
use NoccyLabs\JsonEdit\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";
private bool $running = true;
private bool $modified = false;
/**
* Constructor
*
* @param Terminal $term
*/
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);
$this->setWindowTitle($this->shortfilename." - JSONEdit");
}
/**
* Read a file into the Tree and TreeList
*
* @param string $filename
* @return void
*/
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->setWindowTitle($this->shortfilename." - JSONEdit");
$this->document->load($doc);
$this->list->parseTree();
}
/**
* Load a document from array/object
*
< * @param mixed $document
* @return void
*/
public function loadDocument(mixed $document): void
{
$this->document->load($document);
$this->list->parseTree();
}
/**
* Run the editor
*
* @return void
*/
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':
$this->doEditKey();
break;
case 'e':
$this->doEditValue();
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();
$this->modified = true;
} elseif ($collNode instanceof ObjectNode) {
$collNode->unset($deleteKey);
$this->list->parseTree();
$this->redrawEditor();
$this->modified = true;
} else {
$this->showMessage("\e[97;41mCan only delete from object, array");
}
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':
$menu = new Menu($this->term, [
'value' => "Insert Value",
'object' => "Insert Object{}",
'array' => "Insert Array[]",
//'sep0' => "---",
//'__paste' => "Paste from clipboard",
], 'Insert');
$this->redrawInfoBar([ '^C' => 'Cancel', '↑/↓' => 'Select option', 'Enter' => 'Accept' ]);
$sel = $menu->display(0, 0, 30, 0, "value");
$this->redrawEditor();
switch ($sel) {
case 'value':
$this->doInsertValue();
$this->modified = true;
break;
case 'array':
$this->doInsertValue('[]');
$this->modified = true;
break;
case 'object':
$this->doInsertValue('{}');
$this->modified = true;
break;
}
break;
case 'H':
$this->doShowAbout();
break;
case 'h':
$this->doShowHelp();
break;
case 'i':
$this->doInsertValue();
$this->modified = true;
break;
case "\x18": // ctrl-x
if ($this->modified) {
$menu = new Menu($this->term, [
'cancel' => "Return to editor",
'save' => "Save changes",
'discard' => "Discard changes",
//'sep0' => "---",
//'__paste' => "Paste from clipboard",
], 'Insert');
$this->redrawInfoBar([ '^C' => 'Cancel', '↑/↓' => 'Select option', 'Enter' => 'Accept' ]);
$sel = $menu->display(0, 0, 30, 0, "value");
$this->redrawEditor();
switch ($sel) {
case 'cancel':
break;
case 'save':
if ($this->doWriteFile()) {
$this->running = false;
}
break;
case 'discard':
$this->running = false;
break;
}
} else {
$this->running = false;
}
break;
case "g":
Settings::$indentationGuides = !Settings::$indentationGuides;
$this->redrawEditor();
$this->showMessage("Toggle indentation guides (g)");
break;
case "b":
Settings::$collapseBefore = !Settings::$collapseBefore;
$this->redrawEditor();
$this->showMessage("Toggle collapse icon before item (b)");
break;
case "t":
Settings::$tailLine = !Settings::$tailLine;
$this->redrawEditor();
$this->showMessage("Toggle tail line (t)");
break;
case "+":
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof CollapsibleNode) {
$node->collapse(!$node->isCollapsed());
$this->list->parseTree();
$this->redrawEditor();
}
break;
case "-":
foreach ($this->list as $path => $entry) {
$node = $entry->node;
if ($path == "/") {
if ($node instanceof CollapsibleNode) {
$node->collapse(false);
}
} else {
if ($node instanceof CollapsibleNode) {
$node->collapse(true);
}
}
}
$this->currentRow = 0;
$this->list->parseTree();
$this->redrawEditor();
break;
case "k{LEFT}":
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof CollapsibleNode) {
if ($node->isCollapsed()) {
$path = $this->list->getPathForIndex($this->currentRow);
$parent = $this->list->getIndexForPath(dirname($path));
if ($parent) {
$this->currentRow = $parent;
}
}
$node->collapse(true);
$this->list->parseTree();
$this->redrawEditor();
} else {
$path = $this->list->getPathForIndex($this->currentRow);
$parent = $this->list->getIndexForPath(dirname($path));
if ($parent) {
$this->currentRow = $parent;
}
$this->redrawEditor();
}
break;
case "k{RIGHT}":
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof CollapsibleNode) {
$node->collapse(false);
$this->list->parseTree();
$this->redrawEditor();
}
break;
case "q":
Settings::$editorQuotedKeys = !Settings::$editorQuotedKeys;
$this->redrawEditor();
$this->showMessage("Toggle quoted keys (q)");
break;
case "c":
Settings::$compactGroups = !Settings::$compactGroups;
$this->list->parseTree();
$this->redrawEditor();
$this->showMessage("Toggle compact groups (c)");
break;
case "\x03": // ctrl-c
$this->showMessage("\e[30;43mPress Ctrl-X to exit.");
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;
case "\x12": // ctrl-r
$this->doReadFile();
break;
case "\x17": // ctrl-w
$this->doWriteFile();
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);
}
}
Settings::save(SETTINGS_FILE);
// for ($n = 0; $n < 20; $n++) {
// $this->currentRow = $n;
// $this->redrawEditor();
// sleep(1);
// }
}
/**
* 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 ", "");
if (!file_exists($readFrom)) {
$this->showMessage("\e[97;41mFile does not exist");
return;
}
if (!is_readable($readFrom)) {
$this->showMessage("\e[97;41mFile not readable");
return;
}
$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:
$this->showMessage("\e[97;41mUnable to read format: {$ext}");
return;
}
$this->filename = $readFrom;
$this->shortfilename = basename($readFrom);
$this->document->load($doc);
$this->currentRow = 0;
$this->list->parseTree();
$this->redrawEditor();
$this->setWindowTitle($this->shortfilename." - JSONEdit");
$this->showMessage("\e[97;42mLoaded {$readFrom}");
}
/**
* Handler for the write file command (ctrl-W)
*
* @return void
*/
private function doWriteFile(): bool
{
[$w,$h] = $this->term->getSize();
$saveTo = $this->ask("\e[33mWrite to:\e[0m ", $this->filename);
if (!$saveTo) {
return false;
}
$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:
$this->showMessage("\e[97;41mUnable to write format: {$ext}");
return false;
}
$this->filename = $saveTo;
$this->shortfilename = basename($saveTo);
$this->modified = false;
$this->redrawEditor();
$this->setWindowTitle($this->shortfilename." - JSONEdit");
$this->showMessage("\e[97;42mWrote to {$saveTo}");
return true;
}
private function doShowHelp(): void
{
[$w,$h] = $this->term->getSize();
$text = <<<EOT
Welcome to JSONEdit! The editor you have missed all this time without even knowing it!
# QuickStart
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.
## Useful keys
↑↓ Navigate values in document
h Show this help
H Show information about the app and license
i Insert a new value
I Insert value, array or object
e Edit selected value
E Edit selected key
D Delete selected key
c Toggle compact list view
q Toggle quoted keys in list view
^W Write to file
^R Read from file
^N New document with empty object
^C Cancel/Exit
## Doing stuff
### Adding keys or values
To add a key or a value, navigate to a value in an array or object, or to a specific array or object, and press "i". You will be prompted for the value, and for objects the key.
You can also press "I" to add arrays and objects. Just select what you want to add in the menu and press enter.
### Editing keys
You can edit keys on objects. For this, press "E".
### Editing values
To edit a value, press "e". The value is verbatim JSON, so strings should be quoted and all that. Anything that is unparsable JSON will be used as is, resulting in a string.
### Loading and Saving files
To load a file, press ^R and enter the filename to read. To write to a file, press ^W and enter the filename to write to.
### YAML or JSON?
There is no need to select YAML or JSON mode. All operations work the same, and the format is determined on load or save.
# Disclaimer
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:
* 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.
* There are crashes, and lock-ups. Data corruption is a possibility.
# Support
Go to https://dev.noccylabs.info/noccy/jsonedit to find the source code, issue tracker, and learn more about the project!
EOT;
$width = min(90, $w - 10);
$height = min(40, $h - 6);
$left = round(($w / 2) - ($width / 2));
$top = round(($h / 2) - ($height / 2));
$msg = new MessageBox($this->term, $text, "Help");
$this->redrawInfoBar([ '↑/↓' => 'Scroll', '^C' => 'Close' ]);
$msg->display($left, $top, $width, $height);
$this->redrawEditor();
}
private function doShowAbout(): void
{
[$w,$h] = $this->term->getSize();
$text = <<<EOT
## JSONEdit Alpha - Copyright (C) 2024, NoccyLabs
Licensed under GNU GPL v3.0 or later.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
EOT;
$width = 60;
$height = 20;
$left = round(($w / 2) - ($width / 2));
$top = round(($h / 2) - ($height / 2));
$msg = new MessageBox($this->term, $text, "About JSONEdit");
$this->redrawInfoBar([ '↑/↓' => 'Scroll', '^C' => 'Close' ]);
$msg->display($left, $top, $width, $height);
$this->redrawEditor();
}
/**
* 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)) {
$parent->node->rename($entry->key, $newVal);
$entry->key = $newVal;
$this->list->parseTree();
$this->redrawEditor();
}
} else {
// $this->term->setCursor(1, $h);
// echo "\e[97;41mCan only edit keys on objects\e[K\e[0m";
$this->showMessage("\e[97;41mCan only edit keys on objects");
}
}
/**
* 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;
$this->modified = true;
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
} else {
$this->showMessage("\e[97;41mCan not edit array/object");
}
}
/**
* Insert a new value
*
* @param mixed $value
* @return void
*/
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)) {
$this->redrawInfoBar();
return;
}
}
if ($value === null)
$value = $this->ask("\e[0;32mvalue:\e[0m ");
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;
$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->modified = true;
$this->list->parseTree();
$this->redrawEditor();
} else {
$this->redrawInfoBar();
}
}
/**
* Ask for input
*
* @param string $prompt
* @param string $value
* @return string|null
*/
public function ask(string $prompt, $value = ''): ?string
{
$plainPrompt = preg_replace('<\e\[.+?m>', '', $prompt);
$promptLen = mb_strlen($plainPrompt);
[$w,$h] = $this->term->getSize();
$available = $w - $promptLen - 1;
$prompting = true;
$cursorPos = mb_strlen($value);
$scrollPos = max(0, $cursorPos - $available);
while ($prompting) {
while (($cursorPos - $scrollPos) > $available) {
$scrollPos++;
}
while (($cursorPos - $scrollPos) < 0) {
$scrollPos--;
}
$cursorOffs = $cursorPos - $scrollPos;
$this->term->setCursor(1, $h);
echo $prompt."\e[0m\e[K".mb_substr($value, $scrollPos, $available);
$this->term->setCursor($promptLen+$cursorOffs+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;
case "k{HOME}":
$cursorPos = 0;
break;
case "k{END}":
$cursorPos = mb_strlen($value);
break;
}
}
}
}
/**
* Redraw the editor
*
* @return void
*/
public function redrawEditor()
{
[$w,$h] = $this->term->getSize();
// Jump to the tail line if the cursor is past the end of the entry list
if ($this->currentRow > count($this->list)) $this->currentRow = count($this->list);
// Make sure the selection is in view
while ($this->currentRow < $this->scrollOffset) $this->scrollOffset--;
while ($this->currentRow > $h + $this->scrollOffset - 3) $this->scrollOffset++;
// Nudge back so the tail line is visible but not selected
if ($this->currentRow == count($this->list)) $this->currentRow--;
$path = $this->list->getPathForIndex($this->currentRow);
$node = $this->list->getNodeForIndex($this->currentRow);
$modified = $this->modified ? "\e[31m*\e[37m" : "";
$this->term->setCursor(1, $h-1);
echo "\e[40;37m\e[K{$modified}\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";
}
/**
* Redraw the info bar
*
* @return void
*/
private function redrawInfoBar(?array $keys = null)
{
[$w,$h] = $this->term->getSize();
if (!$keys) {
$keys = [
'h' => 'Help',
'D' => 'Delete',
];
$node = $this->list->getNodeForIndex($this->currentRow);
if ($node instanceof ValueNode) {
$keys = [ ...$keys,
'e' => 'Edit',
];
}
if ($node instanceof ObjectNode || $node instanceof ArrayNode) {
$keys = [ ...$keys,
'I' => 'Insert…',
];
}
if ($this->modified) {
$keys = [ ...$keys,
'^N' => 'New',
'^W' => 'Write',
];
}
$keys = [ ...$keys,
'^R' => 'Read',
'^X' => 'Exit',
];
}
$this->term->setCursor(1, $h);
echo "\e[37;40m\e[K";
foreach ($keys as $key=>$info)
echo "\e[2m\u{f104}\e[22;97;1m{$key}\e[22;37;2m\u{f105} \e[22;36m{$info}\e[37m ";
//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";
}
private function showMessage(string $message): void
{
[$w,$h] = $this->term->getSize();
$this->term->setCursor(1, $h);
echo "\e[0m".$message."\e[K\e[0m";
}
private function setWindowTitle(string $title): void
{
echo "\e]2;{$title}\x07";
}
}