Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~16 мин +50 XP

Мышь: MouseMsg и клики в терминале

Кликнуть по кнопке в терминале — не фантастика: bubbletea мышь поддерживает наравне с клавиатурой, через сообщение MouseMsg. Включим режим tea.WithMouseCellMotion, разберём поля события — Type, Action и координаты X, Y — и научимся определять, по какому элементу щёлкнули. Отдельно поговорим про колесо, совместимость с терминалами и цену, которую платит выделение текста.

Содержание урока · 12 разделов

Панель задач с кнопками, кликабельные вкладки, список, где строку выбирают щелчком, — пользователи 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, только когда без движения никуда. Обратного хода у опции нет: выключить мышь до конца работы нельзя, терминал вернётся в обычное состояние сам при выходе из программы.

Включаем мышь при создании программы

GO
func 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 — перебор срезом масштабируется сам. При изменении размеров окна зоны пересчитываются вместе со всей геометрией — об этом подробно следующий урок.

Кнопка, зона и обработчик клика

GO
type 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

GO
func (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 в настоящем терминале и пощёлкать — локально, потому что браузерные песочницы мышь не передают.

Соберите обработчик клика

Восстановите фильтры события и проверку зоны: какая кнопка, какая фаза и какой метод решает, куда попали.

case tea.MouseMsg: if msg.Type == tea. && msg.Action == tea. { for i, b := range m.buttons { if b.(msg.X, msg.Y) { m.pressed = i } } }

Соберите ветку колеса

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: словарь мыши

Термин

MouseMsg

нажмите, чтобы перевернуть
Определение

Событие мыши в Update: X и Y — координаты ячейки с нуля, Type — кнопка или колесо, Action — фаза: нажатие, отпускание, движение.

1 / 5

Что дальше#

Мышь раскрывается в полноэкранных интерфейсах: когда панель занимает весь терминал, клики по зонам должны следовать за resize — урок про alt-screen и размеры окна показывает, как держать геометрию актуальной. Прокрутка колесом опирается на viewport из одиннадцатого урока — вернитесь к нему, если пропустили. Оба приёма складываются в цельные приложения по мере остальных уроков курса Bubble Tea — там же указан порядок прохождения. Практика: соберите панель с двумя кнопками, включите CellMotion и пощёлкайте локально — только реальный терминал покажет, что зоны совпадают с картинкой.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
МЫШЬ В ТЕРМИНАЛЕ
кликов: 0 · прокручено строк: 0
последнее событие: —
тик клика, тик колеса, m — AllMotion, r — сброс, q — выход

Клавиатура — обработано в 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 по-прежнему выходит. Проверяйте локально в реальном терминале — песочницы клики терминала не эмулируют.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Мышь: MouseMsg и клики в терминале)

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

Мышь включил, но клики молчат. Где искать?

Проверьте три места: опция передана именно в tea.NewProgram, в Update есть case tea.MouseMsg и терминал вообще поддерживает режим отчёта. В tmux без set -g mouse on события перехватывает сам мультиплексор, и до приложения они не доходят.

Координаты сбиваются после прокрутки контента. Так и должно быть?

Да: X и Y — координаты в окне, а не в документе. viewport прокручивает текст, но клики приходят в экранных координатах, поэтому для кликабельных строк внутри прокрученного контента вычитайте смещение YOffset из msg.Y. И пересчитайте диапазон: после прокрутки видимых строк меньше, и верхняя граница индекса тоже сдвинулась.

Стоит ли включать AllMotion ради подсветки под курсором?

Только если hover действительно важен: на каждое движение приходит сообщение, и Update нагружается даже при бездействии. Для панели с кнопками хватает CellMotion, а от hover я в рабочих программах честно отказываюсь — клавиатурное выделение яснее.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Zap25 уроков~9 чпрактика25 уроков уже доступны

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler