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