Содержание урока · 12 разделов
- bubbles/spinner: готовый компонент для терминала
- Пресеты spinner.Spinner
- spinner.Tick: команда, которая тикает
- Свой спиннер и FPS
- Четыре состояния загрузки
- Строка ожидания: спиннер плюс текст
- Когда спиннер — неправильный ответ
- Сколько стоит анимация
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Программа грузит данные десять секунд. Если экран молчит, пользователь решает, что всё зависло, жмёт ctrl+c и уходит — интерфейс виноват, даже если работа шла как надо. Отсюда правило: любая асинхронная операция длиннее секунды обязана быть видимой. В терминале для этого есть спиннер — вращающаяся палочка, брайль-точки или мигающее многоточие, — и в Bubble Tea его не рисуют руками: пакет bubbles привозит готовый компонент со встроенной анимацией, стилями и честным поведением при остановке. Десять строк кода — и программа перестаёт выглядеть зависшей; в этом уроке разберём все десять. Приятно, что механика под капотом — та самая, с прошлого урока: спиннер тикает командой tea.Cmd и приходит сообщением. То есть это ещё и разминка перед большими асинхронными программами: спиннер — маленькая модель внутри вашей, со своим состоянием и своим циклом. Заодно позаботимся о пользователе: молчаливое ожидание — это баг интерфейса, и спиннер — самый дешёвый способ его закрыть.
bubbles/spinner: готовый компонент для терминала#
bubbles — официальный набор компонентов Charm, и спиннер — самый маленький его жилец: пакет github.com/charmbracelet/bubbles/spinner. Идея та же, что у всей библиотеки: компонент — это модель со своими Update и View, которую вы встраиваете в свою модель полем и кормите сообщениями. Встраивание — не наследование: переопределять ничего не нужно, вы храните значение и сами решаете, какие сообщения ему достанутся — этот подход целиком разберёт восьмой урок. spinner.New() возвращает начальный спиннер с пресетом Line; чтобы выбрать другой, присвойте поле Spinner — s.Spinner = spinner.Dot. У модели есть и поле Style для цвета, но о цвете поговорим на уроке про Lip Gloss, пока достаточно и без стилей. Если вы уже трогали другие bubbles-компоненты, схема узнается с первого взгляда — и наоборот, спиннер станет образцом для всего набора. Дальше по плану: рассмотреть пресеты, разобрать команду Tick, которая запускает анимацию, и главный практический навык урока — как спиннер останавливать, потому что вечно крутящийся индикатор врёт пользователю ровно так же, как молчащий экран.
Подключаем спиннер к модели
GOimport (
"github.com/charmbracelet/bubbles/spinner"
)
type model struct {
spinner spinner.Model
loading bool
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
return model{spinner: s, loading: true}
}Поле spinner хранит вложенную модель: у неё свои Update и View, но вызывать их будет ваша модель. spinner.New() берёт пресет Line по умолчанию, поэтому сразу меняем поле Spinner на Dot — крупные брайль-точки, самый заметный вариант. Флаг loading потребуется дальше, чтобы отличать рабочее состояние от завершённого.
Пресеты spinner.Spinner#
| Пресет | Кадры | FPS | Где уместен |
|---|---|---|---|
| spinner.Line | | / - \ | 10 | минимализм, работает в любом шрифте |
| spinner.Dot | ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ | 10 | крупный брайль, классика bubbles |
| spinner.MiniDot | ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ | 12 | компактный брайль для строк справки |
| spinner.Points | ∙∙∙ ●∙∙ ∙●∙ ∙∙● | 7 | ненавязчивое ожидание внутри текста |
| spinner.Meter | ▱▱▱ ▰▱▱ ▰▰▱ ▰▰▰ | 7 | уровень вместо вращения |
| spinner.Ellipsis | пусто, ., .., ... | 3 | суффикс к надписи: Сохраняю... |
spinner.Tick: команда, которая тикает#
Анимация спиннера — это цепочка асинхронных команд из прошлого урока. В модели spinner есть поле Tick — готовая команда первого тика: Init возвращает её, фреймворк запускает, и через промежуток FPS в Update прилетает spinner.TickMsg. Дальше ваша очередь: переслать сообщение вложенной модели — m.spinner, cmd = m.spinner.Update(msg) — и вернуть полученную команду наружу. Внутри она планирует следующий тик, тот снова приходит TickMsg, цепочка замыкается: спиннер крутится, пока Update исправно возвращает команды. Это тот же приём самозапланированной команды, что вы поймали в predict прошлого урока, только закрытый в компоненте. Заметьте, что тик — обычное сообщение: он проходит через ваш switch наравне с клавишами, поэтому вы всегда можете вмешаться — замедлить, пропустить или не вернуть команду вовсе. Схема одинакова для всех тикающих компонентов — часов, таймеров, прогресс-баров: учась на спиннере, вы учитесь всему классу. Заметьте деталь: пересылка сообщения и возврат команды — два отдельных действия, и оба нужны. Забудете вернуть — анимация умрёт; забудете переслать — кадр перестанет меняться, хотя тики продолжат приходить. О том, как цепочку разрывать, — чуть ниже, это главный навык урока.
Программа целиком: тики, работа, остановка
GOtype workDoneMsg struct{ report string }
func workCmd() tea.Cmd {
return func() tea.Msg {
time.Sleep(2 * time.Second) // имитация долгой работы
return workDoneMsg{report: "17 файлов обработано"}
}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "s":
m.loading = true
return m, workCmd()
}
case spinner.TickMsg:
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
case workDoneMsg:
m.loading = false
return m, nil
}
return m, nil
}
func (m model) View() string {
if m.loading {
return m.spinner.View() + " Работаю..."
}
return "Готово\n"
}Init запускает только первый тик; по нажатию s стартует workCmd — с прошлого урока вы знаете, что она выполнится в горутине фреймворка. Пока loading равен true, каждый TickMsg пересылается вложенной модели, и её новая команда уходит наружу — анимация живёт. workDoneMsg выключает флаг, ветка TickMsg перестаёт возвращать команду, цепочка тиков рвётся: спиннер замирает. Запускайте локально: настоящий терминал покажет и вращение, и честную остановку.
Частая ошибка — продолжать пересылать TickMsg и возвращать команды после завершения работы: индикатор бесконечно крутится рядом с «Готово» и разрушает доверие. Остановка не требует метода: перестаньте возвращать команду следующего тика — цепочка умрёт сама.
Свой спиннер и FPS#
Пресет — это просто значение типа spinner.Spinner с двумя полями: Frames — срез строк-кадров и FPS — частота. Никакой магии, поэтому свой спиннер собирается одной переменной: var slowRing = spinner.Spinner{Frames: []string{"◐", "◓", "◑", "◒"}, FPS: time.Second / 2} — и присваивается модели как обычный пресет. Частота подбирается по характеру ожидания: десять кадров в секунду — живое вращение, три-четыре — степенное, «мы работаем, не торопимся». Пара грабель. Брайль-кадры требуют шрифта с поддержкой брайля: на дежурной моноширинной машине вы получите квадратики вместо точек. Эмодзи-кадры вроде глобуса занимают двойную ширину и рвут вёрстку строк. Я по умолчанию беру Line: скучно, зато предсказуемо в любом терминале; выразительные пресеты оставляю для случаев, когда точно знаю аудиторию. Полный список пресетов с их FPS живёт в исходнике пакета — spinner.go, он короткий.
Четыре состояния загрузки#
Загрузка — это машина состояний, и спиннер — только её видимая часть. Минимальный набор: idle — работы нет, экран спокойный; loading — идёт, индикатор крутится; done — результат показан; error — ошибка видна и повтор доступен. Пара полей модели покрывает всё: loading bool и err error, как в прошлом уроке. Ошибки проектирования начинаются, когда состояния смешивают: спиннер крутится поверх уже показанных данных, «Готово» рисуется рядом с ошибкой, повторная загрузка не сбрасывает старый результат. Проверяйте себя простым правилом: каждая ветка View отвечает ровно одному состоянию, а переходы делаются только в Update при приходе сообщений — workDoneMsg, tasksLoadedMsg, errMsg. Если один и тот же переход случается в двух местах, вы уже потеряли контроль. И помните: повторная операция обязана снова включить loading и обнулить прошлый результат — забытый сброс даёт интерфейс, который показывает старое поверх нового.
Строка ожидания: спиннер плюс текст#
Одинокая палочка малоинформативна, поэтому спиннер склеивают с надписью: m.spinner.View() + " Загружаю задачи..." — View возвращает текущий кадр строкой, дальше обычная конкатенация. Рядом уместно писать, что именно происходит: «Проверяю токен», «Скачиваю пакет 2 из 9» — чем конкретнее строка, тем спокойнее пользователь. Цвет и отступы добавит поле Style, когда дойдём до Lip Gloss. Отдельный вопрос — куда влезает спиннер: в инлайн-режиме кадр перерисовывается на месте, и постоянное вращение дёргает вывод, если интерфейс длиннее пары строк; полноэкранным программам с альтернативным экраном это не грозит. И не забывайте про честность: спиннер без слова «отмена» — это спиннер, который нельзя отменить; кнопку Esc по контексту из прошлого урока стоит держать в голове с первого дня. Практика из жизни: строчку статуса полезно менять на этапах — «Подключаюсь», «Разбираю ответ», «Сохраняю» — пользователь читает её как прогресс, даже без процентов.
Когда спиннер — неправильный ответ#
Спиннер — инструмент для неопределённого ожидания: вы не знаете, сколько продлится операция. Когда доля выполненного известна — копирование файла по байтам, загрузка девяти пакетов, — честнее показать полосу прогресса: проценты снимают тревогу лучше любого вращения. Когда операция короче полсекунды, индикатор вообще вреден: он успевает мигнуть и создаёт ощущение дёрганья; классическое правило интерфейсов — показывать ожидание не сразу, а после задержки в 300–500 миллисекунд, и терминал здесь не исключение. Наконец, когда пользователь ждёт осмысленно — установка пакета на пять минут, — спиннер плюс этапы информативнее одного процента: люди предпочитают понимать, где они в процессе. Выбирайте индикатор по природе ожидания, а не по красоте пресета — и интерфейс станет честнее без единой новой зависимости.
Сколько стоит анимация#
Десять кадров в секунду звучит дорого, и для спиннера это неправда: рендерер Bubble Tea сравнивает новый кадр со старым и обновляет только изменившиеся строки — одна строка с палочкой почти ничего не стоит. Но View целиком пересчитывается при каждом сообщении, включая тики: если View строит гигантскую строку на тысячи позиций — таблицу, лог — вы будете пересобирать её десять раз в секунду только ради палочки. Симптом знакомый: спиннер тормозит интерфейс, хотя «ничего не делает». Лечение простое: держите View дешёвым — тяжёлые куски собирайте один раз в полях модели при изменении данных, а на каждый кадр только склеивайте готовое. Второй рычаг — FPS: для медленных операций степенный Ellipsis на трёх кадрах информативен не хуже быстрого Dot. Замеряйте на своём железе: у теории дешёвых перерисовок, как и у всего, есть предел применимости.
TickMsg несёт поле ID — идентификатор спиннера, породившего тик. Когда спиннеров несколько, по ID решают, чьё это сообщение, и пересылают его только владельцу, иначе один тик ускорит всех.
Проверка: запуск анимации
Что нужно вернуть из Init, чтобы спиннер начал крутиться?
Проверка: остановка
Работа закончилась. Как штатно остановить спиннер?
Что покажет View после завершения?
Прочитайте код и выберите, что он выведет:
case spinner.TickMsg:
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
case workDoneMsg:
m.loading = false
return m, nil
// workDoneMsg пришёл. Что будет с экраном дальше?Заполните проводку спиннера
Восстановите запуск тика и обработку сообщения спиннера в Update.
Соберите инициализацию модели
Расставьте строки создания спиннера и начальной модели в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь спиннера
Что дальше#
Спиннер закрыл вопрос «как показать ожидание», но в примере выше работу запускало нажатие клавиши — потому что Init умеет возвращать лишь одну команду. Как запустить тики и загрузку одновременно, объединяя команды в пачки, — тема седьмого урока про tea.Batch и tea.Sequence. Когда ожидание измеримо в процентах, вращение меняется на полосу: прогресс-бары bubbles покажут долю сделанного. А вернуть былую механику можно перечитав пятый урок про tea.Cmd — вся анимация спиннера построена на нём. Программа курса целиком — на странице курса Bubble Tea. Домашнее задание сделайте в терминале: замените Dot на свой пресет из трёх кадров и подберите FPS так, чтобы вращение не раздражало за десять секунд.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: spinner spinner.Model · ticks int = 0 · loading bool = true
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- bubbles/spinner — вложенная модель: New(), пресет в поле Spinner, View() возвращает кадр.
- Анимация — цепочка команд: Init возвращает m.spinner.Tick, Update пересылает TickMsg и возвращает следующую команду.
- Остановка — разрыв цепочки: не возвращайте команду тика, и спиннер замрёт на последнем кадре.
- Пресет — это Frames плюс FPS; свой спиннер собирается одной переменной.
- Брайль требует шрифта, эмодзи ломают ширину — Line безопаснее всего.
- Рядом со спиннером пишите, что происходит: конкретная строка успокаивает лучше вращения.
Домашнее задание
Соберите программу урока локально и доработайте её: добавьте свой пресет из трёх кадров с FPS в одну секунду, а надпись рядом со спиннером сделайте зависящей от этапа — «Подключаюсь», «Скачиваю», «Разбираю». Затем сломайте остановку нарочно: верните команду тика даже после workDoneMsg и посмотрите, как ведёт себя «Готово» рядом с вечно вращающимся индикатором. Почините обратно.
Личные заметкиПусто
Вопросы и ответы по уроку
Спиннер в bubbletea требует своих горутин?
Нет. Тики приходят через команды tea.Cmd, которые фреймворк запускает сам: Init возвращает m.spinner.Tick, а Update пересылает spinner.TickMsg вложенной модели и возвращает её команду следующего тика. Своего цикла у компонента нет — общий.
Почему спиннер не останавливается вместе с работой?
Потому что кто-то продолжает возвращать команду тика из Update. Проверьте ветку spinner.TickMsg: после завершения работы она должна возвращать nil, тогда цепочка анимации рвётся и индикатор замирает. Частый виновник — ветка default, которая бездумно пересылает всё в m.spinner.Update и возвращает команду.
Какой пресет выбрать по умолчанию?
Line — работает в любом шрифте и терминале. Dot и MiniDot красивее, но требуют брайля; эмодзи-пресеты рвут вёрстку на терминалах с одинарной шириной символов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
- tea.Batch и tea.Sequence: комбинации команд
- Крупные модели: композиция подмоделей
- bubbles/list: готовые списки
- textinput: поля ввода текста
- Команды tea.Cmd: асинхронные операции
- Клавиатура: KeyMsg и обработка нажатий
- tea.Program: запуск и управление программой
- Первая программа: Model, Update, View
- Все курсы по Go
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler