CLI приложения #14. Прогресс-бары в Spectre.Console

В прошлом посте мы написали прогресс-бар руками и уперлись в ограничение: он умеет показывать ровно одну задачу. А реальные утилиты часто делают несколько дел сразу — скачивают три файла параллельно, обрабатывают данные в несколько потоков. Каждой задаче нужна своя полоса, и все они должны обновляться независимо.
Писать такое самому — это уже задача со звёздочкой: управление позицией курсора по вертикали, подсчёт занятых строк и обработка изменения размера окна. В лучших традициях - всё придумано до нас и можно взять готовое решение.

AnsiConsole.Progress()
    .Start(ctx =>
    {
        var download = ctx.AddTask("Скачивание");
        var process = ctx.AddTask("Обработка");

        while (!ctx.IsFinished)
        {
            download.Increment(2.5);
            process.Increment(1.2);
            Thread.Sleep(100);
        }
    });

Задачи создаются через AddTask, по умолчанию у каждой шкала от 0 до 100. Свойство ctx.IsFinished становится истинным, когда все задачи дошли до максимума.
Если общий объём работы известен заранее, шкалу удобно задать в единицах самой задачи — например, в количестве файлов:

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

AnsiConsole.Progress()
    .Start(ctx =>
    {
        var task = ctx.AddTask("Обработка файлов", maxValue: files.Length);

        foreach (var file in files)
        {
            ProcessFile(file);
            task.Increment(1);
        }
    });

Никаких процентов считать не нужно — библиотека переведёт сама.
Внешний вид собирается из колонок, и здесь можно взять ровно то, что нужно:

AnsiConsole.Progress()
    .AutoClear(false)      // оставить полосы на экране после завершения
    .HideCompleted(false)  // не прятать завершённые задачи
    .Columns(
        new TaskDescriptionColumn(),
        new ProgressBarColumn(),
        new PercentageColumn(),
        new RemainingTimeColumn(),
        new SpinnerColumn())
    .Start(ctx => { /* ... */ });

Колонка RemainingTimeColumn — это та самая оценка времени, которую мы в прошлом посте считали вручную. Здесь она просто есть.
Для параллельной работы есть асинхронный вариант:

await AnsiConsole.Progress().StartAsync(async ctx =>
{
    var tasks = urls.Select(url =>
    {
        var task = ctx.AddTask(Markup.Escape(url));
        return DownloadAsync(url, task);
    });

    await Task.WhenAll(tasks);
});

Объект задачи передаётся внутрь метода скачивания, и тот дёргает task.Increment(...) по мере получения данных. Полосы обновляются независимо друг от друга.

Важно!
Пока активен прогресс-бар, нельзя писать в консоль напрямую. Любой Console.WriteLine или AnsiConsole.MarkupLine из середины цикла разрушит картинку: библиотека перерисовывает область экрана и не знает о вашем выводе. Сообщения либо накапливайте и выводите после завершения, либо отражайте прямо в описании задачи:

task.Description = $"Обработка: {Markup.Escape(currentFile)}";

По той же причине одновременно может быть активен только один блок Progress.
По умолчанию вывод уходит в stdout, но прогресс — не результат работы, поэтому правильнее отправить его во второй поток:

var console = AnsiConsole.Create(new AnsiConsoleSettings
{
    Out = new AnsiConsoleOutput(Console.Error)
});

console.Progress().Start(ctx => { /* ... */ });

Приятный бонус: при перенаправлении вывода библиотека сама переключится в спокойный режим без анимации. То, что в прошлом посте мы делали проверкой IsErrorRedirected, здесь работает из коробки.