kate-deft/src/acme/acmeplugin.cpp

549 lines
19 KiB
C++

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