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, };