866 lines
30 KiB
C++
866 lines
30 KiB
C++
/*
|
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
|
*/
|
|
#include "ollieplugin.h"
|
|
|
|
#include "palettewidget.h"
|
|
#include "radialmenu.h"
|
|
#include "radialconfig.h"
|
|
|
|
#include <KTextEditor/MainWindow>
|
|
#include <KTextEditor/View>
|
|
#include <KTextEditor/Document>
|
|
|
|
#include <KActionCollection>
|
|
#include <KXMLGUIClient>
|
|
#include <KXMLGUIFactory>
|
|
#include <KLocalizedString>
|
|
#include <KPluginFactory>
|
|
|
|
#include <QAction>
|
|
#include <QApplication>
|
|
#include <QEvent>
|
|
#include <QHash>
|
|
#include <QKeySequence>
|
|
#include <QMouseEvent>
|
|
#include <QSet>
|
|
#include <QWidget>
|
|
|
|
namespace katecustom
|
|
{
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
} // namespace
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OlliePlugin
|
|
// ---------------------------------------------------------------------------
|
|
|
|
OlliePlugin::OlliePlugin(QObject *parent, const QVariantList &args)
|
|
: KTextEditor::Plugin(parent)
|
|
{
|
|
Q_UNUSED(args);
|
|
}
|
|
|
|
OlliePlugin::~OlliePlugin() = default;
|
|
|
|
QObject *OlliePlugin::createView(KTextEditor::MainWindow *mainWindow)
|
|
{
|
|
return new OllieView(this, mainWindow);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OllieView
|
|
// ---------------------------------------------------------------------------
|
|
|
|
OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
|
|
: QObject(plugin)
|
|
, m_mainWindow(mainWindow)
|
|
{
|
|
// The one keyboard door: Alt+X (M-x). A single modifier, no chord, and the
|
|
// keys sit together on a compact layout.
|
|
auto *openAction = new QAction(i18n("Command Palette (M-x)"), this);
|
|
openAction->setObjectName(QStringLiteral("ollie_command_palette"));
|
|
openAction->setShortcut(QKeySequence(Qt::ALT | Qt::Key_X));
|
|
openAction->setShortcutContext(Qt::ApplicationShortcut);
|
|
connect(openAction, &QAction::triggered, this, &OllieView::showCommandPalette);
|
|
|
|
// Attach the shortcut to the window so it is always live.
|
|
if (QWidget *w = m_mainWindow->window()) {
|
|
w->addAction(openAction);
|
|
}
|
|
|
|
setupRadials();
|
|
setupAcmeKeys();
|
|
|
|
// One application-wide filter serves both the mouse-button radial gesture
|
|
// and the Acme-style left+middle chord, catching presses no matter which
|
|
// internal child of the view actually receives them.
|
|
qApp->installEventFilter(this);
|
|
}
|
|
|
|
OllieView::~OllieView()
|
|
{
|
|
delete m_palette;
|
|
delete m_radial;
|
|
}
|
|
|
|
QList<OllieView::ActionEntry> OllieView::collectActions() const
|
|
{
|
|
QList<ActionEntry> entries;
|
|
|
|
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
|
|
if (!factory) {
|
|
return entries;
|
|
}
|
|
|
|
// Dedup by pointer identity: the same QAction is frequently reachable
|
|
// through several GUI clients. Those are true duplicates and must appear
|
|
// only once.
|
|
QSet<QAction *> seen;
|
|
|
|
const QList<KXMLGUIClient *> clients = factory->clients();
|
|
for (KXMLGUIClient *client : clients) {
|
|
KActionCollection *coll = client->actionCollection();
|
|
if (!coll) {
|
|
continue;
|
|
}
|
|
const QString component = coll->componentDisplayName();
|
|
const QList<QAction *> actions = coll->actions();
|
|
for (QAction *action : actions) {
|
|
if (!action || action->isSeparator()) {
|
|
continue;
|
|
}
|
|
if (seen.contains(action)) {
|
|
continue;
|
|
}
|
|
// Only offer things the user could actually invoke now.
|
|
if (!action->isEnabled() || !action->isVisible()) {
|
|
continue;
|
|
}
|
|
const QString label = action->text().remove(QLatin1Char('&')).trimmed();
|
|
if (label.isEmpty()) {
|
|
continue;
|
|
}
|
|
seen.insert(action);
|
|
|
|
ActionEntry e;
|
|
e.action = action;
|
|
e.label = label;
|
|
e.component = component;
|
|
e.shortcut = action->shortcut().toString(QKeySequence::NativeText);
|
|
entries.push_back(e);
|
|
}
|
|
}
|
|
|
|
// Distinct actions can share a visible label (e.g. different plugins each
|
|
// offering "Reload"). These are NOT duplicates — never drop them. Instead
|
|
// guarantee every visible row is distinguishable by enriching colliding
|
|
// labels through a fallback chain of discriminators, and give each entry a
|
|
// unique id so activation always triggers the intended action.
|
|
QHash<QString, int> labelCounts;
|
|
for (const ActionEntry &e : entries) {
|
|
++labelCounts[e.label];
|
|
}
|
|
|
|
// First pass: for any shared label, append the strongest discriminator
|
|
// available (component, else objectName, else shortcut).
|
|
for (ActionEntry &e : entries) {
|
|
if (labelCounts.value(e.label) <= 1) {
|
|
continue;
|
|
}
|
|
const QString objName = e.action ? e.action->objectName() : QString();
|
|
QString disc;
|
|
if (!e.component.isEmpty()) {
|
|
disc = e.component;
|
|
} else if (!objName.isEmpty()) {
|
|
disc = objName;
|
|
} else if (!e.shortcut.isEmpty()) {
|
|
disc = e.shortcut;
|
|
}
|
|
if (!disc.isEmpty()) {
|
|
e.label = e.label + QStringLiteral(" — ") + disc;
|
|
}
|
|
}
|
|
|
|
// Second pass: if any labels are STILL identical (same label and same
|
|
// discriminator), append objectName/shortcut, then a numeric suffix, so no
|
|
// two rows ever look the same.
|
|
QHash<QString, int> stillColliding;
|
|
for (const ActionEntry &e : entries) {
|
|
++stillColliding[e.label];
|
|
}
|
|
QHash<QString, int> dupSeq;
|
|
for (ActionEntry &e : entries) {
|
|
if (stillColliding.value(e.label) <= 1) {
|
|
continue;
|
|
}
|
|
const QString objName = e.action ? e.action->objectName() : QString();
|
|
if (!objName.isEmpty() && !e.label.contains(objName)) {
|
|
e.label += QStringLiteral(" [") + objName + QLatin1Char(']');
|
|
} else if (!e.shortcut.isEmpty() && !e.label.contains(e.shortcut)) {
|
|
e.label += QStringLiteral(" (") + e.shortcut + QLatin1Char(')');
|
|
} else {
|
|
// Last resort: number the indistinguishable rows 1..n.
|
|
const int n = ++dupSeq[e.label];
|
|
e.label += QStringLiteral(" (") + QString::number(n) + QLatin1Char(')');
|
|
}
|
|
}
|
|
|
|
// Assign unique, reasonably stable ids (independent of the display label).
|
|
QHash<QString, int> idSeq;
|
|
for (ActionEntry &e : entries) {
|
|
const QString objName = e.action ? e.action->objectName() : QString();
|
|
QString base = e.component;
|
|
if (!base.isEmpty()) {
|
|
base += QLatin1Char('/');
|
|
}
|
|
base += objName.isEmpty() ? e.label : objName;
|
|
|
|
// Guarantee uniqueness even if base still collides.
|
|
const int n = idSeq[base]++;
|
|
e.id = (n == 0) ? base : base + QStringLiteral("#") + QString::number(n);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
void OllieView::showCommandPalette()
|
|
{
|
|
if (!m_palette) {
|
|
m_palette = new PaletteWidget(m_mainWindow->window());
|
|
connect(m_palette, &PaletteWidget::activatedId, this, &OllieView::runAction);
|
|
}
|
|
|
|
const QList<ActionEntry> entries = collectActions();
|
|
|
|
m_actionsById.clear();
|
|
QList<PaletteItem> items;
|
|
items.reserve(entries.size());
|
|
for (const ActionEntry &e : entries) {
|
|
m_actionsById.insert(e.id, e.action);
|
|
|
|
PaletteItem item;
|
|
item.id = e.id;
|
|
item.label = e.label;
|
|
item.group = e.shortcut; // shortcut shown as the trailing hint
|
|
items.push_back(item);
|
|
}
|
|
|
|
m_palette->setItems(items);
|
|
|
|
// Center the palette near the top of the active window.
|
|
if (QWidget *w = m_mainWindow->window()) {
|
|
const QSize sz = m_palette->size();
|
|
const QPoint topLeft = w->mapToGlobal(QPoint((w->width() - sz.width()) / 2,
|
|
w->height() / 6));
|
|
m_palette->move(topLeft);
|
|
}
|
|
|
|
m_palette->activate();
|
|
}
|
|
|
|
void OllieView::runAction(const QString &actionId)
|
|
{
|
|
QPointer<QAction> action = m_actionsById.value(actionId);
|
|
if (action) {
|
|
action->trigger();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
void OllieView::setupRadials()
|
|
{
|
|
// User config could be loaded here later; for now ship the built-in set.
|
|
m_radialDefs = RadialConfig::builtinDefault();
|
|
|
|
QWidget *w = m_mainWindow->window();
|
|
|
|
for (int i = 0; i < m_radialDefs.size(); ++i) {
|
|
const RadialDef &def = m_radialDefs.at(i);
|
|
|
|
// Key trigger (weapon-wheel style).
|
|
if (!def.trigger.isEmpty()) {
|
|
auto *trigger = new QAction(this);
|
|
trigger->setObjectName(QStringLiteral("ollie_radial_") + def.name);
|
|
trigger->setText(i18n("Radial Menu: %1", def.name));
|
|
trigger->setShortcut(QKeySequence(def.trigger));
|
|
trigger->setShortcutContext(Qt::ApplicationShortcut);
|
|
connect(trigger, &QAction::triggered, this, [this, i]() { showRadial(i); });
|
|
if (w) {
|
|
w->addAction(trigger);
|
|
}
|
|
}
|
|
|
|
// Mouse-button gesture (the natural radial trigger).
|
|
if (!def.mouseButton.isEmpty()) {
|
|
Qt::MouseButton btn = Qt::NoButton;
|
|
if (def.mouseButton == QLatin1String("RightButton")) {
|
|
btn = Qt::RightButton;
|
|
} else if (def.mouseButton == QLatin1String("MiddleButton")) {
|
|
btn = Qt::MiddleButton;
|
|
}
|
|
if (btn != Qt::NoButton) {
|
|
m_buttonToRadial.insert(static_cast<int>(btn), i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
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)
|
|
{
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// While a drag gesture is active, drive the radial directly from the raw
|
|
// events: the opening press leaves an implicit grab on the editor view, so
|
|
// the live move/release events are delivered there, and this application
|
|
// filter is the one place that reliably sees them.
|
|
if (m_gestureActive) {
|
|
switch (event->type()) {
|
|
case QEvent::MouseMove:
|
|
if (m_radial) {
|
|
m_radial->driveHoverGlobal(
|
|
static_cast<QMouseEvent *>(event)->globalPosition().toPoint());
|
|
}
|
|
return true;
|
|
case QEvent::MouseButtonRelease:
|
|
m_gestureActive = false;
|
|
if (m_radial) {
|
|
m_radial->driveReleaseGlobal(
|
|
static_cast<QMouseEvent *>(event)->globalPosition().toPoint());
|
|
}
|
|
return true;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 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). The right-button chord is free because the radial
|
|
// gesture is already suppressed while the left button is held. 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 radial / context menu
|
|
}
|
|
|
|
if (mev->button() == Qt::MiddleButton && inView) {
|
|
if (m_leftDownInView) {
|
|
if (QAction *cut = findActionByName(QStringLiteral("edit_cut"))) {
|
|
cut->trigger();
|
|
}
|
|
}
|
|
// Consume press (and its release) to suppress middle-click primary
|
|
// paste entirely inside the editor.
|
|
m_swallowMiddleRelease = true;
|
|
return true;
|
|
}
|
|
if (mev->button() == Qt::LeftButton && inView) {
|
|
m_leftDownInView = true; // anchor set; selection proceeds normally
|
|
}
|
|
}
|
|
|
|
// Suppress the context menu that would otherwise follow an RMB gesture.
|
|
if (event->type() == QEvent::ContextMenu
|
|
&& m_buttonToRadial.contains(static_cast<int>(Qt::RightButton))) {
|
|
KTextEditor::View *view = m_mainWindow->activeView();
|
|
auto *widget = qobject_cast<QWidget *>(obj);
|
|
if (view && widget && view->isAncestorOf(widget)) {
|
|
return true;
|
|
}
|
|
return QObject::eventFilter(obj, event);
|
|
}
|
|
|
|
if (event->type() != QEvent::MouseButtonPress) {
|
|
return QObject::eventFilter(obj, event);
|
|
}
|
|
auto *me = static_cast<QMouseEvent *>(event);
|
|
const int button = static_cast<int>(me->button());
|
|
if (!m_buttonToRadial.contains(button)) {
|
|
return QObject::eventFilter(obj, event);
|
|
}
|
|
|
|
// Only hijack presses that land inside the active view's editing area.
|
|
KTextEditor::View *view = m_mainWindow->activeView();
|
|
auto *widget = qobject_cast<QWidget *>(obj);
|
|
if (!view || !widget || !view->isAncestorOf(widget)) {
|
|
return QObject::eventFilter(obj, event);
|
|
}
|
|
|
|
// Suppress the radial when the left button is already held: the user is
|
|
// mid-selection (or setting up a left+middle chord), not starting a clean
|
|
// radial gesture. buttons() reports the other buttons down at press time.
|
|
if (m_leftDownInView || (me->buttons() & Qt::LeftButton)) {
|
|
return QObject::eventFilter(obj, event);
|
|
}
|
|
|
|
// Pop the radial at the press position and begin the drag gesture. The
|
|
// press is consumed so the context menu does not appear and the caret is
|
|
// not moved (an intentional, configured trade-off).
|
|
showRadialAt(m_buttonToRadial.value(button), me->globalPosition().toPoint());
|
|
m_gestureActive = true;
|
|
return true;
|
|
}
|
|
|
|
void OllieView::showRadial(int defIndex)
|
|
{
|
|
QPoint global;
|
|
if (KTextEditor::View *view = m_mainWindow->activeView()) {
|
|
global = view->mapToGlobal(view->cursorPositionCoordinates());
|
|
} else if (QWidget *w = m_mainWindow->window()) {
|
|
global = w->mapToGlobal(w->rect().center());
|
|
}
|
|
showRadialAt(defIndex, global);
|
|
}
|
|
|
|
void OllieView::showRadialAt(int defIndex, const QPoint &globalPos)
|
|
{
|
|
if (defIndex < 0 || defIndex >= m_radialDefs.size()) {
|
|
return;
|
|
}
|
|
if (!m_radial) {
|
|
m_radial = new RadialMenu(m_mainWindow->window());
|
|
connect(m_radial, &RadialMenu::activatedId, this, &OllieView::onRadialActivated);
|
|
}
|
|
m_radial->popup(m_radialDefs.at(defIndex).root, globalPos);
|
|
}
|
|
|
|
void OllieView::onRadialActivated(const QString &actionId)
|
|
{
|
|
// The palette is reachable from inside a radial as a special slice.
|
|
if (actionId == QLatin1String("ollie_command_palette")) {
|
|
showCommandPalette();
|
|
return;
|
|
}
|
|
if (QAction *a = findActionByName(actionId)) {
|
|
a->trigger();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Acme-style line-editing keys
|
|
//
|
|
// 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 '_')
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void OllieView::setupAcmeKeys()
|
|
{
|
|
QWidget *w = m_mainWindow->window();
|
|
|
|
struct Binding {
|
|
const char *name;
|
|
QKeySequence seq;
|
|
void (OllieView::*slot)();
|
|
};
|
|
const Binding bindings[] = {
|
|
{"ollie_acme_bol", QKeySequence(Qt::CTRL | Qt::Key_A), &OllieView::acmeBeginningOfLine},
|
|
{"ollie_acme_eol", QKeySequence(Qt::CTRL | Qt::Key_E), &OllieView::acmeEndOfLine},
|
|
{"ollie_acme_erase_char", QKeySequence(Qt::CTRL | Qt::Key_H), &OllieView::acmeEraseChar},
|
|
{"ollie_acme_erase_line", QKeySequence(Qt::CTRL | Qt::Key_U), &OllieView::acmeEraseLine},
|
|
{"ollie_acme_erase_word", QKeySequence(Qt::CTRL | Qt::Key_W), &OllieView::acmeEraseWord},
|
|
};
|
|
|
|
for (const Binding &b : bindings) {
|
|
auto *action = new QAction(this);
|
|
action->setObjectName(QString::fromLatin1(b.name));
|
|
action->setShortcut(b.seq);
|
|
// Override the Kate/KDE defaults (Select All, Close, …) inside the app.
|
|
action->setShortcutContext(Qt::ApplicationShortcut);
|
|
connect(action, &QAction::triggered, this, b.slot);
|
|
if (w) {
|
|
w->addAction(action);
|
|
}
|
|
m_acmeSequences.push_back(b.seq);
|
|
m_acmeActions.push_back(action);
|
|
}
|
|
|
|
// Remove our sequences from any pre-existing Kate/KDE action so Qt does not
|
|
// report an ambiguous shortcut. New views bring their own copies of these
|
|
// actions, so re-run the sweep whenever a view is created. Also defer one
|
|
// sweep to the event loop, after the GUI factory has merged all clients.
|
|
clearConflictingShortcuts();
|
|
QMetaObject::invokeMethod(this, &OllieView::clearConflictingShortcuts,
|
|
Qt::QueuedConnection);
|
|
connect(m_mainWindow, &KTextEditor::MainWindow::viewCreated, this,
|
|
[this](KTextEditor::View *) { clearConflictingShortcuts(); });
|
|
}
|
|
|
|
void OllieView::clearConflictingShortcuts()
|
|
{
|
|
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
|
|
if (!factory) {
|
|
return;
|
|
}
|
|
|
|
// Build the quick-lookup set of sequences we own.
|
|
const QList<KXMLGUIClient *> clients = factory->clients();
|
|
for (KXMLGUIClient *client : clients) {
|
|
KActionCollection *coll = client->actionCollection();
|
|
if (!coll) {
|
|
continue;
|
|
}
|
|
const QList<QAction *> actions = coll->actions();
|
|
for (QAction *a : actions) {
|
|
if (!a || m_acmeActions.contains(a)) {
|
|
continue; // never strip our own actions
|
|
}
|
|
const QList<QKeySequence> scs = a->shortcuts();
|
|
if (scs.isEmpty()) {
|
|
continue;
|
|
}
|
|
QList<QKeySequence> kept;
|
|
kept.reserve(scs.size());
|
|
for (const QKeySequence &s : scs) {
|
|
if (!m_acmeSequences.contains(s)) {
|
|
kept.push_back(s);
|
|
}
|
|
}
|
|
if (kept.size() != scs.size()) {
|
|
a->setShortcuts(kept);
|
|
// Also clear any KActionCollection default so it is not restored.
|
|
KActionCollection::setDefaultShortcuts(a, kept);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 katecustom
|
|
|
|
// 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(OlliePluginFactory, "ollieplugin.json",
|
|
registerPlugin<katecustom::OlliePlugin>();)
|
|
|
|
#include "ollieplugin.moc"
|