Панель задач выросла из одного файла в тысячу двести строк: в Update соседствуют клавиши списка, HTTP-загрузка, валидация формы и стили, а правка строки статуса роняет валидацию пароля в другом конце файла. Это не признак плохого кода — это признак хорошей выучки предыдущих уроков, слитых в одну кучу. Куча чинится картой, и карта для Bubble Tea давно устоялась: экраны живут в отдельных пакетах, стили — в одном styles.go, общие сообщения — в messages.go, а домен — задачи, хранилище, валидация — не знает о существовании терминала. Корневая модель собирает экраны и роутит события. Разберём каждый этаж по порядку, а в конце — чек-лист ревью, который спасает на код-ревью, когда дифф большой, а терпение короткое. Всё собирается локально через go build ./... — песочница тут бессильна, структура проверяется компилятором и тестами.
Пакеты по экранам#
Ключевое решение — резать по экранам, а не по слоям. Пакет update.go со всеми обработчиками программы — антипаттерн: правка списка и правка формы едут в один файл, конфликты в git гарантированы. Экран — самодостаточная фича: у него свой model, конструктор, Update и View, и он не импортирует соседей. Соседство ломает изоляцию мгновенно: экран списка, вызвавший функцию редактора, уже не выкинуть из сборки отдельно. Имена пакетов короткие и по делу: list, editor, help — в импортах это читается как list.Update, list.Model. Корень при этом один: он знает все экраны, а экраны не знают друг о друге. Тонкий вопрос — когда резать: не с первого дня. Приложение из двух экранов честно живёт в одном пакете ui; резать стоит, когда файлы перерастают пятьсот строк или когда второй экран переиспользует первый стиль. Преждевременная нарезка даёт десяток пакетов по три файла, и навигация по проекту становится дороже навигации по одному хорошему файлу.
Дерево типичной панели#
- taskpanel/main.go — точка входа: tea.NewProgram, опции альт-скрина, запуск
- internal/domain/task.go — задачи, статусы, файловое хранилище без tea
- internal/ui/root.go — корневая модель: экраны, режим, роутинг событий
- internal/ui/screens/list/ — экран списка: model.go, update.go, view.go
- internal/ui/screens/editor/ — экран редактирования задачи
- internal/ui/messages/messages.go — общие типы сообщений
- internal/ui/styles/styles.go — все стили Lip Gloss программы
- go.mod — модуль с зависимостями bubbletea, bubbles, lipgloss
styles.go: стили в одном месте#
Каждый экран, рисующий свой lipgloss.NewStyle, через месяц даёт четыре разных красных и три разных отступа — дрейф оттенков заметен только пользователю, который смотрит на экран целиком. Лекарство — один пакет styles: все стили программы объявлены переменными в одном файле, экраны их импортируют и не создают своих. Выигрыш не только в красоте: правка палитры — один коммит в одном файле; адаптивные ширины из урока про раскладку хранятся там же, рядом с базовыми стилями; и код-ревью цветной правки сводится к просмотру одного диффа. Тёмная тема терминала, NO_COLOR и ANSI-256 обсуждались в пятнадцатом уроке — здесь важно только место: стиль объявлен один раз. Спорная деталь, на которой я обжигался: слишком умный пакет styles с функциями-конструкторами и конфигами. Пока стили — простые переменные, всё предсказуемо; как только появляется конфигурация, отладка цвета требует чтения трёх файлов вместо одного. Держите styles.go тупым.
Пакет styles целиком
GOpackage styles
import "github.com/charmbracelet/lipgloss"
var (
DocStyle = lipgloss.NewStyle().Margin(1, 2)
Header = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205"))
StatusErr = lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
Selected = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("212"))
)Все стили программы — один файл, ни один экран не зовёт lipgloss.NewStyle сам. Имена говорящие и не привязаны к экрану: Header используется и списком, и редактором. Правка палитры — правка этого файла и больше ничего.
messages.go: общий язык экранов#
Экраны не импортируют друг друга, но события между ними существуют: редактор сохранил задачу — список обязан перечитаться; фоновый воркер закончил индексацию — статус-бар обязан узнать. Общий язык живёт в отдельном маленьком пакете messages: типы событий, которые производит один, а потребляет корень или другой экран. Правило содержания: сообщение описывает факт — «сохранено по такому пути», «загрузка провалилась», — а не команду чужому экрану вроде «перечитай список». Факт нейтрален: сегодня список перечитывается, завтра пуш-уведомление; команда связывает отправителя и получателя жёстко. Пакет крошечный — десяток типов без единой функции, и это нормально: его задача — типобезопасный словарь событий, который не тянет ни одной зависимости ни вверх, ни вбок. Как только в messages импортируется пакет экрана, начинайте заново: словарь обязан быть независимым, иначе он станет узлом спагетти.
Типы общих событий
GOpackage messages
import "time"
type tickMsg time.Time
type statusMsg string
type savedMsg struct {
path string
err error
}Три события программы: тик часов, строка статуса, факт сохранения. Ни одного импорта экранов — пакет строится за секунду и не создаёт циклов. Ошибка едет полем err по конвенции девятнадцатого урока.
Экран списка захотел тип savedMsg, нашёл его в пакете editor — и компилятор отказался собирать: editor уже импортирует list ради конвертера. Лечение всегда одно: общий тип переезжает в messages. Цикл импорта — сигнал, что событие описали не как факт, а как команду чужому экрану.
Домен без tea#
Самый ценный этаж — internal/domain: задачи, их статусы, правила валидации, файловое хранилище. Ни одного импорта bubbletea — домен не знает, что его показывает терминал. Выгода тройная. Тесты: go test ./internal/domain/ гоняется за миллисекунды без рантайма интерфейса — это тесты из двадцать второго урока в лучшем виде. Переиспользование: CLI-команда add из курса Cobra может писать в то же хранилище, а в гипотетическом fyne-клиенте домен переезжает без правок — интерфейс меняется, предметная область нет. Границы: пока домен чист, IO живёт в его хранилище легально — там это и есть работа слоя; illegaly — когда файловая запись проникает в Update. Стык слоёв прост: команды-фабрики в UI-пакетах вызывают домен и упаковывают результат в messages; Update ничего не вычисляет сам, только кладёт факт в модель. Если правило «Update не ходит в файл» когда-нибудь нарушится, компилятор этого не заметит — заметит только чек-лист ревью ниже.
Проверка: где жить хранилищу
Сохранение задачи в файл падает с ошибкой пути. В каком пакете ищете баг и его тест по правилам урока?
Главный model собирает экраны#
Корень — единственный, кто знает всех. Поля: экраны, текущий режим, строка статуса. Init корня собирает команды Init'ов экранов и свои тики через tea.Batch. Update держит два этажа. Сначала перехват: события messages — savedMsg, тики, размер окна — корень разбирает сам, потому что они межэкранные. WindowSizeMsg рассылается всем экранам сразу — у каждого своя раскладка. Затем роутинг клавиш: активному экрану по mode, остальным — тишина; это та же маршрутизация по фокусу из урока про формы, только уровень выше. View — switch по режиму, каждый экран рисует свой кусок, корень доклеивает статус-бар. Заметьте, что экраны не обязаны возвращать tea.Model: у корневых методов подписи с собственными типами экранов — list.Update возвращает listModel, и корень без приведений. Программе tea нужен только корень, реализующий tea.Model, — внутренности вольны в сигнатурах.
Роутинг событий в корне
GOfunc (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case messages.savedMsg:
m.mode = modeList
m.status = "Сохранено: " + msg.path
case tea.WindowSizeMsg:
m.list, _ = m.list.Update(msg)
m.editor, _ = m.editor.Update(msg)
}
var cmd tea.Cmd
switch m.mode {
case modeEditor:
m.editor, cmd = m.editor.Update(msg)
default:
m.list, cmd = m.list.Update(msg)
}
return m, cmd
}Два этажа: сначала межэкранные события — сохранение переключает режим, размер рассылается всем; затем клавиши и хвосты уходят активному экрану. Экранные Update возвращают собственные типы, поэтому без приведений интерфейсов. tea.Model реализует только корень.
Читаем роутинг
Прочитайте код и выберите, что он выведет:
// mode = modeEditor, пользователь сохранил задачу,
// из команды вернулся messages.savedMsg{path: "tasks.json"}.
case messages.savedMsg:
m.mode = modeList
m.status = "Сохранено: " + msg.path
// Что произойдёт дальше с этим же сообщением
// в нижнем switch?Откуда тестировать#
Структура задаёт и порядок тестов — снизу вверх. Домен: чистые тесты хранилища и валидации, файлы во временной папке, гоняются мгновенно. Экраны: Update каждого экрана по правилам двадцать второго урока — табличные тесты переходов, фиктивные события из messages; экран тестируется без корня и без соседей, это и есть проверка изоляции. Корень: роутинг — savedMsg переключил режим, WindowSizeMsg долетел до обоих экранов; здесь хватает нескольких тестов, потому что логики мало. Стили не тестируются вовсе, а итоговый экран получает один golden-снимок — раскладка панели является частью контракта. Границы теста совпадают с границами пакетов: go test ./internal/domain/ отрабатывает быстрее, чем стартует терминал, и это не метафора. Когда новому разработчику непонятно, с чего начать знакомство с проектом, порядок тестов — он же и порядок чтения: домен, экран, корень. А после тестов — последняя линия обороны: чек-лист ревью из семи пунктов, который я прогоняю по чужому диффу, пока чай не остыл. Он покрывает все этажи структуры и ловит почти каждую рану, встречавшуюся мне в TUI-проектах:
Чек-лист ревью TUI-кода#
- Update не ходит в сеть и в файл: только состояния, модели и возврат команд
- Команды — фабрики без побочных эффектов до вызова замыкания, побочные — внутри замыкания
- Ни одного lipgloss.NewStyle вне styles.go: палитра живёт в одном файле
- Домен собирается и тестируется без bubbletea: go test ./internal/domain/ зелёный
- Экраны не импортируют друг друга: межэкранные события описаны фактами в messages
- У каждого типа сообщения один производитель и одна ветка Update, дедлайны несут метку попытки
- Табличные тесты на переходы клавиш и golden-снимок на главный экран есть в диффе
Проверка: порядок чтения
Новый коллега открывает репозиторий панели впервые. В каком порядке по структуре урока разумнее всего читать код?
Соберите messages.go
Восстановите словарь событий: обёртка времени, строковый статус и структура сохранения с полем ошибки.
Соберите файл сообщений
Расставьте строки messages.go: структура сохранения с полями и двумя скалярными типами событий.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше#
Структура — последний этаж перед выходом в мир: урок про сборку и дистрибуцию соберёт эту программу под три платформы и расскажет, почему деградация частоты тиков спасает SSH-сессии. Итоговый проект курса построен ровно по этому каталогу: список, редактор, статус-бар, файловое хранилище в домене. Порядок тестов отсюда — расшифровка урока про тестирование: снизу вверх, от домена к корню. Обзор всего маршрута — на странице Bubble Tea. Проверка структуры — только локальная: go build ./... и go test ./... в реальном модуле. Практика: разложите свою панель по дереву из урока, сломайте нарочно правило — импортируйте list в editor — и прочитайте ошибку компилятора: она станет вашим лучшим аргументом за messages.go на будущем ревью.
Карточки: словарь структуры
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: files []string (5) · roles []string (5) · cursor int = 0 · opened string
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- Резать по экранам, а не по слоям: пакет list со всем списком, пакет editor со всем редактором.
- styles.go держит палитру программы: ни одного lipgloss.NewStyle в экранах, правка цвета — один файл.
- messages.go — словарь фактов между экранами; команды чужому экрану рождают циклический импорт.
- Домен без импорта tea тестируется мгновенно и переезжает в CLI или GUI без правок.
- Корень перехватывает межэкранные события, рассылает размеры и роутит клавиши по режиму.
- Тесты и чтение идут снизу вверх: домен, экраны, корень; golden — только на главный экран.
Домашнее задание
Разложите свою панель по дереву урока: домен с файловым хранилищем, пакет стилей, messages с тремя общими типами, два экрана и корень с роутингом. Добейтесь зелёных go build ./... и go test ./internal/domain/ локально. Затем напишите тест корня: savedMsg переключает режим на список и пишет статус с путём — два утверждения в одном тесте.
Личные заметкиПусто
Вопросы и ответы по уроку
Сколько пакетов делать — проект ещё маленький?
Держите один пакет ui, пока файлы не перевалили за пятьсот строк или пока второй экран не начал переиспользовать первый. Нарезка раньше срока даёт пакеты по три файла и лишние импорты. Домен выносите первым — он чистый, без циклов, и его границы очевидны; экраны режьте, когда они реально обособились.
Куда класть общие виджеты — кнопку статуса, которой пользуются два экрана?
В отдельный пакет components рядом со screens: маленькие подмодели без состояния программы. Следите за направлением: components импортируют styles и messages, но не экраны. Если виджет начал знать об экранах — это уже не виджет, а часть экрана, и ему место внутри пакета экрана.
Корень разрастается — это нормально?
Нормально до предела: роутинг, перехват сообщений и статус-бар — его работа, и пара сотен строк там уместны. Лечится выносом: групповые операции — в промежуточную подмодель, глобальные клавиши — в функцию handleGlobal, возвращающую (модель, команду, обработано). Если корень начал знать детали домена — событие описали командой, а не фактом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler