Плавная прокрутка в Linux под UTM: темп щелчков и пол‑щелчка, которые libinput прячет в начале каждого жеста
Если вы запускали Linux в UTM на маке, то знаете эту боль: проводишь двумя пальцами по трекпаду или мышью от Apple — и страница прыгает на три строки. Ведёшь медленно, чтобы прокрутить чуть‑чуть, — ничего не происходит, а потом снова прыжок. В редакторе кода с этим можно жить, при чтении документации — нет.
На GitHub UTM этой проблеме посвящён issue #3417. Он открыт с 2021 года, в нём два десятка «у меня то же самое» и советы поставить на мак программу, которая меняет прокрутку во всей системе. Я решил разобраться, где именно теряется плавность, и починить это внутри виртуалки. Причин оказалось три, и самая интересная из них — вовсе не в UTM.
Стенд: MacBook, UTM с бэкендом QEMU и SPICE, гость Debian 13 (arm64), XFCE на X11, libinput 1.28.
Причина первая: SPICE передаёт только целые щелчки
Начнём с того, что приходит в гостевую систему. Записываем события X, пока крутим трекпад:
xinput test-xi2 --root
Прокрутка приходит с устройства spice vdagent tablet (его создаёт гостевой агент SPICE), и каждое событие — ровно один щелчок колеса: 120 единиц высокого разрешения, ни больше ни меньше. Дробных значений нет вообще.
Так и должно быть: в протоколе SPICE прокрутка передаётся как нажатия «кнопок» колеса вверх и вниз, поля для дробной величины там нет. Внутренний ввод QEMU устроен так же. Значит, точное движение пальцев превращается в целые щелчки ещё на маке, внутри UTM. Внутри виртуалки его не восстановить.
Отдельно меня смутило, что у эмулированных устройств QEMU USB Mouse и QEMU USB Tablet в возможностях объявлено колесо высокого разрешения (REL_WHEEL_HI_RES). Это ложный след: ядро Linux само добавляет hi‑res ось любой HID‑мыши с колесом и, если настоящего hi‑res колеса нет, просто умножает каждый щелчок на 120. В записи — только 120, 240, 360…
Но одна информация всё‑таки доходит — темп щелчков. UTM копит движение трекпада и выдаёт щелчок за каждую накопленную порцию, причём вместе с инерцией macOS. Вот как выглядят записанные жесты (интервалы между щелчками, мс):
медленное ведение: 183, 117, 367, 100, 150, 183, ... обычный свайп: 17, 0, 17, 17, 0, 16, 0, 17, ... (кадры по ~17 мс, по 4–5 щелчков в кадре) хвост инерции: 33, 50, 70, 100, 150, 220
Быстрый палец — частые щелчки, медленный — редкие. Из этого и можно восстановить плавное движение.
Демон: перехватить щелчки и разложить их по времени
План простой. Демон на Python с python‑evdev:
захватывает
spice vdagent tablet(EVIOCGRAB), чтобы его события больше не шли в X напрямую;создаёт через uinput клон устройства и пропускает на него всё, кроме колеса: курсор и клики работают как раньше;
каждый щелчок колеса превращает в последовательность мелких шагов
REL_WHEEL_HI_RES, которые libinput и X передают приложениям как плавную прокрутку (XI2.1 smooth scrolling понимают GTK3, Chromium/Electron, Qt5).
Сложность в пункте 3: как разложить щелчки по времени, если про движение пальца известно только, когда пришёл очередной щелчок.
Решения я сравнивал не на ощупь, а прогоном записанных жестов через разные версии алгоритма. Метрики: сколько раз в секунду скорость падает почти до нуля и снова растёт («встал‑поехал»), запаздывание, сохраняется ли пройденный путь.
Попытка 1: анимация на каждый щелчок. Каждый щелчок проигрывается как короткая анимация с затуханием. При частых щелчках получается пульсация, при редких — ступеньки.
Попытка 2: сглаживать скорость. Скорость выдачи плавно подтягивается к темпу щелчков. Получилась раскачка: скорость отстаёт → остаток копится → перелёт → остановка. Сглаживать нужно не скорость, а положение.
Попытка 3: пружина (критически демпфированная — выход тянется к цели без перелёта). Звучит красиво, но на реальных записях «встал‑поехал» случалось чаще, чем у простого варианта.
Итог: буфер интерполяции — тот же приём, которым в сетевых играх сглаживают движение чужих персонажей. Каждый щелчок сдвигает цель, а выход едет к ней равномерно с небольшой задержкой. Задержка подстраивается под темп: при быстром свайпе — ~40 мс, при медленном ведении — до 0,45 с, где задержку почти не видно, а остановок между редкими щелчками нет.
def push(self, hires_value, now): ... iv = [b - a for a, b in zip(self.clicks, list(self.clicks)[1:])] delay = min(max(DELAY_K * max(iv[-3:]), DMIN), DMAX) # запас больше недавнего интервала here = self.ref(now) # где выход сейчас self.target += delta self.points = deque([(now, here), (now + delay, self.target)])
Темп считается по нескольким последним щелчкам, а не по одному интервалу: UTM шлёт их пачками внутри 17-мс кадров, и одиночный интервал «0 мс» врёт.
Ещё два наблюдения из записей:
Шаг должен зависеть от темпа. Один и тот же размер шага либо слишком груб для медленного ведения, либо слишком мал для быстрого листания. Поэтому медленные щелчки дают мелкий шаг, частые — крупнее (степенная функция от темпа с потолком).
Стартовая пачка — не скорость. macOS делает рывок в начале движения пальца, и UTM присылает 2–3 щелчка почти одновременно. Если сразу считать это быстрым темпом, первое движение «выстреливает». Ускорение теперь набирает силу, только если быстрый темп держится ~0,3 с.
Причина вторая: Firefox на X11 берёт только целые щелчки
После демона редактор в VSCode стал плавным, а Firefox — нет. Тестовая страница, которая логирует wheel‑события, показала: Firefox получает deltaY по 114 пикселей, ровно по щелчку. Дело в том, что на X11 Firefox по умолчанию не включает XInput2 и видит только эмулированные кнопки 4/5. Лечится переменной окружения:
export MOZ_USE_XINPUT2=1 # например, в ~/.xsessionrc
С ней та же страница получает шажки по 0,3–3 пикселя.
Причина третья: libinput придерживает первые пол‑щелчка каждого жеста
Главная загадка. После всех доработок первое движение всё равно прыгало примерно на три строки, а при медленном ведении приходилось долго вести пальцем, прежде чем что‑то сдвинется. Дальше всё было гладко.
Сначала я грешил на свой демон. Но его собственная запись показывала, что жест начинается с шага в 1 единицу (1/120 щелчка) через ~25 мс. А тестовая страница в VSCode в первом событии каждого жеста получала около 34 пикселей и только потом — шажки по 0,6. Значит, рывок вставляет кто‑то между демоном и приложением.
Проверка на тестовом uinput‑устройстве:
5 шагов hi‑res колеса по 1 единице — до X не доходит ничего;
80 шагов по 1 единице раз в 20 мс — больше секунды тишины, затем первое событие сразу на 60 единиц, дальше честные шаги по 1;
после паузы — то же самое заново.
60 единиц — это пол‑щелчка, те самые ~34 пикселя. Ищем в исходниках libinput 1.28, src/evdev-wheel.c:
#define ACC_V120_THRESHOLD 60 #define WHEEL_SCROLL_TIMEOUT ms2us(500) static void wheel_handle_state_accumulating_scroll(struct fallback_dispatch *dispatch, struct evdev_device *device, uint64_t time) { if (abs(dispatch->wheel.hi_res.x) >= ACC_V120_THRESHOLD || abs(dispatch->wheel.hi_res.y) >= ACC_V120_THRESHOLD) { wheel_handle_event(dispatch, WHEEL_EVENT_SCROLL_ACCUMULATED, time); wheel_flush_scroll(dispatch, device, time); } }
Конечный автомат такой: в начале жеста колесо переходит в состояние «накопление» и молчит, пока не наберётся 60 единиц, потом выдаёт всё накопленное разом. Состояние «прокрутка» держится, пока события идут чаще чем раз в 500 мс; пауза или смена направления — и всё сначала. Видимо, это защита от случайного касания колеса у настоящих мышей с высоким разрешением. Для обычной мыши она незаметна (там каждый щелчок — сразу 120), а вот для плавной прокрутки мелкими шагами даёт ровно наши симптомы: долгий ход до старта и рывок.
Выключить это нельзя: ни опции, ни квирка. Тип шины устройства (BUS_VIRTUAL) тоже не влияет — проверил.
Обход: прокрутка с зажатой кнопкой
У libinput есть другой путь прокрутки — LIBINPUT_CONFIG_SCROLL_ON_BUTTON_DOWN: пока зажата кнопка, движение мыши превращается в плавную прокрутку. Его порог устроен иначе (src/evdev.c, evdev_post_scroll):
/* We use the trigger to enable, but the delta from this event for * the actual scroll movement. Otherwise we get a jump once * scrolling engages */
Порог здесь только съедается, а не копится и не выдаётся разом. Значит, вертикальную прокрутку можно выдавать через отдельное виртуальное устройство в этом режиме. Осталось решить три мелочи.
Таймер нажатия. Для кнопок 1–5 libinput ждёт 200 мс (вдруг это обычный клик) и всё это время выбрасывает движение. Но рядом в коде:
} else { /* For extra mouse buttons numbered 6 or more (0x115+) we assume it is * dedicated exclusively to scrolling, so we don't apply the timeout * in order to provide immediate scrolling responsiveness. */ device->scroll.button_scroll_state = BUTTONSCROLL_READY; }
Берём BTN_TASK — прокрутка начинается мгновенно. В X это кнопка 12, ни одно приложение её не использует, так что даже случайный «клик» безвреден.
Точность. По умолчанию единица движения — это 1/15 щелчка, в 8 раз грубее hi‑res колеса. Лечится сообщением libinput высокого DPI через udev:
ACTION=="add|change", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="smooth scroll wheel", ENV{MOUSE_DPI}="8000@1000"
Теперь 1 единица движения = 1/120 щелчка, как у hi‑res колеса.
Сам порог — 5 нормализованных единиц, при 8000 DPI это 40 сырых. Демон при нажатии кнопки невидимо «тратит» их. Тут я наступил на грабли: первая версия тратила ровно 40, и событие, пересёкшее порог, выдавалось целиком — рывок на 40 единиц, сделанный своими руками. Правильно тратить 39: тогда порог пересекает первое настоящее движение, и выдаётся только оно само.
def scroll(self, hires_value): dy = -hires_value if not self.held: self.ui.write(e.EV_KEY, e.BTN_TASK, 1) self.ui.write(e.EV_REL, e.REL_Y, 39 if dy > 0 else -39) # порог 40, тратим на 1 меньше self.ui.syn() self.held = True self.ui.write(e.EV_REL, e.REL_Y, dy) self.ui.syn()
Режим прокрутки для устройства включается в xorg.conf.d (Option "ScrollMethod" "button", Option "ScrollButton" "12"). Результат на тестах: первый шаг каждого жеста — 1 единица в обе стороны, курсор не двигается, лишних кликов нет. И долгий ход пальца до начала прокрутки во многом ушёл: большую часть его делал не UTM, а этот порог.
Бонус: почему в режиме захваченной мыши прокрутка не работает вовсе
В UTM мышь можно «захватить» в виртуалку. Тогда события идут через QEMU USB Mouse, мимо SPICE, и прокрутка перестаёт работать совсем. Запись XI2 показала, что до X она доходит, но все события приходят с модификаторами 0xc — Ctrl+Alt. UTM передаёт в гостя нажатие своего сочетания ⌃⌥, а отпускание — только при выходе из захвата. Для приложений Ctrl+Alt+колесо — не прокрутка. Обход: после захвата нажать и отпустить Ctrl, потом Alt.
Окно настроек
Подбирать параметры правкой файла службы неудобно, поэтому к демону прилагается окно на GTK 3 со значком в трее: чувствительность, ускорение, плавность и раздел «Дополнительно», полоса для пробы прокрутки, русский и английский интерфейс. Демон перечитывает файл настроек на лету.
Две мелочи, о которые споткнулся:
Ползунки и колесо. GTK‑ползунок по умолчанию меняет значение колесом. При прокрутке длинного окна ползунок проезжает под курсором и «перехватывает» колесо. Решается перехватом
scroll-eventу ползунков и передачей события окну.Свойства устройства. Демон пересоздаёт виртуальное устройство при каждом перезапуске, а
xorg.conf.dприменяется только при входе в сеанс. До того устройство без режима «прокрутка с кнопкой» двигало бы курсор вместо прокрутки. Значок в трее работает внутри сеанса и восстанавливает свойства черезxinputкаждый раз, когда появляется новое устройство.
Что получилось и ограничения

На записи — одни и те же записанные жесты трекпада, воспроизведённые без демона и с ним. Без него текст прыгает по 40–120 пикселей за кадр, с ним едет шагами по 1–16 пикселей.
Ограничения:
Только X11. Режим прокрутки включается через
xinputиxorg.conf.d. Под Wayland нужен другой способ.Проверено на одной конфигурации: Debian 13 arm64, XFCE, libinput 1.28, UTM с QEMU и SPICE.
Ход пальца до первого щелчка задаёт UTM. Если его хочется сократить, можно прибавить скорость прокрутки в macOS и уменьшить чувствительность в окне настроек.
Похожую задачу решает smooth‑scroll‑daemon на C. Он работает и на Wayland, сглаживает по темпу и добавляет инерцию. Судя по его README, прокрутку он выдаёт через hi‑res колесо, то есть через тот самый путь с порогом в 60 единиц — может пригодиться и его пользователям.
Код, установщик и окно настроек — на GitHub под лицензией MIT: vladsboev‑web/utm‑linux‑smooth‑scroll. Буду рад отчётам с других дистрибутивов и окружений.
Код, статья — Claude code Opus 5.5 High в VScode