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