CLI приложения #12. Перерисовка строки

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

Скачивание файла, обработка тысячи записей, ожидание ответа сервера. Если просто печатать «Обработано 1», «Обработано 2» и так далее, терминал превратится в ленту из тысячи строк. Хочется, чтобы менялась одна строка на месте.

Механика здесь на удивление простая, и никакой библиотеки для неё не нужно.

В любой строке терминала есть курсор — позиция, куда попадёт следующий символ. Обычный Console.WriteLine дописывает текст и переводит курсор на новую строку. А есть символ, который возвращает курсор в начало текущей строки, ничего не стирая. Называется он «возврат каретки» и записывается как \r.

Название пришло из эпохи печатных машинок: каретка физически уезжала влево, а бумага оставалась на месте. Работает ровно так же.

for (int i = 1; i <= 100; i++)
{
    Console.Write($"\rОбработано: {i} из 100");
    Thread.Sleep(30);
}
Console.WriteLine();

Обратите внимание на два момента. Используется Console.Write, а не WriteLine — перевод строки нам как раз не нужен. И в самом конце всё-таки вызывается WriteLine, иначе следующий вывод программы приземлится поверх нашей строки.

Теперь про грабли, на которые наступают все.

Возврат каретки ничего не стирает. Он лишь ставит курсор в начало, а новый текст записывается поверх старого — символ за символом. Если новая строка короче предыдущей, хвост старой останется на экране:

Осталось: 10 секунд     ← напечатали
Осталось: 9 секунд      ← хотели напечатать
Осталось: 9 секундд     ← что увидит пользователь

Лечится двумя способами.

Первый — дополнять строку пробелами до фиксированной длины:

Console.Write($"\r{text}".PadRight(60));

Второй, и более правильный — попросить терминал самому стереть всё от курсора до конца строки. Для этого есть ANSI-последовательность:

Console.Write($"\r\u001b[K{text}");

Ещё одна деталь, которая отличает аккуратную программу от небрежной: мигающий курсор. Пока строка перерисовывается, он прыгает в её конце и раздражает. Спрячем его на время работы:

Console.Write("\u001b[?25l");   // спрятать курсор
try
{
    // здесь долгая работа с перерисовкой
}
finally
{
    Console.Write("\u001b[?25h");   // вернуть обратно
}

Блок finally тут обязателен. Если программа упадёт или пользователь нажмёт Ctrl+C, курсор должен вернуться — иначе человек останется с невидимым курсором в своём терминале и будет нас вспоминать недобрыми словами ))).

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

if (Console.IsErrorRedirected)
{
 // вывод не на экран: печатаем без красивостей и не всё
}

И само собой, писать такую анимацию правильно именно в stderr, а не в stdout. Это не результат работы программы, а сообщение для человека — то, о чём мы говорили ранее.

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