kate-deft/src/plugin/ollieplugin.cpp

677 lines
24 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();
// 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();
}
}
} // 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"