diff --git a/README.md b/README.md index 8c63bb6..ffa29a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index 09732be..bbed2bb 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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`, diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 32855de..c26f8a3 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -17,8 +17,11 @@ #include #include +#include +#include #include #include +#include #include #include @@ -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(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(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); + } + + // 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) diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 3857565..cef9c66 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -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 m_radialDefs; + QHash m_buttonToRadial; // Qt::MouseButton (int) -> def index QHash> m_actionsById; }; diff --git a/src/radial/radialconfig.cpp b/src/radial/radialconfig.cpp index b233ede..c51e3e0 100644 --- a/src/radial/radialconfig.cpp +++ b/src/radial/radialconfig.cpp @@ -49,6 +49,7 @@ QList 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 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"); diff --git a/src/radial/radialconfig.h b/src/radial/radialconfig.h index f323c4a..9751403 100644 --- a/src/radial/radialconfig.h +++ b/src/radial/radialconfig.h @@ -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 diff --git a/src/radial/radialmenu.cpp b/src/radial/radialmenu.cpp index ab191f6..36bf8ec 100644 --- a/src/radial/radialmenu.cpp +++ b/src/radial/radialmenu.cpp @@ -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(); } diff --git a/src/radial/test_radialconfig.cpp b/src/radial/test_radialconfig.cpp index 1e28c82..0c14607 100644 --- a/src/radial/test_radialconfig.cpp +++ b/src/radial/test_radialconfig.cpp @@ -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 defs = RadialConfig::fromJson(json); + QCOMPARE(defs.size(), 1); + QCOMPARE(defs.first().mouseButton, QStringLiteral("RightButton")); + } + void builtinDefaultIsUsable() { const QList 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;