Radial: add right-mouse-button gesture trigger

RMB is the natural radial trigger. The plugin now installs an application
event filter (gated to the active view) that, on an RMB press inside the
editor, pops the radial at the pointer and consumes the press plus the
following ContextMenu event, suppressing Kate's context menu (an
intentional, user-approved trade-off). The radial grabs the mouse on popup
so an in-progress RMB drag is tracked and the release selects the slice.

Config gains a per-radial "button" field (RightButton/MiddleButton)
alongside the key "trigger"; the built-in editing radial binds both Alt+R
and RightButton. All 5 ctest suites green.
This commit is contained in:
Levi Neely 2026-10-07 16:43:52 +02:00
parent a98db2913c
commit 4be3ead30d
8 changed files with 133 additions and 34 deletions

View File

@ -63,8 +63,10 @@ install -D build/bin/kf6/ktexteditor/olliepalette.so \
Then in Kate: Settings → Configure Kate → Plugins → enable
**Ollie Command Palette**. Press **Alt+X** (M-x) to open the command palette;
type to filter, Enter or click to run. Press **Alt+R** to open the editing
radial menu at the caret; move the mouse toward a slice and release (or click) —
type to filter, Enter or click to run. Open the editing **radial menu** either
by pressing **Alt+R** (opens at the caret) or by **pressing the right mouse
button** in the editor (opens at the pointer; the normal context menu is
suppressed while this is enabled). Move toward a slice and release — or click;
a branch slice (marked ▸) opens a submenu, the centre hub goes back/cancels.
## Status
@ -80,9 +82,9 @@ See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
- Milestone 3 — KTextEditor plugin + M-x: **DONE.** Loadable `olliepalette.so`;
`Alt+X` opens the palette over every action in the window's GUI clients.
- Milestone 4 — radial caret menu: **DONE (first increment).** Multi-level
radial menu (`Alt+R` default) at the caret, mouse-driven selection, config
tree with per-radial triggers. Key/action triggers now; mouse-button and
gesture triggers next.
radial menu at the caret (`Alt+R`) or at the pointer via a **right-mouse
gesture**, mouse-driven selection, config tree with per-radial key + mouse
triggers. Free-form stroke gestures next.
- Milestone 5 — command vocabulary + project-as-directory + switchers: next.
## Layout

View File

@ -92,16 +92,21 @@ Therefore the palette is replaced, not extended, and the replacement is built on
ascends/cancels from the dead zone; Esc ascends/cancels.
- `src/radial/radialconfig.{h,cpp}`: JSON → radial tree parser (+ built-in
default) so multiple radials can be bound to different triggers. 8 tests.
- Wired into the plugin: `OllieView::setupRadials()` registers one trigger
action per configured radial (default **Alt+R** → editing radial). The radial
pops at the caret (`View::cursorPositionCoordinates()` → global); selection is
mouse-driven. A slice may reference any Kate action by objectName, resolved via
- Wired into the plugin: `OllieView::setupRadials()` registers a key trigger
(default **Alt+R**) and a **mouse-button gesture** (default **right mouse
button**) per configured radial. The RMB gesture is caught by an application
event filter gated to the active view; it pops the radial at the press point
and suppresses the normal context menu (`MouseButtonPress` + `ContextMenu`
consumed) — an intentional, configured trade-off. The radial grabs the mouse
on popup so an in-progress RMB drag is tracked and the release selects.
Key-triggered radials pop at the caret (`View::cursorPositionCoordinates()`).
- A slice may reference any Kate action by objectName, resolved via
`findActionByName`; the special `ollie_command_palette` slice opens the palette.
- Honours the agreed model: radials are triggerable by keys (weapon-wheel style),
and the config format carries a per-radial trigger so mouse-button / gesture
triggers can be added without code changes.
- Not yet done: mouse-button and gesture triggers (only key/action triggers so
far); user-file config loading (built-in default only); frecency.
- Honours the agreed model: radials are triggerable by keys (weapon-wheel style)
and by mouse buttons/gestures; the config carries both a key `trigger` and a
mouse `button` per radial.
- Not yet done: free-form stroke gestures (only button-press gesture so far);
user-file config loading (built-in default only); frecency.
### M5 — Command vocabulary + project model + switchers — NEXT
- `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`,

View File

@ -17,8 +17,11 @@
#include <KPluginFactory>
#include <QAction>
#include <QApplication>
#include <QEvent>
#include <QHash>
#include <QKeySequence>
#include <QMouseEvent>
#include <QSet>
#include <QWidget>
@ -260,24 +263,93 @@ void OllieView::setupRadials()
m_radialDefs = RadialConfig::builtinDefault();
QWidget *w = m_mainWindow->window();
bool wantMouseGestures = false;
for (int i = 0; i < m_radialDefs.size(); ++i) {
const RadialDef &def = m_radialDefs.at(i);
auto *trigger = new QAction(this);
trigger->setObjectName(QStringLiteral("ollie_radial_") + def.name);
trigger->setText(i18n("Radial Menu: %1", def.name));
// 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);
}
}
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);
wantMouseGestures = true;
}
}
}
// A single application-wide filter catches the gesture no matter which
// internal child of the view actually receives the press.
if (wantMouseGestures) {
qApp->installEventFilter(this);
}
}
bool OllieView::eventFilter(QObject *obj, QEvent *event)
{
// 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);
}
// Pop the radial at the press position and consume the event so the normal
// context menu does not appear (an intentional, configured trade-off).
showRadialAt(m_buttonToRadial.value(button), me->globalPosition().toPoint());
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;
@ -286,16 +358,7 @@ void OllieView::showRadial(int defIndex)
m_radial = new RadialMenu(m_mainWindow->window());
connect(m_radial, &RadialMenu::activatedId, this, &OllieView::onRadialActivated);
}
// Centre on the caret when a view is active; otherwise the window centre.
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());
}
m_radial->popup(m_radialDefs.at(defIndex).root, global);
m_radial->popup(m_radialDefs.at(defIndex).root, globalPos);
}
void OllieView::onRadialActivated(const QString &actionId)

View File

@ -76,13 +76,18 @@ private:
// Build the configured radials and bind each to its trigger key.
void setupRadials();
void showRadial(int defIndex);
void showRadial(int defIndex); // centre on the caret
void showRadialAt(int defIndex, const QPoint &globalPos); // centre on a point
void onRadialActivated(const QString &actionId);
// Catches configured mouse-button gestures inside the active view.
bool eventFilter(QObject *obj, QEvent *event) override;
KTextEditor::MainWindow *m_mainWindow;
PaletteWidget *m_palette = nullptr;
RadialMenu *m_radial = nullptr;
QList<RadialDef> m_radialDefs;
QHash<int, int> m_buttonToRadial; // Qt::MouseButton (int) -> def index
QHash<QString, QPointer<QAction>> m_actionsById;
};

View File

@ -49,6 +49,7 @@ QList<RadialDef> RadialConfig::fromJsonObject(const QJsonObject &obj)
RadialDef def;
def.name = robj.value(QStringLiteral("name")).toString();
def.trigger = robj.value(QStringLiteral("trigger")).toString();
def.mouseButton = robj.value(QStringLiteral("button")).toString();
const QJsonArray slices = robj.value(QStringLiteral("slices")).toArray();
for (const QJsonValue &sv : slices) {
@ -94,6 +95,7 @@ QList<RadialDef> RadialConfig::builtinDefault()
RadialDef edit;
edit.name = QStringLiteral("edit");
edit.trigger = QStringLiteral("Alt+R");
edit.mouseButton = QStringLiteral("RightButton"); // natural radial gesture
RadialNode more;
more.label = QStringLiteral("More");

View File

@ -12,7 +12,8 @@
* "radials": [
* {
* "name": "edit",
* "trigger": "Alt+R",
* "trigger": "Alt+R", // optional key sequence
* "button": "RightButton", // optional mouse-button gesture
* "slices": [
* { "label": "Cut", "action": "edit_cut" },
* { "label": "Copy", "action": "edit_copy" },
@ -39,8 +40,9 @@ namespace katecustom
struct RadialDef {
QString name;
QString trigger; // e.g. "Alt+R", "MiddleButton"
RadialNode root; // root.children are the top-level slices
QString trigger; // key sequence, e.g. "Alt+R" (empty = no key trigger)
QString mouseButton; // "RightButton" | "MiddleButton" (empty = none)
RadialNode root; // root.children are the top-level slices
};
class RadialConfig

View File

@ -60,6 +60,9 @@ void RadialMenu::popup(const RadialNode &root, const QPoint &globalCenter)
show();
raise();
setFocus();
// Grab the mouse so an in-progress button-drag (e.g. an RMB gesture that
// began on the editor) is tracked here and the release lands on us.
grabMouse();
update();
}
@ -195,12 +198,14 @@ void RadialMenu::keyPressEvent(QKeyEvent *event)
void RadialMenu::finishActivated(const QString &id)
{
releaseMouse();
hide();
Q_EMIT activatedId(id);
}
void RadialMenu::finishCancelled()
{
releaseMouse();
hide();
Q_EMIT cancelled();
}

View File

@ -94,12 +94,27 @@ private Q_SLOTS:
QVERIFY(!err.isEmpty());
}
void parsesMouseButtonTrigger()
{
const QByteArray json = R"({
"radials": [
{ "name": "edit", "button": "RightButton", "slices": [
{ "label": "Cut", "action": "edit_cut" }
]}
]
})";
const QList<RadialDef> defs = RadialConfig::fromJson(json);
QCOMPARE(defs.size(), 1);
QCOMPARE(defs.first().mouseButton, QStringLiteral("RightButton"));
}
void builtinDefaultIsUsable()
{
const QList<RadialDef> defs = RadialConfig::builtinDefault();
QVERIFY(!defs.isEmpty());
const RadialDef &d = defs.first();
QVERIFY(!d.trigger.isEmpty());
QCOMPARE(d.mouseButton, QStringLiteral("RightButton"));
QVERIFY(d.root.children.size() >= 4);
// Must contain at least one branch (the "More" submenu).
bool hasBranch = false;