/* * 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"