CLI приложения #13. Пишем прогресс-бар

CLI приложения #13. Пишем прогресс-бар

В прошлом посте разобрали механику: \r возвращает курсор в начало строки, \u001b[K стирает остаток. Этого достаточно, чтобы собрать полноценный прогресс-бар — без единой зависимости.
Что в нём должно быть: полоса заполнения, проценты, и желательно оценка оставшегося времени. Полосу рисуем двумя символами — заполненная часть и пустая.

internal sealed class ProgressBar : IDisposable
{
    private readonly int _total;
    private readonly int _width;
    private readonly bool _animated;
    private readonly Stopwatch _watch = Stopwatch.StartNew();

    public ProgressBar(int total, int width = 30)
    {
        _total = total;
        _width = width;
        _animated = !Console.IsErrorRedirected;

        if (_animated)
            Console.Error.Write("\u001b[?25l");
    }

    public void Report(int current)
    {
        if (!_animated)
            return;

        var fraction = Math.Clamp((double)current / _total, 0, 1);
        var filled = (int)(fraction * _width);

        var bar = new string('█', filled) + new string('░', _width - filled);
        var eta = Estimate(fraction);

        Console.Error.Write($"\r\u001b[K[{bar}] {fraction:P0}  {eta}");
    }

    private string Estimate(double fraction)
    {
        if (fraction <= 0)
            return "";

        var elapsed = _watch.Elapsed;
        var remaining = elapsed * (1 / fraction - 1);

        return $"осталось ~{remaining:mm\\:ss}";
    }

    public void Dispose()
    {
        if (!_animated)
            return;

        Console.Error.Write("\u001b[?25h");
        Console.Error.WriteLine();
    }
}

Пользоваться удобно:

var files = Directory.GetFiles("data");

using (var bar = new ProgressBar(files.Length))
{
    for (int i = 0; i < files.Length; i++)
    {
        ProcessFile(files[i]);
        bar.Report(i + 1);
    }
}

Интерфейс IDisposable здесь не просто так . Он гарантирует, что курсор вернётся на место и строка закроется переводом — даже если внутри цикла вылетит исключение. Ровно та задача, для которой using и придуман.
Про расчёт оставшегося времени. Формула простая: если за прошедшее время сделали 25% работы, значит на все 100% уйдёт вчетверо больше, а осталось три четверти от этого. Работает, пока скорость примерно постоянная. На неравномерной нагрузке оценка будет прыгать — это нормально, все так живут, включая apt и pip.

Важно!
Символы █ и ░ требуют кодировки UTF-8. На Windows её нужно включить явно, иначе вместо полосы будут знаки вопроса:

Console.OutputEncoding = Encoding.UTF8;

А ещё, ранее был пост на тему замены кодировки.

Если хочется совместимости с совсем древними терминалами — можно взять # и -, выглядит скромнее, но работает везде.
Отдельно обратите внимание на поле _animated. Когда вывод перенаправлен, прогресс-бар просто молчит. Это лучше, чем засорять лог, но ещё лучше — в этом режиме печатать обычные строки с большим шагом, скажем каждые 10%. Тогда и в логе сборки будет видно, что процесс движется.

Мы написали около полусотни строк и получили то, что видели в десятках чужих утилит. Понимать эту механику полезно....
...Но в реальном проекте её писать не надо: в Spectre.Console прогресс-бары уже есть, причём с поддержкой нескольких задач одновременно. Ими и займёмся в следующем посте.