diff --git a/composer.json b/composer.json index f5f0e4a8..03ed43a9 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "phpstan/phpstan": "^1.10", "pestphp/pest": "^2.3", "mockery/mockery": "^1.5", - "phpstan/phpstan-mockery": "^1.1" + "phpstan/phpstan-mockery": "^1.1", + "spatie/ray": "^1.39" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", diff --git a/playground/streaming-spinner-process.php b/playground/streaming-spinner-process.php new file mode 100644 index 00000000..2e711ec2 --- /dev/null +++ b/playground/streaming-spinner-process.php @@ -0,0 +1,18 @@ +start(); + + foreach ($process as $type => $data) { + $messenger->output($data); + } + + return 'Callback return'; + }, + 'Updating Composer...', +); + +if ($argv[1] ?? false) { + text('Name '.$i, 'Default '.$i); +} + +spin( + function (SpinnerMessenger $messenger) { + foreach (range(1, 50) as $i) { + $messenger->line("✔︎ Step {$i}"); + + usleep(rand(50_000, 250_000)); + + if ($i === 20) { + $messenger->message('Almost there...'); + } + + if ($i === 35) { + $messenger->message('Still going...'); + } + } + }, + 'Taking necessary steps...', +); diff --git a/src/Connection.php b/src/Connection.php new file mode 100644 index 00000000..48ad840a --- /dev/null +++ b/src/Connection.php @@ -0,0 +1,135 @@ +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; + + try { + $selectResult = socket_select($read, $write, $except, $this->timeoutSeconds, $this->timeoutMicroseconds); + } catch (ErrorException $e) { + if ($this->isInterruptionErrorException()) { + continue; + } + + throw $e; + } + + 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; + + try { + $selectResult = socket_select($read, $write, $except, $this->timeoutSeconds, $this->timeoutMicroseconds); + } catch (ErrorException $e) { + if ($this->isInterruptionErrorException()) { + continue; + } + + throw $e; + } + + if ($selectResult === false) { + break; + } + + if ($selectResult <= 0) { + break; + } + + $outputFromSocket = socket_read($this->socket, $this->bufferSize); + + if ($outputFromSocket === false || $outputFromSocket === '') { + break; + } + + yield $outputFromSocket; + } + } + + private function isInterruptionErrorException(): bool + { + return socket_last_error() === 4; + } +} diff --git a/src/Output/ConsoleOutput.php b/src/Output/ConsoleOutput.php index 60381d62..817084db 100644 --- a/src/Output/ConsoleOutput.php +++ b/src/Output/ConsoleOutput.php @@ -46,4 +46,12 @@ public function writeDirectly(string $message): void { parent::doWrite($message, false); } + + /** + * Write output directly, bypassing newline capture. + */ + public function writeDirectlyWithFormatting(string $message): void + { + $this->writeDirectly($this->getFormatter()->format($message)); + } } diff --git a/src/Prompt.php b/src/Prompt.php index 8c8f3832..0113f16e 100644 --- a/src/Prompt.php +++ b/src/Prompt.php @@ -165,6 +165,17 @@ protected static function writeDirectly(string $message): void }; } + /** + * Write output directly with formatting, bypassing newline capture. + */ + protected static function writeDirectlyWithFormatting(string $message): void + { + match (true) { + method_exists(static::output(), 'writeDirectlyWithFormatting') => static::output()->writeDirectlyWithFormatting($message), + default => static::writeDirectly($message), + }; + } + /** * Get the terminal instance. */ @@ -241,7 +252,7 @@ protected function submit(): void /** * Reset the cursor position to the beginning of the previous frame. */ - private function resetCursorPosition(): void + protected function resetCursorPosition(): void { $lines = count(explode(PHP_EOL, $this->prevFrame)) - 1; diff --git a/src/Spinner.php b/src/Spinner.php index fce1facd..bfbab1c3 100644 --- a/src/Spinner.php +++ b/src/Spinner.php @@ -22,17 +22,32 @@ class Spinner extends Prompt */ public bool $static = false; + /** + * The sockets used to communicate between the spinner and the task. + */ + protected SpinnerSockets $sockets; + /** * The process ID after forking. */ protected int $pid; + /** + * Whether the spinner has streamed output. + */ + public bool $hasStreamingOutput = false; + + /** + * A unique string to indicate that the spinner should stop. + */ + public string $stopIndicator; + /** * Create a new Spinner instance. */ public function __construct(public string $message = '') { - // + $this->stopIndicator = uniqid().uniqid().uniqid(); } /** @@ -47,6 +62,8 @@ public function spin(Closure $callback): mixed { $this->capturePreviousNewLines(); + $this->sockets = SpinnerSockets::create(); + if (! function_exists('pcntl_fork')) { return $this->renderStatically($callback); } @@ -63,6 +80,8 @@ public function spin(Closure $callback): mixed if ($this->pid === 0) { while (true) { // @phpstan-ignore-line + $this->setNewMessage(); + $this->renderStreamedOutput(); $this->render(); $this->count++; @@ -70,7 +89,18 @@ public function spin(Closure $callback): mixed usleep($this->interval * 1000); } } else { - $result = $callback(); + $result = $callback($this->sockets->messenger()); + + // Tell the child process to stop and send back it's last frame + $this->sockets->messenger()->stop($this->stopIndicator); + + // Let the spinner finish its last cycle before exiting + usleep($this->interval * 1000); + + // Read the last frame actually rendered from the spinner + if ($realPrevFrame = $this->sockets->prevFrame()) { + $this->prevFrame = $realPrevFrame; + } $this->resetTerminal($originalAsync); @@ -83,6 +113,49 @@ public function spin(Closure $callback): mixed } } + /** + * Render any streaming output from the spinner, if available. + */ + protected function renderStreamedOutput(): void + { + $output = $this->sockets->streamingOutput(); + + if ($output === '') { + return; + } + + $this->resetCursorPosition(); + $this->eraseDown(); + + if (! $this->hasStreamingOutput && str_starts_with($this->prevFrame, PHP_EOL)) { + // This is the first line of streaming output we're about to write, if the + // previous frame started with a new line, we need to write a new line. + static::writeDirectly(PHP_EOL); + } + + $this->hasStreamingOutput = true; + + collect(explode(PHP_EOL, rtrim($output))) + ->each(fn ($line) => $line === $this->stopIndicator ? null : static::writeDirectlyWithFormatting(' '.$line.PHP_EOL)); + + $this->writeDirectly($this->prevFrame); + + if (str_contains($output, $this->stopIndicator)) { + // Send the last frame actually rendered back to the parent process + $this->sockets->sendPrevFrame($this->prevFrame); + } + } + + /** + * Set the new message if one is available. + */ + protected function setNewMessage(): void + { + if (($message = $this->sockets->message()) !== '') { + $this->message = $message; + } + } + /** * Reset the terminal. */ @@ -91,6 +164,8 @@ protected function resetTerminal(bool $originalAsync): void pcntl_async_signals($originalAsync); pcntl_signal(SIGINT, SIG_DFL); + $this->sockets->close(); + $this->eraseRenderedLines(); } diff --git a/src/SpinnerMessenger.php b/src/SpinnerMessenger.php new file mode 100644 index 00000000..b8f0b3fa --- /dev/null +++ b/src/SpinnerMessenger.php @@ -0,0 +1,43 @@ +outputSocket->write($message); + } + + /** + * Write a message to the output socket with a new line. + */ + public function line(string $message): void + { + $this->output($message.PHP_EOL); + } + + /** + * Write a message to the message socket. + */ + public function message(string $message): void + { + $this->messageSocket->write($message); + } + + /** + * Write the stop indicator to the output socket. + */ + public function stop(string $stopIndicator) + { + $this->line($stopIndicator); + } +} diff --git a/src/SpinnerSockets.php b/src/SpinnerSockets.php new file mode 100644 index 00000000..c950ed87 --- /dev/null +++ b/src/SpinnerSockets.php @@ -0,0 +1,96 @@ +outputToSpinner, $this->messageToSpinner); + } + + /** + * Get the streaming output from the spinner. + */ + public function streamingOutput(): string + { + return $this->getSocketOutput($this->outputToTask); + } + + /** + * Get the most recent message from the spinner. + */ + public function message(): string + { + return $this->getSocketOutput($this->messageToTask); + } + + /** + * Send the previous frame back to the parent process. + */ + public function sendPrevFrame(string $prevFrame) + { + $this->outputToTask->write($prevFrame); + } + + /** + * Read the previous frame from the spinner. + */ + public function prevFrame(): string + { + return $this->getSocketOutput($this->outputToSpinner); + } + + /** + * Read the output from the given socket. + */ + protected function getSocketOutput(Connection $socket) + { + $output = ''; + + foreach ($socket->read() as $chunk) { + $output .= $chunk; + } + + return $output; + } + + /** + * Close the sockets. + */ + public function close(): void + { + $this->outputToSpinner->close(); + $this->outputToTask->close(); + $this->messageToSpinner->close(); + $this->messageToTask->close(); + } +} diff --git a/src/Themes/Default/SpinnerRenderer.php b/src/Themes/Default/SpinnerRenderer.php index c68aef4f..3496c780 100644 --- a/src/Themes/Default/SpinnerRenderer.php +++ b/src/Themes/Default/SpinnerRenderer.php @@ -34,6 +34,15 @@ public function __invoke(Spinner $spinner): string $spinner->interval = $this->interval; + if ($spinner->hasStreamingOutput) { + if ($spinner->newLinesWritten() > 1) { + // Make sure there is always one space above the dividing line. + $this->newLine(); + } + + $this->line($this->dim(str_repeat('─', $spinner->terminal()->cols() - 6))); + } + $frame = $this->frames[$spinner->count % count($this->frames)]; return $this->line(" {$this->cyan($frame)} {$spinner->message}"); diff --git a/src/helpers.php b/src/helpers.php index 5ffb94ae..5e9c186f 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -89,7 +89,7 @@ function multisearch(string $label, Closure $options, string $placeholder = '', * * @template TReturn of mixed * - * @param \Closure(): TReturn $callback + * @param \Closure(SpinnerMessenger): TReturn $callback * @return TReturn */ function spin(Closure $callback, string $message = ''): mixed