acme: extract into standalone [deft:nineify] acme plugin (deft_acme.so)

Stage 3b of the split. acme becomes its own KTextEditor plugin: new
src/acme/acmeplugin.{h,cpp} (AcmePlugin/AcmeView) + acmeplugin.json
("[deft:nineify] acme"), built as deft_acme.so with its own event filter:
the ^A/^E/^H/^U/^W line keys, double-click bracket/quote pair selection, and
the left-button mouse chords (left+middle=Cut, left+right=Paste) plus X11
primary-paste suppression. The bracket/quote/step helpers move here too.

The core deft plugin drops all acme code; its event filter now only
intercepts the Alt+X/P/G launcher-door keys for palette + switch.
This commit is contained in:
Levi Neely 2026-10-08 19:46:59 +02:00
parent 3bf6f9425f
commit ec2802b11d
7 changed files with 669 additions and 542 deletions

View File

@ -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)

18
src/acme/CMakeLists.txt Normal file
View File

@ -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
)

548
src/acme/acmeplugin.cpp Normal file
View File

@ -0,0 +1,548 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "acmeplugin.h"
#include <KTextEditor/Document>
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KActionCollection>
#include <KPluginFactory>
#include <KXMLGUIClient>
#include <KXMLGUIFactory>
#include <QAction>
#include <QApplication>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QWidget>
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<KXMLGUIClient *> 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<QKeyEvent *>(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<QWidget *>(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<QMouseEvent *>(event);
if (mev->button() == Qt::LeftButton) {
KTextEditor::View *v = m_mainWindow->activeView();
auto *w = qobject_cast<QWidget *>(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<QMouseEvent *>(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<QMouseEvent *>(event);
KTextEditor::View *v = m_mainWindow->activeView();
auto *w = qobject_cast<QWidget *>(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<deft::AcmePlugin>();)
#include "acmeplugin.moc"

81
src/acme/acmeplugin.h Normal file
View File

@ -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 <KTextEditor/Plugin>
#include <QObject>
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

14
src/acme/acmeplugin.json Normal file
View File

@ -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"
}
}

View File

@ -31,7 +31,6 @@
#include <QHash>
#include <QKeyEvent>
#include <QKeySequence>
#include <QMouseEvent>
#include <QSet>
#include <QStandardPaths>
#include <QUrl>
@ -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<KXMLGUIClient *> 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<QAction *> 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+<letter> 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<QKeyEvent *>(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<QWidget *>(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+<letter> 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<QMouseEvent *>(event);
if (mev->button() == Qt::LeftButton) {
KTextEditor::View *v = m_mainWindow->activeView();
auto *w = qobject_cast<QWidget *>(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<QMouseEvent *>(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<QMouseEvent *>(event);
KTextEditor::View *v = m_mainWindow->activeView();
auto *w = qobject_cast<QWidget *>(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

View File

@ -103,36 +103,16 @@ private:
// window's GUI factory clients, deduplicated by pointer identity.
QList<ActionEntry> 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<QString, QPointer<QAction>> m_actionsById;
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin