Skip to content

Commit

Permalink
Add Progress Bar Helper (#82)
Browse files Browse the repository at this point in the history
* added progress bar

* progress bar -> progress

* support lazy collections

* Formatting

* Fix progress bar on narrow terminals

* Restore cursor after Ctrl+C

* Remove LazyCollection support

* Allow passing steps as an integer

* Allow advancing multiple steps

* Allow returning the results of the callback

* Allow passing a hint

* Display progress

* messing with updating the label in real time

* add canceled state to progress on ctrl c

* Add setters for label and hint

* allow for an empty title

* label is now optional

* Make progress evenly spaced

* Improve method name

* Alternative approach to optional label

* Fix code styling

* Remove overloading

* Remove overloaded methods

---------

Co-authored-by: Jess Archer <[email protected]>
Co-authored-by: jessarcher <[email protected]>
  • Loading branch information
3 people authored Sep 26, 2023
1 parent ab5afba commit b603410
Show file tree
Hide file tree
Showing 7 changed files with 515 additions and 2 deletions.
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 @@ -163,3 +163,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

0 comments on commit b603410

Please sign in to comment.