Radial: Wayland pointer warp via zwp_pointer_constraints_v1

QCursor::setPos is a no-op on Wayland, so cursor-follows-focus did
nothing there. Add WaylandCursorWarp (src/plugin/waylandcursorwarp.{h,
cpp}): on Wayland, lock the pointer to the top-level window's wl_surface,
set_cursor_position_hint to the target pane centre (surface-local), commit,
then release — the compositor warps to the hint on unlock (the mechanism
plan9port/acme use). Panes are children of one window = one surface, so
it is a warp within that surface. Handles come from QWaylandApplication +
QPlatformNativeInterface 'surface'. X11 keeps QCursor::setPos.

Build: pointer-constraints client glue generated via ECM and compiled as
a C static lib (kcoreaddons_add_plugin silently drops .c sources); enable
C language in project(); link Qt6::GuiPrivate + wayland-client. All
guarded + a stub when the Wayland bits are absent. 13 ctest suites pass.
This commit is contained in:
Levi Neely 2026-10-08 12:18:01 +02:00
parent ea7d84868d
commit eeee28a94a
8 changed files with 371 additions and 5 deletions

View File

@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21)
project(kate-custom VERSION 0.1.0 LANGUAGES CXX)
project(kate-custom VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@ -143,6 +143,21 @@ Therefore the palette is replaced, not extended, and the replacement is built on
all **left+something**, so a middle press that is part of a chord
(left held) still cuts and never opens the radial. The X11 middle-click
primary paste is suppressed on release either way.
- Cursor-follows-focus: after a radial `go_*_split_view` slice (the window
radial's "Focus" sub-branch) the mouse pointer warps onto the newly active
pane. Kate's split panes are child widgets of one top-level window (one
`wl_surface`), so this is a warp *within* that surface — pure geometry
(`view->mapTo(topLevel, view->rect().center())`). On **X11** it uses
`QCursor::setPos`. On **Wayland** (which forbids that) it uses the
`zwp_pointer_constraints_v1` protocol: lock the pointer to the window surface,
`set_cursor_position_hint`, commit, then release — KWin warps to the hint on
unlock (the mechanism plan9port/acme use). Implemented in
`src/plugin/waylandcursorwarp.{h,cpp}`; the pointer-constraints client glue is
generated by ECM (`ecm_add_wayland_client_protocol`) and compiled as a small
C static lib (`kcoreaddons_add_plugin` drops `.c` sources). Everything is
guarded: no Wayland global / surface → `warp()` returns false and the caller
falls back. The feature compiles out entirely if wayland-scanner /
wayland-client / Qt6 GuiPrivate are absent (a stub provides the symbols).
- User-file radial config — DONE. `RadialConfig::load()` reads
`$XDG_CONFIG_HOME/olliepalette/radials.json` when present and non-empty,
otherwise returns `builtinDefault()`; a present-but-broken file also falls

View File

@ -10,11 +10,63 @@ if(NOT KF6TextEditor_FOUND)
return()
endif()
# --- optional Wayland pointer-warp support -------------------------------
# Warping the pointer on Wayland needs the pointer-constraints protocol glue
# (generated with wayland-scanner via ECM) plus Qt's Wayland native interface.
# If any piece is missing we build without it; WaylandCursorWarp::warp() then
# simply returns false and the X11 path (QCursor::setPos) is used instead.
set(OLLIE_WAYLAND OFF)
find_package(PkgConfig QUIET)
find_package(WaylandScanner QUIET)
find_package(Qt6 QUIET COMPONENTS GuiPrivate)
if(PkgConfig_FOUND)
pkg_check_modules(WAYLAND_CLIENT QUIET wayland-client)
pkg_check_modules(WAYLAND_PROTOCOLS QUIET wayland-protocols)
endif()
set(OLLIE_PLUGIN_SOURCES ollieplugin.cpp ollieplugin.h waylandcursorwarp.h)
set(OLLIE_WAYLAND_PROTO_SRCS "")
if(WaylandScanner_FOUND AND WAYLAND_CLIENT_FOUND AND Qt6GuiPrivate_FOUND)
if(WAYLAND_PROTOCOLS_FOUND)
pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
endif()
set(PC_XML
"${WAYLAND_PROTOCOLS_DIR}/unstable/pointer-constraints/pointer-constraints-unstable-v1.xml")
if(EXISTS "${PC_XML}")
# ECM wires the scanner output (basename-client-protocol.h + .c) into the
# given source-list variable with correct generated-source handling.
ecm_add_wayland_client_protocol(OLLIE_WAYLAND_PROTO_SRCS
PROTOCOL "${PC_XML}"
BASENAME pointer-constraints-unstable-v1)
# kcoreaddons_add_plugin only compiles C++ sources — a generated .c in
# its SOURCES is silently dropped. Build the C glue as its own static
# lib and link it in.
add_library(ollie_wayland_proto STATIC ${OLLIE_WAYLAND_PROTO_SRCS})
target_include_directories(ollie_wayland_proto PUBLIC
"${CMAKE_CURRENT_BINARY_DIR}" ${WAYLAND_CLIENT_INCLUDE_DIRS})
set_target_properties(ollie_wayland_proto PROPERTIES
POSITION_INDEPENDENT_CODE ON
LINKER_LANGUAGE C
AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
list(APPEND OLLIE_PLUGIN_SOURCES waylandcursorwarp.cpp)
set(OLLIE_WAYLAND ON)
endif()
endif()
if(NOT OLLIE_WAYLAND)
message(STATUS "Wayland pointer-warp support disabled (missing scanner / "
"wayland-client / Qt6 GuiPrivate / protocol xml)")
list(APPEND OLLIE_PLUGIN_SOURCES waylandcursorwarp_stub.cpp)
endif()
kcoreaddons_add_plugin(olliepalette
SOURCES ollieplugin.cpp ollieplugin.h
SOURCES ${OLLIE_PLUGIN_SOURCES}
INSTALL_NAMESPACE "kf6/ktexteditor"
)
target_include_directories(olliepalette PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(olliepalette PRIVATE
palette
radial
@ -26,3 +78,12 @@ target_link_libraries(olliepalette PRIVATE
KF6::I18n
Qt6::Widgets
)
if(OLLIE_WAYLAND)
target_compile_definitions(olliepalette PRIVATE OLLIE_WAYLAND=1)
target_include_directories(olliepalette PRIVATE ${WAYLAND_CLIENT_INCLUDE_DIRS})
target_link_libraries(olliepalette PRIVATE
ollie_wayland_proto
Qt6::GuiPrivate
${WAYLAND_CLIENT_LIBRARIES})
endif()

View File

@ -39,6 +39,7 @@
#include <QTimer>
#include <QUrl>
#include <QWidget>
#include <QWindow>
namespace katecustom
{
@ -1166,9 +1167,28 @@ void OllieView::warpCursorToActiveView()
if (!view) {
return;
}
// KTextEditor::View is a QWidget; center the pointer in its editing area.
const QPoint center = view->mapToGlobal(view->rect().center());
QCursor::setPos(center);
// Target: the centre of the new active view, in the top-level window's
// coordinates. Kate's split panes are child widgets of one top-level window
// (one wl_surface), so this is the pane centre within that single surface.
QWidget *top = view->window();
if (!top) {
return;
}
const QPoint centerInView = view->rect().center();
const QPoint centerInTop = view->mapTo(top, centerInView);
// Wayland forbids QCursor::setPos; use the pointer-constraints warp against
// the top-level surface. On X11 (or if the Wayland path is unavailable),
// fall back to the direct warp, which works there.
if (WaylandCursorWarp::isWaylandSession()) {
if (QWindow *win = top->windowHandle()) {
if (m_cursorWarp.warp(win, QPointF(centerInTop))) {
return;
}
}
return; // Wayland but warp unavailable — nothing more we can do.
}
QCursor::setPos(top->mapToGlobal(centerInTop));
}
// ---------------------------------------------------------------------------

View File

@ -17,6 +17,7 @@
#include "kateprojectbridge.h"
#include "projectindexer.h"
#include "palettemodel.h" // PaletteItem
#include "waylandcursorwarp.h"
#include <QObject>
#include <QPointer>
@ -151,6 +152,7 @@ private:
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin
ProjectIndexer m_indexer; // async, cached file/symbol indexing
WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11)
};
} // namespace katecustom

View File

@ -0,0 +1,156 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "waylandcursorwarp.h"
#include <QGuiApplication>
#include <QTimer>
#include <QWindow>
#include <qpa/qplatformnativeinterface.h>
#include <QtGui/qguiapplication_platform.h> // QNativeInterface::QWaylandApplication
#include <wayland-client.h>
#include "wayland-pointer-constraints-unstable-v1-client-protocol.h"
namespace katecustom
{
namespace
{
// --- registry listener trampoline ---------------------------------------
void registryGlobal(void *data, wl_registry *registry, uint32_t name,
const char *interface, uint32_t version)
{
static_cast<WaylandCursorWarp *>(data)->onGlobal(registry, name, interface, version);
}
void registryGlobalRemove(void *, wl_registry *, uint32_t) {}
const wl_registry_listener kRegistryListener = {
registryGlobal,
registryGlobalRemove,
};
// Pull a wl_* handle out of Qt's Wayland application native interface.
QNativeInterface::QWaylandApplication *waylandApp()
{
return qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>();
}
} // namespace
WaylandCursorWarp::WaylandCursorWarp(QObject *parent)
: QObject(parent)
{
}
WaylandCursorWarp::~WaylandCursorWarp()
{
if (m_constraints) {
zwp_pointer_constraints_v1_destroy(m_constraints);
m_constraints = nullptr;
}
}
bool WaylandCursorWarp::isWaylandSession()
{
return QGuiApplication::platformName().startsWith(QLatin1String("wayland"));
}
void WaylandCursorWarp::onGlobal(wl_registry *registry, uint32_t name,
const char *interface, uint32_t version)
{
if (qstrcmp(interface, zwp_pointer_constraints_v1_interface.name) == 0) {
// Bind at most the version we were built against.
const uint32_t bindVersion =
qMin<uint32_t>(version, zwp_pointer_constraints_v1_interface.version);
m_constraints = static_cast<zwp_pointer_constraints_v1 *>(
wl_registry_bind(registry, name, &zwp_pointer_constraints_v1_interface,
bindVersion));
}
}
bool WaylandCursorWarp::ensureConstraints()
{
if (m_constraints) {
return true;
}
if (m_triedBind) {
return false; // already looked; the compositor does not offer it
}
m_triedBind = true;
auto *app = waylandApp();
if (!app) {
return false;
}
wl_display *display = app->display();
if (!display) {
return false;
}
wl_registry *registry = wl_display_get_registry(display);
if (!registry) {
return false;
}
wl_registry_add_listener(registry, &kRegistryListener, this);
// Round-trip so the global advertisement is processed and onGlobal() runs.
wl_display_roundtrip(display);
wl_registry_destroy(registry);
return m_constraints != nullptr;
}
bool WaylandCursorWarp::warp(QWindow *window, const QPointF &surfaceLocalPos)
{
if (!window || !isWaylandSession()) {
return false;
}
if (!ensureConstraints()) {
return false;
}
auto *app = waylandApp();
if (!app) {
return false;
}
wl_pointer *pointer = app->pointer();
wl_display *display = app->display();
if (!pointer || !display) {
return false;
}
QPlatformNativeInterface *pni = QGuiApplication::platformNativeInterface();
if (!pni) {
return false;
}
auto *surface = static_cast<wl_surface *>(
pni->nativeResourceForWindow(QByteArrayLiteral("surface"), window));
if (!surface) {
return false;
}
// Lock the pointer to the surface it is already over, hint the target
// position (surface-local), commit so the double-buffered hint is pending,
// then release the lock — the compositor warps to the hint on unlock.
zwp_locked_pointer_v1 *lock = zwp_pointer_constraints_v1_lock_pointer(
m_constraints, surface, pointer, nullptr,
ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT);
if (!lock) {
return false;
}
zwp_locked_pointer_v1_set_cursor_position_hint(
lock, wl_fixed_from_double(surfaceLocalPos.x()),
wl_fixed_from_double(surfaceLocalPos.y()));
wl_surface_commit(surface);
wl_display_flush(display);
// Release on the next event-loop turn: the lock must have been applied by
// the compositor (pointer is over the surface) before we unlock, otherwise
// the position hint is discarded. Destroying later keeps the sequence
// compatible with compositors that apply the hint strictly on unlock.
QTimer::singleShot(0, this, [lock, display]() {
zwp_locked_pointer_v1_destroy(lock);
wl_display_flush(display);
});
return true;
}
} // namespace katecustom

View File

@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* WaylandCursorWarp — warp the pointer on Wayland via the pointer-constraints
* protocol.
*
* Wayland forbids the unconditional pointer warp that QCursor::setPos() uses on
* X11, so that call is a silent no-op under a native Wayland session. The
* sanctioned way to reposition the cursor is zwp_pointer_constraints_v1: lock
* the pointer to the surface it is already over, set a surface-local position
* hint, then release the lock — the compositor warps the pointer to the hint on
* unlock (this is exactly how plan9port/acme moves the cursor on Wayland).
*
* Kate's split panes are child widgets of a single top-level window, i.e. one
* wl_surface, so moving the pointer between panes is a warp *within* that
* surface — a plain geometry problem. The pointer is already over the window
* after a mouse-driven radial gesture, which is the precondition the protocol
* requires for the lock to activate.
*
* All handles come from public Qt native interfaces (QWaylandApplication) plus
* the QPA QPlatformNativeInterface "surface" resource. Everything is guarded:
* if the compositor lacks the global, or the surface/pointer is unavailable,
* warp() returns false and the caller falls back (or no-ops).
*/
#ifndef KATECUSTOM_WAYLANDCURSORWARP_H
#define KATECUSTOM_WAYLANDCURSORWARP_H
#include <QObject>
#include <QPointF>
class QWindow;
struct wl_registry;
struct zwp_pointer_constraints_v1;
namespace katecustom
{
class WaylandCursorWarp : public QObject
{
Q_OBJECT
public:
explicit WaylandCursorWarp(QObject *parent = nullptr);
~WaylandCursorWarp() override;
/*! True if the running session is Wayland (QGuiApplication platform). */
static bool isWaylandSession();
/*!
* Warp the pointer to \a surfaceLocalPos (coordinates relative to the top
* left of \a window's surface, in logical pixels). Returns true if the
* warp was issued, false if the Wayland machinery was unavailable (caller
* should fall back to QCursor::setPos on X11, or give up on Wayland).
*/
bool warp(QWindow *window, const QPointF &surfaceLocalPos);
// Registry callback (public so the C trampoline can reach it).
void onGlobal(wl_registry *registry, uint32_t name, const char *interface,
uint32_t version);
private:
bool ensureConstraints();
zwp_pointer_constraints_v1 *m_constraints = nullptr;
bool m_triedBind = false;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* Stub WaylandCursorWarp for builds without Wayland support (no scanner /
* wayland-client / Qt GuiPrivate / protocol xml). warp() always fails, so the
* caller falls back to QCursor::setPos (X11) or no-ops. isWaylandSession() is
* still answered from the Qt platform name.
*/
#include "waylandcursorwarp.h"
#include <QGuiApplication>
namespace katecustom
{
WaylandCursorWarp::WaylandCursorWarp(QObject *parent)
: QObject(parent)
{
}
WaylandCursorWarp::~WaylandCursorWarp() = default;
bool WaylandCursorWarp::isWaylandSession()
{
return QGuiApplication::platformName().startsWith(QLatin1String("wayland"));
}
void WaylandCursorWarp::onGlobal(wl_registry *, uint32_t, const char *, uint32_t)
{
}
bool WaylandCursorWarp::ensureConstraints()
{
return false;
}
bool WaylandCursorWarp::warp(QWindow *, const QPointF &)
{
return false; // no Wayland support compiled in
}
} // namespace katecustom