Делаем свое контекстное меню для полей ввода в Qt на мобильных устройствах

от автора

…или как самому запатчить Qt

Не так давно возникла задача в Qt QML проекте обработать вставку изображения в поле ввода. Понимаю, что не каждый день приходится писать кроссплатформенные клиенты для настольных и мобильных систем, но если у тебя была похожая задача, то данная статья может быть тебе интересна.

Задача, в целом, не звучит сложной, если речь о настольных системах, но речь как раз пойдет о мобильных системах, и там все гораздо сложнее. Все потому, что Qt полностью рисует свою QML сцену в специальном слое, который, обычно далек от нативных представлений мобильных систем. Более того, все текстовые поля в Qt рисуются полностью самим Qt, а на мобильных системах нужно работать с клавиатурой устройства и хорошо бы при этом показывать не только виртуальную клавиатуру, но и «пины» для работы с текстом. На обеих платформах Qt поступает следующим образом: «под сценой» создается специальный элемент, который реализует нативную работу с клавиатурой и ее событиями. Все события от нативного элемента передаются в C++ часть и обратно, в том числе меню и синхронизация пинов, из-за чего часто возникают различные баги, связанные с рассинхронизацией событий. В общем работа с текстовыми полями и виртуальной клавиатурой в Qt зачастую больно и не приятно.

Хуже всего то, что в Qt не дали особого доступа для работы с нативной частью меню, закрыв это все за платформенными абстракциями. В обеих системах меню управляется самим Qt и наполнение или внешний вид сменить не получится. Ситуация хуже всего на Android: там меню и близко не похоже на системное, при этом выглядит как меню из Windows95.

Исследуем проблемы

Когда мы работаем с полем ввода, то Qt на мобильных системах автоматически наполняет меню, например, если что-то есть в буфере обмена, то в меню появится кнопка “Вставить”. Решение кажется простым: перехватываем действие “Вставить”, если там изображение — игнорируем событие и реализуем нужную нам логику, в противном случае не трогаем событие и передаем управление дальше в Qt.

Первая проблема, которая тут кроется, заключается в том, что к меню-то как раз и нет доступа. Добавить обработчик на кнопку «Вставить» не получится в классическом понимании. Тут можно воспользоваться тем, что (почти) все объекты в Qt унаследованы от QObject, поэтому можно воспользоваться методом QObject::installEventFilter и посмотреть, что за события приходят в поле ввода. Признаться честно, я ожидал что-то необычное, но оказалось, что в момент выбора пункта меню «Вставить» в поле ввода приходит простое событие сочетаний клавиш QKeyEvent, а комбинация там — всем известный Ctrl+V. Ну вот, можем зацепиться за событие и отфильтровать его, а что дальше?

А дальше интереснее. Пока я возился с кнопкой «Вставить», оказалось, что ее просто нет в списке пунктов меню в тот момент, когда в системном буфере обмена есть изображение. Обидно за потраченное время, но это хотя бы опыт и в будущем может пригодиться.

Понять почему так происходит уже сложнее, и фильтрами для событий тут не отделаться. Если копнуть исходный код Qt, то окажется, что все текстовые поля имеют одного родителя, у которого есть метод canPaste, этот метод отвечает за возможность вставить данные из буфера обмена в текущую позицию курсора.

Но если заглянуть в то, как устроен canPaste, то окажется, что он работает с mime-data своего буффера обмена и вызывает «под капотом» QQuickTextControl::canInsertFromMimeData работа с изображениями в котором и не предполагалась:

bool QQuickTextControl::canInsertFromMimeData(const QMimeData *source) const{    Q_D(const QQuickTextControl);    if (d->acceptRichText)        return source->hasText()            || source->hasHtml()            || source->hasFormat(QLatin1String("application/x-qrichtext"))            || source->hasFormat(QLatin1String("application/x-qt-richtext"));    else        return source->hasText();}

Несмотря на то, что в QMimeData есть метод hasImage, в этой функции его не проверяют. А если заглянуть в буфер обмена через QGuiApplication::clipboard, то выяснится, что не смотря на то, что системный буфер содержит изображение, его вообще нет в QMimeData текущего буфера Qt.

Что же делать?

Времени было уже потрачено достаточно, поэтому сдаваться так просто не хотелось, и я продолжил искать решения дальше.

У полей ввода в QML есть свойство inputMethodHints, которое отвечает за различные режимы работы поля ввода: скрытый режим ввода пароля, ввод email-адреса, только цифры, и так далее. Если читать документацию про это свойство с точки зрения QML-компонентов, то ничего интересного там не получится найти. Но эти значения берутся из enum Qt::InputMethodHint в Qt Namespace, который хорошо документирован.

Если посмотреть на enum Qt::InputMethodHint, то можно найти интересные режимы ввода:

Qt::ImhNoEditMenu0x800Do not use built-in edit menu. This flag was introduced in Qt 5.11.Qt::ImhNoTextHandles0x1000Do not use built-in text cursor and selection handles. This flag was introduced in Qt 5.11.

И вот, казалось бы флаг Qt::ImhNoEditMenu — то что нам нужно: отключаем меню, пины остаются. При таком подходе мы уже сможем показать свое меню, со стилями, которые нам нужны, а, главное, мы можем расширить наполнение и поведение пунктов меню.

Но при проверке оказалось, что этот флаг не работает в Android, в то время как на iOS флаг ведет себя так, как это описано в документации. При этом флаг Qt::ImhNoTextHandles работает на обеих платформах, но он отключает и пины для выделения и меню. В таком случае мы, конечно же, можем сделать свое меню, но с выделением будут проблемы, так как пинов нет. Да, можно «сверху» (по оси Z) над полем ввода положить MouseArea, перехватывать все жесты и сделать свои пины, но через небольшой промежуток времени при реализации данного подхода в дверь начинают стучаться санитары и переживают, все ли у нас в порядке.

Где сломалось?

Ничего не остается как пойти и разобраться, а что же сломалось? И как это починить?

Сначала я посмотрел в Java реализацию того, как работают с полями. Но достаточно быстро стало понятно что особо интересного там ничего нет: меню рисуется действительно в Android, но его наполнение приходит из C++, а обработчики действий уходят обратно в C++.

Платформенная абстракция находится в модуле Qt base, и разделена на плагины. Поэтому, скачав исходный код модуля, я быстро, по нужным флагам нашел, что нас интересуют два файла:

src/plugins/platforms/android/qandroidinputcontext.h src/plugins/platforms/android/qandroidinputcontext.cpp

В коде есть функция QAndroidInputContext::updateSelectionHandles, которая как раз и отвечает за наполнение меню и положение пинов на экране. Наполнение кнопок осуществляется с помощью битовых флагов в uint32_t buttons. По какой-то причине флаг Qt::ImhNoEditMenu в этой функции вообще не проверяется. Поэтому наша задача просто оставлять buttons пустым (== 0), тогда, когда флаг Qt::ImhNoEditMenu установлен для поля ввода.

Для этого добавляем свою функцию проверки установки флага:

bool QAndroidInputContext::isImhNoEditMenuSet(){    QSharedPointer<QInputMethodQueryEvent> query = focusObjectInputMethodQuery();    if (query.isNull())        return false;    return query->value(Qt::ImHints).toUInt() & Qt::ImhNoEditMenu;}

И в функции QAndroidInputContext::updateSelectionHandles проверяем установку этого флага: если установлен, то оставляем buttons == 0, иначе используем логику, которая была реализована до нашего вмешательства.

Собираем модуль и подменяем в своей установке Qt получившуюся библиотеку, не забыв при этом, конечно же, сделать бэкапы оригинальной библиотеки. Собрать, кстати, проще всего через Qt Creator той же версии тулкита, как и версия исходного кода Qt base. Так не нужно будет прописывать пути до нужных CMake-файлов вручную.

Запускаемся с новой библиотекой и обнаруживаем, что с установленным флагом Qt::ImhNoEditMenu для поля ввода в Android поведение такое же как в iOS: меню нет, пины есть, все остальное работает штатно. Проблема решена!

Патчим Qt

В принципе, на этом можно было бы закончить: собрать свою версию Qt с этим патчем, закинуть в свой CI/CD и забыть. Но вдруг кто-то столкнется с такой же ситуацией? Статья это хорошо, но давайте поправим Qt, чтобы было меньше боли у тех, кто с этим столкнется.

В целом, в Qt Wiki есть статья, описывающая как правильно вносить изменения в Qt, но там достаточно большое описание для общего случая, поэтому, я коротко опишу как это происходит, по горячим следам.

У Qt большая инфраструктура: есть сайт, блог, форум, багтрекер, система контроля версий. Первым делом необходимо зарегистрировать Qt account, это общий аккаунт для всех сервисов.

После регистрации идем в Qt Jira и заводим баг по своей проблеме. Так больше шансов, что патч быстро попадет в upstream. Я, например, допустил эту ошибку и мейнтейнеры в обсуждении коммита спросили что-то в духе «А зачем?». Тут ничего сложного, заполняем обязательные поля, платформу и модуль, где воспроизводится и максимально детально описываем проблему.

Затем идем в Qt Gerrit и настраиваем там свой аккаунт:

  • Добавляем почту, это обязательно, без этого не получится вносить изменения; анонимно вносить изменения можно, об этом есть в wiki, но я не углублялся.

  • Реальное имя, не Антоша_убийца_666, а реальное имя, вместе с почтой вы навсегда останетесь в истории git Qt; можно добавить отдельно никнейм, если хочется но его будет видно только в обсуждении.

  • Добавляем SSH-ключ для своей учетной записи, думаю, тот, кто пользуется GitHub/GitLab делал это не один раз, но вот тут можно прочитать детальнее, как это сделать.

  • Внизу страницы настроек добавляем соглашение (Agreements), в моем случае это Individual contributor agreement, коммерческой лицензии у меня нет.

После того, как все настроено и готово можно склонировать репозиторий следующей командой:

git clone git://code.qt.io/qt/qt5.git qt6

Корневой репозиторий — это просто супер-репозиторий, состоящий из git-сабмодулей. Все сабмодули нам инициализировать не нужно, достаточно только склонировать Qt base следующей командой:

cd qt6 perl init-repository --module-subset=qtbase

Переходим в папку Qt base и создаем ветку, в которой будем делать свое исправление:

cd qtbasegit checkout devgit pullgit checkout -b fix/android-imh-no-edit-menu

У каждого коммита должен быть уникальный Change-Id. Этот Id проставляется автоматически, с помощью git-hook, который необходимо загрузить отдельно. По не понятной мне причине, стандартные скрипты из wiki не завелись, поэтому скачать нужный hook пришлось вручную следующим образом:

gitdir=$(git rev-parse --git-dir)curl -Lo ${gitdir}/hooks/commit-msg https://codereview.qt-project.org/tools/hooks/commit-msgchmod +x ${gitdir}/hooks/commit-msg

После внесения изменений формируем наш коммит практически как обычно, но не указываем commit message. Вам будет предложено заполнить его в редакторе по умолчанию по заранее готовому шаблону. Указываем коротко суть проблемы, далее описание, можно указать ChangeLog и модуль, в котором были изменения. Важно не забыть указать и какой баг исправляет данный патч, поэтому в конце сообщения добавляем Fixes: QTBUG-xxxx, где xxxx — номер вашего бага в Qt-bugtracker.

git add .git commit

Все готово, отправляем наш патч, указав вместо <gerrit_username> ваш Username в Gerrit на странице Settings:

git push ssh://<gerrit_username>@codereview.qt-project.org:29418/qt/qtbase.git HEAD:refs/for/dev

На этом этапе запустится Early Warning System скрипт, который запускает различные тесты перед отправкой коммита. Если замечаний нет, то наш патч отправится на ревью. В самом Gerrit нам необходимо добавить ревьюверов, так у патча больше шансов быстрее пройти ревью.

Вообще, для новичков в этом деле, как и у меня, ревьювер был назначен автоматически. Но можно добавить мейнтейнеров модуля, а так же посмотреть с помощью git-blame кто вносил изменения в код, рядом с вашим патчем.

Если у ревьюверов будут замечания, то необходимо внести изменения и отправить их по-новой, либо обсудить в комментариях предложения.

Изменеия в Gerrit сделаны с помощью patch set, и устроены немного иначе. В Qt Gerrit принята политика одного коммита на одно изменение и на это есть несколько причин:

  • Gerrit не merge-based — каждый патч это один коммит, который в итоге попадает в репозиторий как единица. Серия patch set’ов это просто история правок одного коммита.

  • Ревьюерам удобнее с этим работать — они видят изменения между patch set’ами и сразу понимают что исправлено по замечаниям.

  • Qt Contribution Guidelines прямо требуют атомарные логические коммиты — один feature/patch = один коммит.

Поэтому исправление замечаний будет выглядеть так:

git add .git commit --amendgit push ssh://<gerrit_username>@codereview.qt-project.org:29418/qt/qtbase.git HEAD:refs/for/dev

После прохождения ревью и исправления всех замечаний на странице патча появится кнопка Stage, нужно будет пропустить его через CI/CD и если все пройдет успешно, то патч автоматически будет влит в целевую ветку. Иногда CI/CD падает с ошибками в других ветках и даже модулях, так что может получиться так, что Stage придется запускать несколько раз, это нормально.

Если все пройдет успешно, то на странице с патчем статус будет помечен как Merged. Армия ботов «вольет» ваши изменения в нужные ветки, а в Jira проставят все необходимые статусы. Вот теперь все готово.

Ссылка на баг и на его патч.

ссылка на оригинал статьи https://habr.com/ru/articles/1052882/