Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions playground/streaming-spinner-process.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

function generateRandomString($length)
{
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$randomString = '';

for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[rand(0, strlen($characters) - 1)];
}

return $randomString;
}

foreach (range(0, 50) as $i) {
echo $i.' '.generateRandomString(rand(1, 100)).PHP_EOL;
usleep(rand(10_000, 250_000));
}
50 changes: 50 additions & 0 deletions playground/streaming-spinner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

use Laravel\Prompts\SpinnerMessenger;
use Symfony\Component\Process\Process;

use function Laravel\Prompts\spin;
use function Laravel\Prompts\text;

require __DIR__.'/../vendor/autoload.php';

if ($argv[1] ?? false) {
text('Name', 'Default');
}

spin(
function (SpinnerMessenger $messenger) {
$process = Process::fromShellCommandline('php '.__DIR__.'/streaming-spinner-process.php');
$process->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("<info>✔︎</info> <comment>Step {$i}</comment>");

usleep(rand(50_000, 250_000));

if ($i === 20) {
$messenger->message('Almost there...');
}

if ($i === 35) {
$messenger->message('Still going...');
}
}
},
'Taking necessary steps...',
);
135 changes: 135 additions & 0 deletions src/Connection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace Laravel\Prompts;

use ErrorException;
use Generator;
use Socket;

class Connection
{
protected int $timeoutSeconds;

protected int $timeoutMicroseconds;

protected 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;

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;
}
}
8 changes: 8 additions & 0 deletions src/Output/ConsoleOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
13 changes: 12 additions & 1 deletion src/Prompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;

Expand Down
79 changes: 77 additions & 2 deletions src/Spinner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand All @@ -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);
}
Expand All @@ -63,14 +80,27 @@ public function spin(Closure $callback): mixed

if ($this->pid === 0) {
while (true) { // @phpstan-ignore-line
$this->setNewMessage();
$this->renderStreamedOutput();
$this->render();

$this->count++;

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);

Expand All @@ -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.
*/
Expand All @@ -91,6 +164,8 @@ protected function resetTerminal(bool $originalAsync): void
pcntl_async_signals($originalAsync);
pcntl_signal(SIGINT, SIG_DFL);

$this->sockets->close();

$this->eraseRenderedLines();
}

Expand Down
Loading