Содержание урока · 12 разделов
- Режим мыши: WithMouseCellMotion и WithMouseAllMotion
- MouseMsg: Type, Action и координаты X, Y
- Клик по элементу: зоны на координатах
- Где зона на самом деле: рамки и широкие символы
- Кликабельный список: выбираем строку
- Движение мыши: hover, если он правда нужен
- Колесо: прокрутка без клавиш
- Совместимость: где клики не работают
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Панель задач с кнопками, кликабельные вкладки, список, где строку выбирают щелчком, — пользователи TUI-приложений этого просят, и в терминале это правда работает. Механика такая: терминал умеет включать режим отчёта о мыши — тогда каждый клик, движение и прокрутка превращаются в escape-последовательность, которая уходит приложению на вход. Bubble Tea разбирает эти последовательности и выдаёт вам tea.MouseMsg — обычное сообщение, которое попадает в тот же Update, что и нажатия клавиш. Привилегированного канала у мыши нет: обработчик клика соседствует с обработчиком q в одном switch, и смешивать источники событий — норма Elm-архитектуры, а не костыль. Бонус: обработчик клика тестируется так же, как клавиатура — в уроке про тестирование TUI тот же приём: подсовываете Update готовое сообщение с координатами и проверяете реакцию модели, без настоящего терминала. По умолчанию режим мыши выключен, и это осмотрительность, а не недоработка: включённый режим перехватывает клики, и привычное выделение текста мышью перестаёт работать. Поэтому мышь включают опцией при создании программы — осознанно и только там, где щелчки действительно нужны. Дальше включим режим, выучим анатомию сообщения, соберём клики по кнопкам панели и кликабельный список строк.
Режим мыши: WithMouseCellMotion и WithMouseAllMotion#
Включение мыши — это опция, переданная в tea.NewProgram, и опций две, с принципиальной разницей. tea.WithMouseCellMotion() включает отчёт о кликах, колесе и движении при зажатой кнопке: того, что нужно для кликабельных элементов и перетаскивания. tea.WithMouseAllMotion() добавляет события на каждое перемещение курсора, даже когда кнопки не тронуты, — режим для hover-подсветки и следящих за курсором эффектов. Цена у него заметная: события сыплются на каждую ячейку, которую пересёк курсор, и Update получает десятки сообщений в секунду, даже если человек просто ведёт мышью мимо ваших кнопок. Названия уходят в историю xterm: базовые клики — режим 1000, движение при зажатой кнопке — 1002, всё движение — 1003; bubbletea собирает нужные запросы за вас одной опцией. Для типичной панели хватает CellMotion — берите его по умолчанию и добавляйте AllMotion, только когда без движения никуда. Обратного хода у опции нет: выключить мышь до конца работы нельзя, терминал вернётся в обычное состояние сам при выходе из программы.
Включаем мышь при создании программы
GOfunc main() {
p := tea.NewProgram(boardModel{},
tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Println("Ошибка:", err)
os.Exit(1)
}
}Опция tea.WithMouseCellMotion переводит терминал в режим отчёта о мыши на время работы программы. Без неё MouseMsg не придёт никогда — фреймворк просто не попросит терминал сообщать о кликах. Остальные опции программы не мешают: мышь комбинируется с alt-screen и любыми настройками вывода.
MouseMsg: Type, Action и координаты X, Y#
MouseMsg — обёртка над структурой с четырьмя полями, и на них держится вся обработка. X и Y — координаты ячейки, по которой случилось событие: отсчёт с нуля, левый верхний угол экрана, единица измерения — символ, ровно тот, которым вы рисуете раскладку. Type — что именно произошло: MouseLeft, MouseRight, MouseMiddle для кнопок, MouseWheelUp, MouseWheelDown, MouseWheelLeft, MouseWheelRight для колеса. Action — фаза события: MouseActionPress, MouseActionRelease или MouseActionMotion. Нажатие и отпускание различаются по Action — поле Type со значением MouseRelease осталось в библиотеке как устаревшее и в свежем коде не используется. Для отладки пригодится то, что MouseMsg умеет печататься: fmt.Stringer выведет координаты и фазу, и в первых итерациях стоит логировать события в файл, чтобы видеть, что терминал реально присылает. Поиск по запросу mousemsg bubbletea почти всегда упирается в один и тот же вопрос — «почему клик обрабатывается дважды»; ответ кроется именно здесь: приходят и press, и release, и если фильтровать по Action, двойной обработки не будет.
Проверка: нажатие или отпускание
Пользователь щёлкнул левой кнопкой. Как в Update отличить нажатие от отпускания?
Клик по элементу: зоны на координатах#
Фреймворк отдаёт координаты и больше ничего: о том, что у вас кнопка, а рядом список, Bubble Tea не знает. Проверку «куда попали» — hit-test — вы пишете сами. Типовая схема: модель хранит геометрию интерактивных элементов, а обработчик клика перебирает их и находит первый, чей прямоугольник накрывает точку msg.X, msg.Y. Прямоугольник — четыре сравнения: точка внутри, если координата больше или равна левому краю, строго меньше правого, и то же по вертикали. Порядок перебора — это приоритет: первая зона, накрывшая точку, побеждает, поэтому перекрывающиеся элементы проверяйте от верхнего слоя к нижнему. Зоны удобно держать рядом с раскладкой: тот же набор констант, который рисует панель в View, кормит и кнопку с координатами. Тогда клики и картинка не разъезжаются — если меняете раскладку, меняются и зоны, потому что считаются из одного места. Держите зоны в срезе, а не в отдельных полях: добавление третьей кнопки не должно размножать ветки if — перебор срезом масштабируется сам. При изменении размеров окна зоны пересчитываются вместе со всей геометрией — об этом подробно следующий урок.
Кнопка, зона и обработчик клика
GOtype button struct {
x, y, w, h int
label string
}
func (b button) hit(x, y int) bool {
return x >= b.x && x < b.x+b.w &&
y >= b.y && y < b.y+b.h
}
func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
if msg.Type == tea.MouseLeft &&
msg.Action == tea.MouseActionPress {
for i, b := range m.buttons {
if b.hit(msg.X, msg.Y) {
m.pressed = i
}
}
}
}
return m, nil
}hit — четыре сравнения, образующие прямоугольник: нижняя и правая границы входят не включительно, потому что ячейка с координатой b.x+b.w — уже соседняя. В Update клик проходит двойной фильтр: левая кнопка и именно нажатие. Дальше перебор кнопок — первая зона, накрывшая точку, и есть цель.
Клик на границе зоны
Прочитайте код и выберите, что он выведет:
var btn = button{x: 5, y: 3, w: 4, h: 2}
// Клик: msg.X = 9, msg.Y = 3.
// Что вернёт btn.hit(msg.X, msg.Y)?Где зона на самом деле: рамки и широкие символы#
Координаты отсчитываются от нуля, а раскладку вы рассчитываете в уме от единицы — отсюда классический сдвиг на один символ. Рамка липглосс съедает колонку слева и строку сверху: кнопка, отрисованная внутри border, начинается на клетку правее и ниже, чем кажется, и зона должна это учесть. Проверенный приём — вычислять зоны не руками, а из тех же констант раскладки: если кнопка рисуется со смещением x+1, значит, и в hit идёт x+1. Вторая ловушка — ширина символов: кириллица занимает одну ячейку, а широкий символ вроде эмодзи — две, и текст правее него уезжает на клетку от вашей арифметики. Функция lipgloss.Width считает видимую ширину честно, с учётом широких символов и ANSI-кодов, поэтому измеряйте ей, а не len. И не забывайте про нумерацию строк: первая строка экрана имеет Y равный нулю, заголовок высотой в две строки сдвигает всё тело на две ячейки. Способ убедиться, что зоны совпали с рисунком, — отладочная обводка: временно рисуйте рамку вокруг каждой зоны по тем же координатам и щёлкайте по краям; расхождение видно сразу, и это быстрее, чем вылавливать промахи наугад.
Кликабельный список: выбираем строку#
Чаще кнопок кликают по строкам списка, и здесь зона проще: прямоугольник шириной во всю панель и высотой в одну строку. Схема: модель держит смещение тела — сколько строк кадра ушло на шапку и заголовок, — и клик переводится в индекс вычитанием: index = msg.Y минус offset тела, с проверкой границ, потому что щёлкнуть могли и в статус-бар под списком. Выбор мышью синхронизируется с клавиатурным: клик ставит тот же курсор, что и стрелки, а выделенный строковый стиль один на оба случая. С встроенными компонентами по-разному: viewport понимает колесо сам, а table кликов не знает вовсе — если таблице нужен выбор щелчком, зоны строк строите вы по высоте строк и смещению. Изнутри это всё тот же hit-test, просто вместо перебора кнопок — арифметика с делением. Храните смещение тела в константе рядом с раскладкой: она меняется при любом обновлении шапки, и рассинхрон зоны с картинкой обнаружится только на живом терминале.
Движение мыши: hover, если он правда нужен#
События движения приходят с Action равным MouseActionMotion: в режиме CellMotion — только при зажатой кнопке, в режиме AllMotion — на каждом шаге курсора. Hover-подсветка кнопки строится так: модель хранит hovered int, ветка движения перебирает зоны и запоминает, над какой кнопкой курсор, а View рисует кнопку в одном из двух стилей. Работает, но стоит помнить о двух вещах. Первое — поток: AllMotion приносит сообщение на каждую ячейку пути, и тяжёлый View начнёт мигать и тормозить; держите ветку движения тонкой, без allocations и рендера больших боксов. Второе — польза: подсветка под курсором в терминале спорна, курсор мыши не часть интерфейса, и клавиатурная навигация с заметным выделением выбранного часто яснее. Я в рабочих панелях hover не включаю вовсе, а оставляю его для демо-экранов и игр — там без движения действительно не обойтись. Если движение всё же включено, обновляйте модель только когда подсвеченная кнопка реально изменилась: сравнение старого и нового индекса экономит и кадры, и глаза — без него перерисовка запускается на каждый шаг курсора.
Колесо: прокрутка без клавиш#
Самое массовое применение мыши в терминале — не клики, а прокрутка. События колеса приходят как Type равные MouseWheelUp и MouseWheelDown, и обрабатывать их руками почти не нужно: viewport из одиннадцатого урока умеет это сам. Если включён режим мыши и сообщение MouseMsg передать в viewport.Update, компонент сдвинет видимую область на фиксированный шаг — MouseWheelDelta, по умолчанию три строки на событие. Горизонтальное колесо тоже существует в API, но поддержка в терминалах неровная: встречается редко, и закладывать на него логику не стоит. Полезная деталь: при обработке колеса в собственном коде шаг стоит брать из константы компонента, а не писать тройку руками — тогда поведение вашей панели и встроенной прокрутки совпадут. И фильтруйте фазу: колесо приходит только в фазе press, отпускания колесо не порождает, поэтому двойных срабатываний здесь, в отличие от кликов, не случается.
Колесо через viewport
GOfunc (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case tea.MouseMsg:
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
return m, nil
}Ветка MouseMsg не разбирает поля сама: сообщение целиком отдаётся viewport.Update, и компонент сам распознаёт колесо и сдвигает YOffset. Если режим мыши не включён, сюда просто ничего не дойдёт, а клавиши прокрутки продолжат работать — они обрабатываются тем же методом компонента.
Проверка: цена выделения текста
Включили режим мыши — пользователь жалуется: не может выделить и скопировать вывод обводкой. Что происходит?
Совместимость: где клики не работают#
Под капотом Bubble Tea просит у терминала SGR-режим отчёта мыши — номер 1006, современный стандарт с точными координатами. Его понимают основные эмуляторы: iTerm2, Alacritty, kitty, Windows Terminal, gnome-terminal, Konsole — на них можно рассчитывать спокойно. Тонкие места начинаются вокруг мультиплексоров: tmux перехватывает мышь сам, и без set -g mouse on события до вашего приложения не доходят вовсе; screen поддерживает режимы частично. Через ssh всё работает — escape-последовательности проходят по каналу как обычный ввод, поэтому серверные TUI остаются кликабельными на локальном терминале. Минимальный общий знаменатель — левый клик и вертикальное колесо; правая кнопка, средняя и горизонтальная прокрутка встречаются не везде, и логику на них лучше вешать дублирующую — ту же операцию стоит показывать клавишей. Практичный ход — клавиша-переключатель мыши: m включает и выключает режим, и пользователь сам решает, что ему сейчас важнее, клики или копирование обводкой. Честная проверка только одна: запустить go run в настоящем терминале и пощёлкать — локально, потому что браузерные песочницы мышь не передают.
Не включайте режим мыши «на всякий случай»: пока он активен, обводка текста работает только через Shift, и пользователи, привыкшие копировать логи мышкой, будут считать программу сломанной. Мышь — осознанная опция для кликабельной раскладки, а не украшение.
Соберите обработчик клика
Восстановите фильтры события и проверку зоны: какая кнопка, какая фаза и какой метод решает, куда попали.
Соберите ветку колеса
Расставьте строки: сообщение, подготовка переменной, проверка типа, прокрутка, закрытие условия, возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь мыши
Что дальше#
Мышь раскрывается в полноэкранных интерфейсах: когда панель занимает весь терминал, клики по зонам должны следовать за resize — урок про alt-screen и размеры окна показывает, как держать геометрию актуальной. Прокрутка колесом опирается на viewport из одиннадцатого урока — вернитесь к нему, если пропустили. Оба приёма складываются в цельные приложения по мере остальных уроков курса Bubble Tea — там же указан порядок прохождения. Практика: соберите панель с двумя кнопками, включите CellMotion и пощёлкайте локально — только реальный терминал покажет, что зоны совпадают с картинкой.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: clicks int = 0 · scroll int = 0 · motion bool = false · last string = "—"
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- Мышь включается опцией tea.WithMouseCellMotion или tea.WithMouseAllMotion в tea.NewProgram — без неё MouseMsg не придёт.
- MouseMsg несёт координаты X и Y, Type — какая кнопка или колесо, Action — фаза: нажатие, отпускание, движение.
- Клик по элементу — hit-test по координатам: фреймворк о вашей раскладке не знает, зоны считает модель.
- Зоны выводите из тех же констант, что и рисунок: рамки съедают строку и колонку, широкие символы занимают две ячейки.
- viewport прокручивает колесо сам, если передать ему MouseMsg; шаг — MouseWheelDelta, по умолчанию три строки.
- Включённая мышь перехватывает выделение текста — обходной путь Shift при обводке; без пользы мышь не включайте.
Домашнее задание
Добавьте в модель с двумя кнопками обработку мыши: включите WithMouseCellMotion, выведите зоны кнопок из констант раскладки и подсветите нажатую кнопку стилем в View. Затем обработайте колесо через viewport и убедитесь, что клавиша q по-прежнему выходит. Проверяйте локально в реальном терминале — песочницы клики терминала не эмулируют.
Личные заметкиПусто
Вопросы и ответы по уроку
Мышь включил, но клики молчат. Где искать?
Проверьте три места: опция передана именно в tea.NewProgram, в Update есть case tea.MouseMsg и терминал вообще поддерживает режим отчёта. В tmux без set -g mouse on события перехватывает сам мультиплексор, и до приложения они не доходят.
Координаты сбиваются после прокрутки контента. Так и должно быть?
Да: X и Y — координаты в окне, а не в документе. viewport прокручивает текст, но клики приходят в экранных координатах, поэтому для кликабельных строк внутри прокрученного контента вычитайте смещение YOffset из msg.Y. И пересчитайте диапазон: после прокрутки видимых строк меньше, и верхняя граница индекса тоже сдвинулась.
Стоит ли включать AllMotion ради подсветки под курсором?
Только если hover действительно важен: на каждое движение приходит сообщение, и Update нагружается даже при бездействии. Для панели с кнопками хватает CellMotion, а от hover я в рабочих программах честно отказываюсь — клавиатурное выделение яснее.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler