---
title: "bubbles/list: готовые списки"
description: "bubbles list для Bubble Tea: готовый список в терминале на Go — элементы list.Item, фильтрация по /, пагинация, DefaultDelegate и когда делегат нужен свой."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/09-list/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 20
---

# bubbles/list: готовые списки

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

Списки в терминале — самый частый интерфейс после полей ввода, и для них в экосистеме Bubble Tea есть готовый пакет bubbles/list. Фильтрация, пагинация, статус-бар, справка — всё уже написано и стыкуется с подмоделями из прошлого урока. Собираем список задач: элементы list.Item, фильтр по клавише /, страницы и случай, когда delegate стоит написать свой.

В прошлом уроке мы вручную написали панель списка: структура с курсором, две ветки в 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

```go
type 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, но это не обязанность. Если у задачи есть теги, можно вернуть строку вида "работа срочно" — и поиск найдёт задачу по слову из тега, которого в заголовке нет.

### Собираем модель списка

```go
func 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 модели — если вы показываете справку, листание страниц уже описано в ней само, без ваших усилий.

### Добавление элементов на ходу

```go
type 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, cmd
```

SetItems заменяет весь набор элементов целиком, 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?

- Унаследоваться от класса ListItem
- ✅ Реализовать методы Title, Description и FilterValue
- Хранить данные в специальной структуре list.Data
- Зарегистрировать тип через list.RegisterItem

Интерфейс list.Item — это три метода: Title, Description, FilterValue. Наследования в Go нет и не требуется: любой тип с этими методами подходит. FilterValue — источник данных для фильтрации, а Title и Description рисует делегат.

### Проверка: клавиша фильтра

**Вопрос:** Пользователь нажал / на списке. Что происходит по умолчанию?

- Программа завершается
- ✅ Открывается встроенное поле фильтра, список сокращается по мере ввода
- Список переходит на следующую страницу
- Ничего: клавиша / не занята и свободна для вашей логики

Слэш — стандартная клавиша фильтра, как в less и vim. Под неё уже встроен textinput, фильтрация идёт по FilterValue элементов, enter применяет фильтр, esc сбрасывает. Если клавиша нужна вам — отключите фильтр SetFilteringEnabled(false).

### Кого найдёт фильтр?

```go
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: "друзья"}
// Пользователь нажал / и ввёл: работа
```

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

- Обе строки: Анна и Борис
- Ничего: фильтр ищет только по заголовкам
- ✅ Только Анна: слово найдено в её FilterValue
- Только Борис: фильтр пропускает первую запись

Фильтр сравнивает ввод с тем, что вернул FilterValue, — здесь это tag. У Анны tag "работа" — она остаётся, у Бориса "друзья" — уходит. Имена «Анна» и «Борис» фильтр вообще не видит: поиск по заголовку работал бы, только если FilterValue возвращал name.

### Заполните сборку списка

Восстановите вызов конструктора списка и присваивание заголовка.

```go
func newList() list.Model {
        items := []list.Item{ /* ... */ }
        d := list.NewDefaultDelegate()
        l := list.___(items, d, 40, 20)
        l.___ = "Книги"
        return l
}
```

Подсказка: Конструктор пакета называется New, а заголовок над списком — строковое поле Title.

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

### Соберите подготовку списка

Расставьте строки функции, которая возвращает готовый список фильмов: тип, метод фильтра, делегат, конструктор, возврат.

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

1. `type movie struct{ name, year string }`
2. `func (m movie) FilterValue() string { return m.name }`
3. `d := list.NewDefaultDelegate()`
4. `l := list.New(items, d, 40, 20)`
5. `l.Title = "Фильмы"`
6. `return l`

### Карточки: словарь bubbles/list

- **list.Item** — Интерфейс элемента: Title, Description, FilterValue. Любой тип с этими методами годится в список.
- **FilterValue** — Строка, по которой фильтр ищет элемент. Может отличаться от заголовка — например, включать теги.
- **DefaultDelegate** — Стандартная отрисовка: две строки на элемент. ShowDescription=false даёт однострочный список; Styles красит состояния.
- **Пагинация** — Разбивка на страницы, когда элементы не влезают в высоту. Включается сама, листают h/l или методы NextPage/PrevPage.
- **FilterState** — Состояние фильтра: Unfiltered, Filtering, FilterApplied. Во время Filtering клавиши идут в текст фильтра.
- **ItemDelegate** — Интерфейс отрисовки: Render, Height, Spacing, Update. Свой нужен для другой плотности, вида или живого поведения элементов.

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

Список закрывает выбор из набора; когда данные по-настоящему табличные — колонки, сортировка, заголовок — переходите к [bubbles/table](/courses/bubbletea/lessons/13-table/): девятый и тринадцатый уроки дополняют друг друга. Красить элементы и рамки научит [урок про Lip Gloss](/courses/bubbletea/lessons/15-lipgloss-osnovy/) — как раз тот случай, когда стили делегата оживают. Если вы знакомы с десктопным GUI по курсу Fyne, взгляните на [урок про списки там](/courses/fyne/lessons/10-spiski/): задача та же — шаблон элемента и данные, — а решение сравнительно громоздче. Полный план — на странице [курса Bubble Tea](/courses/bubbletea/). Практика: запустите список на сотне элементов локально, отфильтруйте по слову, которого нет ни в одном заголовке, но есть в тегах — если фильтр нашёл, вы поняли FilterValue правильно.

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

- 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, чтобы убедиться, что дело в источнике данных.
