---
title: "Lip Gloss: раскладка и выравнивание"
description: "lipgloss layout: раскладка терминального интерфейса — JoinVertical и JoinHorizontal, выравнивание Align, Place, ширина стиля как сетка и адаптив под WindowSizeMsg."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/16-lipgloss-layout/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 18
---

# Lip Gloss: раскладка и выравнивание

*Курс «Bubble Tea: TUI-приложения в терминале», урок 16 из 25. ~18 минут. Обновлено 09.10.2026. Источник: https://golangmaster.ru/courses/bubbletea/lessons/16-lipgloss-layout/*

Раскладка — вторая половина Lip Gloss: стили прошлого урока красят отдельные блоки, а layout-инструменты JoinHorizontal, JoinVertical и Place собирают из них экран терминального приложения. Панель слева, данные справа, статус-бар во всю ширину внизу — всё это конкатенация строк с честным дополнением пробелами. Соберём трёхпанельный экран и научим его пересчитываться при изменении окна.

Терминал не даёт ни flexbox, ни грида из браузера: у вас есть символы, строки и всё. Раскладка здесь — это аккуратная конкатенация, и её главный враг — неровность: у одной панели ширина двадцать, у другой двадцать три, и правая колонка плывёт по строкам. Lip Gloss решает это двумя ходами. Первый: блок, отрендеренный стилем с Width, становится прямоугольником — все его строки дополняются пробелами до одной длины, длинный текст переносится по словам. Второй: функции JoinHorizontal и JoinVertical склеивают блоки, дополняя до общего размера по высоте или ширине. Из этих двух ходов собирается любая панель — от сплит-экрана с сайдбаром до статус-бара, у которого счётчики прижаты влево, а подсказки вправо. Всё это работает и без alt-screen, но по-настоящему полноэкранная раскладка имеет смысл вместе с сообщением WindowSizeMsg — о нём ближе к концу урока.

### Width: каждый блок — прямоугольник

Свойство Width на стиле делает больше, чем кажется. Во-первых, длинный текст переносится по словам на ширину блока минус горизонтальные паддинги. Во-вторых, короткие строки добираются пробелами до полной ширины — даже если вы рендерите одну строку, результат будет именно такой ширины. Высота ведёт себя зеркально: Height добивает блок пустыми строками, а куда именно — регулирует AlignVertical с позициями Top, Center и Bottom. Из этого следует привычка, на которой держится вся раскладка: любой блок, который вы собираетесь склеивать с соседями, рендерите с явной Width. Блок без ширины — лента по ширине самого длинного слова, и при склейке он станет источником кривизны. Ширина в символах, а не в пикселях: с кириллицей проблем нет, но помните, что ширина измеряется в ячейках терминала, и это ровно та единица, которой измеряется окно.

### Сайдбар и данные бок о бок

```go
var 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?

```go
out := lipgloss.JoinVertical(lipgloss.Right, "ab", "a")

// Как выглядит вторая строка out?
```

**Что выведет программа?**

- "a" — строки не дополняются
- "a " — пробел добавился справа
- ✅ " a" — пробел добавился слева
- "  a" — два пробела слева

Ширина склейки — два символа, по самой широкой строке. Позиция Right прижимает строки к правому краю, поэтому короткой строке достаётся один пробел слева. С Left пробел оказался бы справа, с Center — поровну по краям с округлением.

### Align: выравнивание внутри блока

Align управляет текстом внутри блока с заданной Width: Align(lipgloss.Center) отцентрует каждую строку, Left и Right прижмут к краям. Вертикальный собрат AlignVertical делает то же с Height: контент уедет вверх, в центр или вниз блока. Метод Align принимает до двух позиций — сначала горизонталь, потом вертикаль, поэтому Align(Center, Center) — это полностью отцентрованный текст в блоке. Классический приём раскладки — статус-бар с прижатыми к разным краям элементами: левый блок и правый блок рендерятся отдельно, ширина зазора считается вычитанием из общей ширины строки, а между ними кладётся строка пробелов через JoinHorizontal. Функции lipgloss.Width и lipgloss.Height измеряют уже отрендеренный текст — с учётом невидимых управляющих кодов, но без них в счёте: вернуть видимую ширину раскрашенной строки — ровно то, что нужно для такого вычитания.

### Ширина отцентрованного блока

```go
box := lipgloss.NewStyle().
        Width(10).
        Align(lipgloss.Center)

out := box.Render("ok")

// Какова ширина каждой строки out?
```

**Что выведет программа?**

- 2 — Width не влияет на короткий текст
- ✅ 10 — все строки дополняются до ширины блока
- 12 — Width плюс по пробелу с краёв
- 0 — Align отключает добор пробелами

Width задаёт ширину блока, и добор пробелами доводит каждую строку до десяти символов, а Align лишь решает, где именно сидит текст. Паддингов у стиля нет, поэтому двенадцать — ошибка на несуществующие отступы. Именно так готовится любой блок для склейки.

### Статус-бар с двумя краями

```go
func (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)?

- Длину вместе с ANSI-кодами — всё, что в строке
- ✅ Видимую ширину текста, без управляющих кодов
- Ширину терминала минус длину строки
- Ноль: Width измеряет только стили, а не строки

lipgloss.Width измеряет видимую ширину: управляющие последовательности отбрасываются, широкие символы учитываются как две ячейки. Именно поэтому вычитаниями таких ширин можно считать зазоры статус-бара. Если бы коды считались, каждый раскрашенный блок уезжал бы вправо на десятки символов.

### Place: свободное место и центрирование

Функция Place решает обратную задачу: не вставить блок в раскладку, а вставить раскладку вокруг блока. Place(width, height, hPos, vPos, content) возвращает бокс заданного размера, внутри которого контент стоит по указанным позициям — по горизонтали Left, Center, Right, по вертикали Top, Center, Bottom. Частные случаи — PlaceHorizontal и PlaceVertical, когда хватает одной оси. Если контент шире запрошенного бокса, Place не режет его, а оставляет как есть — обрезка ваша забота, и это спасает от пропавших строк. Пустоту внутри бокса можно раскрасить: опции WhitespaceOption добавляют фону цвет через WithWhitespaceBackground или заполняют её не пробелами, а символом через WithWhitespaceChars. Типичные применения: пустое состояние списка по центру экрана, сплэш при старте, подсказка в середине большой панели, когда контента ещё нет.

### Пустое состояние по центру

```go
var 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 с полями Width и Height при старте и на каждый resize. В Update вы сохраняете размеры в модель и пересчитываете раскладку. Считать, что сообщение приходит один раз, — классический баг: окно растянули, а интерфейс остался узким.

### Адаптив под WindowSizeMsg

Раскладка, которая не реагирует на окно, живёт до первого resize. Схема адаптива механическая: в модели появляются поля width и height, Update ловит tea.WindowSizeMsg и сохраняет msg.Width и msg.Height, а View собирает экран из этих чисел — сайдбару фиксированная ширина, таблице всё, что осталось, статус-бару ровно msg.Width. Пересчитывать ширины колонок и размеры внутренних компонентов логично там же, в ветке сообщения: у таблицы и textarea для этого есть свои SetWidth и SetHeight. Здесь проходит граница с полноэкранным режимом: пока рисуетесь построчно, высота окна вас почти не волнует, а в alt-screen весь экран ваш, и высота становится такой же важной, как ширина. Эту пару — alt-screen и полная геометрия окна — разбирает [урок про alt-screen](/courses/bubbletea/lessons/18-altscreen-okno/), здесь берём только правило: раскладка — функция размеров из модели, а не догадка в View.

### Пересчёт при resize

```go
case 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'ом. Если же вписать пробелы в сам текст блока, при смене фона или рамки пустота покрасится вместе с содержимым, и на светлом терминале всплывёт полоса чужого цвета. Содержимое — содержимым, геометрия — геометрией.

### Соберите статус-бар

Восстановите измерения ширин и склейку трёх блоков: левый, пружина, правый.

```go
func (m boardModel) statusBar(w int) string {
        left := m.titleStyle.Render(" Панель задач ")
        right := m.hintStyle.Render(" q — выход ")
        gap := w - lipgloss.___(left) - lipgloss.___(right)
        if gap < 0 {
                gap = 0
        }
        return lipgloss.___(lipgloss.Top, left,
                strings.Repeat(" ", gap), right)
}
```

Подсказка: Измерить раскрашенный текст помогает lipgloss.Width, а склеивает бок о бок JoinHorizontal.

Правильные ответы в пропусках: `Width`, `Width`, `JoinHorizontal`

### Соберите View трёхпанельного экрана

Расставьте строки: функция, шапка, тело, низ, склейка, возврат.

Правильный порядок строк:

1. `func (m boardModel) View() string {`
2. `  header := m.headerStyle.Width(m.width).Render("Задачи")`
3. `  body := lipgloss.JoinHorizontal(lipgloss.Top, m.sidebar(), m.table())`
4. `  footer := m.statusBar(m.width)`
5. `  return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)`
6. `}`

### Карточки: словарь раскладки

- **JoinHorizontal / JoinVertical** — Склейка бок о бок и стопкой. Позиция: Top/Center/Bottom и Left/Center/Right, под капотом — дробь 0..1. Короткие блоки дополняются пустыми строками и пробелами до прямоугольника.
- **Align** — Выравнивание текста внутри блока с Width; два аргумента — горизонталь и вертикаль. Вертикальный собрат — AlignVertical под Height.
- **Place** — Бокс заданных размеров с контентом в указанной позиции. Не обрезает широкий контент; пустоту красит WhitespaceOption.
- **lipgloss.Width / Height** — Видимая ширина и высота уже отрендеренного текста — без ANSI-кодов, с учётом широких символов. Основа вычитаний для зазоров.
- **WindowSizeMsg** — Сообщение с Width и Height терминала: приходит при старте и на каждый resize. Хранится в модели, раскладка пересчитывается в Update.

### Что дальше

Экран собран — следующий шаг заставить его занять весь терминал по-настоящему: [урок про alt-screen и размеры окна](/courses/bubbletea/lessons/18-altscreen-okno/) доведёт WindowSizeMsg до конца и покажет, чем полноэкранный режим отличается от построчного вывода. Панели, собранные здесь, хочется и щёлкать: клики по координатам приносит [урок про мышь](/courses/bubbletea/lessons/17-mysh/). Когда стилей и раскладки становится много, им пора в отдельный файл — этому учит [урок про структуру крупного проекта](/courses/bubbletea/lessons/23-struktura-proekta/), где styles.go становится обязательным жильцом. Полная программа курса — на странице [курса Bubble Tea](/courses/bubbletea/). Практика: соберите экран из шапки, двух панелей и статус-бара, затем растяните и сожмите окно терминала — если панели ведут себя как приклеенные, а зазор статус-бара тянется ровно, адаптив у вас получился.

## Коротко: что запомнить

- Блок с 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 — не перестраховка, а единственный способ, которым раскладка вообще узнаёт об окне.
