Терминал не даёт ни flexbox, ни грида из браузера: у вас есть символы, строки и всё. Раскладка здесь — это аккуратная конкатенация, и её главный враг — неровность: у одной панели ширина двадцать, у другой двадцать три, и правая колонка плывёт по строкам. Lip Gloss решает это двумя ходами. Первый: блок, отрендеренный стилем с Width, становится прямоугольником — все его строки дополняются пробелами до одной длины, длинный текст переносится по словам. Второй: функции JoinHorizontal и JoinVertical склеивают блоки, дополняя до общего размера по высоте или ширине. Из этих двух ходов собирается любая панель — от сплит-экрана с сайдбаром до статус-бара, у которого счётчики прижаты влево, а подсказки вправо. Всё это работает и без alt-screen, но по-настоящему полноэкранная раскладка имеет смысл вместе с сообщением WindowSizeMsg — о нём ближе к концу урока.
Width: каждый блок — прямоугольник#
Свойство Width на стиле делает больше, чем кажется. Во-первых, длинный текст переносится по словам на ширину блока минус горизонтальные паддинги. Во-вторых, короткие строки добираются пробелами до полной ширины — даже если вы рендерите одну строку, результат будет именно такой ширины. Высота ведёт себя зеркально: Height добивает блок пустыми строками, а куда именно — регулирует AlignVertical с позициями Top, Center и Bottom. Из этого следует привычка, на которой держится вся раскладка: любой блок, который вы собираетесь склеивать с соседями, рендерите с явной Width. Блок без ширины — лента по ширине самого длинного слова, и при склейке он станет источником кривизны. Ширина в символах, а не в пикселях: с кириллицей проблем нет, но помните, что ширина измеряется в ячейках терминала, и это ровно та единица, которой измеряется окно.
Сайдбар и данные бок о бок
GOvar sidebarStyle = lipgloss.NewStyle().
Width(16).
Height(10).
Border(lipgloss.RoundedBorder())
var mainStyle = lipgloss.NewStyle().
Width(40).
Height(10)
func (m boardModel) View() string {
return lipgloss.JoinHorizontal(lipgloss.Top,
sidebarStyle.Render(m.menu()),
mainStyle.Render(m.details()))
}Оба блока рендерятся с явными Width и Height — это два прямоугольника по десять строк. JoinHorizontal с позицией Top склеивает их бок о бок, выровняв по верхнему краю. Пустые места внутри блоков заполнены пробелами, поэтому рамка сайдбара рисуется целиком, а не обрывается на коротком меню.
JoinHorizontal и JoinVertical#
JoinHorizontal принимает позицию и любое число блоков, склеивая их по горизонтали. Позиция — где короткий блок прижмётся к высокому: константы Top, Center и Bottom покрывают обычные случаи, но под капотом это просто дробь от нуля до единицы, и JoinHorizontal(0.2, ...) прижмёт блок на пятую часть высоты от верха. Блоки с меньшим числом строк дополняются пустыми строками — сверху, снизу или по обе стороны, в зависимости от позиции. JoinVertical — зеркальный случай: склейка стопкой, где каждая строка дополняется пробелами до ширины самого широкого блока, а позиция Left, Center или Right решает, к какому краю прижиматься. Важная деталь, экономящая часы: JoinHorizontal сам дополняет каждую строку каждого блока до ширины блока, поэтому склеенный результат остаётся прямоугольником — рваные правые края у вас не появятся, даже если забыли про Width. Зазор между панелями — тоже блок: отдельный стиль с PaddingRight или строка пробелов нужной ширины.
Что выйдет из JoinVertical?
Прочитайте код и выберите, что он выведет:
out := lipgloss.JoinVertical(lipgloss.Right, "ab", "a")
// Как выглядит вторая строка out?Align: выравнивание внутри блока#
Align управляет текстом внутри блока с заданной Width: Align(lipgloss.Center) отцентрует каждую строку, Left и Right прижмут к краям. Вертикальный собрат AlignVertical делает то же с Height: контент уедет вверх, в центр или вниз блока. Метод Align принимает до двух позиций — сначала горизонталь, потом вертикаль, поэтому Align(Center, Center) — это полностью отцентрованный текст в блоке. Классический приём раскладки — статус-бар с прижатыми к разным краям элементами: левый блок и правый блок рендерятся отдельно, ширина зазора считается вычитанием из общей ширины строки, а между ними кладётся строка пробелов через JoinHorizontal. Функции lipgloss.Width и lipgloss.Height измеряют уже отрендеренный текст — с учётом невидимых управляющих кодов, но без них в счёте: вернуть видимую ширину раскрашенной строки — ровно то, что нужно для такого вычитания.
Ширина отцентрованного блока
Прочитайте код и выберите, что он выведет:
box := lipgloss.NewStyle().
Width(10).
Align(lipgloss.Center)
out := box.Render("ok")
// Какова ширина каждой строки out?Статус-бар с двумя краями
GOfunc (m boardModel) statusBar(w int) string {
left := m.titleStyle.Render(" Панель задач ")
right := m.hintStyle.Render(" q — выход ")
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return lipgloss.JoinHorizontal(lipgloss.Top,
left, strings.Repeat(" ", gap), right)
}Зазор считается вычитанием видимых ширин раскрашенных блоков из общей ширины строки — lipgloss.Width честно игнорирует ANSI-коды. Отрицательный зазор зажимается нулём: в узком окне блоки просто прилипнут друг к другу, а не сломают строку. Три блока — левый, пружина, правый — склеиваются в одну строку.
Проверка: измерение ширины
left — строка, раскрашенная стилем с цветом. Что вернёт lipgloss.Width(left)?
Place: свободное место и центрирование#
Функция Place решает обратную задачу: не вставить блок в раскладку, а вставить раскладку вокруг блока. Place(width, height, hPos, vPos, content) возвращает бокс заданного размера, внутри которого контент стоит по указанным позициям — по горизонтали Left, Center, Right, по вертикали Top, Center, Bottom. Частные случаи — PlaceHorizontal и PlaceVertical, когда хватает одной оси. Если контент шире запрошенного бокса, Place не режет его, а оставляет как есть — обрезка ваша забота, и это спасает от пропавших строк. Пустоту внутри бокса можно раскрасить: опции WhitespaceOption добавляют фону цвет через WithWhitespaceBackground или заполняют её не пробелами, а символом через WithWhitespaceChars. Типичные применения: пустое состояние списка по центру экрана, сплэш при старте, подсказка в середине большой панели, когда контента ещё нет.
Пустое состояние по центру
GOvar emptyStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "240", Dark: "250"})
func (m boardModel) emptyView(w, h int) string {
text := emptyStyle.Render("Задач нет — жмём n")
return lipgloss.Place(w, h-1,
lipgloss.Center, lipgloss.Center, text)
}Place получает ширину и высоту окна минус резерв под статус-бар и центрирует строку в этом пространстве. На старте, пока задач нет, экран выглядит нарочно, а не случайно. Высоту стоило бы поджимать под реальный резерв раскладки — здесь вычитается одна строка ради примера.
Сетка через Width и Height#
Грида в Lip Gloss нет, но ячейка собирается за минуту: стиль с Width и Height — это ровно одна ячейка фиксированного размера, и матрица из таких ячеек склеивается двумя циклами — строки через JoinVertical с Left, колонки внутри строки через JoinHorizontal. Колонки не разъедутся, если все ячейки одного ряда объявили одинаковую Width: выравнивание сетки — ваша дисциплина, а не забота библиотеки. Для настоящих табличных данных с курсором и прокруткой этот приём не нужен — берите bubbles/table из тринадцатого урока; сетка из стилей хороша для плиток дашборда: четыре карточки два на два с цифрами внутри. Честное ограничение: содержимое ячейки шире Width — перенесётся по словам, что для коротких подписей обычно и нужно; высота больше двух строк в ячейке уже намекает, что пора выделять панель отдельно.
Проверка: WindowSizeMsg
Когда приложение Bubble Tea получает tea.WindowSizeMsg?
Адаптив под WindowSizeMsg#
Раскладка, которая не реагирует на окно, живёт до первого resize. Схема адаптива механическая: в модели появляются поля width и height, Update ловит tea.WindowSizeMsg и сохраняет msg.Width и msg.Height, а View собирает экран из этих чисел — сайдбару фиксированная ширина, таблице всё, что осталось, статус-бару ровно msg.Width. Пересчитывать ширины колонок и размеры внутренних компонентов логично там же, в ветке сообщения: у таблицы и textarea для этого есть свои SetWidth и SetHeight. Здесь проходит граница с полноэкранным режимом: пока рисуетесь построчно, высота окна вас почти не волнует, а в alt-screen весь экран ваш, и высота становится такой же важной, как ширина. Эту пару — alt-screen и полная геометрия окна — разбирает урок про alt-screen, здесь берём только правило: раскладка — функция размеров из модели, а не догадка в View.
Пересчёт при resize
GOcase tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.table.SetWidth(msg.Width - 18)
m.table.SetHeight(msg.Height - 3)
return m, nilСайдбар занимает фиксированные восемнадцать колонок и три строки уходит на заголовок и статус-бар — остальное отдаётся таблице. Компоненты с собственными сеттерами пересчитываются в той же ветке. Числа 18 и 3 — константы вашей раскладки: их стоит держать в именованных переменных, а не в магии посреди Update.
Пробелы между панелями лучше собирать отдельным блоком — строкой пробелов или стилем с PaddingRight — и клеить JoinHorizontal'ом. Если же вписать пробелы в сам текст блока, при смене фона или рамки пустота покрасится вместе с содержимым, и на светлом терминале всплывёт полоса чужого цвета. Содержимое — содержимым, геометрия — геометрией.
Соберите статус-бар
Восстановите измерения ширин и склейку трёх блоков: левый, пружина, правый.
Соберите View трёхпанельного экрана
Расставьте строки: функция, шапка, тело, низ, склейка, возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь раскладки
Что дальше#
Экран собран — следующий шаг заставить его занять весь терминал по-настоящему: урок про alt-screen и размеры окна доведёт WindowSizeMsg до конца и покажет, чем полноэкранный режим отличается от построчного вывода. Панели, собранные здесь, хочется и щёлкать: клики по координатам приносит урок про мышь. Когда стилей и раскладки становится много, им пора в отдельный файл — этому учит урок про структуру крупного проекта, где styles.go становится обязательным жильцом. Полная программа курса — на странице курса Bubble Tea. Практика: соберите экран из шапки, двух панелей и статус-бара, затем растяните и сожмите окно терминала — если панели ведут себя как приклеенные, а зазор статус-бара тянется ровно, адаптив у вас получился.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: menu []string (3) · cursor int = 0 · details string = "выберите пункт слева"
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- Блок с Width — прямоугольник: длинный текст переносится, короткие строки добираются пробелами.
- JoinHorizontal и JoinVertical склеивают блоки, дополняя до общего размера; позиции — дроби 0..1.
- Align выравнивает текст внутри блока, AlignVertical — по высоте; Place центрирует контент в боксе.
- lipgloss.Width измеряет видимую ширину раскрашенного текста — ею считают зазоры статус-бара.
- Сетка — это ячейки из Width и Height, склеенные двумя Join; для табличных данных берите table.
- WindowSizeMsg приходит при старте и на resize: размеры хранятся в модели, раскладка пересчитывается в Update.
Домашнее задание
Соберите трёхпанельный экран: шапка во всю ширину, сайдбар на восемнадцать колонок, таблица на всё остальное и статус-бар с прижатыми к краям элементами. Протяните WindowSizeMsg в модель, пересчитайте ширину таблицы в Update и проверьте на resize от сорока до ста двадцати колонок. Затем отцентруйте пустое состояние через Place и убедитесь, что оно не обрезается в узком окне.
Личные заметкиПусто
Вопросы и ответы по уроку
Панели наезжают друг на друга при узком окне. Что делать?
Считайте минимальную ширину раскладки и зажимайте её: если msg.Width меньше порога, уберите сайдбар вовсе или отдайте панелям фиксированные минимумы, а не проценты. JoinHorizontal не ломает строки, но и не сжимает контент — забота о нижней границе ширины всегда на вашей стороне.
Почему JoinVertical сделал строку шире, чем мой самый длинный текст?
Проверьте, не рендерите ли вы блоки стилями с Width больше содержимого: склейка дополняет до ширины самого широкого блока, а не текста. Лишняя ширина обычно сидит в Width какой-нибудь панели или в паддингах. Измерьте блоки lipgloss.Width по отдельности и найдите самый широкий.
Можно ли строить раскладку прямо в View без хранения размеров?
Можно для фиксированных ширин, но для адаптива нет: View ничего не знает о терминале, размеры ему обязана принести модель из WindowSizeMsg. Хранить ширину в модели и пересчитывать компоненты в Update — не перестраховка, а единственный способ, которым раскладка вообще узнаёт об окне.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler