Анимации в терминале — это сложно (иногда)
- вторник, 1 сентября 2026 г. в 00:00:12
Привет, Хабр!
Не так давно я на ровном месте занялся написанием библиотеки на Go для, казалось бы, тривиальной задачи — спиннера, который гарантированно не будет ломаться при конкурентных записях в stderr и stdout.
Все началось с того, что я писал вообще другой код — консольную утилиту для запуска тестов. И захотел прикрутить туда спиннер. Но не просто спиннер, а такой, что позволил бы использовать оба потока вывода — stderr и stdout, без риска оставить артефакты на экране. Более того, во время анимации мне нужно иметь возможность показывать на экране отчеты по завершившимся тестам, не откладывая это все до конца запуска/остановки спиннера. И так уж почему‑то получилось (спойлер: потому что не слишком просто, иногда совсем невозможно и мало кому нужно), что я не смог найти библиотеку, которая бы мне подошла.
Почему мне не подходило ни одно из готовых решений:
Самые простые варианты готовых библиотек не поддерживают одновременные анимацию и запись (github.com/briandowns/spinner, github.com/yarlson/pin).
Варианты, позволяющие писать в поток спиннера не ломая его, уже гораздо тяжеловеснее и не поддерживают разделение stdout/stderr (github.com/theckman/yacspin).
Варианты, поддерживающие все вышеперечисленное, — только полновесные TUI фреймворки с рендер‑буфером и raw‑режимом.
Весь файл моего приложения весит меньше 9 МБ. Добавление тяжелого фреймворка лишь ради отрисовки спиннера (и как позже получилось — прогресс‑бара) — непозволительное расточительство.
Поэтому я решил написать свой спиннер самостоятельно. На первый взгляд это не слишком сложная задача — нужно знать всего пару вещей:
ANSI‑последовательность "\033[K" визуально очищает всю строку правее текущего положения курсора (есть еще "\033[1K" — очищает все левее, и "\033[2K" — очищает всю строку, но об этом позже) в потоках, которые поддерживают ANSI‑последовательности.
Байт '\r' перемещает курсор в начало текущей строки.
Комбинация "\r\033[K", таким образом, позволяет полностью очистить текущую строку и гарантированно переместить курсор в самое начало. Можно использовать и "\033[1K\r", и "\r\033[2K", но зачем печатать больше, когда можно печатать меньше?
Ну а дальше все должно быть просто. Создать свою имплементацию io.Writer и все готово:
var clearBytes []byte = []byte("\r\033[K") var spinnerBytes []byte = []byte("Тут пока что сложно") type Spinner struct { io.Writer } func (s Spinner) Write(data []byte) (int, error) { s.Writer.Write(clearBytes) res, err := s.Writer.Write(data) s.Writer.Write(spinnerBytes) return res, err }
Но, конечно же, все не так просто. Посмотрим на проблемы, которые у нас уже имеются:
То, что мы написали — это не спиннер. Это скорее элемент интерфейса — статический текст, закрепленный на последней строке терминала.
Мы все еще заворачиваем лишь один поток — любая запись в stdout может привести к появлению артефактов.
Самая мелочь — текущая имплементация будет втихую удалять некоторые данные с экрана. Ведь если data не заканчивается новой строкой, наша последовательность, отправленная в поток при следующей записи, сотрет всю строку — и наш спиннер, и все, что было до него.
Хорошо, начнем с простого. Будем отслеживать то, что нам отправили, и рисовать спиннер только тогда, когда последний байт — это новая строка.
type Spinner struct { io.Writer frame []byte isDrawn bool } func (s *Spinner) Write(data []byte) (int, error) { if s.isDrawn { s.Writer.Write(clearBytes) s.isDrawn = false } res, err := s.Writer.Write(data) // Внимательный читатель скажет "res-1 < len(data) бессмысленная проверка", // но я отвечу - io.Writer нам предоставляет юзер, так что полагаться на // джентльменские договоренности - это или ошибка новичка или шаг // ультрасеньора. Я пока что не попадаю ни в одну из категорий if err == nil && res > 0 && res-1 < len(data) && data[res-1] == '\n' { s.Writer.Write(s.frame) s.isDrawn = true } return res, err }
Заодно я перенес наши байты фрейма в сам спиннер — это понадобится в дальнейшем. Но мы решили одну из трех проблем. Осталось еще две? На самом деле осталось три проблемы. Мы используем bool для определения состояния спиннера, но у нас нет гарантий, что какой‑нибудь другой конкурентный вызов Write не вмешается в нашу логику. Этот код не является потокобезопасным. Проблема решается несложно — добавим мьютекс на весь блок Write:
type Spinner struct { ... mut *sync.Mutex } func (s *Spinner) Write(data []byte) (int, error) { s.mut.Lock() defer s.mut.Unlock() ... }
Вот теперь наконец‑то у нас всего две крупные проблемы. Одна решается просто — отрисовывать фреймы спиннера можно в фоновой горутине раз в какое‑то время:
func (s *Spinner) Start() context.CancelFunc { t := time.NewTicker(100 * time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) ch := make(chan struct{}, 1) s.mut.Lock() s.frame = []byte("- Running") if s.isDrawn { // Здесь и далее я буду использовать append(packageVar, other...) - // это просто для краткости. Не делайте так в реальном коде. s.Writer.Write(append(clearBytes, s.frame...)) } s.mut.Unlock() go func() { states := []byte{'\\', '|', '/', '-'} idx := 0 Loop: for { select { case <-t.C: s.mut.Lock() s.frame[0] = states[idx] if s.isDrawn { s.Writer.Write(append(clearBytes, s.frame...)) } s.mut.Unlock() idx = (idx + 1) % len(states) case <-ctx.Done(): break Loop } } s.mut.Lock() s.frame = []byte{} if s.isDrawn { s.Writer.Write(clearBytes) } s.mut.Unlock() close(ch) }() return func() { t.Stop() cancel() <-ch } }
Это уже рабочий спиннер. Не без изъянов, но рабочий. Как говорят англоязычные — «так далеко так хорошо». Осталось решить одну проблему — координацию двух потоков. Потому что очень часто записи в stderr и stdout отображаются в одном терминале. Ну и под шумок решим проблему того, что ошибки при записи/очистке спиннера на данный момент просто втихую проглатываются. У нас уже есть мьютекс, а значит, мы можем воспользоваться им, чтобы сериализовать все записи. Единственная проблема — придется разделить поток спиннера и поток записи, но при этом объединить все остальное, связанное со спиннером (так как у них общие isDrawn, mut, frame):
type spinnerState struct { io.Writer sync.Mutex errCh chan error frame []byte isDrawn bool } type Spinner struct { io.Writer state *spinnerState } func (s *Spinner) Write(data []byte) (int, error) { s.state.Lock() defer s.state.Unlock() if s.state.isDrawn { _, err := s.state.Write(clearBytes) s.state.isDrawn = false if err != nil { select { case s.state.errCh <- err: default: } } } res, err := s.Writer.Write(data) if err == nil && res > 0 && res-1 < len(data) && data[res-1] == '\n' { _, localErr := s.state.Write(s.state.frame) if localErr != nil { select { case s.state.errCh <- localErr: default: } } else { s.state.isDrawn = true } } return res, err }
Метод Start получает все те же замены s.Writer.Write → s.state.Write, s.mut.Lock → s.state.Lock и так далее, и все так же пишет ошибки в канал через неблокирующий select. Почему мы докладываем об ошибках именно так? Потому что у Start буквально нет другого способа (ну можно возвращать канал с ошибками еще, концептуально мало меняется), а во Write было бы странно, к примеру, на запись в stdout получить ошибку записи в stderr.

В целом, это плюс‑минус все. Для одноразового спиннера, который пишется под одну конкретную задачу. Проблема одноразовых решений под задачу в том, что когда‑нибудь такую задачу придется решать еще раз… И за неимением библиотек, я решил — а бахну‑ка я свою. Как водится с динамической версткой и прогресс‑барами.
Итак, переход к библиотечному коду. Что нам понадобится?
На данный момент Start не идемпотентен, а библиотечный код не должен ломаться от таких мелочей.
Хотелось бы иметь возможность изменять отображаемые фреймы в процессе.
Какие варианты есть у нас для решения этих проблем? Нам, конечно, придется расширять внутреннее состояние spinnerState: добавить туда флаг состояния и что‑то, что поможет нам динамически выбирать новые фреймы. Попробуем описать это:
type FrameFunc func() ([]byte, error) type spinnerState struct { ... getFrame FrameFunc isRunning bool }
Но здесь возникают новые проблемы:
Новое состояние, доступ к которому должен быть сериализован для избежания датарейсов.
При установке новой getFrame новый фрейм должен отобразиться сразу же и не заменяться более ранним.
Так как FrameFunc будет предоставляться пользователями — они могут блокировать, паниковать или возвращать результат очень медленно. А еще они могут не быть потокобезопасными.
Мы можем сериализовать все изменения внутреннего состояния при помощи того же самого мьютекса, который мы уже ввели для Write. Но внутреннее состояние (isRunning, getFrame) и использование Write — это разные вещи, которые не обязательно должны синхронизироваться друг с другом. Поэтому мы можем добавить еще один мьютекс для состояния. Этот вариант добавляет еще больше сложностей в синхронизации — некоторые секции должны забирать оба мьютекса, некоторые только один — поэтому теперь нам будет нужно следить за порядком блокировок, гарантированным освобождением и так далее. Поверх всего этого также накладываются отрисовки результатов пользовательских FrameFunc, обработка их ошибок и сериализация, а еще лучше — объединение вызовов. Поэтому для синхронизации я выбрал другой путь, который заодно дал мне возможность собрать всю логику в одном месте. Я пришел к акторной модели:
type spinnerState struct { ... ctx context.Context task chan any revision uint64 wg sync.WaitGroup inFlight bool } type start struct { notify chan error } type stop struct { notify chan error } type setGetFrame struct { getFrame FrameFunc notify chan error } type drawFrame struct { revision uint64 frame []byte err error } func (st *spinnerState) set(newFrame []byte) error { st.Lock() defer st.Unlock() st.frame = newFrame if st.isDrawn { _, err := st.Write(append(clearBytes, st.frame...)) return err } return nil } func (st *spinnerState) startBackground(t *time.Ticker) { go func() { for { select { case task := <-st.task: switch typed := task.(type) { case start: if st.isRunning { continue } // Дожидаемся вызовов getFrame, которые могут еще быть // активны st.wg.Wait() // Синхронно получаем новый фрейм, пишем его через set case stop: if !st.isRunning { continue } // Очищаем фрейм, проставляем st.isRunning = false case drawFrame: st.inFlight = false if typed.revision != st.revision || !st.isRunning { continue } if typed.err != nil { continue } err := st.set(typed.frame) if err != nil { select { case st.errCh <- err: default: } } case setGetFrame: if typed.getFrame == nil { typed.notify <- errors.New("unable to set nil FrameFunc") close(typed.notify) continue } st.getFrame = typed.getFrame st.revision += 1 st.wg.Wait() if st.isRunning { frame, err := st.getFrame() // err != nil не обозначает ошибку в обычном // смысле - я решил использовать ошибки от // FrameFunc как сигнал, что не нужно ничего // перерисовывать, поэтому она не попадает // в канал if err == nil { err = st.set(frame) if err != nil { st.isRunning = false } typed.notify <- err } } close(typed.notify) default: continue } case <-t.C: if st.inFlight || !st.isRunning { continue } st.wg.Add(1) st.inFlight = true go func(getFrame FrameFunc, revision uint64) { frame, err := getFrame() st.wg.Done() select { case st.task <- drawFrame{ revision: revision, frame: frame, err: err, }: case <-st.ctx.Done(): } }(st.getFrame, st.revision) case <-st.ctx.Done(): return } } }() }
Конечно, мы уже отошли от реалистичного кода — иначе статья была бы раза в два длиннее — поэтому я опускаю многие моменты с обработкой ошибок, защитой от глупых решений и так далее. Это скорее примерное описание того, как можно дойти до готового решения. Но даже так здесь приведен код, написанный с опытом долгих сессий дебаггинга, отлова гонок и в целом немалого количества итераций.
Изначально это все не выглядело так красиво — я даже умудрился затащить golang.org/x/sync/singleflight — ведь логично, что раз мне нужно гарантированно объединять вызовы FrameFunc, лучше всего использовать готовые и надежные решения. К сожалению, надежность решений не спасает от проблемы с прокладкой между стулом и клавиатурой. В качестве ключа объединения я выбрал ревизию, которая на тот момент имела совсем другой смысл — поколение фрейма, а не FrameFunc. И здесь, намешав значений в одну переменную (это и ключ объединения, и механизм защиты от протухших записей), я разработал не фичу, а баг. Баг, который решился отказом от singleflight и разделением значений revision. Поколение FrameFunc осталось у revision, а вот исключение в критической секции переехало в inFlight bool.
Но и помимо этого здесь уже собраны некоторые решения не самых очевидных проблем — к примеру, синхронизация start и setGetFrame с st.wg для избежания асинхронных вызовов даже разных FrameFunc. Дальше остается только подвязать это все к методам нашего Spinner, сделать конструктор для заворачивания двух io.Writer за одним спиннером и в целом все готово. Итак, мы разобрались с асинхронными, но объединенными вызовами FrameFunc, с синхронизацией взаимодействия со внутренним состоянием и даже остановкой и запуском самого спиннера. Пора переходить к самому интересному.
В целом на этом уже можно было остановиться. У меня на руках была библиотека, способная отрисовывать как простые анимированные спиннеры, так и полноценные прогресс‑бары. Правда, без подгонки размера последних под ширину экрана. И если это было позволительно небольшим по размеру анимациям, то для баров это уже сродни преступлению. «В чем сложность?» — спросите вы. В том, что это невозможно сделать нормально. Нет, буквально. Что мы имеем:
Если размер фрейма больше ширины терминала — фрейм перенесется на новую строку. А вы ведь помните нашу ANSI‑последовательность? Перенос курсора в начало текущей строки и удаление всего, что правее курсора.
Среди всего зоопарка ОС и терминалов очень тяжело со стандартизированными способами достать текущую ширину терминала.
Юзеры не уважают мьютексы, поэтому терминалу нельзя сказать «падажжи, не меняй размер».
Информация об изменении ширины терминала приходит асинхронно.
Ну и отдельно о Windows — там нет удобного способа получить сигнал о смене ширины вообще. Можно либо влезать в API, которое повлияет на работу приложения (ну практически перетянет терминал в raw‑режим), либо старый добрый поллинг.
Все вышеперечисленное значит, что как бы мы ни старались, ничто не защитит нас от следующей ситуации:
Проверяем ширину/слушаем событие изменения ширины, видим ширину 60.
Записываем фрейм шириной 40 — потому что использовать всю ширину терминала нам не позволяет религия. Ну или просто без причины. Неважно сколько — важно, что больше 20.
На новый фрейм повторяем п.1 и получаем все те же 60.
Пишем нашу замечательную последовательность для очистки строки. Но хитрый пользователь ждал такой неосмотрительности, поэтому в момент отправки системного вызова он тянет за край окна и сжимает терминал до 20. Наш фрейм, который мы пытаемся стереть, теперь занимает две строки из‑за переформатирования после изменения размера. Последовательность затирает одну строку.
Переходим на п. 1.
Здесь, конечно, нужен небольшой дисклеймер — это будет работать именно так только в терминалах с автоматическим переформатированием. К сожалению, или к счастью, большинство терминалов как раз такие. Поэтому мы опустим сценарий, в котором перенос не происходит. В итоге мы получим следующую картину:
[=================== <- Артефакт! Нижняя половина была стерта, а эта осталась [========> ] 22/33 <- Здесь мы уже спохватились и сжали фрейм
Хорошо, гарантировать 100% корректное отображение вообще без артефактов мы не можем. А что можем? Как минимум — реализовать фреймы динамического размера и best‑effort подгонку под размер при изменении ширины. Для начала представим, как бы выглядело удобное нам API для получения актуальной ширины. Лично мне очень нравятся функции:
type WidthFunc func() int type spinnerState struct { ... width int getWidth WidthFunc }
Тогда мы можем достаточно тривиально получить best‑effort исправление артефактов:
// Не упомянул в тексте - но мы опять будем прибегать к // ANSI-трюкам. Конкретно эта последовательность передвигает // курсор на строку выше var jumpLineUp = []byte("\033[A") // Этот метод должен вызываться только // внутри синхронизированного мьютексом блока func (st *spinnerState) clear() error { if !st.isDrawn { return nil } st.isDrawn = false oldWidth := st.width st.width = st.getWidth() if st.width != 0 && st.width < oldWidth { // Спасаем ситуацию, считаем строки - len(st.frame)/st.width lines := max((len(st.frame)+st.width-1)/st.width, 1) // Как минимум одну строку нам точно надо стирать _, err := st.Write(clearBytes) // Ну а теперь все оставшееся clearLineUp := append(jumpLineUp, clearBytes...) for range lines-1 { _, wErr := st.Write(clearLineUp) err = errors.Join(err, wErr) } return err } else { _, err := st.Write(clearBytes) return err } }
В целом этого уже достаточно для того, чтобы исправлять большинство артефактов. Для пущей нарядности и чувства собственного достоинства мы можем добавить подгонку размера видимого фрейма под новую ширину, проверку не только на очистке, но и на записи, ну и наконец‑то после всего этого сломаться на прогресс‑баре вида 🍗🍗🐔🐔🐔🐔🐔🐔🐔🐔 20/100. Ну да, а чего вы хотели? Впрочем, это мелочи — тут нужно просто найти библиотеку, которая умеет в подсчет видимой ширины строк. Я выбрал github.com/clipperhouse/displaywidth. И встраивается она предельно просто:
var graphemeOpts = displaywidth.Options{ ControlSequences: true, } func (st *spinnerState) clear() error { ... // st.visible здесь это то, что было передано в Writer на последней отрисовке lines := max((graphemeOpts.Bytes(st.visible)+st.width-1)/st.width, 1) ... }
Обратили внимание? ControlSequences: true — нам нужно быть в курсе ANSI‑последовательностей. Потому что в visible кроется еще одна мина. Цвета. Цвета в терминале обозначаются при помощи тех же самых ANSI‑последовательностей. Но эти последовательности абсолютно не отображаются сами по себе — имеют нулевую ширину. Более того, при подгонке фрейма под видимую ширину хорошая библиотека должна сохранять все ANSI‑последовательности в корректном порядке, иначе можно случайно окрасить терминал пользователя или сломать его курсор. Поэтому наш спиннер продолжает обрастать хелперами.
func (st *spinnerState) adjust() { // Быстрый путь - так как каждый байт гарантированно не может приносить // больше 1 столбца видимой ширины if st.width >= len(st.frame) { st.visible = st.frame return } iter := graphemeOpts.BytesGraphemes(st.frame) total := 0 canTakeMore := true result := bytes.Buffer{} for iter.Next() { size := iter.Width() cur := iter.Value() // Записываем все, что не отображается if size == 0 { result.Write(cur) continue } if canTakeMore && total+size <= st.width { total += size result.Write(cur) continue } canTakeMore = false } st.visible = result.Bytes() }
Отлично, с этим мы даже почти что разобрались. Осталась маленькая несостыковочка. Как заставить терминал сообщать нам о своей ширине в том виде, который удобен нам? Лично я выбрал путь наименьшего сопротивления:
func CachedGetWidth(sigwinch <-chan struct{}, getWidth WidthFunc) WidthFunc { getWidth = zeroOnPanic(getWidth) current := atomic.Int64{} current.Store(int64(getWidth())) go func() { for range sigwinch { Drain: for { select { case _, ok := <-sigwinch: if !ok { break Drain } default: break Drain } } current.Store(int64(getWidth())) } }() return func() int { return int(current.Load()) } } func WidthFromFile(file *os.File) (WidthFunc, error) { if file == nil { return func() int { return -1 }, errors.New("unable to determine width for nil file") } // Тут просто нужно вернуть лямбду, заворачивающую // вызов внешней библиотеки, и в этом тексте уже и // так слишком много кода, поэтому вот вам кот: 😺 } func DefaultGetWidth(ctx context.Context) (WidthFunc, error) { if ctx == nil { ctx = context.Background() } getWidth, err := WidthFromFile(os.Stderr) if err != nil { return nil, err } return CachedGetWidth(DefaultSigwinch(ctx), getWidth), nil }
Если вам лень читать код — то я просто регистрирую обработчик сигнала SIGWINCH (на Unix‑подобных системах), который сообщает об изменении параметров окна. На каждый сигнал я пользуюсь библиотекой golang.org/x/term, чтобы получить актуальную ширину и сохраняю ее в атомике. Таким образом, эту функцию достаточно дешево вызывать даже на самом горячем пути — оверхед минимален — уж точно не сравнимый с системным вызовом.
В целом, по моим представлениям все, описанное выше — это максимум, которого можно добиться без буферизации и переключения терминала в raw‑режим, а именно:
Синхронизированная запись в stderr и stdout с сохранением разделения потоков
Динамические размеры и анимации (от FPS до самой конструкции фреймов)
Контроль над жизненным циклом анимации
На самом деле здесь даже не была затронута куда более скучная, но полезная часть процесса написания библиотеки — дизайн и реализация API. Поэтому если вам интересно посмотреть на все, что я здесь описал, в чуть более красивой упаковке (и чуть менее красивом коде — задним умом код писать проще) — милости прошу в мой репозиторий. Там вы найдете это и даже немного больше (Как построить прогресс‑бар? Как запустить это все с минимальными усилиями?). Библиотека называется spinq, что расшифровывается как «Simple sPINner toolQit» и на момент написания еще не получила стабильного релиза, но хочется верить, что он не за горами.
А на этом у меня все. До скорых, надеюсь, встреч!