---
title: "textarea: многострочный ввод"
description: "bubbles textarea в Bubble Tea: многострочный ввод в go — Placeholder, CharLimit, размеры, номера строк, вставка из буфера и парсинг заметок по строкам."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/14-textarea/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 16
---

# textarea: многострочный ввод

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

Описание бага, заметка к задаче, черновик коммит-сообщения — всё, что не помещается в одну строку, вводит bubbles/textarea: многострочный ввод в go-приложении на Bubble Tea, родственник поля textinput из десятого урока. Внутри — настоящий маленький редактор: курсор, перенос строк, прокрутка и номера строк. Разбираем настройку, лимиты, вставку из буфера и превращение заметки в данные.

Поле textinput честно отслужило своё на формах входа, но у любой панели задач есть точка, где оно ломается: пользователь хочет дописать к задаче комментарий в три строки, а ввод принимает ровно одну. Пакет bubbles/textarea закрывает эту дыру: это многострочный ввод с внутренним курсором, который умеет ходить по строкам и столбцам, переносить текст по Enter, прокручивать содержимое, когда оно выше поля, и показывать номера строк. Внутри он устроен как склейка знакомых деталей: значение хранится срезом строк, отрисовка сидит на viewport из одиннадцатого урока, мигающий курсор — общий с textinput компонент cursor. Поэтому почти всё, что вы знаете о подмоделях, переносится сюда без поправок. Нового по сути два: конфигурация поля и работа с целым значением — текстом с переносами, который дальше парсится на строки.

### textarea.Model: поле на много строк

Конструктор textarea.New не принимает аргументов — в отличие от таблицы и списка с их опциями-функциями, конфигурация идёт по полям и сеттерам. Placeholder ставится присваиванием, фокус включается методом Focus, размеры — SetWidth и SetHeight. Value возвращает весь текст вместе с переносами, SetValue ставит его целиком, InsertString вставляет строку под курсором, Reset чистит поле. Фокус возвращает tea.Cmd — команду мигания курсора, и в Init уходит готовый textarea.Blink, как у textinput. Схема хранения та же, что во всех bubbles: поле ta textarea.Model в корневой модели, пересылка сообщений в Update, вызов ta.View() в общей раскладке. Отдельная приятность: поле знает свои строки и символы — LineCount возвращает число строк, Length — число символов, и эти два числа закрывают половину вопросов к заметкам.

### Модель редактора заметок

```go
type noteModel struct {
        ta      textarea.Model
}

func initialNoteModel() noteModel {
        ta := textarea.New()
        ta.Placeholder = "Что нужно не забыть..."
        ta.CharLimit = 500
        ta.SetWidth(50)
        ta.SetHeight(8)
        ta.Focus()
        return noteModel{ta: ta}
}

func (m noteModel) Init() tea.Cmd {
        return textarea.Blink
}
```

Это скелет официального примера textarea. Focus() вызывается в инициализации и возвращает команду мигания, но в Init всё равно уходит константа textarea.Blink — курсор начинает жить сразу. CharLimit ставится до вставок: лимит работает на всё значение, а не на одну строку. Про порядок SetWidth и полей ниже отдельный разговор.

> **Типичная ошибка: забыли Focus().** Уберите из инициализатора ta.Focus() — и поле оживёт только внешне: Placeholder на месте, курсора нет, клавиши игнорируются. Update у textarea начинается с проверки фокуса: размытое поле молча возвращает управление, не трогая ни значение, ни курсор. Симптом тот же, что у таблицы без WithFocused из прошлого урока — интерфейс выглядит рабочим, а печатать некуда. Focus возвращает команду мигания; в примере урока её выбросили, потому что Init отдаёт константу textarea.Blink — это нормально, лишь бы сам вызов фокуса не потерялся.

### Placeholder, CharLimit и размеры

Placeholder — подсказка, которую поле рисует, пока значение пустое: серым, без курсора, до первого нажатия. CharLimit — жёсткий потолок символов на весь текст: вставка и ввод просто перестают принимать лишнее, а не падают. По умолчанию лимит равен нулю, то есть отключён, — в примерах его ставят явно, и это правильная привычка для заметок с сохранением на диск. По умолчанию поле имеет ширину сорок и высоту шесть строк; размеры меняются SetWidth и SetHeight, а MaxHeight ограничивает высоту сверху и важен, когда вы растягиваете поле под окно: больше ста строк редактор в любом случае не отдаст. Важно понимать, что высота фиксирована, а не растёт по содержимому: лишний текст скроллится внутри поля, как в viewport, курсор сам увлекает прокрутку за собой. Счётчик оставшихся символов собирается из Length и CharLimit одной строкой в статус-баре.

### Статус заметки в View

```go
func (m noteModel) View() string {
        left := m.ta.View()
        used := m.ta.Length()
        info := fmt.Sprintf("строк: %d, символов: %d/%d",
                m.ta.LineCount(), used, m.ta.CharLimit)
        return left + "
" + info
}
```

View складывает само поле и строку статистики. Length считает символы по всему значению, LineCount — строки, и оба метода читают модель, не трогая курсор. CharLimit вернётся тем, что вы поставили: рисовать его рядом с used — самый простой способ показать пользователю, сколько места осталось.

### Enter, формы и многострочная вставка

Главная ловушка компонента — Enter. У textarea по умолчанию Enter и ctrl+m вставляют перенос строки: переслали нажатие полю — получили вторую строку, а не отправку формы. Для однострочных форм решение есть в самой модели: отключить привязку m.KeyMap.InsertNewline.SetEnabled(false), тогда Enter до поля дойдёт и умрёт, а приложение обработает его как отправку. Для заметок всё наоборот: перенос нужен, и форма отправляется другой клавишей — ctrl+s в официальном примере. Вставка из буфера прилетает в Update пачкой символов и вставляется целиком: если в буфере лежит десять строк, все десять появятся в поле, а CharLimit честно обрежет хвост, который перестал влезать. С этим связано второе живое правило парсинга: пользователь вставит что угодно, включая пробелы и пустые строки, — и заметка на диске должна это пережить.

### Сохранение заметки

```go
type savedMsg string

func saveNote(v string) tea.Cmd {
        return func() tea.Msg {
                return savedMsg(v)
        }
}

func (m noteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tea.KeyMsg:
                if msg.String() == "ctrl+s" {
                        return m, saveNote(m.ta.Value())
                }
        }
        var cmd tea.Cmd
        m.ta, cmd = m.ta.Update(msg)
        return m, cmd
}
```

ctrl+s перехватывается до пересылки, поэтому в поле не попадает. Всё прочее уходит в textarea: клавиши движения, переносы, вставка. Пересылка здесь прямым присваиванием, как у таблицы из тринадцатого урока. В реальном приложении saveNote внутри команды писал бы файл, а не просто возвращал строку. Что будет, если нажать ctrl+s дважды подряд? Каждая команда уезжает в свою горутину, и две записи поедут параллельно: для крошечной заметки разница невидима, для большого файла — гонка, в которой победит последняя завершившаяся. Тяжёлое сохранение держите под флагом в модели: пока ответ не пришёл, вторую команду не запускайте.

### Проверка: Enter по умолчанию

**Вопрос:** textarea в фокусе, сообщение tea.KeyMsg с Enter переслано полю без изменений. Что произойдёт?

- Поле очистится и позовёт Reset
- ✅ В тексте появится перенос строки — сработает привязка InsertNewline
- Ничего: textarea не обрабатывает Enter
- Программа завершится, как после tea.Quit

По умолчанию InsertNewline привязан к enter и ctrl+m, поэтому пересланный Enter — это перенос строки. Если нужен Enter как отправка формы, отключите привязку методом SetEnabled(false) или перехватите нажатие в своей ветке KeyMsg до пересылки полю.

### Парсинг заметок: строки, слова, пункты

Заметка, сохранённая целиком одной строкой в файл, обратно превращается в данные простым разбором. Value отдаёт текст с переносами; strings.Split по переводу строки режет его на строки, TrimSpace чистит крайние пробелы, фильтр по пустоте выбрасывает случайные дыры. Дальше начинается семантика вашего формата: строка, начинающаяся с дефиса и пробела, — пункт чек-листа, строка с датой — комментарий с меткой времени. Сравните LineCount и длину среза после разбиения: они почти всегда равны, но хвостовые пустые строки в разборе стоит игнорировать явно, а не надеяться на поле. Для валидации длины годится Length: заметка длиннее лимита уходит в ошибку ещё до записи на диск. Этот же разбор — мост к хранению: в итоговом проекте заметки к задачам лягут в файл рядом с самими задачами.

### Что вернёт LineCount?

```go
ta := textarea.New()
ta.SetValue("первая
вторая")

fmt.Println(ta.LineCount())
```

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

- 0 — SetValue не меняет внутренний счётчик
- 1 — SetValue вставляет текст одной строкой
- ✅ 2 — значение режется по переносам на две строки
- 3 — вместе с пустой строкой в конце

SetValue разбирает переданный текст по переносам и хранит его как две строки, поэтому LineCount возвращает два. Пустая строка в конце не появляется: её нет во входном значении. Если бы текст кончался переносом, поведение зависело бы от разбиения — ещё одна причина чистить ввод перед SetValue.

### Лимит и SetValue

```go
ta := textarea.New()
ta.CharLimit = 10
ta.SetValue("строка номер три")

fmt.Println(ta.Length())
```

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

- 15 — SetValue игнорирует CharLimit
- ✅ 10 — значение обрезано до лимита
- 0 — CharLimit применяется только к ручному вводу
- Паника при вставке сверх лимита

SetValue идёт через тот же путь вставки, что и ручной ввод, поэтому CharLimit обрезает значение до десяти символов и Length возвращает десять. Лимит — свойство поля, а не формы: он одинаково работает на клавиатуре, на вставке и на программной установке значения.

### Номера строк, промпт и внешний вид

ShowLineNumbers включает столбец номеров слева — по умолчанию он включён, и под него резервируется четыре колонки: три цифры и отступ. Слева от каждой строки рисуется промпт — по умолчанию толстая вертикальная черта и пробел, как у редакторов из набора Charm. Цвет текущей строки, номеров и промпта собран в структуре стилей с парой FocusedStyle и BlurredStyle: поле само перекрашивается при потере фокуса, и в стилях уже спрятаны адаптивные цвета, о которых честно поговорит пятнадцатый урок. Здесь же — правило, записанное прямо в исходниках: SetWidth обязан вызываться после установки Prompt и ShowLineNumbers, потому что ширина поля считается как запрошенная минус промпт и номера строк. Поменяли внешность после SetWidth — позовите SetWidth ещё раз, иначе текст будет упираться в невидимый резерв.

> **Порядок конфигурации.** Классическая ошибка: поле настраивается, SetWidth вызывается в начале, а номера строк отключаются в конце — и поле остаётся шире задуманного на четыре колонки. Запомните порядок: содержимое-конфигурация (Placeholder, Prompt, ShowLineNumbers, CharLimit), затем размеры. Тот же принцип у таблицы из прошлого урока: сначала то, что влияет на разметку, потом метры.

### Главные методы и поля textarea

| Метод / поле | Что делает |
| --- | --- |
| Value / SetValue | прочитать весь текст / поставить целиком, с учётом лимита |
| InsertString / Reset | вставить строку под курсором / очистить поле |
| Length / LineCount | число символов / число строк значения |
| SetWidth / SetHeight | размеры поля; SetWidth — после Prompt и номеров строк |
| CharLimit | потолок символов; 0 — без лимита (по умолчанию) |
| MaxHeight | ограничение высоты сверху, по умолчанию 99 строк |
| ShowLineNumbers | номера строк слева, по умолчанию включены |
| Focus / Blur | включить фокус (возвращает команду) / снять его |

### Проверка: ширина и номера строк

**Вопрос:** Вы вызвали SetWidth(50), затем отключили ShowLineNumbers. Что нужно, чтобы ширина пересчиталась?

- Ничего: ширина не зависит от номеров строк
- ✅ Вызвать SetWidth(50) ещё раз после смены ShowLineNumbers
- Пересоздать модель через textarea.New
- Увеличить MaxHeight на четыре

SetWidth резервирует место под промпт и столбец номеров в момент вызова. Смена ShowLineNumbers после этого не пересчитывает ширину — в исходниках прямо написано, что SetWidth зовётся последним. Повторный вызов SetWidth считает резерв заново и отдаёт тексту освободившиеся колонки.

### Соберите Init и сохранение

Восстановите команду мигания, чтение значения и пересылку сообщений.

```go
func (m noteModel) Init() tea.Cmd {
        return textarea.___
}

func (m noteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tea.KeyMsg:
                if msg.String() == "ctrl+s" {
                        return m, saveNote(m.ta.___())
                }
        }
        var cmd tea.Cmd
        m.ta, cmd = m.ta.___(msg)
        return m, cmd
}
```

Подсказка: Мигание курсора — textarea.Blink, значение поля достаётся методом Value, пересылка — обычный Update.

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

### Соберите конфигурацию поля

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

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

1. `ta := textarea.New()`
2. `ta.Placeholder = "Что нужно сделать..."`
3. `ta.ShowLineNumbers = false`
4. `ta.CharLimit = 500`
5. `ta.SetWidth(50)`
6. `ta.SetHeight(8)`

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

- **CharLimit** — Потолок символов на всё значение; по умолчанию 0 — лимита нет. Действует на ввод, вставку и SetValue.
- **SetWidth — последним** — Ширина считается минус промпт и номера строк, поэтому SetWidth зовут после Prompt и ShowLineNumbers; сменили внешность — вызовите снова.
- **InsertNewline** — Привязка клавиш, по умолчанию enter и ctrl+m вставляют перенос. Для формы отключается SetEnabled(false) или перехватывается до пересылки.
- **textarea.Blink** — Команда мигания курсора для Init; Focus() включает курсор и тоже возвращает команду.
- **Value / LineCount / Length** — Целый текст с переносами, число строк, число символов — база для парсинга заметок и счётчиков.

> **Запуск и песочница.** Поле ввода живёт только в настоящем терминале: в песочнице сайта курсор и вставку из буфера не повторить. Соберите пример с ctrl+s локально, вставьте из буфера десять строк подряд и посмотрите, как CharLimit обрезает хвост, а LineCount растёт на глазах.

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

Заметка не бывает без владельца: в [уроке про таблицу](/courses/bubbletea/lessons/13-table/) выбранная строка задачи — ровно та сущность, к которой редактор прикручивается боковой панелью. Два поля рядом — уже форма, и тонкости переключения фокуса по Tab, экранных форм и кнопок разбирает [урок про фокус и формы](/courses/bubbletea/lessons/20-focus-formy/). Оба компонента — таблица и редактор — составляют ядро [итогового проекта курса](/courses/bubbletea/lessons/25-itogovyj-proekt/), где заметки сохраняются в файл и парсятся по правилам этого урока. План всей программы — на странице [курса Bubble Tea](/courses/bubbletea/). Практика: добавьте к полю счётчик оставшихся символов, отключите номера строк и следите за шириной, затем соберите разбор заметки на пункты чек-листа и выведите их списком под редактором.

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

- textarea.New без аргументов: конфигурация полями и сеттерами, а не опциями конструктора.
- CharLimit режет всё значение на вводе, вставке и SetValue; по умолчанию лимита нет.
- Высота фиксирована, излишек скроллится внутри; MaxHeight ограничивает растяжение сверху.
- Enter по умолчанию вставляет перенос: для формы отключите InsertNewline или перехватите клавишу.
- SetWidth зовётся после Prompt и ShowLineNumbers — иначе резерв под номера посчитается неверно.
- Value плюс strings.Split — весь парсинг заметок: строки, фильтр пустых, пункты по префиксу.

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

Соберите редактор заметок: поле на восемь строк с лимитом в триста символов, счётчик строк и символов под полем и отправка по ctrl+s. Затем добавьте парсинг: после сохранения разбейте заметку на строки, выбросьте пустые и напечатайте число пунктов, начинающихся с дефиса. Проверьте на вставке в десять строк — лимит и счётчики не должны соврать. Добавьте защиту от пустой заметки: по ctrl+s проверяйте TrimSpace от значения и при пустоте печатайте подсказку через tea.Printf, не запуская сохранение.

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

### Поле не растёт по мере набора текста. Это так задумано?

Да: высота textarea фиксирована, а лишний текст прокручивается внутри, как в viewport. Если нужно ощущение роста, пересчитывайте SetHeight по LineCount в Update, не забывая про потолок MaxHeight. В полноэкранных приложениях высоту обычно привязывают к WindowSizeMsg, о котором будет урок про alt-screen.

### Как ограничить длину отдельной строки?

Прямого ограничения на строку у компонента нет — CharLimit общий. Ставьте валидацию на своей стороне: в ветке сообщения или перед сохранением пройдитесь по Value, разбейте на строки и посчитайте длину каждой. Для заметок чаще всего хватает общего лимита и TrimSpace при разборе.

### Вставка из буфера не работает в SSH-сессии. Почему?

Помимо обычной вставки терминалом, у textarea есть собственный запрос буфера обмена по ctrl+v через протокол OSC52, и его поддержка зависит от терминала. В большинстве случаев достаточно обычной вставки комбинацией терминала: текст придёт пачкой символов и попадёт в поле целиком, с учётом CharLimit.
