diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 528e4b2..253392e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ add_subdirectory(project) add_subdirectory(plumb) add_subdirectory(sam) if(KF6TextEditor_FOUND) + add_subdirectory(acme) add_subdirectory(commands) endif() add_subdirectory(plugin) diff --git a/src/acme/CMakeLists.txt b/src/acme/CMakeLists.txt new file mode 100644 index 0000000..f189426 --- /dev/null +++ b/src/acme/CMakeLists.txt @@ -0,0 +1,18 @@ +# acme — acme-style editing gestures ([deft:nineify] acme). +# +# No separate library: the plugin is self-contained (its bracket/quote/step +# helpers are file-local). Needs KF6::TextEditor for the view/document glue. +if(NOT KF6TextEditor_FOUND) + return() +endif() + +kcoreaddons_add_plugin(deft_acme + SOURCES acmeplugin.cpp acmeplugin.h + INSTALL_NAMESPACE "kf6/ktexteditor" +) +target_link_libraries(deft_acme PRIVATE + KF6::TextEditor + KF6::XmlGui + KF6::CoreAddons + Qt6::Widgets +) diff --git a/src/acme/acmeplugin.cpp b/src/acme/acmeplugin.cpp new file mode 100644 index 0000000..026c360 --- /dev/null +++ b/src/acme/acmeplugin.cpp @@ -0,0 +1,548 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "acmeplugin.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace deft +{ +namespace +{ + +// Bracket pairs understood by the double-click selection feature. +QChar matchingBracket(QChar c, bool &isOpener) +{ + switch (c.unicode()) { + case '(': isOpener = true; return QLatin1Char(')'); + case '[': isOpener = true; return QLatin1Char(']'); + case '{': isOpener = true; return QLatin1Char('}'); + case ')': isOpener = false; return QLatin1Char('('); + case ']': isOpener = false; return QLatin1Char('['); + case '}': isOpener = false; return QLatin1Char('{'); + default: isOpener = false; return QChar(); + } +} + +// Quote delimiters understood by the double-click selection feature. Unlike +// brackets these are symmetric (same opening and closing char) and do not nest, +// so matching is handled separately: scan the click's line for the enclosing +// pair of identical quote chars. +bool isQuote(QChar c) +{ + const ushort u = c.unicode(); + return u == '\'' || u == '"' || u == '`'; +} + +// Advance a cursor by one character, wrapping across line ends. Returns false +// at end of document. +bool stepForward(KTextEditor::Document *doc, KTextEditor::Cursor &c) +{ + if (c.column() < doc->lineLength(c.line())) { + c.setColumn(c.column() + 1); + } else if (c.line() < doc->documentEnd().line()) { + c.setLine(c.line() + 1); + c.setColumn(0); + } else { + return false; + } + return true; +} + +// Retreat a cursor by one character, wrapping across line starts. Returns false +// at start of document. +bool stepBackward(KTextEditor::Document *doc, KTextEditor::Cursor &c) +{ + if (c.column() > 0) { + c.setColumn(c.column() - 1); + } else if (c.line() > 0) { + c.setLine(c.line() - 1); + c.setColumn(doc->lineLength(c.line())); + } else { + return false; + } + return true; +} + +// Scan forward from the position just after an opener for its matching closer, +// honouring nesting. \a pos must point at the first char after the opener. +// Returns the closer's cursor, or an invalid cursor if unbalanced. +KTextEditor::Cursor findCloser(KTextEditor::Document *doc, KTextEditor::Cursor pos, + QChar opener, QChar closer) +{ + int depth = 1; + while (true) { + const QChar ch = doc->characterAt(pos); + if (ch == opener) { + ++depth; + } else if (ch == closer) { + if (--depth == 0) { + return pos; + } + } + if (!stepForward(doc, pos)) { + break; + } + } + return KTextEditor::Cursor::invalid(); +} + +// Scan backward from the position just before a closer for its matching opener. +// \a pos must point at the char immediately before the closer. +KTextEditor::Cursor findOpener(KTextEditor::Document *doc, KTextEditor::Cursor pos, + QChar closer, QChar opener) +{ + int depth = 1; + while (true) { + const QChar ch = doc->characterAt(pos); + if (ch == closer) { + ++depth; + } else if (ch == opener) { + if (--depth == 0) { + return pos; + } + } + if (!stepBackward(doc, pos)) { + break; + } + } + return KTextEditor::Cursor::invalid(); +} + +// Count occurrences of \a q on \a line in columns [0, col). An even count means +// the quote at \a col opens a pair; odd means it closes one. Quotes do not nest, +// so parity of preceding quotes decides direction. +bool quoteIsOpener(KTextEditor::Document *doc, int line, int col, QChar q) +{ + int count = 0; + for (int c = 0; c < col; ++c) { + if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) { + ++count; + } + } + return (count % 2) == 0; +} + +// Find the matching quote char of the same kind on \a line, searching columns +// (col, lineLength) forward. Returns the closer's cursor, or invalid if none. +KTextEditor::Cursor findQuoteForward(KTextEditor::Document *doc, int line, int col, QChar q) +{ + const int len = doc->lineLength(line); + for (int c = col + 1; c < len; ++c) { + if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) { + return KTextEditor::Cursor(line, c); + } + } + return KTextEditor::Cursor::invalid(); +} + +// Find the matching quote char of the same kind on \a line, searching columns +// [0, col) backward. Returns the opener's cursor, or invalid if none. +KTextEditor::Cursor findQuoteBackward(KTextEditor::Document *doc, int line, int col, QChar q) +{ + for (int c = col - 1; c >= 0; --c) { + if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) { + return KTextEditor::Cursor(line, c); + } + } + return KTextEditor::Cursor::invalid(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// AcmePlugin +// --------------------------------------------------------------------------- +AcmePlugin::AcmePlugin(QObject *parent, const QVariantList &args) + : KTextEditor::Plugin(parent) +{ + Q_UNUSED(args); +} + +AcmePlugin::~AcmePlugin() = default; + +QObject *AcmePlugin::createView(KTextEditor::MainWindow *mainWindow) +{ + return new AcmeView(this, mainWindow); +} + +// --------------------------------------------------------------------------- +// AcmeView +// --------------------------------------------------------------------------- +AcmeView::AcmeView(AcmePlugin *plugin, KTextEditor::MainWindow *mainWindow) + : QObject(plugin) + , m_mainWindow(mainWindow) +{ + // One application-wide filter serves the Acme line-editing keys, the + // double-click bracket/quote selection, and the Acme left-button chords, + // catching events no matter which internal child of the view receives them. + qApp->installEventFilter(this); +} + +AcmeView::~AcmeView() = default; + +QAction *AcmeView::findActionByName(const QString &nameOrText) const +{ + KXMLGUIFactory *factory = m_mainWindow->guiFactory(); + if (!factory || nameOrText.isEmpty()) { + return nullptr; + } + const QList clients = factory->clients(); + for (KXMLGUIClient *client : clients) { + if (KActionCollection *coll = client->actionCollection()) { + if (QAction *a = coll->action(nameOrText)) { + return a; + } + } + } + return nullptr; +} + +bool AcmeView::selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos) +{ + if (!view) { + return false; + } + KTextEditor::Document *doc = view->document(); + if (!doc) { + return false; + } + + const QPoint local = view->mapFromGlobal(globalPos); + const KTextEditor::Cursor at = view->coordinatesToCursor(local); + if (!at.isValid()) { + return false; + } + + // Characters immediately to the right and left of the click position. + const QChar rightCh = doc->characterAt(at); + KTextEditor::Cursor leftCur = at; + const bool haveLeft = stepBackward(doc, leftCur); + const QChar leftCh = haveLeft ? doc->characterAt(leftCur) : QChar(); + + // Quote pairs ('' "" ``) are symmetric and non-nesting: resolve them on the + // click's line and select the content between the enclosing pair. Handled + // before brackets since the two delimiter sets are disjoint. + { + QChar q; + int qCol = -1; + if (isQuote(rightCh)) { + q = rightCh; + qCol = at.column(); + } else if (haveLeft && isQuote(leftCh)) { + q = leftCh; + qCol = leftCur.column(); + } + if (!q.isNull()) { + const int line = at.line(); + KTextEditor::Cursor qOpen = KTextEditor::Cursor::invalid(); + KTextEditor::Cursor qClose = KTextEditor::Cursor::invalid(); + if (quoteIsOpener(doc, line, qCol, q)) { + qOpen = KTextEditor::Cursor(line, qCol); + qClose = findQuoteForward(doc, line, qCol, q); + } else { + qClose = KTextEditor::Cursor(line, qCol); + qOpen = findQuoteBackward(doc, line, qCol, q); + } + if (!qOpen.isValid() || !qClose.isValid()) { + return false; // unbalanced quote on this line + } + const KTextEditor::Cursor innerStart(line, qOpen.column() + 1); + view->setSelection(KTextEditor::Range(innerStart, qClose)); + view->setCursorPosition(qClose); + return true; + } + } + + bool rOpener = false; + const QChar rMatch = matchingBracket(rightCh, rOpener); + bool lOpener = false; + const QChar lMatch = matchingBracket(leftCh, lOpener); + + KTextEditor::Cursor opener = KTextEditor::Cursor::invalid(); + KTextEditor::Cursor closer = KTextEditor::Cursor::invalid(); + + // Prefer an opener to the right (select its contents forward), then a closer + // to the left (select its contents backward), then the remaining adjacency + // cases. "Everything between" excludes the bracket characters themselves. + if (!rMatch.isNull() && rOpener) { + KTextEditor::Cursor after = at; + if (stepForward(doc, after)) { + opener = at; + closer = findCloser(doc, after, rightCh, rMatch); + } + } else if (!lMatch.isNull() && !lOpener) { + KTextEditor::Cursor before = leftCur; + if (stepBackward(doc, before)) { + closer = leftCur; + opener = findOpener(doc, before, leftCh, lMatch); + } + } else if (!rMatch.isNull() && !rOpener) { + // Closer to the right: match backward from just before it. + KTextEditor::Cursor before = at; + if (stepBackward(doc, before)) { + closer = at; + opener = findOpener(doc, before, rightCh, rMatch); + } + } else if (!lMatch.isNull() && lOpener) { + // Opener to the left: match forward from the click position. + opener = leftCur; + closer = findCloser(doc, at, leftCh, lMatch); + } else { + return false; // no bracket adjacent — let Kate's default run + } + + if (!opener.isValid() || !closer.isValid()) { + return false; + } + + // Select the content strictly between the pair. The inner range starts just + // after the opener and ends at the closer. + KTextEditor::Cursor innerStart = opener; + if (!stepForward(doc, innerStart)) { + return false; + } + const KTextEditor::Range inner(innerStart, closer); + view->setSelection(inner); + view->setCursorPosition(closer); + return true; +} + +bool AcmeView::eventFilter(QObject *obj, QEvent *event) +{ + // Acme line-editing keys. Handle them here instead of via QActions to avoid + // ambiguous-shortcut clashes with Kate's own Ctrl+A/Ctrl+W/etc. On + // ShortcutOverride we accept the event so Kate's registered shortcut does + // not fire; the matching KeyPress is then delivered and we act on it. + if (event->type() == QEvent::ShortcutOverride || event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + const bool isAcme = + (ke->modifiers() & Qt::KeyboardModifierMask) == Qt::ControlModifier + && (ke->key() == Qt::Key_A || ke->key() == Qt::Key_E || ke->key() == Qt::Key_H + || ke->key() == Qt::Key_U || ke->key() == Qt::Key_W); + if (isAcme) { + KTextEditor::View *v = m_mainWindow->activeView(); + auto *w = qobject_cast(obj); + const bool inView = v && w && v->isAncestorOf(w); + if (inView) { + if (event->type() == QEvent::ShortcutOverride) { + event->accept(); // deliver as a normal keypress, not a shortcut + return true; + } + dispatchAcmeKey(ke); + return true; // consume the keypress + } + } + } + + // Bracket-pair selection: double-clicking next to a bracket selects the + // text between the matching pair (multi-line). Falls through to Kate's + // default double-click (word select) when no bracket is adjacent. The + // second click of a double-click arrives as DblClick (not a plain press) + // and leaves the left button held, so it also arms the chord anchor — + // letting the user go straight from a double-click into a mouse chord. + if (event->type() == QEvent::MouseButtonDblClick) { + auto *mev = static_cast(event); + if (mev->button() == Qt::LeftButton) { + KTextEditor::View *v = m_mainWindow->activeView(); + auto *w = qobject_cast(obj); + const bool inView = v && w && v->isAncestorOf(w); + if (inView) { + m_leftDownInView = true; // held after the dblclick: chord anchor + if (selectBracketPairAt(v, mev->globalPosition().toPoint())) { + return true; // handled: suppress default word-select + } + } + } + } + + // Acme-style left-button chords, plus suppression of X11 primary-selection + // paste. Button 1 (left) held while selecting is the anchor: + // left + middle tap = Cut, left + right tap = Paste. + // Both use edit_cut / edit_paste, which use the standard KDE clipboard + // (Klipper-backed). Middle-button events in the view are consumed on BOTH + // press and release to kill the middle-click primary paste (it fires on + // release, not just press). + if (event->type() == QEvent::MouseButtonRelease) { + auto *mev = static_cast(event); + if (mev->button() == Qt::LeftButton) { + m_leftDownInView = false; + } + if (mev->button() == Qt::MiddleButton && m_swallowMiddleRelease) { + m_swallowMiddleRelease = false; + return true; // suppress the primary paste that fires on release + } + if (mev->button() == Qt::RightButton && m_swallowRightRelease) { + m_swallowRightRelease = false; + return true; // paired release of a paste chord + } + } else if (event->type() == QEvent::MouseButtonPress) { + auto *mev = static_cast(event); + KTextEditor::View *v = m_mainWindow->activeView(); + auto *w = qobject_cast(obj); + const bool inView = v && w && v->isAncestorOf(w); + + // left + right tap = Paste over the selection. + if (mev->button() == Qt::RightButton && m_leftDownInView && inView) { + if (QAction *paste = findActionByName(QStringLiteral("edit_paste"))) { + paste->trigger(); + } + m_swallowRightRelease = true; + return true; // consume: chord, not a context menu + } + + if (mev->button() == Qt::MiddleButton && inView) { + if (m_leftDownInView) { + // left + middle = Cut the selection (Acme chord). Consume both + // press and release. + if (QAction *cut = findActionByName(QStringLiteral("edit_cut"))) { + cut->trigger(); + } + m_swallowMiddleRelease = true; + return true; + } + // A plain middle press (no chord): suppress the X11 primary paste + // that fires on release, and consume the press. + m_swallowMiddleRelease = true; + return true; + } + if (mev->button() == Qt::LeftButton && inView) { + m_leftDownInView = true; // anchor set; selection proceeds normally + } + } + + return QObject::eventFilter(obj, event); +} + +bool AcmeView::dispatchAcmeKey(QKeyEvent *ke) +{ + if ((ke->modifiers() & Qt::KeyboardModifierMask) != Qt::ControlModifier) { + return false; + } + switch (ke->key()) { + case Qt::Key_A: acmeBeginningOfLine(); return true; + case Qt::Key_E: acmeEndOfLine(); return true; + case Qt::Key_H: acmeEraseChar(); return true; + case Qt::Key_U: acmeEraseLine(); return true; + case Qt::Key_W: acmeEraseWord(); return true; + default: return false; + } +} + +void AcmeView::acmeBeginningOfLine() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + const KTextEditor::Cursor c = view->cursorPosition(); + view->setCursorPosition(KTextEditor::Cursor(c.line(), 0)); +} + +void AcmeView::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 AcmeView::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 AcmeView::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 AcmeView::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 deft + +// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The +// matching .moc include is required in this translation unit. +K_PLUGIN_FACTORY_WITH_JSON(AcmePluginFactory, "acmeplugin.json", + registerPlugin();) + +#include "acmeplugin.moc" diff --git a/src/acme/acmeplugin.h b/src/acme/acmeplugin.h new file mode 100644 index 0000000..5b08232 --- /dev/null +++ b/src/acme/acmeplugin.h @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * [deft:nineify] acme — acme-style editing gestures for Kate. + * + * AcmePlugin is the KTextEditor::Plugin entry point. For every MainWindow it + * creates an AcmeView, which installs an application event filter providing: + * - acme line-editing keys ^A/^E/^H/^U/^W (handled on the key event to avoid + * ambiguous-shortcut clashes with Kate's own Ctrl bindings); + * - double-click bracket/quote pair selection; and + * - the acme left-button mouse chords (left+middle = Cut, left+right = Paste) + * plus suppression of the X11 middle-click primary paste. + */ +#ifndef DEFT_ACMEPLUGIN_H +#define DEFT_ACMEPLUGIN_H + +#include + +#include + +class QAction; +class QKeyEvent; + +namespace KTextEditor +{ +class MainWindow; +class View; +} + +namespace deft +{ + +class AcmePlugin : public KTextEditor::Plugin +{ + Q_OBJECT + +public: + explicit AcmePlugin(QObject *parent, const QVariantList &args = {}); + ~AcmePlugin() override; + + QObject *createView(KTextEditor::MainWindow *mainWindow) override; +}; + +class AcmeView : public QObject +{ + Q_OBJECT + +public: + AcmeView(AcmePlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~AcmeView() override; + +private: + // Acme-style line-editing keys (no QActions, so nothing for Qt to report as + // an ambiguous shortcut). + void acmeBeginningOfLine(); // C-a + void acmeEndOfLine(); // C-e + void acmeEraseChar(); // C-h + void acmeEraseLine(); // C-u + void acmeEraseWord(); // C-w + // Dispatch an Acme key combo. Returns true if it was one of ours. + bool dispatchAcmeKey(QKeyEvent *ke); + + // On double-click adjacent to a bracket/quote, select the text between the + // matching pair (multi-line aware). Returns true if a pair was selected. + bool selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos); + + // Find a Kate action by its objectName across all GUI clients. Used by the + // mouse chords to invoke edit_cut / edit_paste. + QAction *findActionByName(const QString &objectName) const; + + bool eventFilter(QObject *obj, QEvent *event) override; + + KTextEditor::MainWindow *m_mainWindow; + bool m_leftDownInView = false; // LMB held inside the view (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 +}; + +} // namespace deft + +#endif diff --git a/src/acme/acmeplugin.json b/src/acme/acmeplugin.json new file mode 100644 index 0000000..14887f1 --- /dev/null +++ b/src/acme/acmeplugin.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Levi Neely" + } + ], + "Description": "Acme editing gestures: ^A/^E/^H/^U/^W line keys, double-click bracket/quote selection, and left-button mouse chords (cut/paste).", + "Icon": "input-keyboard", + "License": "LGPL-2.0-or-later", + "Name": "[deft:nineify] acme", + "Version": "0.1.0" + } +} diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 67985df..dc69a97 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -31,7 +31,6 @@ #include #include #include -#include #include #include #include @@ -42,144 +41,6 @@ namespace deft namespace { -// Bracket pairs understood by the double-click selection feature. -QChar matchingBracket(QChar c, bool &isOpener) -{ - switch (c.unicode()) { - case '(': isOpener = true; return QLatin1Char(')'); - case '[': isOpener = true; return QLatin1Char(']'); - case '{': isOpener = true; return QLatin1Char('}'); - case ')': isOpener = false; return QLatin1Char('('); - case ']': isOpener = false; return QLatin1Char('['); - case '}': isOpener = false; return QLatin1Char('{'); - default: isOpener = false; return QChar(); - } -} - -// Quote delimiters understood by the double-click selection feature. Unlike -// brackets these are symmetric (same opening and closing char) and do not nest, -// so matching is handled separately: scan the click's line for the enclosing -// pair of identical quote chars. -bool isQuote(QChar c) -{ - const ushort u = c.unicode(); - return u == '\'' || u == '"' || u == '`'; -} - -// Advance a cursor by one character, wrapping across line ends. Returns false -// at end of document. -bool stepForward(KTextEditor::Document *doc, KTextEditor::Cursor &c) -{ - if (c.column() < doc->lineLength(c.line())) { - c.setColumn(c.column() + 1); - } else if (c.line() < doc->documentEnd().line()) { - c.setLine(c.line() + 1); - c.setColumn(0); - } else { - return false; - } - return true; -} - -// Retreat a cursor by one character, wrapping across line starts. Returns false -// at start of document. -bool stepBackward(KTextEditor::Document *doc, KTextEditor::Cursor &c) -{ - if (c.column() > 0) { - c.setColumn(c.column() - 1); - } else if (c.line() > 0) { - c.setLine(c.line() - 1); - c.setColumn(doc->lineLength(c.line())); - } else { - return false; - } - return true; -} - -// Scan forward from the position just after an opener for its matching closer, -// honouring nesting. \a pos must point at the first char after the opener. -// Returns the closer's cursor, or an invalid cursor if unbalanced. -KTextEditor::Cursor findCloser(KTextEditor::Document *doc, KTextEditor::Cursor pos, - QChar opener, QChar closer) -{ - int depth = 1; - while (true) { - const QChar ch = doc->characterAt(pos); - if (ch == opener) { - ++depth; - } else if (ch == closer) { - if (--depth == 0) { - return pos; - } - } - if (!stepForward(doc, pos)) { - break; - } - } - return KTextEditor::Cursor::invalid(); -} - -// Scan backward from the position just before a closer for its matching opener. -// \a pos must point at the char immediately before the closer. -KTextEditor::Cursor findOpener(KTextEditor::Document *doc, KTextEditor::Cursor pos, - QChar closer, QChar opener) -{ - int depth = 1; - while (true) { - const QChar ch = doc->characterAt(pos); - if (ch == closer) { - ++depth; - } else if (ch == opener) { - if (--depth == 0) { - return pos; - } - } - if (!stepBackward(doc, pos)) { - break; - } - } - return KTextEditor::Cursor::invalid(); -} - -// Count occurrences of \a q on \a line in columns [0, col). An even count means -// the quote at \a col opens a pair; odd means it closes one. Quotes do not nest, -// so parity of preceding quotes decides direction. -bool quoteIsOpener(KTextEditor::Document *doc, int line, int col, QChar q) -{ - int count = 0; - for (int c = 0; c < col; ++c) { - if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) { - ++count; - } - } - return (count % 2) == 0; -} - -// Find the matching quote char of the same kind on \a line, searching columns -// (col, lineLength) forward. Returns the closer's cursor, or invalid if none. -KTextEditor::Cursor findQuoteForward(KTextEditor::Document *doc, int line, int col, QChar q) -{ - const int len = doc->lineLength(line); - for (int c = col + 1; c < len; ++c) { - if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) { - return KTextEditor::Cursor(line, c); - } - } - return KTextEditor::Cursor::invalid(); -} - -// Find the matching quote char of the same kind on \a line, searching columns -// [0, col) backward. Returns the opener's cursor, or invalid if none. -KTextEditor::Cursor findQuoteBackward(KTextEditor::Document *doc, int line, int col, QChar q) -{ - for (int c = col - 1; c >= 0; --c) { - if (doc->characterAt(KTextEditor::Cursor(line, c)) == q) { - return KTextEditor::Cursor(line, c); - } - } - return KTextEditor::Cursor::invalid(); -} - // Prefix marking a palette id that is a ":" command string to execute rather // than a QAction to trigger. const QLatin1String kCmdPrefix("ollie:cmd:"); @@ -783,187 +644,15 @@ void OllieView::goToSymbol(const QString &locationId) } } -QAction *OllieView::findActionByName(const QString &nameOrText) const -{ - KXMLGUIFactory *factory = m_mainWindow->guiFactory(); - if (!factory || nameOrText.isEmpty()) { - return nullptr; - } - const QList clients = factory->clients(); - - // First pass: exact objectName match (fast, unambiguous). - for (KXMLGUIClient *client : clients) { - if (KActionCollection *coll = client->actionCollection()) { - if (QAction *a = coll->action(nameOrText)) { - return a; - } - } - } - - // Fallback: match by visible text, case-insensitively. Keeps radial slices - // working when a Kate action's objectName differs from our config id across - // versions (we can reference it by its menu label instead). - const QString wanted = nameOrText.trimmed(); - for (KXMLGUIClient *client : clients) { - KActionCollection *coll = client->actionCollection(); - if (!coll) { - continue; - } - const QList actions = coll->actions(); - for (QAction *a : actions) { - if (!a) { - continue; - } - const QString text = a->text().remove(QLatin1Char('&')).trimmed(); - if (text.compare(wanted, Qt::CaseInsensitive) == 0) { - return a; - } - } - } - return nullptr; -} - -bool OllieView::selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos) -{ - if (!view) { - return false; - } - KTextEditor::Document *doc = view->document(); - if (!doc) { - return false; - } - - const QPoint local = view->mapFromGlobal(globalPos); - const KTextEditor::Cursor at = view->coordinatesToCursor(local); - if (!at.isValid()) { - return false; - } - - // Characters immediately to the right and left of the click position. - const QChar rightCh = doc->characterAt(at); - KTextEditor::Cursor leftCur = at; - const bool haveLeft = stepBackward(doc, leftCur); - const QChar leftCh = haveLeft ? doc->characterAt(leftCur) : QChar(); - - // Quote pairs ('' "" ``) are symmetric and non-nesting: resolve them on the - // click's line and select the content between the enclosing pair. Handled - // before brackets since the two delimiter sets are disjoint. - { - QChar q; - int qCol = -1; - if (isQuote(rightCh)) { - q = rightCh; - qCol = at.column(); - } else if (haveLeft && isQuote(leftCh)) { - q = leftCh; - qCol = leftCur.column(); - } - if (!q.isNull()) { - const int line = at.line(); - KTextEditor::Cursor qOpen = KTextEditor::Cursor::invalid(); - KTextEditor::Cursor qClose = KTextEditor::Cursor::invalid(); - if (quoteIsOpener(doc, line, qCol, q)) { - qOpen = KTextEditor::Cursor(line, qCol); - qClose = findQuoteForward(doc, line, qCol, q); - } else { - qClose = KTextEditor::Cursor(line, qCol); - qOpen = findQuoteBackward(doc, line, qCol, q); - } - if (!qOpen.isValid() || !qClose.isValid()) { - return false; // unbalanced quote on this line - } - const KTextEditor::Cursor innerStart(line, qOpen.column() + 1); - view->setSelection(KTextEditor::Range(innerStart, qClose)); - view->setCursorPosition(qClose); - return true; - } - } - - bool rOpener = false; - const QChar rMatch = matchingBracket(rightCh, rOpener); - bool lOpener = false; - const QChar lMatch = matchingBracket(leftCh, lOpener); - - KTextEditor::Cursor opener = KTextEditor::Cursor::invalid(); - KTextEditor::Cursor closer = KTextEditor::Cursor::invalid(); - - // Prefer an opener to the right (select its contents forward), then a closer - // to the left (select its contents backward), then the remaining adjacency - // cases. "Everything between" excludes the bracket characters themselves. - if (!rMatch.isNull() && rOpener) { - KTextEditor::Cursor after = at; - if (stepForward(doc, after)) { - opener = at; - closer = findCloser(doc, after, rightCh, rMatch); - } - } else if (!lMatch.isNull() && !lOpener) { - KTextEditor::Cursor before = leftCur; - if (stepBackward(doc, before)) { - closer = leftCur; - opener = findOpener(doc, before, leftCh, lMatch); - } - } else if (!rMatch.isNull() && !rOpener) { - // Closer to the right: match backward from just before it. - KTextEditor::Cursor before = at; - if (stepBackward(doc, before)) { - closer = at; - opener = findOpener(doc, before, rightCh, rMatch); - } - } else if (!lMatch.isNull() && lOpener) { - // Opener to the left: match forward from the click position. - opener = leftCur; - closer = findCloser(doc, at, leftCh, lMatch); - } else { - return false; // no bracket adjacent — let Kate's default run - } - - if (!opener.isValid() || !closer.isValid()) { - return false; - } - - // Select the content strictly between the pair. The inner range starts just - // after the opener and ends at the closer. - KTextEditor::Cursor innerStart = opener; - if (!stepForward(doc, innerStart)) { - return false; - } - const KTextEditor::Range inner(innerStart, closer); - view->setSelection(inner); - view->setCursorPosition(closer); - return true; -} - bool OllieView::eventFilter(QObject *obj, QEvent *event) { - // Acme line-editing keys. Handle them here instead of via QActions to avoid - // ambiguous-shortcut clashes with Kate's own Ctrl+A/Ctrl+W/etc. On - // ShortcutOverride we accept the event so Kate's registered shortcut does - // not fire; the matching KeyPress is then delivered and we act on it. + // Launcher doors: Alt+X (M-x), Alt+P (Go to File), Alt+G (Go to Symbol). + // Handled here rather than via QAction shortcuts so they can never clash + // with Kate's own Alt+ bindings (which produced "ambiguous + // shortcut" and swallowed the key). Scope to this plugin's window so we do + // not hijack the combo in other Kate main windows. if (event->type() == QEvent::ShortcutOverride || event->type() == QEvent::KeyPress) { auto *ke = static_cast(event); - const bool isAcme = - (ke->modifiers() & Qt::KeyboardModifierMask) == Qt::ControlModifier - && (ke->key() == Qt::Key_A || ke->key() == Qt::Key_E || ke->key() == Qt::Key_H - || ke->key() == Qt::Key_U || ke->key() == Qt::Key_W); - if (isAcme) { - KTextEditor::View *v = m_mainWindow->activeView(); - auto *w = qobject_cast(obj); - const bool inView = v && w && v->isAncestorOf(w); - if (inView) { - if (event->type() == QEvent::ShortcutOverride) { - event->accept(); // deliver as a normal keypress, not a shortcut - return true; - } - dispatchAcmeKey(ke); - return true; // consume the keypress - } - } - - // Launcher doors: Alt+X (M-x), Alt+P (Go to File), Alt+G (Go to Symbol). - // Handled here rather than via QAction shortcuts so they can never clash - // with Kate's own Alt+ bindings (which produced "ambiguous - // shortcut" and swallowed the key). Scope to this plugin's window so we - // do not hijack the combo in other Kate main windows. const bool altOnly = (ke->modifiers() & Qt::KeyboardModifierMask) == Qt::AltModifier; if (altOnly @@ -987,213 +676,9 @@ bool OllieView::eventFilter(QObject *obj, QEvent *event) } } - // Bracket-pair selection: double-clicking next to a bracket selects the - // text between the matching pair (multi-line). Falls through to Kate's - // default double-click (word select) when no bracket is adjacent. The - // second click of a double-click arrives as DblClick (not a plain press) - // and leaves the left button held, so it also arms the chord anchor — - // letting the user go straight from a double-click into a mouse chord. - if (event->type() == QEvent::MouseButtonDblClick) { - auto *mev = static_cast(event); - if (mev->button() == Qt::LeftButton) { - KTextEditor::View *v = m_mainWindow->activeView(); - auto *w = qobject_cast(obj); - const bool inView = v && w && v->isAncestorOf(w); - if (inView) { - m_leftDownInView = true; // held after the dblclick: chord anchor - if (selectBracketPairAt(v, mev->globalPosition().toPoint())) { - return true; // handled: suppress default word-select - } - } - } - } - - // Acme-style left-button chords, plus suppression of X11 primary-selection - // paste. Button 1 (left) held while selecting is the anchor: - // left + middle tap = Cut, left + right tap = Paste. - // Both use edit_cut / edit_paste, which use the standard KDE clipboard - // (Klipper-backed). Middle-button events in the view are consumed on BOTH - // press and release to kill the middle-click primary paste (it fires on - // release, not just press). - if (event->type() == QEvent::MouseButtonRelease) { - auto *mev = static_cast(event); - if (mev->button() == Qt::LeftButton) { - m_leftDownInView = false; - } - if (mev->button() == Qt::MiddleButton && m_swallowMiddleRelease) { - m_swallowMiddleRelease = false; - return true; // suppress the primary paste that fires on release - } - if (mev->button() == Qt::RightButton && m_swallowRightRelease) { - m_swallowRightRelease = false; - return true; // paired release of a paste chord - } - } else if (event->type() == QEvent::MouseButtonPress) { - auto *mev = static_cast(event); - KTextEditor::View *v = m_mainWindow->activeView(); - auto *w = qobject_cast(obj); - const bool inView = v && w && v->isAncestorOf(w); - - // left + right tap = Paste over the selection. - if (mev->button() == Qt::RightButton && m_leftDownInView && inView) { - if (QAction *paste = findActionByName(QStringLiteral("edit_paste"))) { - paste->trigger(); - } - m_swallowRightRelease = true; - return true; // consume: chord, not a context menu - } - - if (mev->button() == Qt::MiddleButton && inView) { - if (m_leftDownInView) { - // left + middle = Cut the selection (Acme chord). Consume both - // press and release. - if (QAction *cut = findActionByName(QStringLiteral("edit_cut"))) { - cut->trigger(); - } - m_swallowMiddleRelease = true; - return true; - } - // A plain middle press (no chord): suppress the X11 primary paste - // that fires on release, and consume the press. - m_swallowMiddleRelease = true; - return true; - } - if (mev->button() == Qt::LeftButton && inView) { - m_leftDownInView = true; // anchor set; selection proceeds normally - } - } - return QObject::eventFilter(obj, event); } -// --------------------------------------------------------------------------- -// Acme-style line-editing keys -// -// Handled directly in the application event filter rather than via QActions: -// registering QActions for Ctrl+A/W/… would collide with Kate's own actions -// (Select All, Close, …) and Qt would report ambiguous shortcuts. Instead we -// accept the ShortcutOverride for these combos (so Kate's shortcut does not -// fire) and act on the KeyPress, consuming it. -// -// 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 '_') -// --------------------------------------------------------------------------- - -bool OllieView::dispatchAcmeKey(QKeyEvent *ke) -{ - if ((ke->modifiers() & Qt::KeyboardModifierMask) != Qt::ControlModifier) { - return false; - } - switch (ke->key()) { - case Qt::Key_A: acmeBeginningOfLine(); return true; - case Qt::Key_E: acmeEndOfLine(); return true; - case Qt::Key_H: acmeEraseChar(); return true; - case Qt::Key_U: acmeEraseLine(); return true; - case Qt::Key_W: acmeEraseWord(); return true; - default: return false; - } -} - -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 deft // 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 d6f8609..f4be721 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -103,36 +103,16 @@ private: // window's GUI factory clients, deduplicated by pointer identity. QList collectActions() const; - // Find a Kate action by its raw objectName across all GUI clients. Used by - // the Acme mouse chords to invoke edit_cut / edit_paste. - QAction *findActionByName(const QString &objectName) const; - - // Acme-style line-editing keys, handled in the event filter (no QActions, - // so there is nothing for Qt to report as an ambiguous shortcut). - void acmeBeginningOfLine(); // C-a - void acmeEndOfLine(); // C-e - void acmeEraseChar(); // C-h - void acmeEraseLine(); // C-u - void acmeEraseWord(); // C-w - // Dispatch an Acme key combo. Returns true if it was one of ours. - bool dispatchAcmeKey(QKeyEvent *ke); - - // Catches configured mouse-button gestures inside the active view. + // The application event filter: intercepts the launcher-door keys + // (Alt+X/P/G) so they can never clash with Kate's own Alt bindings. bool eventFilter(QObject *obj, QEvent *event) override; - // On double-click adjacent to a bracket, select the text between the - // matching pair (multi-line aware). Returns true if a pair was selected. - bool selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos); - KTextEditor::MainWindow *m_mainWindow; PaletteWidget *m_palette = nullptr; PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher QString m_fileSwitcherRoot; // project root for the open switcher PaletteWidget *m_symbolPalette = nullptr; // project "Go to Symbol" switcher QString m_symbolSwitcherRoot; // project root for the symbol switcher - 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 QHash> m_actionsById; FrecencyStore m_frecency; // persisted usage feeding the palette ranking KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin