termwind icon indicating copy to clipboard operation
termwind copied to clipboard

Animated Rendering for Progress Bars, Loaders, and More

Open pdphilip opened this issue 5 months ago • 0 comments

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

  • Enables real-time CLI components to re-render by tracking the current terminal position and refreshing the content when reRender() is called
  • Ideal for Progress Bars
$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
  • Blade source for each example is shown in addendum below.

2. asyncFunction(callable $task): AsyncHtmlRenderer

  • Runs an asynchronous task with an animated loader or spinner while the task is processed in the background.
  • Since PHP is synchronous this function creates a fork to run a given task in parallel.
  • If pcntl_fork() is unavailable, it falls back to synchronous behaviour. ie render -> task -> render
  • Ideal for Loaders/Spinners
$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:

  • If nothing is returned in the callable task, then $result will be true
  • If an exception was thrown in the task, then $result will be false

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
$length = $screenWidth - (30);
$progress = floor($current / $max * $length);
$remaining = $length - $progress;
$percentage = round(($current / $max) * 100);
$progressColor = "bg-sky-600 ";
$borderColor = "text-sky-500";
if ($max == $current) {
    $borderColor = "text-emerald-300";
    $progressColor = "bg-emerald-600 ";
}
?>
<div class="mx-1">
    <div class="flex w-{{$length + 12}}">
        <span class="w-11"></span>
        <span class="{{$borderColor}}  w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
    </div>
    <div class="flex">
        <span class="w-11 text-right pr-2">{{$current}}/{{$max}}</span>
        <span class="{{$progressColor}} {{$borderColor}} w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="bg-slate-700 text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
        <span class="ml-2">{{$percentage}}%</span>
    </div>
</div>
Progress Bar: Example 2
<?php
$length = $screenWidth - (30);
$progress = floor($current / $max * $length);
$remaining = $length - $progress;
$percentage = round(($current / $max) * 100);
$colors = ['rose', 'red', 'orange', 'amber', 'yellow', 'lime', 'cyan', 'teal', 'emerald', 'green', 'green'];
$i = (int) floor(($current / $max) * 10);
$selectedColor = $colors[$i];

$progressColor = "bg-$selectedColor-600";
$borderColor = "text-$selectedColor-400";
if ($max == $current) {
    $progressColor = "bg-green-600 ";
    $borderColor = "text-green-400";
}
?>
<div class="mx-1">
    <div class="flex w-{{$length + 12}}">
        <span class="w-11"></span>
        <span class="{{$borderColor}}  w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
    </div>
    <div class="flex">
        <span class="w-11 text-right pr-2"><span class="{{$borderColor}}">{{$current}}</span>/{{$max}}</span>
        <span class="{{$progressColor}} {{$borderColor}} w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="bg-slate-700 text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
        <span class="ml-2">{{$percentage}}%</span>
    </div>
</div>
Progress Bar: Example 3
<?php
$length = $screenWidth - (30);
$progress = floor($current / $max * $length);
$remaining = $length - $progress;
$percentage = round(($current / $max) * 100);
$progressColor = "bg-rose-600 text-rose-400";
if ($progress > 25) {
    $progressColor = "bg-sky-600 text-sky-400";
}
if ($max == $current) {
    $progressColor = "bg-emerald-600 text-emerald-300";
}
?>
<div class="mx-1">
    <div class="flex w-{{$length + 12}}">
        <span class="w-10"></span><span>╭</span><span class="flex-1 content-repeat-[─]"></span><span>╮</span>
    </div>
    <div class="flex">
        <span class="w-9 text-right">{{$current}}/{{$max}}</span>
        <span class="w-1"></span>
        <span class="w-1">│</span>
        <span class="{{$progressColor}}  w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="bg-slate-700 text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
        <span class="w-1">│</span>
        <span class="ml-2">{{$percentage}}%</span>
    </div>
    <div class="flex w-{{$length + 12}}">
        <span class="w-10"></span><span>╰</span><span class="flex-1 content-repeat-[─]"></span><span>╯</span>
    </div>
</div>
Loader: Example 1
<?php
$characters = [
    "⠁", "⠂", "⠄", "⡀", "⡈", "⡐", "⡠", "⣀", "⣁", "⣂", "⣄", "⣌", "⣔", "⣤", "⣥", "⣦", "⣮", "⣶", "⣷", "⣿", "⡿", "⠿", "⢟", "⠟", "⡛", "⠛", "⠫", "⢋", "⠋", "⠍", "⡉", "⠉", "⠑", "⠡", "⢁"
];
$intervals = count($characters) - 1;
while ($i > $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;

}
?>
<div class="m-1 flex">
    <span class="{{$textColor}} mx-1">
        {{ $show }}
    </span>
    <span class="mx-1">{{$message}}</span>
    @if(!empty($details))
        <span class="mx-1 text-slate-600">{{$details}}</span>
    @endif
</div>
Loader: Example 2
<?php
$characters = ["⠉⠉", "⠈⠙", "⠀⠹", "⠀⢸", "⠀⣰", "⢀⣠", "⣀⣀", "⣄⡀", "⣆⠀", "⡇⠀", "⠏⠀", "⠋⠁"];
$colors = ["text-amber-500", "text-emerald-500", "text-rose-500", "text-sky-500"];
$intervals = count($characters) - 1;
$j = 0;
while ($i > $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;

}
?>
<div class="m-1 flex">
    <span class="{{$textColor}} mx-1">
        {{ $show }}
    </span>
    <span class="mx-1">{{$message}}</span>
    @if(!empty($details))
        <span class="mx-1 text-slate-600">{{$details}}</span>
    @endif
</div>
Loader: Example 3 (This one is insanely complicated, but just showing what’s possible)
<?php
$colors = ['cyan', 'sky', 'blue', 'indigo', 'violet', 'indigo', 'blue', 'sky'];
$stepsPerColor = 20;
$i %= count($colors) * $stepsPerColor;
$colorIndex = floor($i / $stepsPerColor);
$nextColorIndex = ($colorIndex + 1) % count($colors);
$step = $i % $stepsPerColor;

$transitions = [
    [500, 500, 500, 500, 500], [400, 500, 500, 500, 500], [300, 400, 500, 500, 500],
    [300, 300, 400, 500, 500], [400, 300, 300, 400, 500], [500, 400, 300, 300, 400],
    [500, 500, 400, 300, 300], [500, 500, 500, 400, 300], [500, 500, 500, 500, 400],
    [500, 500, 500, 500, 500]
];

$colorTransitions = [
    [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0],
    [1, 0, 0, 0, 0], [1, 1, 0, 0, 0], [1, 1, 1, 0, 0],
    [1, 1, 1, 1, 0], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1]
];

$blockWeights = $step < 10 ? $transitions[$step] : [500, 500, 500, 500, 500];
$blockColors = $step < 10 ? array_map(fn($v) => $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];
?>
<div class="m-1 flex">
    @if($show)
        <div class="{{$textColor}} mx-1">{{$show}}</div>
    @else
        <div class="mx-1 flex">
            @foreach($blockWeights as $index => $weight)
                <span class="w-1 bg-{{$colors[$blockColors[$index]]}}-{{$weight}}"></span>
            @endforeach
        </div>
    @endif
    <span class="mx-1">{{$message}}</span>
    @if(!empty($details))
        <span class="mx-1 text-slate-600">{{$details}}</span>
    @endif
</div>

pdphilip avatar Sep 05 '24 21:09 pdphilip