diff --git a/composer.json b/composer.json
index 437a9eada..d61f24b9e 100644
--- a/composer.json
+++ b/composer.json
@@ -77,6 +77,9 @@
"tempest/view": "self.version",
"tempest/vite": "self.version"
},
+ "suggest": {
+ "ext-pcntl": "Required to use some interactive console components."
+ },
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
diff --git a/src/Tempest/Console/src/Components/ComponentState.php b/src/Tempest/Console/src/Components/ComponentState.php
index 43a8f37a7..0bb899a68 100644
--- a/src/Tempest/Console/src/Components/ComponentState.php
+++ b/src/Tempest/Console/src/Components/ComponentState.php
@@ -30,4 +30,12 @@ enum ComponentState
* Input is blocked.
*/
case BLOCKED;
+
+ public function isFinished(): bool
+ {
+ return match ($this) {
+ self::ACTIVE, self::ERROR, self::BLOCKED => false,
+ default => true,
+ };
+ }
}
diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php
new file mode 100644
index 000000000..6fc327a94
--- /dev/null
+++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php
@@ -0,0 +1,186 @@
+handler = $this->resolveHandler($handler);
+ $this->renderer = new TaskRenderer(new SpinnerRenderer(), $label);
+ $this->startedAt = hrtime(as_number: true);
+ }
+
+ public function render(Terminal $terminal): Generator
+ {
+ // If there is no task handler, we don't need to fork the process, as
+ // it is a time-consuming operation. We can simply consider it done.
+ if ($this->handler === null) {
+ $this->state = ComponentState::SUBMITTED;
+
+ yield $this->renderTask($terminal);
+
+ return true;
+ }
+
+ $this->sockets = stream_socket_pair(domain: STREAM_PF_UNIX, type: STREAM_SOCK_STREAM, protocol: STREAM_IPPROTO_IP);
+ $this->processId = pcntl_fork();
+
+ if ($this->processId === -1) {
+ throw new RuntimeException('Could not fork process');
+ }
+
+ if (! $this->processId) {
+ $this->executeHandler();
+ }
+
+ try {
+ fclose($this->sockets[0]);
+ stream_set_blocking($this->sockets[1], enable: false);
+
+ while (true) {
+ // The process is still running, so we continue looping.
+ if (pcntl_waitpid($this->processId, $status, flags: WNOHANG) === 0) {
+ yield $this->renderTask(
+ terminal: $terminal,
+ line: fread($this->sockets[1], length: 1024) ?: null,
+ );
+
+ usleep($this->renderer->delay());
+
+ continue;
+ }
+
+ // The process is done, we register the finishing timestamp,
+ // close the communication socket and determine the finished state.
+ fclose($this->sockets[1]);
+ $this->finishedAt = hrtime(as_number: true);
+ $this->state = match (pcntl_wifexited($status)) {
+ true => match (pcntl_wexitstatus($status)) {
+ 0 => ComponentState::SUBMITTED,
+ default => ComponentState::ERROR,
+ },
+ default => ComponentState::CANCELLED,
+ };
+
+ yield $this->renderTask($terminal);
+
+ return $this->state === ComponentState::SUBMITTED;
+ }
+ } finally {
+ if ($this->state->isFinished() && $this->processId) {
+ posix_kill($this->processId, SIGTERM);
+ }
+
+ $this->cleanupSockets();
+ }
+ }
+
+ private function renderTask(Terminal $terminal, ?string $line = null): string
+ {
+ if ($line) {
+ $this->log[] = $line;
+ }
+
+ return $this->renderer->render(
+ terminal: $terminal,
+ state: $this->state,
+ startedAt: $this->startedAt,
+ finishedAt: $this->finishedAt,
+ hint: end($this->log) ?: null,
+ );
+ }
+
+ private function cleanupSockets(): void
+ {
+ foreach ($this->sockets as $socket) {
+ if (is_resource($socket)) {
+ @fclose($socket);
+ }
+ }
+
+ $this->sockets = [];
+ }
+
+ private function executeHandler(): void
+ {
+ $log = function (string ...$lines): void {
+ arr($lines)
+ ->flatMap(fn (string $line) => explode("\n", $line))
+ ->each(function (string $line): void {
+ fwrite($this->sockets[0], $line);
+ });
+ };
+
+ try {
+ exit((int) (($this->handler ?? static fn (): bool => true)($log) === false));
+ } catch (Throwable) {
+ exit(1);
+ }
+ }
+
+ private function resolveHandler(null|Process|Closure $handler): ?Closure
+ {
+ if ($handler === null) {
+ return null;
+ }
+
+ if ($handler instanceof Process) {
+ return static function (Closure $log) use ($handler): bool {
+ return $handler->run(function (string $output, string $buffer) use ($log): bool {
+ if ($output === Process::ERR) {
+ return true;
+ }
+
+ if ($line = trim($buffer)) {
+ $log($buffer);
+ }
+
+ return true;
+ }) === 0;
+ };
+ }
+
+ return $handler;
+ }
+
+ public function renderFooter(Terminal $terminal): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php
index fea620836..7ac565844 100644
--- a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php
+++ b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php
@@ -16,6 +16,7 @@
use Tempest\Validation\Exceptions\InvalidValueException;
use Tempest\Validation\Rule;
use Tempest\Validation\Validator;
+use function Tempest\Support\arr;
final class InteractiveComponentRenderer
{
@@ -153,6 +154,8 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso
continue;
}
+ Fiber::suspend();
+
// If valid, we can return
return $return;
}
@@ -231,6 +234,19 @@ private function validate(mixed $value, array $validation): ?Rule
return null;
}
+ public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool
+ {
+ if (! arr($component->extensions ?? [])->every(fn (string $ext) => extension_loaded($ext))) {
+ return false;
+ }
+
+ if (! new Terminal($console)->supportsTty()) {
+ return false;
+ }
+
+ return true;
+ }
+
private function createTerminal(Console $console): Terminal
{
$terminal = new Terminal($console);
diff --git a/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php
new file mode 100644
index 000000000..b8419b82a
--- /dev/null
+++ b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php
@@ -0,0 +1,31 @@
+index;
+
+ $this->index = ($this->index + 1) % count(self::FRAMES);
+
+ return self::FRAMES[$previous];
+ }
+}
diff --git a/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php
new file mode 100644
index 000000000..11d30a89f
--- /dev/null
+++ b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php
@@ -0,0 +1,62 @@
+prepareRender($terminal, $state);
+ $this->label($this->label);
+
+ $runtime = fn (float $finishedAt) => $finishedAt
+ ? number_format(($finishedAt - $startedAt) / 1_000_000, decimals: 0)
+ : null;
+
+ $hint = match ($this->state) {
+ ComponentState::ERROR => '',
+ ComponentState::CANCELLED => '',
+ ComponentState::SUBMITTED => $finishedAt
+ ? '.'
+ : '',
+ default => $hint ?? $runtime(hrtime(as_number: true)) . 'ms',
+ };
+
+ $this->line(
+ append: str()
+ ->append(match ($this->state) {
+ ComponentState::SUBMITTED => '',
+ ComponentState::ERROR => '',
+ ComponentState::CANCELLED => '',
+ default => '',
+ })
+ ->append(''),
+ );
+
+ // If a task has an error, it is no longer active.
+ if (in_array($this->state, [ComponentState::ACTIVE, ComponentState::CANCELLED])) {
+ $this->newLine();
+ }
+
+ return $this->finishRender();
+ }
+
+ public function delay(): int
+ {
+ return $this->spinner->speed;
+ }
+}
diff --git a/src/Tempest/Console/src/Console.php b/src/Tempest/Console/src/Console.php
index ceae91792..eb20d4422 100644
--- a/src/Tempest/Console/src/Console.php
+++ b/src/Tempest/Console/src/Console.php
@@ -7,6 +7,7 @@
use BackedEnum;
use Closure;
use Stringable;
+use Symfony\Component\Process\Process;
use Tempest\Highlight\Language;
use Tempest\Support\ArrayHelper;
@@ -58,6 +59,8 @@ public function progressBar(iterable $data, Closure $handler): array;
*/
public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed;
+ public function task(string $label, null|Process|Closure $handler): bool;
+
public function header(string $header, ?string $subheader = null): self;
public function info(string $line, ?string $symbol = null): self;
diff --git a/src/Tempest/Console/src/GenericConsole.php b/src/Tempest/Console/src/GenericConsole.php
index edc1525ab..7971e94af 100644
--- a/src/Tempest/Console/src/GenericConsole.php
+++ b/src/Tempest/Console/src/GenericConsole.php
@@ -7,6 +7,7 @@
use BackedEnum;
use Closure;
use Stringable;
+use Symfony\Component\Process\Process;
use Tempest\Console\Actions\ExecuteConsoleCommand;
use Tempest\Console\Components\Interactive\ConfirmComponent;
use Tempest\Console\Components\Interactive\MultipleChoiceComponent;
@@ -14,6 +15,7 @@
use Tempest\Console\Components\Interactive\ProgressBarComponent;
use Tempest\Console\Components\Interactive\SearchComponent;
use Tempest\Console\Components\Interactive\SingleChoiceComponent;
+use Tempest\Console\Components\Interactive\TaskComponent;
use Tempest\Console\Components\Interactive\TextInputComponent;
use Tempest\Console\Components\InteractiveComponentRenderer;
use Tempest\Console\Exceptions\UnsupportedComponent;
@@ -183,7 +185,7 @@ public function withLabel(string $label): self
public function component(InteractiveConsoleComponent $component, array $validation = []): mixed
{
- if ($this->componentRenderer !== null) {
+ if ($this->componentRenderer !== null && $this->componentRenderer->isComponentSupported($this, $component)) {
return $this->componentRenderer->render($this, $component, $validation);
}
@@ -274,6 +276,11 @@ public function progressBar(iterable $data, Closure $handler): array
return $this->component(new ProgressBarComponent($data, $handler));
}
+ public function task(string $label, null|Process|Closure $handler = null): bool
+ {
+ return $this->component(new TaskComponent($label, $handler));
+ }
+
public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed
{
return $this->component(new SearchComponent($label, $search, $multiple, $default));
diff --git a/tests/Integration/Console/Components/TaskComponentTest.php b/tests/Integration/Console/Components/TaskComponentTest.php
new file mode 100644
index 000000000..009f9d51c
--- /dev/null
+++ b/tests/Integration/Console/Components/TaskComponentTest.php
@@ -0,0 +1,88 @@
+markTestSkipped('These tests require the pcntl extension, which is not available on Windows.');
+ }
+
+ if (! $this->container->get(DatabaseConfig::class)->connection instanceof SQLiteConnection) {
+ $this->markTestSkipped('These tests duplicate PDO connections due to `pnctl_fork`, so they are skipped until the framework supports closing connections.');
+ }
+ }
+
+ public function test_no_task(): void
+ {
+ $this->console->withoutPrompting()->call(function (Console $console): void {
+ $terminal = new Terminal($console);
+ $component = new TaskComponent('Task in progress');
+
+ $frames = iterator_to_array($component->render($terminal));
+
+ $this->assertStringContainsString('Task in progress', $frames[0]);
+ $this->assertStringContainsString('Done.', $frames[0]);
+ });
+ }
+
+ public function test_process_task(): void
+ {
+ $this->console->withoutPrompting()->call(function (Console $console): void {
+ $terminal = new Terminal($console);
+ $process = new Process(['echo', 'hello world']);
+ $component = new TaskComponent('Task in progress', $process);
+
+ $frames = iterator_to_array($component->render($terminal));
+
+ $this->assertStringContainsString('Task in progress', $frames[0]);
+ $this->assertStringContainsString('Done in', $frames[1]);
+ });
+ }
+
+ public function test_successful_task(): void
+ {
+ $this->console->withoutPrompting()->call(function (Console $console): void {
+ $terminal = new Terminal($console);
+ $component = new TaskComponent('Task in progress', function (): void {});
+
+ $frames = iterator_to_array($component->render($terminal));
+
+ $this->assertStringContainsString('Task in progress', $frames[0]);
+ $this->assertStringContainsString('Done in', $frames[1]);
+ });
+ }
+
+ public function test_failing_task(): void
+ {
+ $this->console->withoutPrompting()->call(function (Console $console): void {
+ $terminal = new Terminal($console);
+ $component = new TaskComponent('Task in progress', function (): never {
+ throw new Exception('Failure');
+ });
+
+ $frames = iterator_to_array($component->render($terminal));
+
+ $this->assertStringContainsString('Task in progress', $frames[0]);
+ $this->assertStringContainsString('An error occurred.', $frames[1]);
+ });
+ }
+}