nunomaduro / termwind

🍃 In short, it's like Tailwind CSS, but for the PHP command-line applications.
MIT License
2.27k stars 76 forks source link

Animated Rendering for Progress Bars, Loaders, and More #188

Open pdphilip opened 2 weeks ago

pdphilip commented 2 weeks ago

This PR introduces animated rendering capabilities to Termwind, allowing for dynamic updates of CLI components like progress bars or any other live-rendered element. I've developed this feature to use in my own package (see: https://github.com/pdphilip/elasticlens) and would love to contribute it back to Termwind.

Why:

Termwind offers excellent flexibility in designing CLI interfaces, but the standard progress bars often look out of place. This PR enhances Termwind's potential by enabling real-time updates to rendered elements, leveraging all its existing design capabilities. This is useful for progress bars, loaders, or any component that requires continuous updates.

Real-world example:

ElasticLens Build

2 New Functions:

1. liveRender(string $html = '', int $options): LiveHtmlRenderer

$live = liveRender($view); 
// Same as termwind's render() but returns the $live instance
// Methods:
$screenWidth = $live->getScreenWidth(); // Helper method to get terminal width
$live->reRender($view); // Re-renders the $live instance with $view

Example: Progress Bar

$max = 250;
$current = 0;
$live = liveRender(); //Doesnt render at this point, but creates a $live instance at this line
$live->reRender(view('cli.components.progress', [
    'screenWidth' => $live->getScreenWidth(), //The view component needs this
    'current' => 0,
    'max' => $max,
]));
while ($current <= $max) {
    $live->reRender(view('cli.components.progress', [
        'screenWidth' => $live->getScreenWidth(),
        'current' => $current,
        'max' => $max,
    ]));
    $current++;
    usleep(1000);
}

A More Practical Example:

$max = User::count();
$i = 0;
$live = liveRender();
//Start at zero
$live->reRender(view('cli.components.progress', [
    'screenWidth' => $live->getScreenWidth(),
    'current' => $i,
    'max' => $max,
]));
User::chunk(100, function ($users) use ($i, $max, $live) {
    foreach ($users as $user) {
        // Do something to $user
        // Move progress +1
        $i++;
        $live->reRender(view('cli.components.progress', [
            'screenWidth' => $live->getScreenWidth(),
            'current' => $i,
            'max' => $max,
        ]));

    }
});

Demo*

Termwind Progress

2. asyncFunction(callable $task): AsyncHtmlRenderer

$async = asyncFunction(callable $task);  
// Initates the Async instance on the given line, 
// And sets the task as a callback

// Methods:
$i = $async->getInterval(); // Returns the current interval
$isRunning = $async->getIsRunning();  // Helper to see if the task is still running
$screenWidth = $async->getScreenWidth(); // Helper method you can use in your view
$async->render($view); // (re)renders a view
$async->withFailOver($view); // Optional view that will be used if pcntl_fork is not available
$result = $async->run(callable $render, int $si = 1000) //Executes the $task and loops the $render in $si micro-sec intervals & returns the $result of the $task once it's done

Note:

Usage:

//Initate the Async Instance (on this line) and define the task
$async = asyncFunction(function () {
    //Run a task
    sleep(5);
    //Return the result
    return [
        'state' => 'success',
        'message' => 'Completed',
        'details' => 'Index migrated successfully',
    ];
});
//Set a failover view in case the terminal can't fork
$async->withFailOver(view('cli.components.loader', [
    'state' => 'failover',
    'message' => 'Migrating Index',
    'i' => 1,
]));
// Run the task and re-render the view every 0.05s
// Once the task has been completed, it will return $result
$result = $async->run(function () use ($async) {
    $async->render(view('cli.components.loader', [
        'state' => 'running',
        'message' => 'Migrating Index',
        'i' => $async->getInterval(),
    ]));
},50000); //every 0.05 sec
// Use $result to render again
$async->render(view('cli.components.loader', [
    'state' => $result['state'],
    'message' => $result['message'],
    'details' => $result['details'],
    'i' => 0,
]));

Demo

Termwind Loaders

Addendum

Below are the blade files used in the demo examples:

Progress Bar: Example 1 ```php
{{$current}}/{{$max}} {{$percentage}}%
```
Progress Bar: Example 2 ```php
{{$current}}/{{$max}} {{$percentage}}%
```
Progress Bar: Example 3 ```php 25) { $progressColor = "bg-sky-600 text-sky-400"; } if ($max == $current) { $progressColor = "bg-emerald-600 text-emerald-300"; } ?>
{{$current}}/{{$max}} {{$percentage}}%
```
Loader: Example 1 ```php $intervals) { $i -= $intervals; } $show = $characters[$i]; $textColor = "text-amber-500"; switch ($state) { case 'success': $textColor = "text-emerald-500"; $show = "✔"; break; case 'warning': $textColor = "text-amber-500"; $show = "⚠"; break; case 'failover': $textColor = "text-amber-500"; $show = "◴"; break; case 'error': $textColor = "text-rose-500"; $show = "✘"; break; } ?>
{{ $show }} {{$message}} @if(!empty($details)) {{$details}} @endif
```
Loader: Example 2 ```php $intervals) { $i -= $intervals; $j++; if ($j > 3) { $j = 0; } } $show = $characters[$i]; $textColor = $colors[$j]; switch ($state) { case 'success': $textColor = "text-emerald-500"; $show = "✔"; break; case 'warning': $textColor = "text-amber-500"; $show = "⚠"; break; case 'failover': $textColor = "text-amber-500"; $show = "◴"; break; case 'error': $textColor = "text-rose-500"; $show = "✘"; break; } ?>
{{ $show }} {{$message}} @if(!empty($details)) {{$details}} @endif
```
Loader: Example 3 (This one is insanely complicated, but just showing what’s possible) ```php $v ? $nextColorIndex : $colorIndex, $colorTransitions[$step]) : array_fill(0, 5, $nextColorIndex); $stateConfig = [ 'success' => ['text-emerald-500', '✔'], 'warning' => ['text-amber-500', '⚠'], 'failover' => ['text-amber-500', '◴'], 'error' => ['text-rose-500', '✘'] ]; [$textColor, $show] = $stateConfig[$state] ?? [null, false]; ?>
@if($show)
{{$show}}
@else
@foreach($blockWeights as $index => $weight) @endforeach
@endif {{$message}} @if(!empty($details)) {{$details}} @endif
```