После списка из девятого урока у панели задач осталась одна неувязка: элемент — это одна строка с заголовком и описанием, а любая табличная сущность живёт колонками. Очередь сборок: имя, ветка, статус, длительность. Пачка бэкапов: файл, размер, время, результат. Писать выравнивание через Sprintf можно ровно до первого длинного значения, ломающего всю сетку. Пакет bubbles/table решает задачу целиком: он рисует заголовок, держит курсор на строке, прокручивает данные внутри фиксированной высоты, подсвечивает выбранную строку и умеет обновлять содержимое на лету. По устройству это обёртка над знакомым по одиннадцатому уроку viewport: сверху строка заголовков, внутри — прокручиваемый блок строк. Если вы собирали подмодели по восьмому уроку, ничего нового в схеме встраивания не будет — новыми здесь будут только сами колонки и правила их ширины.
table.Model: колонки и строки#
Таблица описывается двумя срезами. Колонка — тип table.Column с полями Title (заголовок) и Width (ширина колонки в символах). Строка — тип table.Row, под которым прячется просто []string: значения по порядку колонок. Ключевое правило: строки рисуются по числу колонок, и значение, не влезающее в свою Width, честно обрезается с многоточием — сетка не поедет никогда. Конструктор table.New принимает опции: WithColumns задаёт колонки, WithRows — данные, WithFocused — фокус при старте, WithHeight — высоту таблицы, WithWidth — ширину. Курсор — это не координаты на экране, а индекс выбранной строки: метод Cursor возвращает его, SetCursor ставит с зажимом в допустимые границы. Отсюда простая привычка: думать о таблице как о данных с индексом, а не как о разметке.
Модель с таблицей задач
GOtype taskModel struct {
table table.Model
}
func initialTaskModel() taskModel {
cols := []table.Column{
{Title: "Задача", Width: 24},
{Title: "Ветка", Width: 12},
{Title: "Статус", Width: 10},
}
rows := []table.Row{
{"почистить кэш", "main", "готово"},
{"переиндексация", "dev", "идёт"},
}
t := table.New(
table.WithColumns(cols),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
return taskModel{table: t}
}
func (m taskModel) View() string {
return m.table.View()
}Пакет импортируется как github.com/charmbracelet/bubbles/table. Колонок три, каждая со своей шириной в символах. WithFocused(true) включает реакцию на клавиши сразу, без него таблица отрисуется, но на движение курсора не ответит. Высота семь включает строку заголовка, значит под данные остаётся шесть строк — об этом правиле дальше.
Высота, ширина и прокрутка#
Первое, что ловит всех новичков: WithHeight задаёт высоту таблицы целиком, вместе со строкой заголовка. Попросили семь — увидите шесть строк данных. Логика внутри честная: из высоты вычитается высота headersView, остаток отдаётся встроенному viewport. Если строк больше, чем влезает, прокрутка появляется сама, и курсор, дойдя до края, увлекает её за собой. Ширина работает иначе: SetWidth задаёт ширину окна, в котором живёт таблица, но колонки от этого не растягиваются — их ширина та, что вы указали в Column. Что не влезло в окно, обрезается на краю терминала, поэтому сумма Width колонок должна вписываться в ваш экран. Клавиши движения предустановлены и совпадают со списком из девятого урока: стрелки и j/k по строке, страницы и половины страницы, прыжки в начало и конец. Полный набор — в таблице ниже, а помощь можно рисовать через HelpView — метод существует, но в View он не выводится сам.
table.New стартует с нулевой шириной: без WithWidth или SetWidth внутренний вьюпорт живёт с шириной ноль и перестаёт резать строки по горизонтали — длинные значения уходят за край терминала вместо обрезки. Вторая половина ловушки: собственной ветки WindowSizeMsg у таблицы нет — она никогда не пересчитывает размеры сама. Окно растянули — таблица осталась узкой; сжали — строки обрезал край экрана. Раскладке, зависящей от размеров терминала, приходится ловить WindowSizeMsg в корневом Update и переставлять SetWidth и SetHeight руками — так делает любой полноэкранный интерфейс из восемнадцатого урока.
Клавиши table по умолчанию#
| Клавиши | Действие |
|---|---|
| up / k | строка вверх |
| down / j | строка вниз |
| b / pgup | страница вверх |
| f / pgdn / space | страница вниз |
| u / ctrl+u | полстраницы вверх |
| d / ctrl+d | полстраницы вниз |
| home / g | к первой строке |
| end / G | к последней строке |
Фокус и пересылка сообщений#
Таблица — подмодель, и всё движение клавиш живёт в одной ветке Update. Нюанс, на котором спотыкаются: без фокуса Update у таблицы игнорирует любые клавиши молча, ни ошибки, ни движения. Поэтому в примерах вокруг таблицы всегда есть ветка esc: она снимает и возвращает фокус через Blur и Focus, оставляя таблицу на экране, но отдавая клавиши остальному приложению. Второй нюанс — тип возвращаемого значения. Метод Update у table.Model возвращает пару (table.Model, tea.Cmd), а не (tea.Model, tea.Cmd), как у progress в двенадцатом уроке: там результат приходилось доставать утверждением типа, здесь присваивание прямое. Это мелочь, но именно на таких мелочах держится ощущение однородности восьмого урока: каждая библиотечная подмодель чуть-чуть своя, и шаблон пересылки копируется не вслепую, а глазами.
Выбор строки и пересылка клавиш
GOfunc (m taskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.table.Focused() {
m.table.Blur()
} else {
m.table.Focus()
}
case "enter":
return m, tea.Printf("Выбрано: %s",
m.table.SelectedRow()[0])
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}SelectedRow возвращает строку под курсором как table.Row — обычный срез строк, отсчёт с нуля по колонкам. tea.Printf из стандартного набора команд печатает строку над интерфейсом. Все прочие клавиши падают в пересылку внизу: таблица сама разберёт j/k и страницы. Пустой var cmd перед пересылкой нужен, чтобы ветки свитча не конкурировали за область видимости.
Проверка: фокус
Таблица создана без WithFocused, пользователь жмёт j. Что произойдёт?
Проверка: высота
Сколько строк данных покажет таблица после WithHeight(7)?
Что вернёт Cursor()?
Прочитайте код и выберите, что он выведет:
rows := []table.Row{
{"alpha"}, {"beta"}, {"gamma"}, {"delta"},
}
t := table.New(table.WithRows(rows))
t.SetCursor(100)
fmt.Println(t.Cursor())
fmt.Println(t.SelectedRow() == nil)Обновление строк на живых данных#
Статичная таблица — редкость: обычно данные приезжают из команды — список задач из сети, результаты сканирования из воркера. Схема та же, что в двенадцатом уроке: команда возвращает сообщение, Update раскладывает его по модели. Для строк у таблицы есть пара SetRows и Rows: первый кладёт новый срез и сам обновляет внутренний viewport, второй отдаёт текущие данные, если их нужно доработать. Есть и мост из простого мира текстовых файлов — метод FromValues разбирает строку вида «a,b c,d» с указанным разделителем и превращает её в строки таблицы, удобно для CSV-выгрузок без внешних библиотек. И живая граблина: в текущей версии bubbles после SetRows курсор не поджимается под новый размер. Строк было сто, стало три, курсор остался на девяносто девятой — SelectedRow вернёт nil, и первый же выбор по enter уронит обращение по индексу. Поджимайте вручную: SetCursor(len(rows) - 1) сразу после SetRows, если строк стало меньше, чем был курсор.
Приход данных из команды
GOtype rowsMsg []table.Row
func (m taskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case rowsMsg:
m.table.SetRows(msg)
if m.table.Cursor() > len(msg)-1 {
m.table.SetCursor(len(msg) - 1)
}
return m, nil
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}rowsMsg несёт готовый срез table.Row — его выгрузили из сети или собрали из файлов в команде пятого урока. SetRows обновляет отрисовку, проверка курсора страхует от выхода за границы после сжатия данных. Обратите внимание: msg приводится к срезу напрямую, поэтому передаётся в SetRows без преобразований. Прогоните ветку мысленно на пустом срезе: SetRows очистит таблицу, а len(msg)-1 станет минус единицей — SetCursor(-1) поставит курсор перед первой строкой, и SelectedRow вернёт nil при первом же enter. Пустую выгрузку лучше перехватить отдельной веткой и показать «ничего не найдено», чем кормить интерфейс мёртвым курсором.
В отличие от списка, таблица не умеет скроллить по горизонтали: колонки, не влезающие в ширину терминала, обрезаются краем экрана без спасательного курсора. Пять колонок по тридцать символов на узком окне не оживут никакими настройками. Решения два: сокращать Width и сокращать число колонок, а периферийные данные переносить в панель деталей рядом.
Стили: заголовок, ячейки, выделение#
Внешний вид таблицы собран в структуре table.Styles из трёх стилей Lip Gloss — она появится в пятнадцатом уроке во всей красе, а здесь достаточно имен. Header красит строку заголовков: по умолчанию жирный текст с отступом по одному пробелу. Cell — базовый вид ячейки, те же отступы. Selected — наложение на всю выбранную строку целиком: жирный текст цветом 212 по палитре ANSI. Меняется всё через SetStyles: возьмите DefaultStyles как базу, подправьте нужное поле и отдайте обратно — таблица сразу перерисует содержимое. Именно так официальный пример снимает жирность с заголовка и красит выделение фиолетовым фоном. Здесь же есть подвох: Selected работает поверх строки, поэтому его фон перекрывает фон ячеек — цветные статусы в колонках лучше выбирать так, чтобы они читались и под выделением.
Таблица — библиотечный компонент, в песочнице сайта его не собрать: нужен локальный Go и настоящий терминал. Запустите пример из урока, нажмите esc и верните фокус, дожмите G до конца списка и выберите строку по enter. Затем урежьте высоту до пяти и посмотрите, как шесть строк данных превращаются в четыре.
Соберите инициализацию таблицы
Восстановите конструктор: создание модели, опции колонок и строк.
Соберите сборку таблицы по шагам
Расставьте строки в порядке: данные, конструктор, размеры, возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь table
Что дальше#
Таблица не живёт одна. Выбранная строка просит подробного описания — заметки к задаче вводятся многострочным полем, и этим занимается урок про textarea: таблица и редактор вместе дают полноценную панель задач. Цвета заголовка и выделения здесь названы вскользь — урок про Lip Gloss разберёт стили терминала по-настоящему, от ANSI-кодов до рамок. В итоговом проекте таблица займёт центр экрана и будет кормиться данными из файлового воркера. Если вы знакомы с десктопным GUI по курсу Fyne, взгляните для сравнения на таблицы там — задача та же, а слов на настройку уходит заметно больше. Практика: соберите таблицу из урока, урежьте высоту до пяти, добавьте четвёртую колонку длительности и убедитесь, что при ширине терминала в восемьдесят символов она уже не влезает — почувствуйте, почему горизонтальной прокрутки просит каждый второй.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: names []string (3) · branches []string (3) · statuses []string (3) · cursor int = 0 · selected string
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- table.Model — колонки из table.Column, строки из table.Row, конструктор table.New с опциями.
- Высота включает строку заголовка: WithHeight(7) оставляет шесть строк данных.
- Без фокуса таблица молча игнорирует клавиши; Update пересылается прямым присваиванием без assertion.
- SetRows обновляет строки и viewport; после сжатия данных курсор поджимают вручную.
- Стили собраны в table.Styles: Header, Cell, Selected — меняются через SetStyles.
- Горизонтальной прокрутки нет: не влезло в ширину терминала — обрезалось краем экрана.
Домашнее задание
Соберите таблицу задач из урока: три колонки — задача, статус, длительность — и не меньше восьми строк, чтобы заработала прокрутка. Повесьте enter на печать выбранной строки через tea.Printf и esc на снятие фокуса. Затем добавьте команду, которая через три секунды присылает rowsMsg с обновлёнными статусами, и не забудьте поджать курсор, если строк стало меньше. Последним пунктом проверьте пустой ответ: пусть команда присылает пустой срез строк, а интерфейс вместо пустой сетки печатает «ничего не найдено» через tea.Printf и держит курсор на месте.
Личные заметкиПусто
Вопросы и ответы по уроку
Колонки растягиваются на всю ширину таблицы?
Нет: ширина колонки — та, что указана в Column.Width, а SetWidth задаёт лишь ширину окна таблицы. Лишнее место остаётся пустым справа, нехватка — обрезается краем терминала. Растягивать колонки под ширину окна придётся вручную, пересчитывая Width при WindowSizeMsg.
Как узнать, какая строка выбрана, если SelectedRow вернул nil?
nil означает курсор за пределами данных — обычно после SetRows с меньшим числом строк. Проверьте Cursor() против len(m.table.Rows()) и подожмите индекс через SetCursor(len(rows)-1). В свежих версиях bubbles это поджимается автоматически, но страховка в коде стоит одной строки.
Можно ли сортировать таблицу по колонке?
Готовой сортировки в компоненте нет — это виджет отображения, а не данных. Отсортируйте срез table.Row на своей стороне (sort.Slice по нужному индексу строки) и отдайте результат в SetRows. Курсор сохранится по индексу, а не по содержимому, поэтому после сортировки может понадобиться SetCursor заново.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler