Skip to content

Commit

Permalink
CLI improvements
Browse files Browse the repository at this point in the history
Added process control signal handler
Added Spinner
Added functionality to hide/show/restore the cursor
  • Loading branch information
freost committed Oct 26, 2024
1 parent 416666b commit 8c09c31
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 1 deletion.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ The major version bump is due to upping the required PHP version from `8.1` to `
* Reactor command names can now be registered with the `CommandName` attribute.
* Reactor command descriptions can now be registered with the `CommandDescription` attribute.
* Reactor command arguments can now be registered with the `Arguments` attribute.
* Added a signal handler to make it easier to handle async process control signals.
* Added a spinner CLI output helper.
* It is now possible to hide and show the cursor in the CLI.
* New and improved look of rendered CLI tables.

#### Changes
Expand Down
32 changes: 31 additions & 1 deletion src/mako/application/cli/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use mako\cli\output\Output;
use mako\cli\output\writer\Error;
use mako\cli\output\writer\Standard;
use mako\cli\signals\SignalHandler;
use mako\database\ConnectionManager as DatabaseConnectionManager;
use mako\file\Finder;
use mako\http\routing\Routes;
Expand Down Expand Up @@ -78,6 +79,14 @@ protected function outputFactory(): Output
return new Output(new Standard, new Error, new Formatter);
}

/**
* Creates a signal handler instance.
*/
protected function signalHandlerFactory(): SignalHandler
{
return new SignalHandler;
}

/**
* Creates a reactor instance.
*/
Expand Down Expand Up @@ -134,9 +143,30 @@ protected function registerAndhandleGlobalArguments(): void
*/
protected function startReactor(): void
{
// Register input, output and signal handler instances

$this->container->registerSingleton([Input::class, 'input'], fn () => $this->inputFactory());

$this->container->registerSingleton([Output::class, 'output'], fn () => $this->outputFactory());
$output = $this->outputFactory();

$this->container->registerInstance([Output::class, 'output'], $output);

$signalHandler = $this->signalHandlerFactory();

$this->container->registerInstance(SignalHandler::class, $signalHandler);

// Ensure that the cursor is restored in case of a SIGINT call

if ($signalHandler->canHandleSignals()) {
$signalHandler->addHandler(SIGINT, function ($signal, $isLast) use ($output): void {
$output->restoreCursor();
if ($isLast) {
exit(130);
}
});
}

// Create reactor instance

$this->reactor = $this->reactorFactory();

Expand Down
45 changes: 45 additions & 0 deletions src/mako/cli/output/Output.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class Output
*/
protected bool $muted = false;

/**
* Is the cursor hidden?
*/
protected bool $cursorHidden = false;

/**
* Constructor.
*/
Expand All @@ -44,6 +49,14 @@ public function __construct(
) {
}

/**
* Destructor.
*/
public function __destruct()
{
$this->restoreCursor();
}

/**
* Returns the chosen writer.
*/
Expand Down Expand Up @@ -189,4 +202,36 @@ public function clearLines(int $lines): void
}
}
}

/**
* Hides the cursor.
*/
public function hideCursor(): void
{
if ($this->environment->hasAnsiSupport()) {
$this->write("\e[?25l");
$this->cursorHidden = true;
}
}

/**
* Shows the cursor.
*/
public function showCursor(): void
{
if ($this->environment->hasAnsiSupport()) {
$this->write("\e[?25h");
$this->cursorHidden = false;
}
}

/**
* Restores the cursor.
*/
public function restoreCursor(): void
{
if ($this->cursorHidden) {
$this->showCursor();
}
}
}
4 changes: 4 additions & 0 deletions src/mako/cli/output/helpers/Countdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ protected function sleep(): void
*/
public function draw(int $from = 5): void
{
$this->output->hideCursor();

$dots = 0;

$fromLength = strlen($from);
Expand All @@ -66,5 +68,7 @@ public function draw(int $from = 5): void
while ($from-- > 1);

$this->output->write("\r" . str_repeat(' ', $totalLength) . "\r");

$this->output->showCursor();
}
}
119 changes: 119 additions & 0 deletions src/mako/cli/output/helpers/Spinner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

/**
* @copyright Frederic G. Østby
* @license http://www.makoframework.com/license
*/

namespace mako\cli\output\helpers;

use mako\cli\output\Output;

use function count;
use function extension_loaded;
use function pcntl_fork;
use function pcntl_wait;
use function posix_getpid;
use function posix_kill;
use function usleep;

/**
* Spinner.
*/
class Spinner
{
/**
* Spinner frames.
*/
public const array FRAMES = ['', '', '', '', '', '', '', '', '', ''];

/**
* Time between redraw in microseconds.
*/
protected const int TIME_BETWEEN_REDRAW = 100000;

/**
* Can we fork the process?
*/
protected bool $canFork;

/**
* Constructor.
*/
public function __construct(
protected Output $output,
protected array $frames = Spinner::FRAMES,
) {
$this->canFork = $this->canFork();
}

/**
* Returns TRUE if we can fork the process and FALSE if not.
*/
protected function canFork(): bool
{
return extension_loaded('pcntl') && extension_loaded('posix');
}

/**
* Draws the spinner.
*/
protected function spinner(string $message): void
{
$i = 0;

$frames = count($this->frames);

while (true) {
$this->output->write("\r" . $this->frames[$i++ % $frames] . " {$message}");

usleep(static::TIME_BETWEEN_REDRAW);

if (posix_kill(posix_getpid(), 0) === false) {
break;
}
}
}

/**
* Draws the spinner.
*/
public function spin(string $message, callable $callback): mixed
{
$result = null;

$this->output->hideCursor();

$pid = $this->canFork ? pcntl_fork() : -1;

if ($pid == -1) {
// We were unable to fork the process so we'll just run the callback in the current process

$this->output->write($message);

$result = $callback();

$this->output->clearLine();
}
elseif ($pid) {
// We're in the parent process so we'll execute the callback here

$result = $callback();

posix_kill($pid, SIGKILL);

pcntl_wait($status);

$this->output->clearLine();
}
else {
// We're in the child process so we'll display the spinner

$this->spinner($message);
}

$this->output->showCursor();

return $result;
}
}
71 changes: 71 additions & 0 deletions src/mako/cli/signals/SignalHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/**
* @copyright Frederic G. Østby
* @license http://www.makoframework.com/license
*/

namespace mako\cli\signals;

use function count;
use function function_exists;
use function pcntl_async_signals;
use function pcntl_signal;

/**
* Signal handler.
*/
class SignalHandler
{
/**
* Can we handle signals?
*/
protected bool $canHandle = false;

/**
* Signal handlers.
*/
protected array $handlers = [];

/**
* Constructor.
*/
public function __construct()
{
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);

$this->canHandle = true;
}
}

/**
* Can we handle signals?
*/
public function canHandleSignals(): bool
{
return $this->canHandle;
}

/**
* Registers a singal handler for the chosen signal.
*/
public function addHandler(array|int $signal, callable $handler): void
{
foreach ((array) $signal as $signalToHandle) {
$this->handlers[$signalToHandle][] = $handler;

if (count($this->handlers[$signalToHandle]) === 1) {
pcntl_signal($signalToHandle, function ($signal): void {
$count = count($this->handlers[$signal]);

foreach ($this->handlers[$signal] as $i => $handler) {
$isLast = $i === $count - 1;

$handler($signal, $isLast);
}
});
}
}
}
}
9 changes: 9 additions & 0 deletions src/mako/reactor/traits/CommandHelperTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use mako\cli\output\helpers\Countdown;
use mako\cli\output\helpers\OrderedList;
use mako\cli\output\helpers\ProgressBar;
use mako\cli\output\helpers\Spinner;
use mako\cli\output\helpers\Table;
use mako\cli\output\helpers\UnorderedList;
use mako\cli\output\Output;
Expand Down Expand Up @@ -108,6 +109,14 @@ protected function progressBar(int $items, float $minTimeBetweenRedraw = 0.1, ?s
return $progressBar;
}

/**
* Draws a spinner while executing the callback.
*/
protected function spinner(string $message, callable $callback, array $frames = Spinner::FRAMES): void
{
(new Spinner($this->output, $frames))->spin($message, $callback);
}

/**
* Draws a table.
*/
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/cli/output/helpers/CountdownTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public function testCountdownFromDefault(): void
/** @var \mako\cli\output\Output|\Mockery\MockInterface $output */
$output = Mockery::mock(Output::class);

$output->shouldReceive('hideCursor')->once();
$output->shouldReceive('showCursor')->once();

$output->shouldReceive('write')->once()->with("\r5 ");
$output->shouldReceive('write')->once()->with("\r5 . ");
$output->shouldReceive('write')->once()->with("\r5 .. ");
Expand Down Expand Up @@ -66,6 +69,9 @@ public function testCountdownFrom2(): void
/** @var \mako\cli\output\Output|\Mockery\MockInterface $output */
$output = Mockery::mock(Output::class);

$output->shouldReceive('hideCursor')->once();
$output->shouldReceive('showCursor')->once();

$output->shouldReceive('write')->once()->with("\r2 ");
$output->shouldReceive('write')->once()->with("\r2 . ");
$output->shouldReceive('write')->once()->with("\r2 .. ");
Expand Down
Loading

0 comments on commit 8c09c31

Please sign in to comment.