---
title: "Alt-screen и размеры окна"
description: "bubbletea altscreen: второй буфер через tea.WithAltScreen, WindowSizeMsg с шириной и высотой, пересчёт раскладки и viewport при resize терминала."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/18-altscreen-okno/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 18
---

# Alt-screen и размеры окна

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

Полноэкранный терминальный интерфейс начинается с двух вещей: bubbletea altscreen переключает программу во второй буфер терминала, а WindowSizeMsg приносит ширину и высоту окна. Включим режим tea.WithAltScreen, научимся хранить размеры в модели и пересчитывать раскладку при resize — так, чтобы панели не ломались при растягивании терминала.

Запустите htop, выйдите — и терминал выглядит так, будто его не трогали: вся история команд на месте. Это alt-screen, второй буфер: программы вроде htop, vim и less рисуют в нём, а при выходе терминал возвращает прежнее содержимое. Bubble Tea по умолчанию работает иначе — рисует инлайн, дозаписывая кадры вниз обычного вывода. Для однострочного прогресса в CI-пайплайне это идеальное поведение: история остаётся в терминале, вывод можно скопировать. Но как только интерфейс становится панелью на весь экран, инлайн превращается в проблему: пользователь прокручивает историю терминала — и картинка уезжает вверх, кадры наслаиваются друг на друга, а под панелью остаётся мусор от прошлых отрисовок, который вы не контролируете. Лечится одной опцией при создании программы. Плюс второй буфер прощает сбои: даже если Run завершился с ошибкой, состояние терминала восстанавливается — в инлайне же после падения остаётся полуживой кадр, который вы уже не уберёте. Вторая половина урока — размеры: полноэкранная раскладка обязана знать ширину и высоту терминала, и фреймворк приносит их сообщением WindowSizeMsg при старте и на каждом resize.

### tea.WithAltScreen: второй буфер терминала

Опция tea.WithAltScreen() передаётся в tea.NewProgram, как мышь или скорость кадров: терминал переключается во второй буфер, и весь экран становится вашим холстом. При завершении программы терминал сам вернёт первый буфер — история команд, вывод прошлых запусков, приглашение оболочки, всё останется нетронутым. Механизм старше Bubble Tea на десятилетия: vim и less жили в нём всегда, и пользователь терминала ожидает именно такое поведение от полноэкранных программ. Переключаться можно и на ходу: команды tea.EnterAltScreen и tea.ExitAltScreen возвращаются из Update как обычные tea.Cmd — так делают виджеты, которые по клавише f разворачиваются на весь экран и сворачиваются обратно. Есть нюанс: во втором буфере нет истории прокрутки, потому что там и нечего прокручивать — вы перерисовываете весь кадр каждый раз. Отсюда правило: в alt-screen живут панели и дашборды, чей кадр помещается на экран целиком или прокручивается внутренним viewport, а всё, что должно остаться в терминальной истории — логи, результаты, вывод команд, — оставляйте инлайну. Смешивать можно: базовый вывод — инлайном, а по клавише f — разворот во второй буфер через tea.EnterAltScreen, рабочий компромисс для утилит с двумя режимами.

### Запуск в alt-screen

```go
func main() {
        p := tea.NewProgram(dashboardModel{},
                tea.WithAltScreen())
        if _, err := p.Run(); err != nil {
                fmt.Println("Ошибка:", err)
                os.Exit(1)
        }
}
```

Одна опция — и кадр рисуется во втором буфере на весь экран, а при выходе терминал восстанавливается без следов программы. Никаких изменений в модели и Update это не требует: View по-прежнему возвращает строку, рендерер сам управляет буферами.

### WindowSizeMsg: ширина и высота от фреймворка

Сообщение tea.WindowSizeMsg — единственный честный способ узнать размеры терминала. У него два поля: Width и Height, оба в ячейках, оба — размер всего окна. Приходит оно дважды по смыслу: один раз при старте, до первых нажатий, и на каждое изменение размера окна — пользователь потянул край, развернул на полный экран, переехал в tmux-сплит. Важно, что измеряет фреймворк само: вы не вызываете функции из golang.org/x/term и не ловите SIGWINCH руками — цикл Bubble Tea делает это за вас и кладёт результат в очередь сообщений. Игнорировать сообщение можно, но недолго: раскладка с жёсткими константами будет жить в мёртвом прямоугольнике посреди изменившегося окна. Первый WindowSizeMsg — самое первое событие вашей программы, оно обрабатывается раньше любого нажатия клавиш. Храните размеры в модели как поля width и height и держите в голове: View не умеет спрашивать терминал, ему размеры обязана принести модель из Update. Раскладка — функция этих чисел, а не догадка на этапе компиляции. Полезная привычка на время разработки — печатать текущие размеры в отладочной строке футера: сразу видно, доходит ли сообщение и какие числа приходят в tmux-сплите и при сворачивании окна.

### Проверка: когда приходит WindowSizeMsg

**Вопрос:** Когда программа Bubble Tea получает tea.WindowSizeMsg?

- Только один раз при запуске
- Только при изменении размера окна, а на старте размеры неизвестны
- ✅ При запуске и на каждое изменение размера терминала
- Никогда — размеры нужно запрашивать функцией самому

Фреймворк измеряет терминал при старте и присылает WindowSizeMsg первым сообщением, а затем повторяет на каждый resize. Считать, что сообщение приходит один раз, — классический баг: интерфейс рисуется правильно, но после растягивания окна остаётся узким.

### Перерасчёт раскладки в Update

Ветка WindowSizeMsg — место, где живёт вся адаптивность. Схема механическая: сохранить msg.Width и msg.Height в модель, пересчитать размеры компонентов их сеттерами, вернуть модель. Компоненты bubbles перенастраиваются методами SetWidth и SetHeight — таблица из тринадцатого урока, textarea, viewport. Вычитаемые числа — резервы вашей раскладки: сколько колонок съедает сайдбар, сколько строк уходит заголовку и статус-бару. Держите их в именованных константах, а не в магических числах посреди Update: раскладка меняется часто, и разыскивать «а что тут за минус восемнадцать» вы будете у себя же. Если в модели есть производные от размеров поля — ширины колонок, лимиты переноса — пересчитывайте их в той же ветке: рассинхрон между полями модели и компонентами ловится хуже всего. Связь с [предыдущим уроком](/courses/bubbletea/lessons/16-lipgloss-layout/) прямая: там собирались фиксированные панели, здесь их размеры становятся вычисляемыми. Отдельно помните про первый запуск: пока WindowSizeMsg не обработан, размеры в модели нулевые, и View может отрисовать пустоту — либо обрабатывайте сообщение до первого кадра, либо задайте полям разумные дефолты в конструкторе.

### Ветка WindowSizeMsg: полный пересчёт

```go
const (
        sidebarWidth = 18
        chromeHeight = 3 // заголовок и статус-бар
)

type dashboardModel struct {
        width, height int
        viewport      viewport.Model
        table         table.Model
}

func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tea.WindowSizeMsg:
                m.width = msg.Width
                m.height = msg.Height
                m.viewport.SetWidth(msg.Width - sidebarWidth)
                m.viewport.SetHeight(msg.Height - chromeHeight)
                m.table.SetWidth(msg.Width - sidebarWidth)
                m.table.SetHeight(msg.Height - chromeHeight)
                return m, nil
        }
        return m, nil
}
```

Сайдбар занимает фиксированные восемнадцать колонок, три строки уходят шапке и статус-бару — остальное отдаётся компонентам. Константы держат резервы раскладки в одном месте. Сеттеры вызываются в той же ветке, где сохраняются размеры: компоненты и модель всегда согласованы.

### Первые миллисекунды программы

```go
type model struct {
        width int
}

func (m model) View() string {
        return lipgloss.NewStyle().
                Width(m.width).
                Render("Панель")
}

// Ветка tea.WindowSizeMsg ещё не сработала.
// Что покажет терминал?
```

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

- «Панель» на всю ширину терминала — ширина подставится автоматически
- Панику: ширина не задана
- Программа не стартует без обработчика WindowSizeMsg
- ✅ Кадр нулевой ширины — Width(0) добивает строку пробелами в никуда

Пока WindowSizeMsg не дошёл и не обработан, поле width равно нулю, и стиль рисует строку нулевой ширины — видимой панели нет. Первое сообщение приходит до нажатий, но гонку с первым кадром лучше не закладывать: задавайте дефолтные размеры в конструкторе модели.

### Viewport под высоту окна

В инлайн-режиме высоту терминала вы почти не чувствуете: строки дозаписываются и прокручиваются средствами самого терминала. В alt-screen высота становится равноценной ширине: экран высотой в тридцать строк — это тридцать строк под весь интерфейс, включая шапку и статус-бар. Формула простая: viewport.SetHeight(msg.Height) минус высота всех соседей по кадру. Высоту шапки и футера не обязательно считать руками — lipgloss.Height измеряет уже отрендеренную строку, и вызов его в ветке WindowSizeMsg экономит от расползания констант: поменяли шапку — пересчёт сам подхватился. Тело панели живёт в viewport и прокручивается клавишами и колесом мыши из [урока про мышь](/courses/bubbletea/lessons/17-mysh/). Держите бюджет строк в голове при каждой правке кадра: добавили разделитель — высота тела уменьшилась на единицу, забрали — появилась пустая строка. Если содержимого меньше высоты, viewport добьёт остаток пустотой — это нормально и лучше, чем прыгающий футер. Проверять высотную раскладку нужно вживую: только локальный запуск в настоящем терминале покажет, что на высоте в двадцать строк панель не срезала статус-бар — браузерные эмуляции resize терминала не передают.

### Высота из измерения шапки

```go
case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        header := lipgloss.Height(m.headerView())
        footer := lipgloss.Height(m.footerView())
        m.viewport.SetWidth(msg.Width)
        m.viewport.SetHeight(msg.Height - header - footer)
        return m, nil
```

lipgloss.Height считает строки в отрендеренной шапке и футере — резервы больше не магические числа, а результат измерения. Viewport получает всю ширину и остаток высоты. Если шапка станет двухстрочной, формула пересчитает высоту тела без правки констант.

### Что происходит при resize

Пользователь тянет край окна — терминал посылает процессу сигнал SIGWINCH, «окно изменилось». Цикл Bubble Tea ловит его, заново измеряет терминал и кладёт в очередь WindowSizeMsg с новыми числами; дальше работает обычный механизм: Update, пересчёт, перерисовка. Что стоит сделать в этой ветке кроме пересчёта размеров. Первое — поджать позицию прокрутки: если viewport показывал конец лога, после уменьшения высоты окно могло оказаться больше содержимого или наоборот — часть программ переходит к концу списка принудительно, вычитая из высоты контента новую высоту окна. Второе — деградация раскладки: на ширину меньше минимальной сайдбар лучше убрать вовсе, а не сжимать панели до нечитаемости; порог — тоже константа рядом с резервами, и решение об убранной панели — ветка if в той же обработке. Третье — не доверять кратковременным размерам: перетаскивание края порождает целую серию сообщений, и тяжёлые вычисления в ветке resize лучше делать по последнему значению, а не на каждое промежуточное. И проверка после правок: несколько растяжений-сжатий подряд не должны оставлять после себя мусор — кадр рисуется с нуля каждый раз.

### Inline против alt-screen: что выбрать

Выбор режима — это ответ на вопрос, что должно остаться в терминале после выхода. Инлайн оставляет след: последний кадр дозаписан в историю, вывод можно листать, копировать и встречать в CI-логах — идеально для прогресса, однолинейных виджетов и утилит, которые вызывают в пайплайнах. Alt-screen следа не оставляет: после выхода терминал выглядит нетронутым — это режим панелей, дашбордов и редакторов, интерфейс-приложений, а не интерфейс-вывода. Отдельная опция tea.WithOutput расширяет выбор: программа рисует в переданный io.Writer, а не в stdout — так тестируют рендер в буфер строк и встраивают интерфейсы в нестандартные окружения вроде SSH-сессий, где терминал находится на стороне клиента. Опции комбинируются: WithAltScreen с WithMouseCellMotion даёт полноэкранную кликабельную панель, а WithOutput чаще нужен для тестов — о них ближе к [уроку про тестирование TUI](/courses/bubbletea/lessons/22-testirovanie-tui/). Решение не навсегда: маленькая утилита может начать с инлайна и переехать в alt-screen по мере роста, смена — одна строка в main. Переехать обратно тоже можно, но пользователи будут удивлены, если утилита вдруг начнёт съедать экран целиком — режим вывода — часть контракта с тем, кто вызывает программу из скриптов. Сводка различий — в таблице ниже.

### Два режима вывода

|  | Inline (по умолчанию) | WithAltScreen |
| --- | --- | --- |
| Кадр | Дозапись вниз обычного вывода | Весь экран во втором буфере |
| После выхода | Последний кадр остаётся в истории | Терминал восстанавливается полностью |
| Прокрутка | Средствами терминала, по истории | Внутри приложения: viewport |
| Когда выбирать | Прогресс, однолинейные виджеты, CI | Панели, дашборды, формы на весь экран |

### Кадр по частям: шапка, тело, футер

Полноэкранную раскладку удобно собрать из трёх приватных методов: headerView, bodyView и footerView, каждый возвращает строку, а View склеивает их JoinVertical из прошлого урока. Такой разрез даёт две выгоды. Первая — измерение: lipgloss.Height от шапки и футера в ветке WindowSizeMsg считается именно по этим методам, и формула высоты тела всегда согласована с реальным рисунком. Вторая — изоляция: шапку с титулом и часами правите вы отдельно, тело с viewport — отдельно, и правка одного куска не задевает другой. Футер — самое практичное место: справа подсказки клавиш, слева состояние — эта строка тянется на всю ширину через вычитание ширин. Проверенный порядок сборки: сначала методы возвращают содержимое без ширины, затем View обёртывает каждый в стиль с Width из модели — так один и тот же headerView работает и в полноэкранной панели, и в узком инлайн-виджете. Порядок строк в JoinVertical задаёт и порядок чтения: шапка, тело, футер — на нём же строится представление пользователя о том, что где искать. Если методы начинают тянуть в себя десяток полей модели — это сигнал, что экрану пора в отдельный файл, но об этом дальше по курсу.

> **Один источник размеров.** Не измеряйте терминал сами через golang.org/x/term в View или Update: фреймворк уже сделал это и прислал WindowSizeMsg. Второй источник правды расходится с первым после resize и в tmux — и вы отлаживаете рассинхрон, которого могло не быть. Размеры живут в модели и приходят только сообщением.

### Проверка: окно растянули

**Вопрос:** Пользователь развернул терминал на весь экран, а панели остались прежними узкими. Что забыли сделать?

- Вызвать p.Resize() у программы в main
- ✅ Обработать WindowSizeMsg и пересчитать размеры компонентов их сеттерами
- Перезапустить программу — раскладка фиксируется на старте
- Добавить tea.WithAutoResize в tea.NewProgram

Автопересчёта в фреймворке нет: на каждый resize приходит WindowSizeMsg, а пересчёт — ваша работа в Update. Сохранить размеры в модель и вызвать SetWidth и SetHeight у компонентов — стандартная ветка, без неё раскладка замирает в размерах старта.

### Соберите запуск и ветку resize

Восстановите включение alt-screen и обработку размеров: опция, тип сообщения, откуда берём ширину и куда кладём высоту.

```go
func main() {
        p := tea.NewProgram(m, tea.___())
        // ...
}

// В Update:
case tea.___:
        m.width = ___.Width
        m.viewport.SetHeight(msg.___ - chromeHeight)
```

Подсказка: Опция называется WithAltScreen, сообщение — WindowSizeMsg, а поля сообщения читаются через msg.Width и msg.Height.

Правильные ответы в пропусках: `WithAltScreen`, `WindowSizeMsg`, `msg`, `Height`

### Соберите ветку WindowSizeMsg

Расставьте строки: сообщение, размеры в модель, ширина и высота компонента, возврат.

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

1. `case tea.WindowSizeMsg:`
2. `  m.width = msg.Width`
3. `  m.height = msg.Height`
4. `  m.viewport.SetWidth(msg.Width - sidebarWidth)`
5. `  m.viewport.SetHeight(msg.Height - chromeHeight)`
6. `  return m, nil`

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

- **Alt-screen** — Второй буфер терминала: рисуетесь на весь экран, после выхода терминал восстанавливается. Включается опцией tea.WithAltScreen или командами tea.EnterAltScreen и tea.ExitAltScreen на ходу.
- **WindowSizeMsg** — Поля Width и Height в ячейках. Первое сообщение при старте и повтор на каждый resize. Хранится в модели, раскладка пересчитывается в Update.
- **Inline-режим** — Поведение по умолчанию: кадры дозаписываются в обычный вывод. След остаётся в истории терминала — хорошо для прогресса и CI.
- **SetWidth / SetHeight** — Сеттеры компонентов bubbles: table, textarea, viewport. Вызываются в ветке WindowSizeMsg рядом с сохранением размеров.
- **WithOutput** — Опция программы: рендер в свой io.Writer вместо stdout. Нужна для тестов вывода в буфер и нестандартных окружений вроде SSH.

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

Полноэкранная геометрия — фундамент для остального. Клики по зонам, которые должны следовать за resize, разбирает [урок про мышь](/courses/bubbletea/lessons/17-mysh/) — вместе эти два урока дают живую панель. Часы, тикающие в углу полноэкранного дашборда, — дело [урока про таймеры и tea.Tick](/courses/bubbletea/lessons/21-tajmery/). Когда панелей и экранов станет несколько, раскладка с константами переберётся в отдельные файлы — этому учит [урок про структуру крупного проекта](/courses/bubbletea/lessons/23-struktura-proekta/). Обзор остальных тем — на странице [курса Bubble Tea](/courses/bubbletea/). Практика: запустите панель в alt-screen, растяните и сожмите окно от сорока до ста двадцати колонок и проверьте, что статус-бар тянется, а viewport не съедает футер.

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

- WithAltScreen рисует во втором буфере: весь экран ваш, после выхода терминал восстанавливается.
- Inline оставляет последний кадр в истории — выбирается для прогресса и CI, alt-screen — для панелей.
- WindowSizeMsg с Width и Height приходит при старте и на каждый resize; размеры живут в модели.
- Ветка WindowSizeMsg пересчитывает компоненты сеттерами SetWidth и SetHeight из констант раскладки.
- Высоту резерва удобно измерять lipgloss.Height отрендеренной шапки и футера, а не хардкодить.
- Resize — это SIGWINCH, превращённый фреймворком в сообщение; деградация раскладки на минималках — ваша забота.

## Домашнее задание

Соберите панель: шапка, viewport с телом и статус-бар во всю ширину, запущенная через tea.WithAltScreen. Протяните WindowSizeMsg в модель, вычислите высоту viewport через lipgloss.Height шапки и футера и проверьте на resize от двадцати строк до полного экрана. Добавьте минимальную ширину, ниже которой сайдбар скрывается, — и убедитесь, что при выходе терминал выглядит нетронутым.

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

### View рисует пустоту в первые мгновения программы. Это норм?

Первый кадр может уйти до обработки WindowSizeMsg, когда размеры в модели ещё нулевые. Задайте конструктору модели разумные дефолты или не рисуйте панели, пока width равен нулю. Первое сообщение приходит до нажатий, так что задержка незаметна, если нет паники на нуле. Разные терминалы стартуют с разными размерами, поэтому дефолт вроде восьмидесяти на двадцать четыре — лишь временное решение на долю секунды.

### В tmux панели ломаются при resize. Кто виноват?

tmux сам эмулирует терминал и присылает размеры своей панели, обычно это работает штатно. Проверьте, что обработка WindowSizeMsg пересчитывает все компоненты, а не только сохраняет числа, и что минимальная ширина деградирует раскладку, а не ломает её. Если сплит меняют во время работы, сообщение придёт так же, как от обычного resize, — отдельных сценариев для tmux не нужно.

### Можно ли включить alt-screen после запуска программы?

Да: верните из Update команду tea.EnterAltScreen — терминал переключится на ходу, а tea.ExitAltScreen вернёт инлайн. Так делают виджет с клавишей разворота. Учтите, что при переходе старое содержимое экрана исчезнет — перерисуйте кадр целиком. А после ExitAltScreen терминал вернёт основной буфер с прежней историей — именно туда и продолжится вывод.
