Плавная прокрутка в 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:

  1. захватывает spice vdagent tablet (EVIOCGRAB), чтобы его события больше не шли в X напрямую;

  2. создаёт через uinput клон устройства и пропускает на него всё, кроме колеса: курсор и клики работают как раньше;

  3. каждый щелчок колеса превращает в последовательность мелких шагов 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

Комментарии (0)