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:
parent
3bf6f9425f
commit
ec2802b11d
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue