From bb2d815b1f71e217b2b4f4f7d788cb129f6facc1 Mon Sep 17 00:00:00 2001 From: David Philip Date: Thu, 5 Sep 2024 15:16:21 +0200 Subject: [PATCH 1/2] liveRender() and asyncRender() --- src/Async/Connection.php | 111 +++++++++++++++++++++++++ src/Async/Task.php | 103 +++++++++++++++++++++++ src/Async/TaskException.php | 9 ++ src/AsyncHtmlRenderer.php | 160 ++++++++++++++++++++++++++++++++++++ src/Functions.php | 20 +++++ src/LiveHtmlRenderer.php | 132 +++++++++++++++++++++++++++++ 6 files changed, 535 insertions(+) create mode 100644 src/Async/Connection.php create mode 100644 src/Async/Task.php create mode 100644 src/Async/TaskException.php create mode 100644 src/AsyncHtmlRenderer.php create mode 100644 src/LiveHtmlRenderer.php diff --git a/src/Async/Connection.php b/src/Async/Connection.php new file mode 100644 index 0000000..2441d80 --- /dev/null +++ b/src/Async/Connection.php @@ -0,0 +1,111 @@ +socket); + $this->timeoutSeconds = (int) floor($this->timeout); + $this->timeoutMicroseconds = (int) ($this->timeout * 1_000_000) - ($this->timeoutSeconds * 1_000_000); + } + + /** + * @return self[] + */ + public static function createPair(): array + { + socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets); + + [$socketToParent, $socketToChild] = $sockets; + + return [ + new self($socketToParent), + new self($socketToChild), + ]; + } + + public function close(): self + { + socket_close($this->socket); + + return $this; + } + + public function write(string $payload): self + { + socket_set_nonblock($this->socket); + + while ($payload !== '') { + $write = [$this->socket]; + $read = null; + $except = null; + $selectResult = socket_select($read, $write, $except, $this->timeoutSeconds, $this->timeoutMicroseconds); + + if ($selectResult === false) { + break; + } + + if ($selectResult <= 0) { + break; + } + + $length = strlen($payload); + + $amountOfBytesSent = socket_write($this->socket, $payload, $length); + + if ($amountOfBytesSent === false || $amountOfBytesSent === $length) { + break; + } + + $payload = substr($payload, $amountOfBytesSent); + } + + return $this; + } + + public function read(): Generator + { + + socket_set_nonblock($this->socket); + + while (true) { + $read = [$this->socket]; + + $write = null; + + $except = null; + + $selectResult = socket_select($read, $write, $except, $this->timeoutSeconds, $this->timeoutMicroseconds); + + if ($selectResult === false) { + break; + } + + if ($selectResult <= 0) { + break; + } + + $outputFromSocket = socket_read($this->socket, $this->bufferSize); + + if ($outputFromSocket === false || $outputFromSocket === '') { + break; + } + + yield $outputFromSocket; + } + } +} diff --git a/src/Async/Task.php b/src/Async/Task.php new file mode 100644 index 0000000..7a81b24 --- /dev/null +++ b/src/Async/Task.php @@ -0,0 +1,103 @@ +callable = $callable(...); + } + + public function setConnection(Connection $connection): self + { + $this->connection = $connection; + + return $this; + } + + public function execute(): string|bool + { + $output = ($this->callable)(); + + if (is_string($output)) { + return $output; + } + + return self::SERIALIZATION_TOKEN.serialize($output); + } + + public function output(): mixed + { + foreach ($this->connection->read() as $output) { + $this->output .= $output; + } + + $this->connection->close(); + + $output = $this->output; + + if (str_starts_with($output, self::SERIALIZATION_TOKEN)) { + $output = unserialize( + substr($output, strlen(self::SERIALIZATION_TOKEN)) + ); + } + if ($output === null) { + return true; + } + + return $output; + } + + public function pid(): int + { + return $this->pid; + } + + public function setPid(int $pid): self + { + $this->pid = $pid; + + return $this; + } + + public function isFinished(): bool + { + $this->output .= $this->connection->read()->current(); + + $status = pcntl_waitpid($this->pid(), $status, WNOHANG | WUNTRACED); + + if ($status === $this->pid) { + return true; + } + + if ($status !== 0) { + throw new TaskException('Could not manage async task'); + } + + return false; + } +} diff --git a/src/Async/TaskException.php b/src/Async/TaskException.php new file mode 100644 index 0000000..866bebf --- /dev/null +++ b/src/Async/TaskException.php @@ -0,0 +1,9 @@ +requiresSync = true; + } + $this->task = $task(...); + $this->render = new LiveHtmlRenderer('', $options); + } + + public function getInterval(): int + { + return $this->interval; + } + + public function getIsRunning(): bool + { + return $this->isRunning; + } + + public function getScreenWidth(): int + { + return $this->render->getScreenWidth(); + } + + public function render(string $html): void + { + $this->render->reRender($html); + } + + public function withFailOver(string $html): void + { + $this->failOverHtml = $html; + } + + public function run(callable $render, int $si = 1000): mixed + { + if ($this->requiresSync) { + return $this->executeSync($render); + } + + return $this->executeAsync($render, $si); + } + + //---------------------------------------------------------------------- + // Sync Fail Over + //---------------------------------------------------------------------- + + public function executeSync(callable $render): mixed + { + $this->isRunning = true; + //Render first time + $this->renderSync($render); + //Execute + $output = ($this->task)(); + $this->isRunning = false; + //Render again + $this->renderSync($render); + if ($output) { + return $output; + } + + return true; + } + + private function renderSync(callable $render): void + { + if ($this->failOverHtml !== '') { + $this->render($this->failOverHtml); + } else { + $render(); + } + } + + //---------------------------------------------------------------------- + // Async Fork methods + //---------------------------------------------------------------------- + + private function executeAsync(callable $render, int $si = 1000): mixed + { + $this->isRunning = true; + + $task = Task::set($this->task); + $forkedTask = $this->forkTask($task); + while (! $forkedTask->isFinished()) { + $render(); + $this->interval++; + usleep($si); + } + $this->isRunning = false; + // Render one last time - in case the user needs getIsRunning() to be false + $render(); + + return $forkedTask->output(); + } + + private function forkTask(Task $task): Task + { + [$socketToParent, $socketToChild] = Connection::createPair(); + + $processId = pcntl_fork(); + + if ($this->currentlyInChildTask($processId)) { + $socketToChild->close(); + try { + $this->executeInChildTask($task, $socketToParent); + } finally { + $pid = getmypid(); + if ($pid !== false) { + posix_kill($pid, SIGKILL); + } + } + } + + $socketToParent->close(); + $task->setPid($processId); + $task->setConnection($socketToChild); + + return $task; + } + + private function currentlyInChildTask(int $pid): bool + { + return $pid === 0; + } + + private function executeInChildTask(Task $task, Connection $connectionToParent): void + { + $output = $task->execute(); + if (is_bool($output)) { + $output = (string) $output; + } + $connectionToParent->write($output); + $connectionToParent->close(); + } +} diff --git a/src/Functions.php b/src/Functions.php index 1d25a3f..53e605b 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -42,6 +42,26 @@ function render(string $html, int $options = OutputInterface::OUTPUT_NORMAL): vo } } +if (! function_exists('Termwind\liveRender')) { + /** + * Returns a live render instance to the terminal. + */ + function liveRender(string $html = '', int $options = OutputInterface::OUTPUT_NORMAL): LiveHtmlRenderer + { + return new LiveHtmlRenderer($html, $options); + } +} + +if (! function_exists('Termwind\asyncRender')) { + /** + * Returns an async render instance to the terminal. + */ + function asyncFunction(callable $task): AsyncHtmlRenderer + { + return new AsyncHtmlRenderer($task); + } +} + if (! function_exists('Termwind\parse')) { /** * Parse HTML to a string that can be rendered in the terminal. diff --git a/src/LiveHtmlRenderer.php b/src/LiveHtmlRenderer.php new file mode 100644 index 0000000..957d0d3 --- /dev/null +++ b/src/LiveHtmlRenderer.php @@ -0,0 +1,132 @@ +output = new ConsoleOutput; + $this->htmlRenderer = new HtmlRenderer; + $this->options = $options; + $this->cursor = new Cursor($this->output); + $this->width = (new Terminal)->getWidth(); + if ($html !== null) { + $this->reRender($html); + } + } + + public function getScreenWidth(): int + { + return $this->width; + } + + public function newLine(int $count = 1): void + { + $this->output->write(str_repeat(\PHP_EOL, $count)); + } + + public function reRender(string $html): void + { + $message = $this->convertHtmlToMessage($html); + if ($message === $this->currentMessage) { + return; + } + $this->cursor->hide(); + $previousMessage = $this->currentMessage; + if ($previousMessage === null) { + $this->captureFirstRow(); + } + if ($previousMessage !== null) { + if (strlen($previousMessage) > strlen($message)) { + /** + * The new message is shorter than the previous message, so it needs to be cleared out as + * pasting the new render over the previous will leave some of the previous visible. + * We only do this if we have to because clearing brings jank. + * We hate jank. + */ + $this->clearPrevious(); + } + $this->cursor->moveToPosition(1, $this->startingRow); + } + $this->currentMessage = $message; + $this->htmlRenderer->parse($html)->render($this->options); + $this->setEndRow($message); + + } + + //---------------------------------------------------------------------- + // Private Methods + //---------------------------------------------------------------------- + private function setEndRow(?string $message): void + { + $this->endingRow = $this->cursor->getCurrentPosition()[1]; + /** + * Set the starting row ready for the next render + * It may have changed if we hit the bottom + * of the terminal window + */ + $rows = $this->calculateMessageRows($message); + $moveUp = $rows + 1; + $this->startingRow = $this->endingRow - $moveUp; + } + + private function captureFirstRow(): void + { + $this->startingRow = $this->cursor->getCurrentPosition()[1]; + } + + private function calculateMessageRows(?string $message): int + { + if ($message !== null) { + return count(explode("\n", $message)); + } + + return 0; + } + + private function clearPrevious(): void + { + $this->cursor->moveToPosition(1, $this->endingRow); + $rows = $this->endingRow - $this->startingRow; + for ($i = 0; $i < $rows; $i++) { + $this->cursor->moveUp(); + $this->cursor->clearLine(); + } + + } + + private function convertHtmlToMessage(string $html): string + { + return $this->htmlRenderer->parse($html)->toString(); + } +} From 3e3ee6895bccd26e134b0dcc679ff442bd4980a8 Mon Sep 17 00:00:00 2001 From: David Philip Date: Mon, 9 Sep 2024 15:56:29 +0200 Subject: [PATCH 2/2] Update Async to allow setting a new task - Rename $si to $us for microseconds - asyncFunction() was missing $options parameter on init --- src/AsyncHtmlRenderer.php | 15 +++++++++++---- src/Functions.php | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/AsyncHtmlRenderer.php b/src/AsyncHtmlRenderer.php index 4b34463..e44efa4 100644 --- a/src/AsyncHtmlRenderer.php +++ b/src/AsyncHtmlRenderer.php @@ -57,13 +57,20 @@ public function withFailOver(string $html): void $this->failOverHtml = $html; } - public function run(callable $render, int $si = 1000): mixed + public function withTask(callable $task): self + { + $this->task = $task(...); + + return $this; + } + + public function run(callable $render, int $us = 1000): mixed { if ($this->requiresSync) { return $this->executeSync($render); } - return $this->executeAsync($render, $si); + return $this->executeAsync($render, $us); } //---------------------------------------------------------------------- @@ -100,7 +107,7 @@ private function renderSync(callable $render): void // Async Fork methods //---------------------------------------------------------------------- - private function executeAsync(callable $render, int $si = 1000): mixed + private function executeAsync(callable $render, int $us = 1000): mixed { $this->isRunning = true; @@ -109,7 +116,7 @@ private function executeAsync(callable $render, int $si = 1000): mixed while (! $forkedTask->isFinished()) { $render(); $this->interval++; - usleep($si); + usleep($us); } $this->isRunning = false; // Render one last time - in case the user needs getIsRunning() to be false diff --git a/src/Functions.php b/src/Functions.php index 53e605b..f077b25 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -56,9 +56,9 @@ function liveRender(string $html = '', int $options = OutputInterface::OUTPUT_NO /** * Returns an async render instance to the terminal. */ - function asyncFunction(callable $task): AsyncHtmlRenderer + function asyncFunction(callable $task, int $options = OutputInterface::OUTPUT_NORMAL): AsyncHtmlRenderer { - return new AsyncHtmlRenderer($task); + return new AsyncHtmlRenderer($task, $options); } }