Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 18 из 25 ~18 мин +50 XP

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

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

Содержание урока · 12 разделов

Запустите 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?

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

Ветка WindowSizeMsg — место, где живёт вся адаптивность. Схема механическая: сохранить msg.Width и msg.Height в модель, пересчитать размеры компонентов их сеттерами, вернуть модель. Компоненты bubbles перенастраиваются методами SetWidth и SetHeight — таблица из тринадцатого урока, textarea, viewport. Вычитаемые числа — резервы вашей раскладки: сколько колонок съедает сайдбар, сколько строк уходит заголовку и статус-бару. Держите их в именованных константах, а не в магических числах посреди Update: раскладка меняется часто, и разыскивать «а что тут за минус восемнадцать» вы будете у себя же. Если в модели есть производные от размеров поля — ширины колонок, лимиты переноса — пересчитывайте их в той же ветке: рассинхрон между полями модели и компонентами ловится хуже всего. Связь с предыдущим уроком прямая: там собирались фиксированные панели, здесь их размеры становятся вычисляемыми. Отдельно помните про первый запуск: пока 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
}

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

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

Прочитайте код и выберите, что он выведет:

type model struct {
        width int
}

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

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

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

В инлайн-режиме высоту терминала вы почти не чувствуете: строки дозаписываются и прокручиваются средствами самого терминала. В alt-screen высота становится равноценной ширине: экран высотой в тридцать строк — это тридцать строк под весь интерфейс, включая шапку и статус-бар. Формула простая: viewport.SetHeight(msg.Height) минус высота всех соседей по кадру. Высоту шапки и футера не обязательно считать руками — lipgloss.Height измеряет уже отрендеренную строку, и вызов его в ветке WindowSizeMsg экономит от расползания констант: поменяли шапку — пересчёт сам подхватился. Тело панели живёт в viewport и прокручивается клавишами и колесом мыши из урока про мышь. Держите бюджет строк в голове при каждой правке кадра: добавили разделитель — высота тела уменьшилась на единицу, забрали — появилась пустая строка. Если содержимого меньше высоты, 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. Решение не навсегда: маленькая утилита может начать с инлайна и переехать в alt-screen по мере роста, смена — одна строка в main. Переехать обратно тоже можно, но пользователи будут удивлены, если утилита вдруг начнёт съедать экран целиком — режим вывода — часть контракта с тем, кто вызывает программу из скриптов. Сводка различий — в таблице ниже.

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

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

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

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

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

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

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

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

func main() { p := tea.NewProgram(m, tea.()) // ... } // В Update: case tea.: m.width = .Width m.viewport.SetHeight(msg. - chromeHeight)

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Alt-screen

нажмите, чтобы перевернуть
Определение

Второй буфер терминала: рисуетесь на весь экран, после выхода терминал восстанавливается. Включается опцией tea.WithAltScreen или командами tea.EnterAltScreen и tea.ExitAltScreen на ходу.

1 / 5

Что дальше#

Полноэкранная геометрия — фундамент для остального. Клики по зонам, которые должны следовать за resize, разбирает урок про мышь — вместе эти два урока дают живую панель. Часы, тикающие в углу полноэкранного дашборда, — дело урока про таймеры и tea.Tick. Когда панелей и экранов станет несколько, раскладка с константами переберётся в отдельные файлы — этому учит урок про структуру крупного проекта. Обзор остальных тем — на странице курса Bubble Tea. Практика: запустите панель в alt-screen, растяните и сожмите окно от сорока до ста двадцати колонок и проверьте, что статус-бар тянется, а viewport не съедает футер.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
alt-screen · окно 80×24
────────────────────
▸ заголовок
список
статус-бар
тик — ресайз окна, ↑/↓ — курсор, q — выход

Клавиатура — обработано в Update

Журнал программы

модель собрана: width int = 80 · height int = 24 · items []string (3) · cursor int = 0

вне эмуляции: клавиша tea.WindowSizeMsg

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

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

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

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

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Alt-screen и размеры окна)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Zap25 уроков~9 чпрактика25 уроков уже доступны

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler