549 lines
19 KiB
C++
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"
|