2024-10-01 18:46:03 +02:00
< ? php
namespace NoccyLabs\JEdit\Editor ;
use NoccyLabs\JEdit\List\TreeList ;
2024-10-02 00:53:11 +02:00
use NoccyLabs\JEdit\Settings ;
2024-10-01 18:46:03 +02:00
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 " ;
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}' :
2024-10-01 22:44:11 +02:00
$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' );
$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.
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 (press ctrl-C to close) " );
$msg -> display ( 5 , 3 , 70 , 20 );
$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 ( $newVal !== null ) {
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 ) {
2024-10-01 22:44:11 +02:00
$key = $this -> ask ( " \ e[0;33mkey: \ e[0m " );
2024-10-01 22:31:51 +02:00
if ( $key === null ) {
$this -> redrawInfoBar ();
return ;
}
}
if ( $value === null )
2024-10-01 22:44:11 +02:00
$value = $this -> ask ( " \ e[0;32mvalue: \ e[0m " );
2024-10-01 22:31:51 +02:00
if ( $value !== null ) {
2024-10-01 22:44:11 +02:00
$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
*/
2024-10-01 18:46:03 +02:00
private function redrawInfoBar ()
{
[ $w , $h ] = $this -> term -> getSize ();
$keys = [
'e' => 'Edit' ,
'E' => 'Edit key' ,
2024-10-01 22:44:11 +02:00
'I' => 'Insert' ,
'i' => 'Insert value' ,
2024-10-01 18:46:03 +02:00
'D' => 'Delete' ,
2024-10-01 22:44:11 +02:00
//'C' => 'Copy',
//'P' => 'Paste',
'^R' => 'Read' ,
'^W' => 'Write' ,
2024-10-01 18:46:03 +02:00
'^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 " ;
}
}