Acme keys: resolve shortcut ambiguity by stripping conflicting bindings

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.
This commit is contained in:
Levi Neely 2026-10-07 17:43:39 +02:00
parent 9bcbece09a
commit 1b56cca1a2
4 changed files with 217 additions and 1 deletions

View File

@ -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 <shell>`, etc. These populate M-x and the radial.

View File

@ -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<KXMLGUIClient *> clients = factory->clients();
for (KXMLGUIClient *client : clients) {
KActionCollection *coll = client->actionCollection();
if (!coll) {
continue;
}
const QList<QAction *> actions = coll->actions();
for (QAction *a : actions) {
if (!a || m_acmeActions.contains(a)) {
continue; // never strip our own actions
}
const QList<QKeySequence> scs = a->shortcuts();
if (scs.isEmpty()) {
continue;
}
QList<QKeySequence> 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

View File

@ -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<QKeySequence> m_acmeSequences; // shortcuts we own (for conflict clearing)
QList<QPointer<QAction>> m_acmeActions; // our Acme actions (never cleared)
QHash<QString, QPointer<QAction>> m_actionsById;
};

View File

@ -122,7 +122,7 @@ QList<RadialDef> 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,
};