From 1b56cca1a2a17e004ce60655812f38df5ef99872 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 17:43:39 +0200 Subject: [PATCH] Acme keys: resolve shortcut ambiguity by stripping conflicting bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding our Ctrl+A/E/H/U/W actions alongside Kate's own (Select All, Close, lowercase, …) left both claiming the same sequence, so Qt reported ambiguous shortcuts. clearConflictingShortcuts() now walks every GUI-client action collection and removes our sequences from any foreign action, also clearing their KActionCollection default shortcuts so they are not restored. The sweep runs at setup, once more deferred to the event loop (after the GUI factory merges all clients), and on every viewCreated so new documents don't re-introduce the conflict. Also implements the five Acme motions against the View/Document API with semantics ported from plan9port acme text.c. All 5 ctest suites green. --- docs/PLAN.md | 14 +++ src/plugin/ollieplugin.cpp | 189 ++++++++++++++++++++++++++++++++++++ src/plugin/ollieplugin.h | 13 +++ src/radial/radialconfig.cpp | 2 +- 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index fd2ae8b..2926f1b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -156,6 +156,20 @@ Therefore the palette is replaced, not extended, and the replacement is built on the user can go straight from a double-click (bracket- or word-select) into a left+middle / left+right chord without releasing. +### Acme line-editing keys — DONE +- Keys bound (overriding Kate/KDE defaults, `ApplicationShortcut`): + **C-a** beginning of line, **C-e** end of line, **C-h** erase char back, + **C-u** erase to line start, **C-w** erase word back. Semantics ported from + plan9port acme `text.c` (`^A`/`^U` stop at the line's newline; `^W` skips + non-alphanumerics then eats the alphanumeric run; isalnum excludes `_`). +- Implemented directly against the View/Document API (`cursorPosition`, + `setCursorPosition`, `removeText`, `characterAt`, `lineLength`). +- Shortcut conflicts (e.g. `Ctrl+A` = Select All, `Ctrl+W` = Close) are resolved + by stripping our sequences from every other GUI-client action + (`clearConflictingShortcuts`), which also clears their `KActionCollection` + defaults. The sweep runs at setup, deferred once via the event loop (after the + GUI factory merges all clients), and again on every `viewCreated`. + ### M5 — Command vocabulary + project model + switchers — NEXT - `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`, `uuid`, `case`, `pipe `, etc. These populate M-x and the radial. diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 8211d75..bcffd85 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -160,6 +160,7 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) } setupRadials(); + setupAcmeKeys(); // One application-wide filter serves both the mouse-button radial gesture // and the Acme-style left+middle chord, catching presses no matter which @@ -666,6 +667,194 @@ void OllieView::onRadialActivated(const QString &actionId) } } +// --------------------------------------------------------------------------- +// Acme-style line-editing keys +// +// Semantics ported from plan9port acme text.c (textbswidth / Ktype): +// ^A move to beginning of line (stop at the preceding newline) +// ^E move to end of line (stop at the next newline) +// ^H erase one character back +// ^U erase back to the beginning of line +// ^W erase back one word: skip trailing non-alphanumerics, then the +// alphanumeric run (Acme uses C isalnum, which excludes '_') +// --------------------------------------------------------------------------- + +void OllieView::setupAcmeKeys() +{ + QWidget *w = m_mainWindow->window(); + + struct Binding { + const char *name; + QKeySequence seq; + void (OllieView::*slot)(); + }; + const Binding bindings[] = { + {"ollie_acme_bol", QKeySequence(Qt::CTRL | Qt::Key_A), &OllieView::acmeBeginningOfLine}, + {"ollie_acme_eol", QKeySequence(Qt::CTRL | Qt::Key_E), &OllieView::acmeEndOfLine}, + {"ollie_acme_erase_char", QKeySequence(Qt::CTRL | Qt::Key_H), &OllieView::acmeEraseChar}, + {"ollie_acme_erase_line", QKeySequence(Qt::CTRL | Qt::Key_U), &OllieView::acmeEraseLine}, + {"ollie_acme_erase_word", QKeySequence(Qt::CTRL | Qt::Key_W), &OllieView::acmeEraseWord}, + }; + + for (const Binding &b : bindings) { + auto *action = new QAction(this); + action->setObjectName(QString::fromLatin1(b.name)); + action->setShortcut(b.seq); + // Override the Kate/KDE defaults (Select All, Close, …) inside the app. + action->setShortcutContext(Qt::ApplicationShortcut); + connect(action, &QAction::triggered, this, b.slot); + if (w) { + w->addAction(action); + } + m_acmeSequences.push_back(b.seq); + m_acmeActions.push_back(action); + } + + // Remove our sequences from any pre-existing Kate/KDE action so Qt does not + // report an ambiguous shortcut. New views bring their own copies of these + // actions, so re-run the sweep whenever a view is created. Also defer one + // sweep to the event loop, after the GUI factory has merged all clients. + clearConflictingShortcuts(); + QMetaObject::invokeMethod(this, &OllieView::clearConflictingShortcuts, + Qt::QueuedConnection); + connect(m_mainWindow, &KTextEditor::MainWindow::viewCreated, this, + [this](KTextEditor::View *) { clearConflictingShortcuts(); }); +} + +void OllieView::clearConflictingShortcuts() +{ + KXMLGUIFactory *factory = m_mainWindow->guiFactory(); + if (!factory) { + return; + } + + // Build the quick-lookup set of sequences we own. + const QList clients = factory->clients(); + for (KXMLGUIClient *client : clients) { + KActionCollection *coll = client->actionCollection(); + if (!coll) { + continue; + } + const QList actions = coll->actions(); + for (QAction *a : actions) { + if (!a || m_acmeActions.contains(a)) { + continue; // never strip our own actions + } + const QList scs = a->shortcuts(); + if (scs.isEmpty()) { + continue; + } + QList kept; + kept.reserve(scs.size()); + for (const QKeySequence &s : scs) { + if (!m_acmeSequences.contains(s)) { + kept.push_back(s); + } + } + if (kept.size() != scs.size()) { + a->setShortcuts(kept); + // Also clear any KActionCollection default so it is not restored. + KActionCollection::setDefaultShortcuts(a, kept); + } + } + } +} + +void OllieView::acmeBeginningOfLine() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + const KTextEditor::Cursor c = view->cursorPosition(); + view->setCursorPosition(KTextEditor::Cursor(c.line(), 0)); +} + +void OllieView::acmeEndOfLine() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + KTextEditor::Document *doc = view->document(); + const KTextEditor::Cursor c = view->cursorPosition(); + view->setCursorPosition(KTextEditor::Cursor(c.line(), doc->lineLength(c.line()))); +} + +void OllieView::acmeEraseChar() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + KTextEditor::Document *doc = view->document(); + KTextEditor::Cursor q0 = view->cursorPosition(); + KTextEditor::Cursor from = q0; + if (!stepBackward(doc, from)) { + return; // at start of document + } + doc->removeText(KTextEditor::Range(from, q0)); +} + +void OllieView::acmeEraseLine() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + KTextEditor::Document *doc = view->document(); + const KTextEditor::Cursor q0 = view->cursorPosition(); + // Erase back to the beginning of the line; if already at column 0, eat the + // preceding newline (join with the previous line), matching Acme's ^U. + if (q0.column() > 0) { + doc->removeText(KTextEditor::Range(KTextEditor::Cursor(q0.line(), 0), q0)); + } else if (q0.line() > 0) { + const int prevLen = doc->lineLength(q0.line() - 1); + doc->removeText(KTextEditor::Range(KTextEditor::Cursor(q0.line() - 1, prevLen), q0)); + } +} + +void OllieView::acmeEraseWord() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + KTextEditor::Document *doc = view->document(); + const KTextEditor::Cursor q0 = view->cursorPosition(); + + // Replicate textbswidth(^W): walk back, stopping at a newline (eating it + // only if we are standing on it); otherwise skip non-alphanumerics, then + // consume the following alphanumeric run. + KTextEditor::Cursor q = q0; + bool skipping = true; + while (true) { + KTextEditor::Cursor prev = q; + if (!stepBackward(doc, prev)) { + break; // start of document + } + // Crossing to a previous line is Acme's newline stop. Eat the newline + // only if we are standing on it (nothing erased yet on this line). + if (prev.line() != q.line()) { + if (q == q0) { + q = prev; + } + break; + } + const QChar r = doc->characterAt(prev); + const bool alnum = r.isLetterOrNumber(); + if (alnum && skipping) { + skipping = false; + } else if (!alnum && !skipping) { + break; + } + q = prev; + } + if (q != q0) { + doc->removeText(KTextEditor::Range(q, q0)); + } +} + } // namespace katecustom // K_PLUGIN_FACTORY generates the factory and the moc-level registration. The diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 03830cf..86b18a4 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -81,6 +81,17 @@ private: void showRadialAt(int defIndex, const QPoint &globalPos); // centre on a point void onRadialActivated(const QString &actionId); + // Acme-style line-editing keys, bound to override the Kate/KDE defaults. + void setupAcmeKeys(); + // Strip our Acme shortcuts from any other action that also claims them, to + // avoid Qt "ambiguous shortcut" warnings. Safe to call repeatedly. + void clearConflictingShortcuts(); + void acmeBeginningOfLine(); // C-a + void acmeEndOfLine(); // C-e + void acmeEraseChar(); // C-h + void acmeEraseLine(); // C-u + void acmeEraseWord(); // C-w + // Catches configured mouse-button gestures inside the active view. bool eventFilter(QObject *obj, QEvent *event) override; @@ -97,6 +108,8 @@ private: 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 + QList m_acmeSequences; // shortcuts we own (for conflict clearing) + QList> m_acmeActions; // our Acme actions (never cleared) QHash> m_actionsById; }; diff --git a/src/radial/radialconfig.cpp b/src/radial/radialconfig.cpp index 7cf4958..3a212d2 100644 --- a/src/radial/radialconfig.cpp +++ b/src/radial/radialconfig.cpp @@ -122,7 +122,7 @@ QList RadialConfig::builtinDefault() // Multi-cursor on every selected line: a quintessential in-place action // (select lines → gesture → keep typing). Referenced by visible text so // it resolves regardless of the Kate action's objectName. - leaf(QStringLiteral("Cursors to Line Ends"), + leaf(QStringLiteral("Multi-Cursor"), QStringLiteral("Add Cursors to Line Ends")), more, };