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.
This commit is contained in:
Levi Neely 2026-10-08 19:41:09 +02:00
parent cd72aa043b
commit 3bf6f9425f
12 changed files with 526 additions and 317 deletions

View File

@ -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()

View File

@ -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 <QAction>
#include <QApplication>
#include <QCursor>
#include <QDateTime>
#include <QDir>
#include <QEvent>
@ -37,35 +34,14 @@
#include <QMouseEvent>
#include <QSet>
#include <QStandardPaths>
#include <QTimer>
#include <QUrl>
#include <QWidget>
#include <QWindow>
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::ActionEntry> 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<int>(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<QMouseEvent *>(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<QMouseEvent *>(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<QMouseEvent *>(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<int>(Qt::MiddleButton))) {
return true;
}
// else: do not return — let the radial-trigger dispatch handle it.
}
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<int>(Qt::RightButton))) {
KTextEditor::View *view = m_mainWindow->activeView();
auto *widget = qobject_cast<QWidget *>(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<QMouseEvent *>(event);
const int button = static_cast<int>(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<QWidget *>(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));
}
// ---------------------------------------------------------------------------

View File

@ -17,7 +17,6 @@
#include "kateprojectbridge.h"
#include "projectindexer.h"
#include "palettemodel.h" // PaletteItem
#include "waylandcursorwarp.h"
#include <QObject>
#include <QPointer>
@ -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<ActionEntry> 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<RadialDef> m_radialDefs;
QHash<int, int> 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

View File

@ -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()

View File

@ -151,10 +151,10 @@ QList<RadialDef> 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 = {

311
src/radial/radialplugin.cpp Normal file
View File

@ -0,0 +1,311 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "radialplugin.h"
#include "radialconfig.h"
#include "radialmenu.h"
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KActionCollection>
#include <KLocalizedString>
#include <KPluginFactory>
#include <KXMLGUIClient>
#include <KXMLGUIFactory>
#include <QAction>
#include <QApplication>
#include <QCursor>
#include <QEvent>
#include <QKeySequence>
#include <QMouseEvent>
#include <QTimer>
#include <QWidget>
#include <QWindow>
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<int>(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<KXMLGUIClient *> 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<QAction *> 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<QMouseEvent *>(event)->globalPosition().toPoint());
}
return true;
case QEvent::MouseButtonRelease:
m_gestureActive = false;
if (m_radial) {
m_radial->driveReleaseGlobal(
static_cast<QMouseEvent *>(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<int>(Qt::RightButton))) {
KTextEditor::View *view = m_mainWindow->activeView();
auto *widget = qobject_cast<QWidget *>(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<QMouseEvent *>(event);
const int button = static_cast<int>(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<QWidget *>(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<deft::RadialPlugin>();)
#include "radialplugin.moc"

90
src/radial/radialplugin.h Normal file
View File

@ -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 <KTextEditor/Plugin>
#include "radialconfig.h" // RadialDef
#include "waylandcursorwarp.h"
#include <QHash>
#include <QList>
#include <QObject>
#include <QPoint>
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<RadialDef> m_radialDefs;
QHash<int, int> 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

View File

@ -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"
}
}

View File

@ -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()