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(plumb)
|
||||||
add_subdirectory(sam)
|
add_subdirectory(sam)
|
||||||
if(KF6TextEditor_FOUND)
|
if(KF6TextEditor_FOUND)
|
||||||
|
add_subdirectory(acme)
|
||||||
add_subdirectory(commands)
|
add_subdirectory(commands)
|
||||||
endif()
|
endif()
|
||||||
add_subdirectory(plugin)
|
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 <QHash>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QKeySequence>
|
#include <QKeySequence>
|
||||||
#include <QMouseEvent>
|
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
@ -42,144 +41,6 @@ namespace deft
|
||||||
|
|
||||||
namespace
|
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
|
// Prefix marking a palette id that is a ":" command string to execute rather
|
||||||
// than a QAction to trigger.
|
// than a QAction to trigger.
|
||||||
const QLatin1String kCmdPrefix("ollie:cmd:");
|
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)
|
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.
|
|
||||||
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).
|
// 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
|
// Handled here rather than via QAction shortcuts so they can never clash
|
||||||
// with Kate's own Alt+<letter> bindings (which produced "ambiguous
|
// with Kate's own Alt+<letter> bindings (which produced "ambiguous
|
||||||
// shortcut" and swallowed the key). Scope to this plugin's window so we
|
// shortcut" and swallowed the key). Scope to this plugin's window so we do
|
||||||
// do not hijack the combo in other Kate main windows.
|
// 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 altOnly =
|
const bool altOnly =
|
||||||
(ke->modifiers() & Qt::KeyboardModifierMask) == Qt::AltModifier;
|
(ke->modifiers() & Qt::KeyboardModifierMask) == Qt::AltModifier;
|
||||||
if (altOnly
|
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);
|
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
|
} // namespace deft
|
||||||
|
|
||||||
// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The
|
// 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.
|
// window's GUI factory clients, deduplicated by pointer identity.
|
||||||
QList<ActionEntry> collectActions() const;
|
QList<ActionEntry> collectActions() const;
|
||||||
|
|
||||||
// Find a Kate action by its raw objectName across all GUI clients. Used by
|
// The application event filter: intercepts the launcher-door keys
|
||||||
// the Acme mouse chords to invoke edit_cut / edit_paste.
|
// (Alt+X/P/G) so they can never clash with Kate's own Alt bindings.
|
||||||
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.
|
|
||||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
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;
|
KTextEditor::MainWindow *m_mainWindow;
|
||||||
PaletteWidget *m_palette = nullptr;
|
PaletteWidget *m_palette = nullptr;
|
||||||
PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher
|
PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher
|
||||||
QString m_fileSwitcherRoot; // project root for the open switcher
|
QString m_fileSwitcherRoot; // project root for the open switcher
|
||||||
PaletteWidget *m_symbolPalette = nullptr; // project "Go to Symbol" switcher
|
PaletteWidget *m_symbolPalette = nullptr; // project "Go to Symbol" switcher
|
||||||
QString m_symbolSwitcherRoot; // project root for the 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;
|
QHash<QString, QPointer<QAction>> m_actionsById;
|
||||||
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
|
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
|
||||||
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin
|
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue