В прошлом уроке мы вручную написали панель списка: структура с курсором, две ветки в Update, проверка границ. Для трёх строк она работала. Но настоящий список задач хотят большего: найти элемент среди двухсот, перелистывать страницы, подсветить выбранное цветом, показать подсказку по клавишам. Каждая мелочь — ещё десяток строк в той же модели, и ручной список быстро превращается в проект внутри проекта. Выход такой же, как со спиннером из шестого урока: взять компонент из набора bubbles. Пакет github.com/charmbracelet/bubbles/list — это готовый список терминала с навигацией, фильтрацией и пагинацией, собранный ровно по тем правилам, которые мы разобрали на подмоделях: компонент — структура со своим Update и View, вы храните его полем и пересылаете ему сообщения. Ручной listPane не пропал зря — теперь вы понимаете, что именно делает за вас библиотека.
list.Model: что уже умеет готовый список#
list.New(items, delegate, width, height) принимает начальные элементы, делегат отрисовки и размеры в символах — ширину и высоту целиком вместе с заголовком и статус-баром. Что вы получаете сразу, не написав ни строки: курсор со стрелками и j/k, встроенный ввод фильтра по клавише /, разбиение на страницы, когда элементы не влезают в высоту, статус-бар с количеством элементов, серую строку справки внизу. Элемент в DefaultDelegate занимает две строки: заголовок и описание, — значит, высота 20 вмещает примерно шесть элементов плюс оформление. Заголовок задаётся полем l.Title, а если размеры окна изменились, у модели есть методы SetWidth и SetHeight — их удобно звать из ветки tea.WindowSizeMsg, о которой подробнее поговорим на уроке про alt-screen. Схема встраивания знакома до боли: поле list list.Model в корневой модели, пересылка сообщений и вызов View.
Элемент списка: интерфейс list.Item
GOtype task struct {
title, desc string
}
func (t task) Title() string { return t.title }
func (t task) Description() string { return t.desc }
func (t task) FilterValue() string { return t.title }Любой тип с тремя методами становится элементом списка — отдельный интерфейсный обёртки не нужны. Title рисуется первой строкой, Description — второй, серым, когда элемент выбран. FilterValue — это то, по чему ищет фильтр: обычно title, но это не обязанность. Если у задачи есть теги, можно вернуть строку вида "работа срочно" — и поиск найдёт задачу по слову из тега, которого в заголовке нет.
Собираем модель списка
GOfunc initialList() list.Model {
items := []list.Item{
task{title: "Дописать отчёт", desc: "Срок — пятница"},
task{title: "Позвонить в банк", desc: "Счёт за август"},
task{title: "Забрать посылку", desc: "Пункт выдачи на Садовой"},
}
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, 40, 20)
l.Title = "Задачи"
return l
}list.New принимает срез list.Item, делегат и размеры. NewDefaultDelegate — стандартная отрисовка: две строки на элемент, подсветка выбранного. Поле Title рисуется над списком, его тип — просто string. Никаких ошибок функция не возвращает: модель сразу готова к работе, а пустые width и height — единственный способ выстрелить себе в ногу на этом шаге.
Встраивание в программу повторяет композицию из прошлого урока почти дословно. Корневая модель держит поле list list.Model; Update пересылает ему сообщения — m.list, cmd = m.list.Update(msg), — и возвращает команду дальше; View вызывает m.list.View() и получает готовый кадр. Пересылать можно всё подряд: список сам разберётся, что его касается, — клавиши, изменение размеров, тики спиннера из статус-бара. Если экран сложный, примените маршрутизацию по фокусу из восьмого урока: список — просто ещё одна панель, которой клавиши достаются, только когда она активна. Список не выходит из программы и не знает о ней ничего: q он не обрабатывает, Tea.Quit сам не возвращает — выход по-прежнему ваша забота в корневом Update. Именно поэтому список так легко ставить рядом с формой, вьюпортом и любым другим компонентом: у него нет мнения о судьбе вашего приложения.
Фильтрация: клавиша /#
Фильтр — главная причина, почему ручные списки проигрывают готовым. Пользователь нажимает /, под заголовком появляется поле ввода, и по мере набора список сокращается до элементов, у которых FilterValue содержит введённое. Enter оставляет фильтр применённым, esc сбрасывает его и возвращает полный список. Состояние доступно методом FilterState: Unfiltered — фильтра нет, Filtering — пользователь печатает, FilterApplied — фильтр применён и поле скрыто. Это знание пригождается: во время Filtering клавиши идут в текст фильтра, а не в навигацию, и ваше приложение не должно перехватывать что-то вроде Enter в этот момент. Если фильтр в вашем приложении не нужен — список настроек, где искать нечего, — отключите его методом SetFilteringEnabled(false): клавиша / перестанет реагировать, и одна клавиша освободится под вашу логику. Под капотом фильтра — тот самый textinput, которого будет посвящён следующий урок.
Классическая ловушка: фильтр молча не находит ничего, хотя элементы на экране есть. Причина почти всегда в FilterValue — метод вернул пустую строку или не то поле. Задача с заголовком «Позвонить в банк» и FilterValue по внутреннему id поиском по слову «банк» не находится. Фильтр ищет строго по тому, что вернул метод, — проверьте его первым делом.
Пагинация включается сама, когда элементы перестают помещаться в высоту. Внизу появляются точки и номер страницы, листают страницы клавишами h/l или стрелками влево-вправо, методы NextPage и PrevPage делают то же из кода. Никакой настройки не требуется: список считает, сколько строк доступно под элементы, и сам решает, где резать. Статус-бар считает элементы и подписывает их по-английски — «3 items»; метод SetStatusBarItemName("задача", "задачи") заменяет подпись на русскую. Всё это мелочи по отдельности, но вместе они и есть разница между списком, который можно показывать людям, и учебным прототипом с курсором: библиотека закрывает их одной моделью, а вы тратите время на своё приложение, а не на подсчёт строк. Отдельный плюс: у пагинации есть биндинги в KeyMap модели — если вы показываете справку, листание страниц уже описано в ней само, без ваших усилий.
Добавление элементов на ходу
GOtype taskAddedMsg struct{ t task }
// ветка корневого Update:
case taskAddedMsg:
var cmd tea.Cmd
m.list, cmd = m.list.InsertItem(len(m.list.Items()), msg.t)
return m, cmdSetItems заменяет весь набор элементов целиком, InsertItem вставляет по индексу — здесь в конец. Обе функции возвращают команду: она даёт списку шанс пересчитать пагинацию и статус-бар, поэтому возвращайте её из Update, а не выбрасывайте. Так загруженные из файла или сети задачи появляются в списке без перезапуска программы.
DefaultDelegate рисует две строки: жирный заголовок и приглушённое описание. Выбранный элемент подсвечивается, найденный фильтром — приглушается. Это уместно для задач и файлов, но не для всех списков: для списка из тысячи городов две строки на элемент — половина экрана уходит впустую. Поле ShowDescription, выставленное в false, превращает элементы в однострочные, SetHeight и SetSpacing меняют высоту элемента и отступ между ними, а Styles — набор стилей DefaultItemStyles для нормального, выбранного и приглушённого состояния. Стили — это Lip Gloss, и про них отдельный урок: пока достаточно знать, что перекрашивание выбранного элемента не требует своего делегата — достаточно заменить пару полей Styles. И не пытайтесь впихнуть в элемент многословные описания: делегат честно обрежет строки по ширине, но двухстрочный описанный в три строки элемент не влезет в отведённую высоту — либо расширяйте SetHeight, либо сокращайте текст.
Когда писать свой delegate#
Delegate — интерфейс, который решает, как элемент превращается в строки: методы Render, Height, Spacing, Update и функции справки. Свой делегат нужен в трёх случаях. Первый — другая плотность: одна строка на элемент или три с блоком деталей. Второй — свой внешний вид: цвет, префикс, иконка статуса прямо в элементе. Третий — живое поведение: делегат получает Update с сообщениями и может, например, показывать анимацию на выбранном элементе. Render получает writer и item — вы пишете в него готовую строку. Но не спешите: собственный делегат — это уже инфраструктура, и для половины задач хватает ShowDescription и стилей. Правило, которое я для себя вывел: пишите делегат, когда списку нужен собственный язык отображения — статус, прогресс, различия между элементами. Окраску выбранных строк лучше отложить до урока про Lip Gloss и его цвета.
Клавиши списка по умолчанию#
| Клавиша | Действие |
|---|---|
| ↑ / k, ↓ / j | курсор вверх и вниз по элементам |
| / | открыть ввод фильтра |
| enter | применить фильтр (esc — сбросить) |
| h / l, ← / → | перелистнуть страницу назад и вперёд |
| g / home, G / end | перейти к началу и к концу списка |
| ? | показать полную справку по клавишам |
list — библиотечный компонент: в песочнице сайта TUI не запускается, нужен локальный Go и настоящий терминал. Схема знакомая: go mod init, go get github.com/charmbracelet/bubbles, go run main.go. Возьмите данные на сто-двести элементов и попробуйте фильтр и страницы руками.
Проверка: элемент списка
Что нужно от типа, чтобы его экземпляры стали элементами bubbles/list?
Проверка: клавиша фильтра
Пользователь нажал / на списке. Что происходит по умолчанию?
Кого найдёт фильтр?
Прочитайте код и выберите, что он выведет:
type contact struct {
name, tag string
}
func (c contact) Title() string { return c.name }
func (c contact) Description() string { return c.tag }
func (c contact) FilterValue() string { return c.tag }
// Элементы: anna{name: "Анна", tag: "работа"},
// boris{name: "Борис", tag: "друзья"}
// Пользователь нажал / и ввёл: работаЗаполните сборку списка
Восстановите вызов конструктора списка и присваивание заголовка.
Соберите подготовку списка
Расставьте строки функции, которая возвращает готовый список фильмов: тип, метод фильтра, делегат, конструктор, возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь bubbles/list
Что дальше#
Список закрывает выбор из набора; когда данные по-настоящему табличные — колонки, сортировка, заголовок — переходите к bubbles/table: девятый и тринадцатый уроки дополняют друг друга. Красить элементы и рамки научит урок про Lip Gloss — как раз тот случай, когда стили делегата оживают. Если вы знакомы с десктопным GUI по курсу Fyne, взгляните на урок про списки там: задача та же — шаблон элемента и данные, — а решение сравнительно громоздче. Полный план — на странице курса Bubble Tea. Практика: запустите список на сотне элементов локально, отфильтруйте по слову, которого нет ни в одном заголовке, но есть в тегах — если фильтр нашёл, вы поняли FilterValue правильно.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: items []string (3) · cursor int = 0 · filtering bool = false · query textinput.Model
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- bubbles/list — готовый список: навигация, фильтрация по /, пагинация, статус-бар и справка уже внутри.
- Элемент — любой тип с методами Title, Description и FilterValue; фильтр ищет по FilterValue.
- Список встраивается подмоделью: поле в корне, пересылка сообщений в Update, View в кадр.
- SetItems и InsertItem меняют элементы на ходу и возвращают команду — не выбрасывайте её.
- DefaultDelegate даёт две строки на элемент; плотность и вид меняются без своего делегата.
- Свой delegate нужен для другого языка отображения — статусы, прогресс, живое поведение элементов.
Домашнее задание
Соберите список из тридцати задач со своим типом: FilterValue пусть ищет по тегам, а не по заголовкам. Запустите локально, проверьте фильтр по слову из тега, перелистните страницы и поставьте русский статус-бар через SetStatusBarItemName. Затем выставьте ShowDescription в false и сравните, сколько элементов стало помещаться на страницу.
Личные заметкиПусто
Вопросы и ответы по уроку
Как задать список высотой ровно под окно?
Сохраняйте размеры из tea.WindowSizeMsg и вызывайте m.list.SetWidth и SetHeight. Помните, что заголовок, фильтр и статус-бар тоже занимают высоту внутри модели, — их вычитать не нужно, список разметит сам, но строку статуса вашего приложения под списком стоит предусмотреть отдельно.
Можно ли отключить фильтр и пагинацию?
Фильтр — SetFilteringEnabled(false): клавиша / перестанет открывать поле. Пагинация не отключается явно: она появляется только когда элементы не помещаются, — увеличьте высоту или сделайте элементы однострочными, и страницы исчезнут.
Почему фильтр не находит элементы, которые видны на экране?
Почти всегда дело в FilterValue: он возвращает пустую строку или не то поле. Фильтр сравнивает ввод именно с возвращённой строкой, а не с заголовком или описанием. Логируйте значение метода — или временно верните в нём Title, чтобы убедиться, что дело в источнике данных.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler