Initial commit
This commit is contained in:
commit
e2868cd7bf
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/vendor/
|
4
bin/jedit
Executable file
4
bin/jedit
Executable file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require_once __DIR__."/../src/entry.php";
|
20
composer.json
Normal file
20
composer.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "noccylabs/jedit",
|
||||
"description": "JSON/YAML editor for the terminal",
|
||||
"type": "application",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"NoccyLabs\\JEdit\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christopher Vagnetoft",
|
||||
"email": "labs@noccy.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"symfony/yaml": "^7.1"
|
||||
}
|
||||
}
|
169
composer.lock
generated
Normal file
169
composer.lock
generated
Normal file
@ -0,0 +1,169 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f4286ca23e3bc801977031988fb6cfe0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.31.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
|
||||
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-ctype": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-ctype": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"name": "symfony/polyfill",
|
||||
"url": "https://github.com/symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Ctype\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gert de Pagter",
|
||||
"email": "BackEndTea@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for ctype functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"ctype",
|
||||
"polyfill",
|
||||
"portable"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "4e561c316e135e053bd758bf3b3eb291d9919de4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/4e561c316e135e053bd758bf3b3eb291d9919de4",
|
||||
"reference": "4e561c316e135e053bd758bf3b3eb291d9919de4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/polyfill-ctype": "^1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0"
|
||||
},
|
||||
"bin": [
|
||||
"Resources/bin/yaml-lint"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Yaml\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.1.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-17T12:49:58+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
}
|
336
src/Editor/Editor.php
Normal file
336
src/Editor/Editor.php
Normal file
@ -0,0 +1,336 @@
|
||||
<?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":
|
||||
$this->running = false;
|
||||
break;
|
||||
|
||||
case null:
|
||||
break;
|
||||
default:
|
||||
$this->term->setCursor(1, $h, true);
|
||||
echo $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) {
|
||||
$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";
|
||||
}
|
||||
|
||||
}
|
18
src/List/Entry.php
Normal file
18
src/List/Entry.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\List;
|
||||
|
||||
use NoccyLabs\JEdit\Tree\Tree;
|
||||
use NoccyLabs\JEdit\Tree\Node;
|
||||
|
||||
class Entry
|
||||
{
|
||||
public function __construct(
|
||||
public int $depth,
|
||||
public string|int|null $key,
|
||||
public ?Node $node,
|
||||
public bool $closer = false,
|
||||
)
|
||||
{ }
|
||||
}
|
||||
|
179
src/List/TreeList.php
Normal file
179
src/List/TreeList.php
Normal file
@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\List;
|
||||
|
||||
use Countable;
|
||||
use NoccyLabs\JEdit\Tree\ArrayNode;
|
||||
use NoccyLabs\JEdit\Tree\Tree;
|
||||
use NoccyLabs\JEdit\Tree\Node;
|
||||
use NoccyLabs\JEdit\Tree\ObjectNode;
|
||||
use NoccyLabs\JEdit\Tree\ValueNode;
|
||||
|
||||
class TreeList implements Countable
|
||||
{
|
||||
/** @var array<string,Entry> */
|
||||
public array $list = [];
|
||||
|
||||
public function __construct(private Tree $tree)
|
||||
{
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->list);
|
||||
}
|
||||
|
||||
public function parseTree(): void
|
||||
{
|
||||
$this->list = [];
|
||||
$this->parseNode($this->tree->root, [], null);
|
||||
}
|
||||
|
||||
private function parseNode(Node $node, array $path, string|int|null $key): void
|
||||
{
|
||||
$level = count($path);
|
||||
$entryKey = join("/", $path) . (is_null($key) ? "/" : (is_int($key) ? sprintf("/%d", $key) : sprintf("/%s", $key)));
|
||||
|
||||
$entry = new Entry(depth: $level, key: $key, node: $node);
|
||||
|
||||
$this->list[$entryKey] = $entry;
|
||||
|
||||
if ($node instanceof ArrayNode) {
|
||||
$index = 0;
|
||||
foreach ($node->items as $item) {
|
||||
$this->parseNode($item, [ ...$path, $key ], $index++);
|
||||
}
|
||||
$this->list[$entryKey.'$'] = new Entry(depth: $level, key: $key, node: $node, closer: true);
|
||||
} elseif ($node instanceof ObjectNode) {
|
||||
foreach ($node->properties as $nodekey=>$item) {
|
||||
$this->parseNode($item, [ ...$path, $key ], $nodekey);
|
||||
}
|
||||
$this->list[$entryKey.'$'] = new Entry(depth: $level, key: $key, node: $node, closer: true);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public function getPathForIndex(int $index): ?string
|
||||
{
|
||||
return array_keys($this->list)[$index]??null;
|
||||
}
|
||||
|
||||
public function getIndexForPath(string $path): ?int
|
||||
{
|
||||
return array_search($path, array_keys($this->list));
|
||||
}
|
||||
|
||||
public function getNodeForPath(string $path): ?Node
|
||||
{
|
||||
$entry = $this->list[$path]??null;
|
||||
return $entry ? $entry->node : null;
|
||||
}
|
||||
|
||||
public function getNodeForIndex(int $index): ?Node
|
||||
{
|
||||
$key = array_keys($this->list)[$index]??null;
|
||||
$entry = $this->list[$key]??null;
|
||||
if (!$entry) return null;
|
||||
return $entry->node;
|
||||
}
|
||||
|
||||
public function getEntryForIndex(int $index): ?Entry
|
||||
{
|
||||
$key = array_keys($this->list)[$index]??null;
|
||||
$entry = $this->list[$key]??null;
|
||||
if (!$entry) return null;
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function findNearestCollection(int $index, bool $ignoreSelf = false): ?int
|
||||
{
|
||||
$path = $this->getPathForIndex($index);
|
||||
if ($ignoreSelf) {
|
||||
$path = dirname($path);
|
||||
}
|
||||
while (strlen($path) > 0) {
|
||||
$entry = $this->list[$path];
|
||||
$node = $entry->node;
|
||||
if ($node instanceof ArrayNode || $node instanceof ObjectNode) {
|
||||
return $this->getIndexForPath($path);
|
||||
}
|
||||
$path = dirname($path);
|
||||
}
|
||||
|
||||
// $depth = $this->getEntryForIndex($index)->depth;
|
||||
// if ($ignoreSelf) $depth = max(0, $depth - 1);
|
||||
// echo "{start={$depth}}"; sleep(1);
|
||||
// while ($index >= 0) {
|
||||
// $entry = $this->getEntryForIndex($index);
|
||||
// if ($entry->depth < $depth) {
|
||||
// $node = $entry->node;
|
||||
// if ($node instanceof ArrayNode || $node instanceof ObjectNode) {
|
||||
// return $index;
|
||||
// }
|
||||
// }
|
||||
// $index--;
|
||||
// }
|
||||
return null;
|
||||
}
|
||||
|
||||
public function drawEntry(int $screenRow, int $entryRow, int $columns, bool $selected): void
|
||||
{
|
||||
|
||||
$keys = array_keys($this->list);
|
||||
echo "\e[{$screenRow};1H";
|
||||
if ($entryRow >= count($keys)) {
|
||||
echo ($selected?"\e[100;97m":"\e[0;90m")."\e[K";
|
||||
if ($entryRow == count($keys)) echo str_repeat("\u{2574}",$columns);
|
||||
echo "\e[0m";
|
||||
//else echo "\e[90m\u{2805}\e[0m";
|
||||
return;
|
||||
}
|
||||
$key = $keys[$entryRow];
|
||||
if (!isset($this->list[$key])) {
|
||||
echo "\e[0m\e[K\e[31mE_NO_KEY_IN_LIST\e[0m";
|
||||
return;
|
||||
}
|
||||
$entry = $this->list[$key];
|
||||
if (!$entry) {
|
||||
echo "\e[0m\e[K\e[31mE_NO_ENTRY_IN_LIST\e[0m";
|
||||
return;
|
||||
}
|
||||
echo "\e[{$screenRow};1H\e[0m";
|
||||
echo ($selected?"\e[100;97m":"\e[0;37m")."\e[K";
|
||||
echo "\e[90m".str_repeat("\u{258f} ",$entry->depth)."\e[37m";
|
||||
|
||||
if ($entry->closer) {
|
||||
if ($entry->node instanceof ArrayNode) {
|
||||
echo "]";
|
||||
} elseif ($entry->node instanceof ObjectNode) {
|
||||
echo "}";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_null($entry->key)) {
|
||||
echo (is_int($entry->key)
|
||||
?"\e[36;2m\u{e0b6}\e[7m#{$entry->key}\e[27m\u{e0b4}\e[22m "
|
||||
:"\e[36m\"{$entry->key}\":\e[37m ");
|
||||
}
|
||||
if ($entry->node instanceof ArrayNode) {
|
||||
echo "[";
|
||||
} elseif ($entry->node instanceof ObjectNode) {
|
||||
echo "{";
|
||||
} elseif ($entry->node instanceof ValueNode) {
|
||||
$value = $entry->node->value;
|
||||
echo match (gettype($value)) {
|
||||
'string' => "\e[33m",
|
||||
'integer' => "\e[34m",
|
||||
'double' => "\e[32m",
|
||||
'boolean' => "\e[35m",
|
||||
'NULL' => "\e[31m",
|
||||
default => "",
|
||||
};
|
||||
echo json_encode($entry->node->value, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
135
src/Terminal/Terminal.php
Normal file
135
src/Terminal/Terminal.php
Normal file
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Terminal;
|
||||
|
||||
|
||||
class Terminal
|
||||
{
|
||||
private static int $init = 0;
|
||||
|
||||
private ?string $oldStty = null;
|
||||
|
||||
private int $lines = 0;
|
||||
|
||||
private int $columns = 0;
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (self::$init++ == 0) {
|
||||
$this->setup();
|
||||
}
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
if (--self::$init == 0) {
|
||||
$this->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private function setup(): void
|
||||
{
|
||||
$this->oldStty = exec("stty -g");
|
||||
exec("stty raw -echo");
|
||||
|
||||
//ob_start();
|
||||
|
||||
echo "\e[s"; // save cursor pos
|
||||
echo "\e[?1047h"; // alt buffer
|
||||
echo "\e[H\e[0m\e[2J"; // clear screen
|
||||
echo "\e[?7l"; // autowrap
|
||||
|
||||
//ob_flush();
|
||||
|
||||
$this->measureTerminal();
|
||||
pcntl_signal(SIGWINCH, $this->measureTerminal(...));
|
||||
|
||||
$this->active = true;
|
||||
}
|
||||
|
||||
private function measureTerminal(): void
|
||||
{
|
||||
$this->lines = intval(exec("tput lines"));
|
||||
$this->columns = intval(exec("tput cols"));
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
//ob_end_clean();
|
||||
if (!$this->active) return;
|
||||
|
||||
exec("stty {$this->oldStty}");
|
||||
|
||||
echo "\e[?1047l"; // main buffer
|
||||
echo "\e[u\e[?25h"; // restore cursor pos and visibility
|
||||
echo "\e[?7h"; // autowrap
|
||||
|
||||
$this->active = false;
|
||||
}
|
||||
|
||||
public function getSize(): array
|
||||
{
|
||||
return [ $this->columns, $this->lines ];
|
||||
}
|
||||
|
||||
public function setCursor(int $column, int $row, bool $visible = false): void
|
||||
{
|
||||
if ($row > 0 && $column > 0)
|
||||
printf("\e[%d;%dH", $row, $column);
|
||||
echo "\e[?25".($visible?"h":"l");
|
||||
}
|
||||
|
||||
private string $inputBuffer = '';
|
||||
|
||||
public function readKey(): ?string
|
||||
{
|
||||
$r = [ STDIN ]; $w = $e = [];
|
||||
if (stream_select($r, $w, $e, 0)) {
|
||||
$read = fread(STDIN, 64);
|
||||
$this->inputBuffer .= $read;
|
||||
}
|
||||
return $this->parseInputBuffer();
|
||||
}
|
||||
|
||||
private function parseInputBuffer(): ?string
|
||||
{
|
||||
if (str_starts_with($this->inputBuffer, "\e")) {
|
||||
if (strncmp($this->inputBuffer, "\e[A", 3) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 3);
|
||||
return "k{UP}";
|
||||
} elseif (strncmp($this->inputBuffer, "\e[B", 3) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 3);
|
||||
return "k{DOWN}";
|
||||
} elseif (strncmp($this->inputBuffer, "\e[C", 3) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 3);
|
||||
return "k{RIGHT}";
|
||||
} elseif (strncmp($this->inputBuffer, "\e[D", 3) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 3);
|
||||
return "k{LEFT}";
|
||||
} elseif (strncmp($this->inputBuffer, "\e[5~", 4) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 4);
|
||||
return "k{PGUP}";
|
||||
} elseif (strncmp($this->inputBuffer, "\e[6~", 4) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 4);
|
||||
return "k{PGDN}";
|
||||
} elseif (strncmp($this->inputBuffer, "\e\e", 2) === 0) {
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 2);
|
||||
return chr(27);
|
||||
}
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 1);
|
||||
}
|
||||
if (mb_strlen($this->inputBuffer) > 0) {
|
||||
$ch = mb_substr($this->inputBuffer, 0, 1);
|
||||
// if (ord($ch) == 3) {
|
||||
// return "k{^C}";
|
||||
// }
|
||||
$this->inputBuffer = mb_substr($this->inputBuffer, 1);
|
||||
return $ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
31
src/Tree/ArrayNode.php
Normal file
31
src/Tree/ArrayNode.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
class ArrayNode extends Node implements CollapsibleNode
|
||||
{
|
||||
use CollapsibleNodeTrait;
|
||||
|
||||
public function __construct(public array $items)
|
||||
{
|
||||
}
|
||||
|
||||
public function append(Node $value)
|
||||
{
|
||||
$this->items[] = $value;
|
||||
}
|
||||
|
||||
public function removeIndex(int $index)
|
||||
{
|
||||
unset($this->items[$index]);
|
||||
$this->items = array_values($this->items);
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$items = array_map(fn($v) => clone $v, $this->items);
|
||||
return new ArrayNode($items);
|
||||
}
|
||||
|
||||
}
|
||||
|
11
src/Tree/CollapsibleNode.php
Normal file
11
src/Tree/CollapsibleNode.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
interface CollapsibleNode
|
||||
{
|
||||
public function collapse(bool $collapsed): self;
|
||||
|
||||
public function isCollapsed(): bool;
|
||||
}
|
||||
|
21
src/Tree/CollapsibleNodeTrait.php
Normal file
21
src/Tree/CollapsibleNodeTrait.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
trait CollapsibleNodeTrait
|
||||
{
|
||||
private bool $collapsed = false;
|
||||
|
||||
public function collapse(bool $collapsed): self
|
||||
{
|
||||
$this->collapsed = $collapsed;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCollapsed(): bool
|
||||
{
|
||||
return $this->collapsed;
|
||||
}
|
||||
|
||||
}
|
||||
|
8
src/Tree/Node.php
Normal file
8
src/Tree/Node.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
abstract class Node
|
||||
{
|
||||
}
|
||||
|
33
src/Tree/ObjectNode.php
Normal file
33
src/Tree/ObjectNode.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
class ObjectNode extends Node implements CollapsibleNode
|
||||
{
|
||||
use CollapsibleNodeTrait;
|
||||
|
||||
public function __construct(public array $properties)
|
||||
{
|
||||
}
|
||||
|
||||
public function set(string $key, Node $value)
|
||||
{
|
||||
$this->properties[$key] = $value;
|
||||
}
|
||||
|
||||
public function unset(string $key)
|
||||
{
|
||||
unset($this->properties[$key]);
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$properties = array_combine(
|
||||
array_keys($this->properties),
|
||||
array_map(fn($v) => clone $v, $this->properties)
|
||||
);
|
||||
return new ObjectNode($properties);
|
||||
}
|
||||
|
||||
}
|
||||
|
35
src/Tree/Tree.php
Normal file
35
src/Tree/Tree.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
class Tree
|
||||
{
|
||||
|
||||
public ?Node $root = null;
|
||||
|
||||
public function load(mixed $document): self
|
||||
{
|
||||
$this->root = $this->parseNode($document);
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function parseNode(mixed $node): Node
|
||||
{
|
||||
if (is_array($node) && array_is_list($node)) {
|
||||
return new ArrayNode(
|
||||
array_map($this->parseNode(...), $node)
|
||||
);
|
||||
} elseif (is_object($node) || is_array($node)) {
|
||||
return new ObjectNode(
|
||||
array_combine(
|
||||
array_keys((array)$node),
|
||||
array_map($this->parseNode(...), (array)$node)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return new ValueNode($node);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
11
src/Tree/ValueNode.php
Normal file
11
src/Tree/ValueNode.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\JEdit\Tree;
|
||||
|
||||
class ValueNode extends Node
|
||||
{
|
||||
public function __construct(public mixed $value)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
24
src/entry.php
Normal file
24
src/entry.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__."/../vendor/autoload.php";
|
||||
|
||||
$filename = $argv[1]??null; // ?(__DIR__."/../composer.json");
|
||||
|
||||
$terminal = new NoccyLabs\JEdit\Terminal\Terminal();
|
||||
|
||||
$editor = new NoccyLabs\JEdit\Editor\Editor($terminal);
|
||||
if ($filename) {
|
||||
$editor->loadFile($filename);
|
||||
} else {
|
||||
$editor->loadDocument((object)[]);
|
||||
}
|
||||
|
||||
|
||||
set_exception_handler(function (\Throwable $t) use ($terminal) {
|
||||
register_shutdown_function(function () use ($t, $terminal) {
|
||||
$terminal->shutdown();
|
||||
echo $t."\n";
|
||||
});
|
||||
});
|
||||
|
||||
$editor->run();
|
Loading…
Reference in New Issue
Block a user