From 3bf6f9425f4b02a9aeab0d1a9aeb912c05710de4 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 19:41:09 +0200 Subject: [PATCH] radial: extract into standalone [deft:util] radial plugin (deft_radial.so) Stage 3a of the util split. radial becomes its own KTextEditor plugin: new src/radial/radialplugin.{h,cpp} (RadialPlugin/RadialView) + radialplugin.json ("[deft:util] radial"), built as deft_radial.so with its own event filter (mouse-button gesture + context-menu suppression + pane-focus pointer warp). The optional Wayland pointer-warp glue (waylandcursorwarp.* + the ECM wayland-scanner protocol lib) moves from src/plugin into src/radial, since the warp is only used to follow focus after a radial pane action. Door object names renamed ollie_* -> deft_* (deft_command_palette / deft_goto_file / deft_goto_symbol) so the radial resolves the sibling plugins' launcher actions via the GUI factory; built-in radial config + test updated. The core deft plugin drops radial code, members, the radial branches of its event filter, and the Wayland glue; it keeps palette + switch + acme keys. --- src/plugin/CMakeLists.txt | 65 +--- src/plugin/ollieplugin.cpp | 246 +------------- src/plugin/ollieplugin.h | 21 +- src/radial/CMakeLists.txt | 84 +++++ src/radial/radialconfig.cpp | 6 +- src/radial/radialplugin.cpp | 311 ++++++++++++++++++ src/radial/radialplugin.h | 90 +++++ src/radial/radialplugin.json | 14 + src/radial/test_radialconfig.cpp | 6 +- src/{plugin => radial}/waylandcursorwarp.cpp | 0 src/{plugin => radial}/waylandcursorwarp.h | 0 .../waylandcursorwarp_stub.cpp | 0 12 files changed, 526 insertions(+), 317 deletions(-) create mode 100644 src/radial/radialplugin.cpp create mode 100644 src/radial/radialplugin.h create mode 100644 src/radial/radialplugin.json rename src/{plugin => radial}/waylandcursorwarp.cpp (100%) rename src/{plugin => radial}/waylandcursorwarp.h (100%) rename src/{plugin => radial}/waylandcursorwarp_stub.cpp (100%) diff --git a/src/plugin/CMakeLists.txt b/src/plugin/CMakeLists.txt index e03f6cf..d3994e5 100644 --- a/src/plugin/CMakeLists.txt +++ b/src/plugin/CMakeLists.txt @@ -10,58 +10,11 @@ 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() - +# The core deft plugin: command palette (M-x), project file/symbol switchers, +# and the Acme line-editing keys + bracket/quote selection. (radial, sam, and +# plumb are now their own plugins; the util features here will split further.) kcoreaddons_add_plugin(deft - SOURCES ${OLLIE_PLUGIN_SOURCES} + SOURCES ollieplugin.cpp ollieplugin.h INSTALL_NAMESPACE "kf6/ktexteditor" ) @@ -69,7 +22,6 @@ target_include_directories(deft PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") target_link_libraries(deft PRIVATE palette - radial olliecommands_lib project_lib KF6::TextEditor @@ -78,12 +30,3 @@ target_link_libraries(deft PRIVATE KF6::I18n Qt6::Widgets ) - -if(OLLIE_WAYLAND) - target_compile_definitions(deft PRIVATE OLLIE_WAYLAND=1) - target_include_directories(deft PRIVATE ${WAYLAND_CLIENT_INCLUDE_DIRS}) - target_link_libraries(deft PRIVATE - ollie_wayland_proto - Qt6::GuiPrivate - ${WAYLAND_CLIENT_LIBRARIES}) -endif() diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 7302309..67985df 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -5,8 +5,6 @@ #include "palettewidget.h" #include "frecencystore.h" -#include "radialmenu.h" -#include "radialconfig.h" #include "olliecommands.h" #include "projectindex.h" #include "symbolindex.h" @@ -26,7 +24,6 @@ #include #include -#include #include #include #include @@ -37,35 +34,14 @@ #include #include #include -#include #include #include -#include namespace deft { namespace { -// True for the Kate actions that change which split pane is active: moving -// focus between panes, creating a split (Kate activates the new pane), or -// closing a pane (Kate activates a neighbour). After one of these fires we warp -// the mouse pointer onto the now-active pane so the cursor follows the focus -// (mouse-first UX). -bool isPaneFocusAction(const QString &id) -{ - return id == QLatin1String("go_left_split_view") - || id == QLatin1String("go_right_split_view") - || id == QLatin1String("go_upward_split_view") - || id == QLatin1String("go_downward_split_view") - || id == QLatin1String("go_next_split_view") - || id == QLatin1String("go_prev_split_view") - || id == QLatin1String("view_split_vert") - || id == QLatin1String("view_split_horiz") - || id == QLatin1String("view_close_current_space") - || id == QLatin1String("view_close_others"); -} - // Bracket pairs understood by the double-click selection feature. QChar matchingBracket(QChar c, bool &isOpener) { @@ -302,15 +278,15 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) // ambiguous because nothing is registered. The QActions are kept only for // their objectName/label (command-palette harvest, radial escape-hatch). auto *openAction = new QAction(i18n("Command Palette (M-x) [Alt+X]"), this); - openAction->setObjectName(QStringLiteral("ollie_command_palette")); + openAction->setObjectName(QStringLiteral("deft_command_palette")); connect(openAction, &QAction::triggered, this, &OllieView::showCommandPalette); auto *gotoFileAction = new QAction(i18n("Go to File (project) [Alt+P]"), this); - gotoFileAction->setObjectName(QStringLiteral("ollie_goto_file")); + gotoFileAction->setObjectName(QStringLiteral("deft_goto_file")); connect(gotoFileAction, &QAction::triggered, this, &OllieView::showFileSwitcher); auto *gotoSymbolAction = new QAction(i18n("Go to Symbol (project) [Alt+G]"), this); - gotoSymbolAction->setObjectName(QStringLiteral("ollie_goto_symbol")); + gotoSymbolAction->setObjectName(QStringLiteral("deft_goto_symbol")); connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher); // Keep them discoverable as window actions (no shortcut attached). @@ -344,11 +320,9 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) [this]() { m_indexer.invalidateAll(); }); } - setupRadials(); - - // One application-wide filter serves both the mouse-button radial gesture - // and the Acme-style left+middle chord, catching presses no matter which - // internal child of the view actually receives them. + // Application-wide filter for the Acme line-editing keys, the launcher + // doors (Alt+X/P/G), and the Acme mouse chords, catching events no matter + // which internal child of the view actually receives them. qApp->installEventFilter(this); } @@ -357,7 +331,6 @@ OllieView::~OllieView() delete m_palette; delete m_filePalette; delete m_symbolPalette; - delete m_radial; } QList OllieView::collectActions() const @@ -850,45 +823,6 @@ QAction *OllieView::findActionByName(const QString &nameOrText) const return nullptr; } -void OllieView::setupRadials() -{ - // Prefer the user's config (~/.config/olliepalette/radials.json); fall back - // to the built-in set when it is absent, empty, or invalid. - m_radialDefs = RadialConfig::load(); - - QWidget *w = m_mainWindow->window(); - - for (int i = 0; i < m_radialDefs.size(); ++i) { - const RadialDef &def = m_radialDefs.at(i); - - // Key trigger (weapon-wheel style). - if (!def.trigger.isEmpty()) { - auto *trigger = new QAction(this); - trigger->setObjectName(QStringLiteral("ollie_radial_") + def.name); - trigger->setText(i18n("Radial Menu: %1", def.name)); - trigger->setShortcut(QKeySequence(def.trigger)); - trigger->setShortcutContext(Qt::ApplicationShortcut); - connect(trigger, &QAction::triggered, this, [this, i]() { showRadial(i); }); - if (w) { - w->addAction(trigger); - } - } - - // Mouse-button gesture (the natural radial trigger). - if (!def.mouseButton.isEmpty()) { - Qt::MouseButton btn = Qt::NoButton; - if (def.mouseButton == QLatin1String("RightButton")) { - btn = Qt::RightButton; - } else if (def.mouseButton == QLatin1String("MiddleButton")) { - btn = Qt::MiddleButton; - } - if (btn != Qt::NoButton) { - m_buttonToRadial.insert(static_cast(btn), i); - } - } - } -} - bool OllieView::selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos) { if (!view) { @@ -1074,39 +1008,13 @@ bool OllieView::eventFilter(QObject *obj, QEvent *event) } } - // While a drag gesture is active, drive the radial directly from the raw - // events: the opening press leaves an implicit grab on the editor view, so - // the live move/release events are delivered there, and this application - // filter is the one place that reliably sees them. - if (m_gestureActive) { - switch (event->type()) { - case QEvent::MouseMove: - if (m_radial) { - m_radial->driveHoverGlobal( - static_cast(event)->globalPosition().toPoint()); - } - return true; - case QEvent::MouseButtonRelease: - m_gestureActive = false; - m_swallowMiddleRelease = false; // the gesture consumed this release - if (m_radial) { - m_radial->driveReleaseGlobal( - static_cast(event)->globalPosition().toPoint()); - } - return true; - default: - break; - } - } - // Acme-style left-button chords, plus suppression of X11 primary-selection // paste. Button 1 (left) held while selecting is the anchor: // left + middle tap = Cut, left + right tap = Paste. // Both use edit_cut / edit_paste, which use the standard KDE clipboard - // (Klipper-backed). The right-button chord is free because the radial - // gesture is already suppressed while the left button is held. Middle-button - // events in the view are consumed on BOTH press and release to kill the - // middle-click primary paste (it fires on release, not just press). + // (Klipper-backed). Middle-button events in the view are consumed on BOTH + // press and release to kill the middle-click primary paste (it fires on + // release, not just press). if (event->type() == QEvent::MouseButtonRelease) { auto *mev = static_cast(event); if (mev->button() == Qt::LeftButton) { @@ -1132,154 +1040,30 @@ bool OllieView::eventFilter(QObject *obj, QEvent *event) paste->trigger(); } m_swallowRightRelease = true; - return true; // consume: chord, not a radial / context menu + return true; // consume: chord, not a context menu } if (mev->button() == Qt::MiddleButton && inView) { if (m_leftDownInView) { // left + middle = Cut the selection (Acme chord). Consume both - // press and release; never a radial. + // press and release. if (QAction *cut = findActionByName(QStringLiteral("edit_cut"))) { cut->trigger(); } m_swallowMiddleRelease = true; return true; } - // A plain middle press (no chord): always suppress the X11 primary - // paste that fires on release. If the middle button is bound to a - // radial, fall through to the radial dispatch below; otherwise - // consume the press here too. + // A plain middle press (no chord): suppress the X11 primary paste + // that fires on release, and consume the press. m_swallowMiddleRelease = true; - if (!m_buttonToRadial.contains(static_cast(Qt::MiddleButton))) { - return true; - } - // else: do not return — let the radial-trigger dispatch handle it. + return true; } if (mev->button() == Qt::LeftButton && inView) { m_leftDownInView = true; // anchor set; selection proceeds normally } } - // Suppress the context menu that would otherwise follow an RMB gesture. - if (event->type() == QEvent::ContextMenu - && m_buttonToRadial.contains(static_cast(Qt::RightButton))) { - KTextEditor::View *view = m_mainWindow->activeView(); - auto *widget = qobject_cast(obj); - if (view && widget && view->isAncestorOf(widget)) { - return true; - } - return QObject::eventFilter(obj, event); - } - - if (event->type() != QEvent::MouseButtonPress) { - return QObject::eventFilter(obj, event); - } - auto *me = static_cast(event); - const int button = static_cast(me->button()); - if (!m_buttonToRadial.contains(button)) { - return QObject::eventFilter(obj, event); - } - - // Only hijack presses that land inside the active view's editing area. - KTextEditor::View *view = m_mainWindow->activeView(); - auto *widget = qobject_cast(obj); - if (!view || !widget || !view->isAncestorOf(widget)) { - return QObject::eventFilter(obj, event); - } - - // Suppress the radial when the left button is already held: the user is - // mid-selection (or setting up a left+middle chord), not starting a clean - // radial gesture. buttons() reports the other buttons down at press time. - if (m_leftDownInView || (me->buttons() & Qt::LeftButton)) { - return QObject::eventFilter(obj, event); - } - - // Pop the radial at the press position and begin the drag gesture. The - // press is consumed so the context menu does not appear and the caret is - // not moved (an intentional, configured trade-off). - showRadialAt(m_buttonToRadial.value(button), me->globalPosition().toPoint()); - m_gestureActive = true; - return true; -} - -void OllieView::showRadial(int defIndex) -{ - QPoint global; - if (KTextEditor::View *view = m_mainWindow->activeView()) { - global = view->mapToGlobal(view->cursorPositionCoordinates()); - } else if (QWidget *w = m_mainWindow->window()) { - global = w->mapToGlobal(w->rect().center()); - } - showRadialAt(defIndex, global); -} - -void OllieView::showRadialAt(int defIndex, const QPoint &globalPos) -{ - if (defIndex < 0 || defIndex >= m_radialDefs.size()) { - return; - } - if (!m_radial) { - m_radial = new RadialMenu(m_mainWindow->window()); - connect(m_radial, &RadialMenu::activatedId, this, &OllieView::onRadialActivated); - } - m_radial->popup(m_radialDefs.at(defIndex).root, globalPos); -} - -void OllieView::onRadialActivated(const QString &actionId) -{ - // The launcher doors are reachable from inside a radial as special slices. - if (actionId == QLatin1String("ollie_command_palette")) { - showCommandPalette(); - return; - } - if (actionId == QLatin1String("ollie_goto_file")) { - showFileSwitcher(); - return; - } - if (actionId == QLatin1String("ollie_goto_symbol")) { - showSymbolSwitcher(); - return; - } - if (QAction *a = findActionByName(actionId)) { - a->trigger(); - // If this changed the active pane (focus move, or a split that creates - // and activates a new pane), warp the pointer onto it so the mouse - // follows the focus. Deferred by ~a frame so Kate has finished - // activating — and, for a freshly created split, laying out — the pane. - if (isPaneFocusAction(actionId)) { - QTimer::singleShot(16, this, [this]() { warpCursorToActiveView(); }); - } - } -} - -void OllieView::warpCursorToActiveView() -{ - KTextEditor::View *view = m_mainWindow->activeView(); - if (!view) { - return; - } - // 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)); + return QObject::eventFilter(obj, event); } // --------------------------------------------------------------------------- diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index cd2608c..d6f8609 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -17,7 +17,6 @@ #include "kateprojectbridge.h" #include "projectindexer.h" #include "palettemodel.h" // PaletteItem -#include "waylandcursorwarp.h" #include #include @@ -38,9 +37,7 @@ namespace deft { class PaletteWidget; -class RadialMenu; class OllieCommands; -struct RadialDef; class OlliePlugin : public KTextEditor::Plugin { @@ -92,9 +89,6 @@ private: // small margin), anchored near the top. void positionPalette(PaletteWidget *palette); - // Move the OS pointer onto the center of the active view — used after a - // radial pane-focus action so the mouse follows the keyboard focus. - void warpCursorToActiveView(); // One harvested, runnable action with the provenance needed to build a // unique id and to disambiguate labels shared by several commands. struct ActionEntry { @@ -109,16 +103,10 @@ private: // window's GUI factory clients, deduplicated by pointer identity. QList collectActions() const; - // Find a Kate action by its raw objectName across all GUI clients. Used to - // resolve radial-menu slice ids (which reference Kate action names). + // Find a Kate action by its raw objectName across all GUI clients. Used by + // the Acme mouse chords to invoke edit_cut / edit_paste. QAction *findActionByName(const QString &objectName) const; - // Build the configured radials and bind each to its trigger key. - void setupRadials(); - void showRadial(int defIndex); // centre on the caret - void showRadialAt(int defIndex, const QPoint &globalPos); // centre on a point - void onRadialActivated(const QString &actionId); - // Acme-style line-editing keys, handled in the event filter (no QActions, // so there is nothing for Qt to report as an ambiguous shortcut). void acmeBeginningOfLine(); // C-a @@ -142,10 +130,6 @@ private: QString m_fileSwitcherRoot; // project root for the open switcher PaletteWidget *m_symbolPalette = nullptr; // project "Go to Symbol" switcher QString m_symbolSwitcherRoot; // project root for the symbol switcher - RadialMenu *m_radial = nullptr; - QList m_radialDefs; - QHash m_buttonToRadial; // Qt::MouseButton (int) -> def index - bool m_gestureActive = false; // an RMB drag gesture is in progress bool m_leftDownInView = false; // LMB held inside the view (Acme chord anchor) bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord @@ -153,7 +137,6 @@ 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 deft diff --git a/src/radial/CMakeLists.txt b/src/radial/CMakeLists.txt index 517dd71..7ad6032 100644 --- a/src/radial/CMakeLists.txt +++ b/src/radial/CMakeLists.txt @@ -1,3 +1,9 @@ +# radial — the radial caret menu. +# +# `radial` (the static lib) is the pure model + menu widget + config loader, +# unit tested headless. `deft_radial` is the Kate plugin ([deft:util] radial); +# it also carries the optional Wayland pointer-warp glue used to follow the +# keyboard focus between split panes after a pane-focus radial action. add_library(radial STATIC radialmodel.cpp radialmodel.h @@ -18,3 +24,81 @@ if(Qt6Test_FOUND) target_link_libraries(test_radialconfig PRIVATE radial Qt6::Test Qt6::Widgets) add_test(NAME radialconfig COMMAND test_radialconfig) endif() + +# --- [deft:util] radial — the Kate plugin ---------------------------------- +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(DEFT_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(RADIAL_PLUGIN_SOURCES radialplugin.cpp radialplugin.h waylandcursorwarp.h) +set(DEFT_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(DEFT_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(deft_wayland_proto STATIC ${DEFT_WAYLAND_PROTO_SRCS}) + target_include_directories(deft_wayland_proto PUBLIC + "${CMAKE_CURRENT_BINARY_DIR}" ${WAYLAND_CLIENT_INCLUDE_DIRS}) + set_target_properties(deft_wayland_proto PROPERTIES + POSITION_INDEPENDENT_CODE ON + LINKER_LANGUAGE C + AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) + list(APPEND RADIAL_PLUGIN_SOURCES waylandcursorwarp.cpp) + set(DEFT_WAYLAND ON) + endif() +endif() + +if(NOT DEFT_WAYLAND) + message(STATUS "Wayland pointer-warp support disabled (missing scanner / " + "wayland-client / Qt6 GuiPrivate / protocol xml)") + list(APPEND RADIAL_PLUGIN_SOURCES waylandcursorwarp_stub.cpp) +endif() + +kcoreaddons_add_plugin(deft_radial + SOURCES ${RADIAL_PLUGIN_SOURCES} + INSTALL_NAMESPACE "kf6/ktexteditor" +) +target_include_directories(deft_radial PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +target_link_libraries(deft_radial PRIVATE + radial + KF6::TextEditor + KF6::XmlGui + KF6::CoreAddons + KF6::I18n + Qt6::Widgets +) + +if(DEFT_WAYLAND) + target_compile_definitions(deft_radial PRIVATE OLLIE_WAYLAND=1) + target_include_directories(deft_radial PRIVATE ${WAYLAND_CLIENT_INCLUDE_DIRS}) + target_link_libraries(deft_radial PRIVATE + deft_wayland_proto + Qt6::GuiPrivate + ${WAYLAND_CLIENT_LIBRARIES}) +endif() diff --git a/src/radial/radialconfig.cpp b/src/radial/radialconfig.cpp index e674d1a..024eeab 100644 --- a/src/radial/radialconfig.cpp +++ b/src/radial/radialconfig.cpp @@ -151,10 +151,10 @@ QList RadialConfig::builtinDefault() // Panel-opening switchers live in the "More" branch next to the palette // escape hatch — not the top ring of in-place actions — because they pull // attention away from the caret. Resolved directly by onRadialActivated. - leaf(QStringLiteral("File…"), QStringLiteral("ollie_goto_file")), - leaf(QStringLiteral("Sym…"), QStringLiteral("ollie_goto_symbol")), + leaf(QStringLiteral("File…"), QStringLiteral("deft_goto_file")), + leaf(QStringLiteral("Sym…"), QStringLiteral("deft_goto_symbol")), // Opens the full command palette (handled specially by the plugin). - leaf(QStringLiteral("M-x"), QStringLiteral("ollie_command_palette")), + leaf(QStringLiteral("M-x"), QStringLiteral("deft_command_palette")), }; edit.root.children = { diff --git a/src/radial/radialplugin.cpp b/src/radial/radialplugin.cpp new file mode 100644 index 0000000..1e85e0b --- /dev/null +++ b/src/radial/radialplugin.cpp @@ -0,0 +1,311 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "radialplugin.h" + +#include "radialconfig.h" +#include "radialmenu.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace deft +{ +namespace +{ + +// Kate pane-focus / split actions. After one of these the active pane changes, +// so the pointer is warped to follow the keyboard focus. +bool isPaneFocusAction(const QString &id) +{ + return id == QLatin1String("go_left_split_view") + || id == QLatin1String("go_right_split_view") + || id == QLatin1String("go_upward_split_view") + || id == QLatin1String("go_downward_split_view") + || id == QLatin1String("go_next_split_view") + || id == QLatin1String("go_prev_split_view") + || id == QLatin1String("view_split_vert") + || id == QLatin1String("view_split_horiz") + || id == QLatin1String("view_close_current_space") + || id == QLatin1String("view_close_others"); +} + +} // namespace + +// --------------------------------------------------------------------------- +// RadialPlugin +// --------------------------------------------------------------------------- +RadialPlugin::RadialPlugin(QObject *parent, const QVariantList &args) + : KTextEditor::Plugin(parent) +{ + Q_UNUSED(args); +} + +RadialPlugin::~RadialPlugin() = default; + +QObject *RadialPlugin::createView(KTextEditor::MainWindow *mainWindow) +{ + return new RadialView(this, mainWindow); +} + +// --------------------------------------------------------------------------- +// RadialView +// --------------------------------------------------------------------------- +RadialView::RadialView(RadialPlugin *plugin, KTextEditor::MainWindow *mainWindow) + : QObject(plugin) + , m_mainWindow(mainWindow) +{ + setupRadials(); + + // Application-wide filter: the opening mouse press leaves an implicit grab + // on the editor view, so the live move/release events are delivered there, + // and this filter is the one place that reliably sees them. + qApp->installEventFilter(this); +} + +RadialView::~RadialView() +{ + delete m_radial; +} + +void RadialView::setupRadials() +{ + // Prefer the user's config (~/.config/olliepalette/radials.json); fall back + // to the built-in set when it is absent, empty, or invalid. + m_radialDefs = RadialConfig::load(); + + QWidget *w = m_mainWindow->window(); + + for (int i = 0; i < m_radialDefs.size(); ++i) { + const RadialDef &def = m_radialDefs.at(i); + + // Key trigger (weapon-wheel style). + if (!def.trigger.isEmpty()) { + auto *trigger = new QAction(this); + trigger->setObjectName(QStringLiteral("deft_radial_") + def.name); + trigger->setText(i18n("Radial Menu: %1", def.name)); + trigger->setShortcut(QKeySequence(def.trigger)); + trigger->setShortcutContext(Qt::ApplicationShortcut); + connect(trigger, &QAction::triggered, this, [this, i]() { showRadial(i); }); + if (w) { + w->addAction(trigger); + } + } + + // Mouse-button gesture (the natural radial trigger). + if (!def.mouseButton.isEmpty()) { + Qt::MouseButton btn = Qt::NoButton; + if (def.mouseButton == QLatin1String("RightButton")) { + btn = Qt::RightButton; + } else if (def.mouseButton == QLatin1String("MiddleButton")) { + btn = Qt::MiddleButton; + } + if (btn != Qt::NoButton) { + m_buttonToRadial.insert(static_cast(btn), i); + } + } + } +} + +void RadialView::showRadial(int defIndex) +{ + QPoint global; + if (KTextEditor::View *view = m_mainWindow->activeView()) { + global = view->mapToGlobal(view->cursorPositionCoordinates()); + } else if (QWidget *w = m_mainWindow->window()) { + global = w->mapToGlobal(w->rect().center()); + } + showRadialAt(defIndex, global); +} + +void RadialView::showRadialAt(int defIndex, const QPoint &globalPos) +{ + if (defIndex < 0 || defIndex >= m_radialDefs.size()) { + return; + } + if (!m_radial) { + m_radial = new RadialMenu(m_mainWindow->window()); + connect(m_radial, &RadialMenu::activatedId, this, &RadialView::onRadialActivated); + } + m_radial->popup(m_radialDefs.at(defIndex).root, globalPos); +} + +void RadialView::onRadialActivated(const QString &actionId) +{ + // Launcher doors (palette / switchers) are reachable from inside a radial as + // special slices. Sibling deft plugins register these actions on the window; + // trigger them by objectName so no cross-plugin link is needed. + if (QAction *a = findActionByName(actionId)) { + a->trigger(); + // If this changed the active pane (focus move, or a split that creates + // and activates a new pane), warp the pointer onto it so the mouse + // follows the focus. Deferred by ~a frame so Kate has finished + // activating — and, for a freshly created split, laying out — the pane. + if (isPaneFocusAction(actionId)) { + QTimer::singleShot(16, this, [this]() { warpCursorToActiveView(); }); + } + } +} + +QAction *RadialView::findActionByName(const QString &nameOrText) const +{ + KXMLGUIFactory *factory = m_mainWindow->guiFactory(); + if (!factory || nameOrText.isEmpty()) { + return nullptr; + } + const QList clients = factory->clients(); + + // First pass: exact objectName match (fast, unambiguous). + for (KXMLGUIClient *client : clients) { + if (KActionCollection *coll = client->actionCollection()) { + if (QAction *a = coll->action(nameOrText)) { + return a; + } + } + } + + // Fallback: match by visible text, case-insensitively. Keeps radial slices + // working when a Kate action's objectName differs from our config id across + // versions (we can reference it by its menu label instead). + const QString wanted = nameOrText.trimmed(); + for (KXMLGUIClient *client : clients) { + KActionCollection *coll = client->actionCollection(); + if (!coll) { + continue; + } + const QList actions = coll->actions(); + for (QAction *a : actions) { + if (!a) { + continue; + } + const QString text = a->text().remove(QLatin1Char('&')).trimmed(); + if (text.compare(wanted, Qt::CaseInsensitive) == 0) { + return a; + } + } + } + return nullptr; +} + +void RadialView::warpCursorToActiveView() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + // 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)); +} + +bool RadialView::eventFilter(QObject *obj, QEvent *event) +{ + // While a drag gesture is active, drive the radial directly from the raw + // events: the opening press leaves an implicit grab on the editor view, so + // the live move/release events are delivered there, and this application + // filter is the one place that reliably sees them. + if (m_gestureActive) { + switch (event->type()) { + case QEvent::MouseMove: + if (m_radial) { + m_radial->driveHoverGlobal( + static_cast(event)->globalPosition().toPoint()); + } + return true; + case QEvent::MouseButtonRelease: + m_gestureActive = false; + if (m_radial) { + m_radial->driveReleaseGlobal( + static_cast(event)->globalPosition().toPoint()); + } + return true; + default: + break; + } + } + + // Suppress the context menu that would otherwise follow an RMB gesture. + if (event->type() == QEvent::ContextMenu + && m_buttonToRadial.contains(static_cast(Qt::RightButton))) { + KTextEditor::View *view = m_mainWindow->activeView(); + auto *widget = qobject_cast(obj); + if (view && widget && view->isAncestorOf(widget)) { + return true; + } + return QObject::eventFilter(obj, event); + } + + if (event->type() != QEvent::MouseButtonPress) { + return QObject::eventFilter(obj, event); + } + auto *me = static_cast(event); + const int button = static_cast(me->button()); + if (!m_buttonToRadial.contains(button)) { + return QObject::eventFilter(obj, event); + } + + // Only hijack presses that land inside the active view's editing area. + KTextEditor::View *view = m_mainWindow->activeView(); + auto *widget = qobject_cast(obj); + if (!view || !widget || !view->isAncestorOf(widget)) { + return QObject::eventFilter(obj, event); + } + + // Suppress the radial when the left button is already held: the user is + // mid-selection (or setting up an acme chord), not starting a clean radial + // gesture. buttons() reports the other buttons down at press time. + if (me->buttons() & Qt::LeftButton) { + return QObject::eventFilter(obj, event); + } + + // Pop the radial at the press position and begin the drag gesture. The + // press is consumed so the context menu does not appear and the caret is + // not moved (an intentional, configured trade-off). + showRadialAt(m_buttonToRadial.value(button), me->globalPosition().toPoint()); + m_gestureActive = true; + return true; +} + +} // namespace deft + +// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The +// matching .moc include is required in this translation unit. +K_PLUGIN_FACTORY_WITH_JSON(RadialPluginFactory, "radialplugin.json", + registerPlugin();) + +#include "radialplugin.moc" diff --git a/src/radial/radialplugin.h b/src/radial/radialplugin.h new file mode 100644 index 0000000..564a039 --- /dev/null +++ b/src/radial/radialplugin.h @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * [deft:util] radial — the radial caret menu plugin for Kate. + * + * RadialPlugin is the KTextEditor::Plugin entry point. For every MainWindow it + * creates a RadialView, which builds the configured radial menus (each bound to + * a key and/or a mouse button) and pops the right one at the caret or pointer. + * A radial slice triggers a Kate action by objectName; sibling deft plugins + * (palette, switch) expose their launcher actions on the window, so a radial can + * open them too. On a pane-focus action the pointer follows the keyboard focus. + */ +#ifndef DEFT_RADIALPLUGIN_H +#define DEFT_RADIALPLUGIN_H + +#include + +#include "radialconfig.h" // RadialDef +#include "waylandcursorwarp.h" + +#include +#include +#include +#include + +class QAction; + +namespace KTextEditor +{ +class MainWindow; +} + +namespace deft +{ + +class RadialMenu; + +class RadialPlugin : public KTextEditor::Plugin +{ + Q_OBJECT + +public: + explicit RadialPlugin(QObject *parent, const QVariantList &args = {}); + ~RadialPlugin() override; + + QObject *createView(KTextEditor::MainWindow *mainWindow) override; +}; + +class RadialView : public QObject +{ + Q_OBJECT + +public: + RadialView(RadialPlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~RadialView() override; + +private Q_SLOTS: + void onRadialActivated(const QString &actionId); + +private: + // Build the configured radials and bind each to its trigger key / button. + void setupRadials(); + void showRadial(int defIndex); // centre on the caret + void showRadialAt(int defIndex, const QPoint &globalPos); // centre on a point + + // Find a Kate action by its raw objectName (then visible text) across all + // GUI clients. Resolves radial-slice ids, including the launcher actions + // that sibling deft plugins register on the window. + QAction *findActionByName(const QString &objectName) const; + + // Move the OS pointer onto the center of the active view — used after a + // radial pane-focus action so the mouse follows the keyboard focus. + void warpCursorToActiveView(); + + // The application event filter: catches the configured mouse-button gesture + // inside the active view and drives the radial, and suppresses the context + // menu that would otherwise follow a right-button gesture. + bool eventFilter(QObject *obj, QEvent *event) override; + + KTextEditor::MainWindow *m_mainWindow; + RadialMenu *m_radial = nullptr; + QList m_radialDefs; + QHash m_buttonToRadial; // Qt::MouseButton (int) -> def index + bool m_gestureActive = false; // an RMB drag gesture is in progress + WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11) +}; + +} // namespace deft + +#endif diff --git a/src/radial/radialplugin.json b/src/radial/radialplugin.json new file mode 100644 index 0000000..3d35228 --- /dev/null +++ b/src/radial/radialplugin.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Levi Neely" + } + ], + "Description": "A radial caret menu: pop a configurable action wheel at the caret (key) or pointer (mouse button), select by moving and releasing.", + "Icon": "application-menu", + "License": "LGPL-2.0-or-later", + "Name": "[deft:util] radial", + "Version": "0.1.0" + } +} diff --git a/src/radial/test_radialconfig.cpp b/src/radial/test_radialconfig.cpp index bc0553f..5097712 100644 --- a/src/radial/test_radialconfig.cpp +++ b/src/radial/test_radialconfig.cpp @@ -168,9 +168,9 @@ private Q_SLOTS: ids << c.actionId; } } - QVERIFY(ids.contains(QStringLiteral("ollie_goto_file"))); - QVERIFY(ids.contains(QStringLiteral("ollie_goto_symbol"))); - QVERIFY(ids.contains(QStringLiteral("ollie_command_palette"))); + QVERIFY(ids.contains(QStringLiteral("deft_goto_file"))); + QVERIFY(ids.contains(QStringLiteral("deft_goto_symbol"))); + QVERIFY(ids.contains(QStringLiteral("deft_command_palette"))); } void loadFileReadsValidConfig() diff --git a/src/plugin/waylandcursorwarp.cpp b/src/radial/waylandcursorwarp.cpp similarity index 100% rename from src/plugin/waylandcursorwarp.cpp rename to src/radial/waylandcursorwarp.cpp diff --git a/src/plugin/waylandcursorwarp.h b/src/radial/waylandcursorwarp.h similarity index 100% rename from src/plugin/waylandcursorwarp.h rename to src/radial/waylandcursorwarp.h diff --git a/src/plugin/waylandcursorwarp_stub.cpp b/src/radial/waylandcursorwarp_stub.cpp similarity index 100% rename from src/plugin/waylandcursorwarp_stub.cpp rename to src/radial/waylandcursorwarp_stub.cpp