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:
parent
a98db2913c
commit
4be3ead30d
12
README.md
12
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
|
||||
|
|
|
|||
23
docs/PLAN.md
23
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`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue