Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animated Rendering for Progress Bars, Loaders, and More #188

Open
wants to merge 2 commits into
base: 2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/Async/Connection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace Termwind\Async;

use Generator;
use Socket;

final class Connection
{
protected int $timeoutSeconds;

protected int $timeoutMicroseconds;

private function __construct(
protected Socket $socket,
protected int $bufferSize = 1024,
protected float $timeout = 0.0001,
) {
socket_set_nonblock($this->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;
}
}
}
103 changes: 103 additions & 0 deletions src/Async/Task.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Termwind\Async;

use Closure;

final class Task
{
protected const SERIALIZATION_TOKEN = '[[serialized::';

protected int $pid;

protected int $status;

protected Connection $connection;

protected ?Closure $successCallback = null;

protected Closure $callable;

protected string $output = '';

public static function set(callable $callable): self
{
return new self($callable);
}

public function __construct(callable $callable)
{
$this->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;
}
}
9 changes: 9 additions & 0 deletions src/Async/TaskException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Termwind\Async;

use Exception;

final class TaskException extends Exception {}
167 changes: 167 additions & 0 deletions src/AsyncHtmlRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace Termwind;

use Closure;
use Symfony\Component\Console\Output\OutputInterface;
use Termwind\Async\Connection;
use Termwind\Async\Task;

final class AsyncHtmlRenderer
{
protected Closure $task;

private LiveHtmlRenderer $render;

private string $failOverHtml = '';

private int $interval = 0;

private bool $isRunning = false;

private bool $requiresSync = false;

public function __construct(callable $task, int $options = OutputInterface::OUTPUT_NORMAL)
{
if (! function_exists('pcntl_fork')) {
$this->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 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, $us);
}

//----------------------------------------------------------------------
// 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 $us = 1000): mixed
{
$this->isRunning = true;

$task = Task::set($this->task);
$forkedTask = $this->forkTask($task);
while (! $forkedTask->isFinished()) {
$render();
$this->interval++;
usleep($us);
}
$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();
}
}
Loading