document = new Tree(); // $this->document->load((object)[ // 'foo' => true, // 'bar' => [ // 'a', 'b', 'c' // ], // 'what' => 42 // ]); $this->list = new TreeList($this->document); } /** * 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->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) - 1, $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->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': $menu = new Menu($this->term, [ 'value' => "Insert Value", 'object' => "Insert Object{}", 'array' => "Insert Array[]", ], 'Insert'); $this->redrawInfoBar([ '^C' => 'Cancel', '↑/↓' => 'Select option', 'Enter' => 'Accept' ]); $sel = $menu->display(5, 2, 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 $this->running = false; break; case "q": Settings::$editorQuotedKeys = !Settings::$editorQuotedKeys; $this->redrawEditor(); break; case "c": Settings::$compactGroups = !Settings::$compactGroups; $this->list->parseTree(); $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; 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 ", ""); $this->term->setCursor(1, $h); if (!file_exists($readFrom)) { echo "\e[97;41mFile does not exist\e[K\e[0m"; return; } if (!is_readable($readFrom)) { echo "\e[97;41mFile not readable\e[K\e[0m"; 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: echo "\e[97;41mUnable to read format: {$ext}\e[K\e[0m"; return; } $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: $this->term->setCursor(1, $h); echo "\e[97;41mUnable to write format: {$ext}\e[K\e[0m"; } $this->filename = $saveTo; $this->shortfilename = basename($saveTo); $this->modified = false; $this->redrawEditor(); $this->term->setCursor(1, $h); echo "\e[97;42mWrote to {$saveTo}\e[K\e[0m"; } private function doShowHelp(): void { [$w,$h] = $this->term->getSize(); $text = <<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 = <<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"; } } /** * 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->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 */ 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(); $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; } } } } /** * Redraw the editor * * @return void */ 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); $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[0;40m\e[K"; 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"; } }