Часы в шапке дашборда показывают время старта программы и замирают. Модель ничего плохого не делала — она просто не знает, что время идёт: Bubble Tea перерисовывает экран только по сообщениям, а сообщений о времени ей никто не присылает. Механика команд из пятого урока умеет планировать будущее: команда — это func() tea.Msg, который фреймворк достанет в горутине через сколько угодно миллисекунд. Спиннер из шестого урока уже ездил на этом приёме, но там цепочку тиков прятал компонент. Здесь достанем её на поверхность: tea.Tick из пакета bubbletea — таймер tui в каноническом виде, и вокруг него собираются три задачи урока. Часы в шапке, которые тикают каждую секунду. Дедлайн операции, который честно объявляет ошибку, когда сервер молчит. И частота тиков, которая снижается, когда экран простаивает, — потому что каждый кадр это работа процессора и пакет по SSH. Тот же механизм вращает прогресс-бары из двенадцатого урока и любое автообновление — класс один, и разобрав его здесь, вы закрываете сразу весь список.
tea.Tick: команда по расписанию#
Сигнатура короткая: tea.Tick(d, fn) принимает длительность и функцию func(time.Time) tea.Msg, а возвращает обычную команду. Верните её из Init или Update — фреймворк запустит, подождёт d и вызовет fn с текущим моментом времени; что fn вернёт, то и приедет в Update сообщением. Ключевое слово — одноразовость: одна команда стреляет один раз. Цепочка держится на самозапланировании, которое вы уже видели у спиннера: ветка tickMsg в Update обязана вернуть новую команду тика, иначе через секунду цепочка оборвётся и часы замрут. Сообщение принято объявлять типом-обёрткой над временем — type tickMsg time.Time: так оно не спутается с сырым time.Time, а внутри модели момент достаётся приведением time.Time(msg). Время в сообщении — не украшение: именно оно позволяет View показать момент события, а логам — понять, когда тик опоздал. Опоздания, кстати, реальны: под нагрузкой терминал собирает кадры, и секунда между тиками иногда превращается в полторы.
Сообщение-время и фабрика тика
GOtype tickMsg time.Time
func tickCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m dashModel) Init() tea.Cmd {
return tea.Batch(tickCmd(), m.list.Tick)
}tickMsg — свой тип над time.Time, чтобы путаницы с сырым временем не было. Фабрика tickCmd скрывает интервал: сменить частоту — правка одной строки. Init объединяет собственный тик с командой готового компонента через Batch из седьмого урока.
Часы в шапке#
Модель хранит последний момент — поле now time.Time, — ветка tickMsg его обновляет и возвращает следующий тик, View форматирует. Конструктор ставит now в time.Now(): без этого до первого тика шапка покажет нулевое время рандомной эпохи, а целую секунду — заметно долго. Формат по-русски привычен: m.now.Format("15:04:05") выдаёт часы, минуты и секунды; раскладка шапки — занятие урока про раскладку Lip Gloss, здесь достаточно строки. Заметьте разделение труда: модель знает момент, View решает, как его показывать, а команда не знает ничего о том, зачем её позвали. Из-за этого часы можно переставить в футер или выкинуть совсем — правка затрагивает View и не трогает таймер. Проверять ход часов в песочнице бессмысленно — время эмулирует только живой терминал, поэтому запускайте локально: time.Second уменьшите до пары сотен миллисекунд, и бегущие цифры покажут и частоту, и опоздания кадров наглядно. Одна деталь, которую стоит видеть своими глазами: при потере фокуса окна терминал может приостановить отрисовку, и после возврата цифры прыгнут — модель ни при чём, догонять кадры не нужно.
Ветка тика и строка шапки
GOcase tickMsg:
m.now = time.Time(msg)
m.beats++
return m, tickCmd()
// во View:
header := fmt.Sprintf("Панель задач · %s",
m.now.Format("15:04:05"))Три строки ветки — обновить момент, посчитать тик, запланировать следующий. Забытый return tickCmd() — главная ошибка темы: компилятор молчит, программа жива, только часы стоят. Форматирование живёт во View: момент в модели, представление — отдельно.
Проверка: чем живёт цепочка
Часы тикали минуту и замерли на одном времени, программа продолжает работать. Что случилось?
Таймауты операций#
Вторая работа таймера — дедлайны. Запрос ушёл на сервер — запланируйте рядом команду-таймаут: tea.Tick на пять секунд возвращает timeoutMsg, и если ответ не успел, ветка переводит модель в состояние ошибки с честным текстом «сервер молчит дольше пяти секунд». Для HTTP правильнее контекст из девятнадцатого урока — клиент отменяет запрос физически; таймер в модели нужен для произвольных операций: долгая индексация, воркер без таймаута, ответ от внешней программы. Но у таймера есть подвох, которого у контекста нет: он стреляет всегда, даже когда уже не нужен. Пользователь нажал r — загрузка перезапущена — а старый таймер первой попытки всё ещё в полёте и через пять секунд влепит ошибку в успешно загруженные данные. Лечение — метка принадлежности: у модели счётчик попыток gen, каждая команда-таймер уносит номер своей попытки, и ветка сравнивает msg.gen с текущим. Чужой таймер молча сгорает: return m, nil. Три строки страховки против загадочного флота ошибок через полминуты после успешной загрузки.
Дедлайн с генерацией попытки
GOfunc loadWithTimeout(url string, gen int) tea.Cmd {
return tea.Batch(
fetchItemsCmd(url),
tea.Tick(5*time.Second, func(time.Time) tea.Msg {
return timeoutMsg{gen: gen}
}),
)
}
case timeoutMsg:
if msg.gen != m.gen {
return m, nil
}
m.state = stateError
m.err = errors.New("сервер молчит дольше пяти секунд")
return m, nilBatch запускает запрос и дедлайн параллельно: кто первый — тот и владеет исходом. Ветка timeoutMsg верит только таймеру своей попытки; устаревший gen — сигнал, что дедлайн опоздал, и экран не трогаем. Ответ fetchItemsCmd сбрасывает состояние и гасит опасения симметрично.
Уберите сравнение gen — и получите классический флот: загрузка успешна, данные на экране, а через пять минут ветка timeoutMsg прошлой попытки рисует «сервер молчит». Отменить полетевший таймер нельзя — можно только не поверить ему. Счётчик попыток дешевле каждой альтернативы, которую я пробовал.
Забытая генерация
Прочитайте код и выберите, что он выведет:
// Загрузка шла долго, пользователь нажал r:
// gen стал 2, вторая попытка успешно загрузила данные.
// Первый таймер (gen 1) стреляет только сейчас:
case timeoutMsg:
// сравнения msg.gen с m.gen в ветке нет
m.state = stateError
m.err = errors.New("сервер молчит дольше пяти секунд")
return m, nil
// Что покажет экран?Деградация частоты#
Секундный тик стоит денег: кадр перерисовывается, процессор просыпается, по SSH уходит пакет. Когда панель в фокусе и на ней смотрят часы — это цена правильная. Когда экран простаивает или свёрнут — секунда превращается в расточительность, и частоту стоит деградировать: раз в тридцать секунд вместо ежесекундно. Реализация честнее всего живёт в фабрике: интервал вычисляется из состояния модели, ветка тика просто возвращает tickCmd(m.interval()). Панель в фокусе — секунда; ушёл фокус — тридцать секунд; идёт загрузка — четверть секунды, чтобы спиннер жил. Заметьте направление зависимости: Update не решает частоту сам, он спрашивает у модели — состояние в одном месте, и тестировать интервал можно отдельной функцией без терминала. Деградация — не только про экономию: медленный тик на простаивающем дашборде означает, что возврат из скринсейвера не принесёт очередь из тридцати пропущенных кадров. Пропущенные тики догонять не нужно — опоздавший таймер просто рисует текущий момент, догонять нечего.
Интервал из состояния
GOfunc (m dashModel) interval() time.Duration {
if m.loading {
return 250 * time.Millisecond
}
if m.focused {
return time.Second
}
return 30 * time.Second
}
// в ветке tickMsg:
return m, tickCmd(m.interval())Три режима в одной функции: спиннер живёт на четверть секунды, активная панель — на секунде, фоновая — на полминуте. Фабрика принимает длительность параметром, поэтому смена режима — два if, а не новая ветка в Update.
Проверка: time.After на повтор
Команда вернула tickMsg, полученный через <-time.After(d). Сколько ещё сообщений принесёт этот же time.After?
time.After против tea.Tick#
Альтернатива без хелпера выглядит так: return func() tea.Msg { return tickMsg(<-time.After(d)) } — горутина фреймворка блокируется на канале таймера и через d доставляет сообщение. Поведение то же, и в проектах такой вариант встречается. Различия мягкие, но есть. tea.Tick передаёт момент времени в функцию — время уже внутри сообщения; с time.After момент приходится собирать отдельно вызовом time.Now() в замыкании, и между выстрелом таймера и снимком момента успевает пройти ещё пара микросекунд. tea.Tick — каноническая идиома, по которой проект на Bubble Tea узнаётся с первого взгляда: grep по tea.Tick находит все таймеры репозитория. time.After прячет Timer, который живёт до срабатывания: для секунды незаметно, для часовых дедлайнов — уже счёт. Мое правило — tea.Tick везде, кроме одного случая: тесты подменяют реальное время фиктивными сообщениями, и там таймер не нужен вовсе, о чём подробно расскажет следующий урок. Ticker из стандартного пакета не трогаем: повторяющийся источник с обязательным Stop чужд архитектуре, где частота задаётся возвратом команды.
Соберите фабрику тика
Восстановите идиому: хелпер Bubble Tea, тип функции и сообщение, несущее момент.
Соберите минутное обновление
Расставьте ветку тика с накопленным обновлением раз в минуту: сообщение, обновление момента, счётчик, условие, усиленный возврат, обычный возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше#
Цепочка тиков — последний кирпич каркаса: клавиши, команды, композиция, сеть и теперь время. Следующая остановка — тестирование TUI: там фиктивный tickMsg с заданным моментом проверит часы без единой секунды ожидания, а дедлайны проверят подменой таймера моком. Повторить материал спиннера можно в шестом уроке — там та же цепочка спрятана в готовый компонент. Для сетевых запросов дедлайном остаётся контекст из урока про HTTP — таймер модели дополняет его, но не заменяет. А урок про сборку и дистрибуцию покажет, где деградация частоты окупается по-настоящему: медленные SSH-сессии, где каждый кадр — пакет через океан. Обзор курса целиком — на странице Bubble Tea. Проверять тики честно можно только локально: поставьте интервал в сто миллисекунд, запустите панель в настоящем терминале и посмотрите, как опоздания под нагрузкой съедают ровность хода.
Карточки: словарь времени
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: left int = 30 · paused bool = false · expired bool = false
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- tea.Tick(d, fn) — канонический таймер tui: команда ждёт d и доставляет сообщение с моментом времени.
- Цепочка живёт самозапланированием: ветка tickMsg обязана вернуть tickCmd(), иначе часы замрут без ошибок.
- Модель хранит момент, View форматирует; сообщение-обёртка type tickMsg time.Time не путается с сырым временем.
- Дедлайн операции — tea.Tick рядом с запросом; метка gen защищает от таймеров прошлых попыток.
- Частота деградирует по состоянию: интервал вычисляет метод модели, фабрика принимает его параметром.
- time.After — равносильная самописная альтернатива; Ticker с Stop в архитектуре команд не нужен.
Домашнее задание
Соберите шапку с часами и деградацией: секундный тик в фокусе, тридцатисекундный в фоне, переключение по клавише f. Добавьте к загрузке из урока про HTTP таймаут на пять секунд с gen и проверьте сценарий: медленный ответ, повтор по r, успех — старый таймер обязан сгореть молча. Прогоните локально с интервалом сто миллисекунд и посмотрите на опоздания кадров.
Личные заметкиПусто
Вопросы и ответы по уроку
Часы отстают под нагрузкой — тики опаздывают. Чинить?
Опоздание — норма: таймер гарантирует не раньше, а не ровно. Под нагрузкой терминал и планировщик сдвигают кадры. Догонять пропущенные тики не нужно — каждый тик рисует текущий момент. Если точность важна, показывайте минуты без секунд или деградируйте частоту: моргание секунд на перегруженной машине всё равно никто не читает.
Как остановить тики совсем, например на экране ошибки?
Перестаньте возвращать tickCmd() из ветки — цепочка умрёт сама, как у спиннера. Если часы должны вернуться, заведите флаг paused и в ветке возвращайте команду только при false. Жёсткой отмены у летящего таймера нет: он доставит ещё одно сообщение, и ветка должна это пережить без изменений состояния.
Можно ли заводить несколько независимых таймеров?
Да: разные фабрики с разными сообщениями — tickMsg для часов, refreshMsg для данных, timeoutMsg для дедлайнов. Различайте их типами, а не полями, и не планируйте два таймера одной фабрикой с разными интервалами — сообщения неразличимы, и частота перепутается. Своё имя типа на каждый источник — дешёвая страховка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler