Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Progress Bar Helper #82

Merged
merged 23 commits into from
Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 57 additions & 0 deletions playground/progress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

use function Laravel\Prompts\progress;

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

$states = [
'Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado',
'Connecticut', 'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho',
];

progress(
label: 'Adding States',
steps: $states,
callback: function ($item, $progress) {
usleep(250_000);

if ($item === 'Arkansas') {
$progress->label = 'Arkansas is not a state! Nice try.';
}

return $item.' added.';
},
);

progress(
label: 'Adding States With Label',
steps: $states,
callback: function ($item, $progress) {
usleep(250_000);
$progress
->label('Adding '.$item)
->hint("{$item} has ".strlen($item).' characters');
},
);

$progress = progress(
label: 'Adding States Manually',
steps: $states,
);

$progress->start();

foreach ($states as $state) {
usleep(250_000);
$progress
->hint($state)
->advance();
}

$progress->finish();

progress(
'Processing with Exception',
$states,
fn ($item) => $item === 'Arkansas' ? throw new Exception('Issue with Arkansas!') : usleep(250_000),
);
3 changes: 3 additions & 0 deletions src/Concerns/Themes.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Note;
use Laravel\Prompts\PasswordPrompt;
use Laravel\Prompts\Progress;
use Laravel\Prompts\SearchPrompt;
use Laravel\Prompts\SelectPrompt;
use Laravel\Prompts\Spinner;
Expand All @@ -19,6 +20,7 @@
use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer;
use Laravel\Prompts\Themes\Default\NoteRenderer;
use Laravel\Prompts\Themes\Default\PasswordPromptRenderer;
use Laravel\Prompts\Themes\Default\ProgressRenderer;
use Laravel\Prompts\Themes\Default\SearchPromptRenderer;
use Laravel\Prompts\Themes\Default\SelectPromptRenderer;
use Laravel\Prompts\Themes\Default\SpinnerRenderer;
Expand Down Expand Up @@ -51,6 +53,7 @@ trait Themes
Spinner::class => SpinnerRenderer::class,
Note::class => NoteRenderer::class,
Table::class => TableRenderer::class,
Progress::class => ProgressRenderer::class,
],
];

Expand Down
205 changes: 205 additions & 0 deletions src/Progress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

namespace Laravel\Prompts;

use Closure;
use InvalidArgumentException;
use RuntimeException;
use Throwable;

/**
* @template TSteps of iterable<mixed>|int
*/
class Progress extends Prompt
{
/**
* The current progress bar item count.
*/
public int $progress = 0;

/**
* The total number of steps.
*/
public int $total = 0;

/**
* The original value of pcntl_async_signals
*/
protected bool $originalAsync;

/**
* Create a new ProgressBar instance.
*
* @param TSteps $steps
*/
public function __construct(public string $label, public iterable|int $steps, public string $hint = '')
{
$this->total = match (true) {
is_int($this->steps) => $this->steps,
is_countable($this->steps) => count($this->steps),
is_iterable($this->steps) => iterator_count($this->steps),
default => throw new InvalidArgumentException('Unable to count steps.'),
};

if ($this->total === 0) {
throw new InvalidArgumentException('Progress bar must have at least one item.');
}
}

/**
* Map over the steps while rendering the progress bar.
*
* @template TReturn
*
* @param Closure((TSteps is int ? int : value-of<TSteps>), $this): TReturn $callback
* @return array<TReturn>
*/
public function map(Closure $callback): array
{
$this->start();

$result = [];

try {
if (is_int($this->steps)) {
for ($i = 0; $i < $this->steps; $i++) {
$result[] = $callback($i, $this);
$this->advance();
}
} else {
foreach ($this->steps as $step) {
$result[] = $callback($step, $this);
$this->advance();
}
}
} catch (Throwable $e) {
$this->state = 'error';
$this->render();
$this->restoreCursor();
$this->resetSignals();

throw $e;
}

if ($this->hint !== '') {
// Just pause for one moment to show the final hint
// so it doesn't look like it was skipped
usleep(250_000);
}

$this->finish();

return $result;
}

/**
* Start the progress bar.
*/
public function start(): void
{
$this->capturePreviousNewLines();

if (function_exists('pcntl_signal')) {
$this->originalAsync = pcntl_async_signals(true);
pcntl_signal(SIGINT, function () {
$this->state = 'cancel';
$this->render();
exit();
});
}

$this->state = 'active';
$this->hideCursor();
$this->render();
}

/**
* Advance the progress bar.
*/
public function advance(int $step = 1): void
{
$this->progress += $step;

if ($this->progress > $this->total) {
$this->progress = $this->total;
}

$this->render();
}

/**
* Finish the progress bar.
*/
public function finish(): void
{
$this->state = 'submit';
$this->render();
$this->restoreCursor();
$this->resetSignals();
}

/**
* Update the label.
*/
public function label(string $label): static
{
$this->label = $label;

return $this;
}

/**
* Update the hint.
*/
public function hint(string $hint): static
{
$this->hint = $hint;

return $this;
}

/**
* Get the completion percentage.
*/
public function percentage(): int|float
{
return $this->progress / $this->total;
}

/**
* Disable prompting for input.
*
* @throws \RuntimeException
*/
public function prompt(): never
{
throw new RuntimeException('Progress Bar cannot be prompted.');
}

/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}

/**
* Reset the signal handling.
*/
protected function resetSignals(): void
{
if (isset($this->originalAsync)) {
pcntl_async_signals($this->originalAsync);
pcntl_signal(SIGINT, SIG_DFL);
}
}

/**
* Restore the cursor.
*/
public function __destruct()
{
$this->restoreCursor();
}
}
7 changes: 5 additions & 2 deletions src/Themes/Default/Concerns/DrawsBoxes.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ protected function box(
->toArray()
);

$topBorder = str_repeat('─', $width - mb_strwidth($this->stripEscapeSequences($title)));
$this->line("{$this->{$color}(' ┌')} {$title} {$this->{$color}($topBorder.'┐')}");
$titleLength = mb_strwidth($this->stripEscapeSequences($title));
$titleLabel = $titleLength > 0 ? " {$title} " : '';
$topBorder = str_repeat('─', $width - $titleLength + ($titleLength > 0 ? 0 : 2));

$this->line("{$this->{$color}(' ┌')}{$titleLabel}{$this->{$color}($topBorder.'┐')}");

$bodyLines->each(function ($line) use ($width, $color) {
$this->line("{$this->{$color}(' │')} {$this->pad($line, $width)} {$this->{$color}('│')}");
Expand Down
63 changes: 63 additions & 0 deletions src/Themes/Default/ProgressRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Laravel\Prompts\Themes\Default;

use Laravel\Prompts\Progress;

class ProgressRenderer extends Renderer
{
use Concerns\DrawsBoxes;

/**
* The character to use for the progress bar.
*/
protected string $barCharacter = '█';

/**
* Render the progress bar.
*
* @param Progress<int|iterable<mixed>> $progress
*/
public function __invoke(Progress $progress): string
{
$filled = str_repeat($this->barCharacter, (int) ceil($progress->percentage() * min($this->minWidth, $progress->terminal()->cols() - 6)));

return match ($progress->state) {
'submit' => $this
->box(
$this->dim($this->truncate($progress->label, $progress->terminal()->cols() - 6)),
$this->dim($filled),
info: $progress->progress.'/'.$progress->total,
),

'error' => $this
->box(
$this->truncate($progress->label, $progress->terminal()->cols() - 6),
$this->dim($filled),
color: 'red',
info: $progress->progress.'/'.$progress->total,
),

'cancel' => $this
->box(
$this->truncate($progress->label, $progress->terminal()->cols() - 6),
$this->dim($filled),
color: 'red',
info: $progress->progress.'/'.$progress->total,
)
->error('Cancelled.'),

default => $this
->box(
$this->cyan($this->truncate($progress->label, $progress->terminal()->cols() - 6)),
$this->dim($filled),
info: $progress->progress.'/'.$progress->total,
)
->when(
$progress->hint,
fn () => $this->hint($progress->hint),
fn () => $this->newLine() // Space for errors
)
};
}
}
21 changes: 21 additions & 0 deletions src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,24 @@ function table(array|Collection $headers = [], array|Collection $rows = null): v
{
(new Table($headers, $rows))->display();
}

/**
* Display a progress bar.
*
* @template TSteps of iterable<mixed>|int
* @template TReturn
*
* @param TSteps $steps
* @param ?Closure((TSteps is int ? int : value-of<TSteps>), Progress<TSteps>): TReturn $callback
* @return ($callback is null ? Progress<TSteps> : array<TReturn>)
*/
function progress(string $label, iterable|int $steps, Closure $callback = null, string $hint = ''): array|Progress
{
$progress = new Progress($label, $steps, $hint);

if ($callback !== null) {
return $progress->map($callback);
}

return $progress;
}
Loading