Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 6 из 25 ~16 мин +50 XP

Спиннеры: индикаторы загрузки

Спиннер в терминале на Go собирается за десять строк из пакета bubbles spinner: пресет Dot или Line, команда Tick и ветка TickMsg в Update. Разберём подключение компонента к модели, полный список пресетов, остановку тиков и собственный спиннер с нужным FPS — на примере программы, которая честно показывает ожидание.

Содержание урока · 12 разделов

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

Подключаем спиннер к модели

GO
import (
        "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 наравне с клавишами, поэтому вы всегда можете вмешаться — замедлить, пропустить или не вернуть команду вовсе. Схема одинакова для всех тикающих компонентов — часов, таймеров, прогресс-баров: учась на спиннере, вы учитесь всему классу. Заметьте деталь: пересылка сообщения и возврат команды — два отдельных действия, и оба нужны. Забудете вернуть — анимация умрёт; забудете переслать — кадр перестанет меняться, хотя тики продолжат приходить. О том, как цепочку разрывать, — чуть ниже, это главный навык урока.

Программа целиком: тики, работа, остановка

GO
type 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 перестаёт возвращать команду, цепочка тиков рвётся: спиннер замирает. Запускайте локально: настоящий терминал покажет и вращение, и честную остановку.

Свой спиннер и 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. Замеряйте на своём железе: у теории дешёвых перерисовок, как и у всего, есть предел применимости.

Проверка: запуск анимации

Что нужно вернуть из 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.

func (m model) Init() tea.Cmd { return m.spinner. } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case spinner.: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd }

Соберите инициализацию модели

Расставьте строки создания спиннера и начальной модели в правильном порядке.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: словарь спиннера

Термин

spinner.Model

нажмите, чтобы перевернуть
Определение

Вложенная модель из bubbles: поле Spinner — пресет, Style — стиль, Tick — команда первого тика. Кормится TickMsg через Update.

1 / 5

Что дальше#

Спиннер закрыл вопрос «как показать ожидание», но в примере выше работу запускало нажатие клавиши — потому что Init умеет возвращать лишь одну команду. Как запустить тики и загрузку одновременно, объединяя команды в пачки, — тема седьмого урока про tea.Batch и tea.Sequence. Когда ожидание измеримо в процентах, вращение меняется на полосу: прогресс-бары bubbles покажут долю сделанного. А вернуть былую механику можно перечитав пятый урок про tea.Cmd — вся анимация спиннера построена на нём. Программа курса целиком — на странице курса Bubble Tea. Домашнее задание сделайте в терминале: замените Dot на свой пресет из трёх кадров и подберите FPS так, чтобы вращение не раздражало за десять секунд.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
⣾ тик №0
идёт загрузка
тик — шаг анимации, s — пауза, q — выход

Клавиатура — обработано в 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 и посмотрите, как ведёт себя «Готово» рядом с вечно вращающимся индикатором. Почините обратно.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Спиннеры: индикаторы загрузки)

Вопросы и ответы по уроку

Спиннер в bubbletea требует своих горутин?

Нет. Тики приходят через команды tea.Cmd, которые фреймворк запускает сам: Init возвращает m.spinner.Tick, а Update пересылает spinner.TickMsg вложенной модели и возвращает её команду следующего тика. Своего цикла у компонента нет — общий.

Почему спиннер не останавливается вместе с работой?

Потому что кто-то продолжает возвращать команду тика из Update. Проверьте ветку spinner.TickMsg: после завершения работы она должна возвращать nil, тогда цепочка анимации рвётся и индикатор замирает. Частый виновник — ветка default, которая бездумно пересылает всё в m.spinner.Update и возвращает команду.

Какой пресет выбрать по умолчанию?

Line — работает в любом шрифте и терминале. Dot и MiniDot красивее, но требуют брайля; эмодзи-пресеты рвут вёрстку на терминалах с одинарной шириной символов.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Zap25 уроков~9 чпрактика25 уроков уже доступны

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler