kate-deft/src/plugin/ollieplugin.cpp

1628 lines
60 KiB
C++

/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "ollieplugin.h"
#include "palettewidget.h"
#include "frecencystore.h"
#include "radialmenu.h"
#include "radialconfig.h"
#include "olliecommands.h"
#include "projectindex.h"
#include "symbolindex.h"
#include "plumbresolve.h"
#include "sampanel.h"
#include "samengine.h"
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <KTextEditor/Command>
#include <KActionCollection>
#include <KXMLGUIClient>
#include <KXMLGUIFactory>
#include <KLocalizedString>
#include <KPluginFactory>
#include <QAction>
#include <QApplication>
#include <QCursor>
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QEvent>
#include <QFileInfo>
#include <QHash>
#include <QIcon>
#include <QKeyEvent>
#include <QKeySequence>
#include <QMouseEvent>
#include <QRegularExpression>
#include <QSet>
#include <QStandardPaths>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>
#include <QWindow>
namespace katecustom
{
namespace
{
// True for the Kate actions that change which split pane is active: moving
// focus between panes, creating a split (Kate activates the new pane), or
// closing a pane (Kate activates a neighbour). After one of these fires we warp
// the mouse pointer onto the now-active pane so the cursor follows the focus
// (mouse-first UX).
bool isPaneFocusAction(const QString &id)
{
return id == QLatin1String("go_left_split_view")
|| id == QLatin1String("go_right_split_view")
|| id == QLatin1String("go_upward_split_view")
|| id == QLatin1String("go_downward_split_view")
|| id == QLatin1String("go_next_split_view")
|| id == QLatin1String("go_prev_split_view")
|| id == QLatin1String("view_split_vert")
|| id == QLatin1String("view_split_horiz")
|| id == QLatin1String("view_close_current_space")
|| id == QLatin1String("view_close_others");
}
// 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();
}
// Prefix marking a palette id that is a ":" command string to execute rather
// than a QAction to trigger.
const QLatin1String kCmdPrefix("ollie:cmd:");
// Prefix marking a palette id that is a project-relative file path to open in
// the file switcher.
const QLatin1String kFilePrefix("ollie:file:");
// Prefix marking a palette id that is a symbol location ("relpath\tline") to
// jump to in the symbol switcher.
const QLatin1String kSymbolPrefix("ollie:sym:");
// The complete (argument-free) command verbs offered in the palette. Verbs that
// need free-text arguments (pipe) stay on the command line; `case` is expanded
// into one entry per style so each is directly runnable.
struct VerbItem {
QString command; // the full command string to execute
QString label; // palette label
};
QList<VerbItem> paletteVerbs()
{
return {
{QStringLiteral("osort"), QStringLiteral("Sort Lines")},
{QStringLiteral("osort u"), QStringLiteral("Sort Lines (unique)")},
{QStringLiteral("rsort"), QStringLiteral("Reverse Lines")},
{QStringLiteral("case upper"), QStringLiteral("Case: UPPER")},
{QStringLiteral("case lower"), QStringLiteral("Case: lower")},
{QStringLiteral("case title"), QStringLiteral("Case: Title")},
{QStringLiteral("case snake"), QStringLiteral("Case: snake_case")},
{QStringLiteral("case camel"), QStringLiteral("Case: camelCase")},
{QStringLiteral("case kebab"), QStringLiteral("Case: kebab-case")},
{QStringLiteral("b64enc"), QStringLiteral("Base64 Encode")},
{QStringLiteral("b64dec"), QStringLiteral("Base64 Decode")},
{QStringLiteral("rot13"), QStringLiteral("ROT13")},
{QStringLiteral("uuid"), QStringLiteral("Insert UUID")},
};
}
// Directory of the active document in \a mainWindow, or empty when there is no
// view, no document, or the document is untitled / not a local file.
QString activeDocumentDirFor(KTextEditor::MainWindow *mainWindow)
{
if (KTextEditor::View *view = mainWindow->activeView()) {
if (KTextEditor::Document *doc = view->document()) {
const QUrl url = doc->url();
if (url.isLocalFile()) {
return QFileInfo(url.toLocalFile()).absolutePath();
}
}
}
return QString();
}
} // namespace
// ---------------------------------------------------------------------------
// OlliePlugin
// ---------------------------------------------------------------------------
OlliePlugin::OlliePlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
// The ":"-verb pack registers itself with the global editor on construction.
m_commands = new OllieCommands(this);
}
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)
{
// Load persisted usage so frecency ranking is available from the first
// palette invocation. The store lives under the user's generic config dir.
const QString cfgDir =
QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
m_frecency.setFilePath(QDir(cfgDir).filePath(
QStringLiteral("olliepalette/frecency.json")));
m_frecency.load();
// The three launcher "doors". Their shortcuts are NOT registered as
// QAction/ApplicationShortcut: Kate (and other plugins) already claim many
// Alt+<letter> combos, and a registered duplicate makes Qt refuse to fire
// either, reporting an "ambiguous shortcut". We therefore register NO
// shortcut here and instead intercept the key in eventFilter() (the same
// technique used for the Acme line-editing keys), which cannot be
// ambiguous because nothing is registered. The QActions are kept only for
// their objectName/label (command-palette harvest, radial escape-hatch).
auto *openAction = new QAction(i18n("Command Palette (M-x) [Alt+X]"), this);
openAction->setObjectName(QStringLiteral("ollie_command_palette"));
connect(openAction, &QAction::triggered, this, &OllieView::showCommandPalette);
auto *gotoFileAction = new QAction(i18n("Go to File (project) [Alt+P]"), this);
gotoFileAction->setObjectName(QStringLiteral("ollie_goto_file"));
connect(gotoFileAction, &QAction::triggered, this, &OllieView::showFileSwitcher);
auto *gotoSymbolAction = new QAction(i18n("Go to Symbol (project) [Alt+G]"), this);
gotoSymbolAction->setObjectName(QStringLiteral("ollie_goto_symbol"));
connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher);
// Plumb: resolve the token/selection under the caret through the plan9
// plumber. Unlike the three "doors" above, F2 is a function key that does
// not collide with Kate's Alt+<letter> accelerators, so we register it as a
// normal shortcut here (testing trigger; the final gesture will be RMB).
auto *plumbAction = new QAction(i18n("Plumb (plan9) [F2]"), this);
plumbAction->setObjectName(QStringLiteral("ollie_plumb"));
plumbAction->setShortcut(QKeySequence(Qt::Key_F2));
connect(plumbAction, &QAction::triggered, this, &OllieView::plumbAtCursor);
// Keep them discoverable as window actions (no shortcut attached).
if (QWidget *w = m_mainWindow->window()) {
w->addAction(openAction);
w->addAction(gotoFileAction);
w->addAction(gotoSymbolAction);
w->addAction(plumbAction);
}
// Kate becomes a full plumb client: incoming "edit" messages open files
// here; outgoing plumbs (plumbAtCursor) go through the "send" port. The
// reader runs on its own thread and delivers via a queued signal.
connect(&m_plumber, &Plumber::edit, this, &OllieView::onPlumbEdit);
connect(&m_plumber, &Plumber::readerError, this, [](const QString &msg) {
// Non-fatal: the plumber may simply not be running. Log and carry on.
qWarning("ollie plumb: %s", qUtf8Printable(msg));
});
m_plumber.startReader();
// sam tool view: a dockable panel to run sam structural-regexp commands
// against the active document (or project-wide via X/Y). Created on the
// left sidebar; the host owns the returned widget.
m_samToolView = m_mainWindow->createToolView(
plugin, QStringLiteral("ollie_sam"), KTextEditor::MainWindow::Left,
QIcon::fromTheme(QStringLiteral("edit-find-replace")), i18n("Sam"));
if (m_samToolView) {
m_samPanel = new SamPanel(m_samToolView);
// The tool view returned by Kate (KateMDI::ToolView) already installs
// its own layout; creating a second one here is silently rejected by Qt
// and leaves SamPanel un-managed (tiny, pinned top-left). Add to the
// existing layout when there is one, otherwise create it ourselves.
QLayout *tvLayout = m_samToolView->layout();
if (!tvLayout) {
tvLayout = new QVBoxLayout(m_samToolView);
tvLayout->setContentsMargins(0, 0, 0, 0);
}
tvLayout->addWidget(m_samPanel);
connect(m_samPanel, &SamPanel::runRequested, this, &OllieView::runSamProgram);
}
// Build on Kate's project plugin: attach to its view now (if present) and
// follow it as plugins come and go. The bridge is read-only; the switchers
// scope to its project when one is loaded, else to VCS discovery.
{
static const QString kProjectPlugin = QStringLiteral("kateprojectplugin");
m_projectBridge.setProjectView(m_mainWindow->pluginView(kProjectPlugin));
connect(m_mainWindow, &KTextEditor::MainWindow::pluginViewCreated, this,
[this](const QString &name, QObject *view) {
if (name == QStringLiteral("kateprojectplugin")) {
m_projectBridge.setProjectView(view);
}
});
connect(m_mainWindow, &KTextEditor::MainWindow::pluginViewDeleted, this,
[this](const QString &name, QObject *) {
if (name == QStringLiteral("kateprojectplugin")) {
m_projectBridge.setProjectView(nullptr);
}
});
// When Kate's project changes, drop the cached index so the next open
// re-indexes the new project rather than serving stale results.
connect(&m_projectBridge, &KateProjectBridge::projectChanged, this,
[this]() { m_indexer.invalidateAll(); });
}
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_filePalette;
delete m_symbolPalette;
delete m_radial;
delete m_samToolView; // deletes the embedded SamPanel with it
}
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::positionPalette(PaletteWidget *palette)
{
QWidget *w = m_mainWindow->window();
if (!w) {
return;
}
// Three quarters of the window width, horizontally centered, anchored near
// the top — roomy enough for long paths / symbol labels without looking
// edge-to-edge.
const int width = (w->width() * 3) / 4;
palette->resize(width, 320);
palette->move(w->mapToGlobal(QPoint((w->width() - width) / 2, w->height() / 6)));
}
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() + 16);
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);
}
// Also offer the ":"-verb pack as directly-runnable palette entries.
for (const VerbItem &v : paletteVerbs()) {
PaletteItem item;
item.id = kCmdPrefix + v.command;
item.label = v.label;
item.group = QStringLiteral(":") + v.command;
items.push_back(item);
}
// Offer the project file switcher as a palette entry too (also on Alt+P).
{
PaletteItem item;
item.id = QStringLiteral("ollie:switch:file");
item.label = i18n("Go to File (project)");
item.group = QStringLiteral("Alt+P");
items.push_back(item);
}
// Offer the project symbol switcher as a palette entry too (also on Alt+G).
{
PaletteItem item;
item.id = QStringLiteral("ollie:switch:symbol");
item.label = i18n("Go to Symbol (project)");
item.group = QStringLiteral("Alt+G");
items.push_back(item);
}
// Apply the persisted frecency bonus so habitual commands rank higher.
const qint64 now = QDateTime::currentSecsSinceEpoch();
for (PaletteItem &item : items) {
item.frecency = m_frecency.bonus(item.id, now);
}
m_palette->setItems(items);
// Span the full window width, anchored near the top.
positionPalette(m_palette);
m_palette->activate();
}
void OllieView::recordUsage(const QString &id)
{
if (id.isEmpty()) {
return;
}
m_frecency.bump(id, QDateTime::currentSecsSinceEpoch());
m_frecency.save();
}
QString OllieView::activeDocumentDir() const
{
return activeDocumentDirFor(m_mainWindow);
}
QString OllieView::currentProjectRoot() const
{
// Precedence: Kate's project plugin (if a project is loaded) wins; else
// discovery from the active document, with cwd as the final fallback
// inside resolveRoot().
const QString bridgeDir = m_projectBridge.baseDir();
if (!bridgeDir.isEmpty() && QDir(bridgeDir).exists()) {
return QDir(bridgeDir).absolutePath();
}
return ProjectIndex::resolveRoot(QString(), activeDocumentDir());
}
void OllieView::runAction(const QString &actionId)
{
// Count every command-palette activation toward frecency (verbs, switcher
// entries, and harvested actions all flow through here).
recordUsage(actionId);
// A ":"-verb entry: execute the command directly on the active view.
// We call OllieCommands::runVerb rather than Editor::queryCommand because
// our Command's auto-registration is not reliably visible to queryCommand.
if (actionId.startsWith(kCmdPrefix)) {
const QString command = actionId.mid(QString(kCmdPrefix).size());
if (KTextEditor::View *view = m_mainWindow->activeView()) {
QString msg;
OllieCommands::runVerb(view, command, msg);
}
return;
}
// The project file switcher, surfaced as a palette entry.
if (actionId == QLatin1String("ollie:switch:file")) {
showFileSwitcher();
return;
}
// The project symbol switcher, surfaced as a palette entry.
if (actionId == QLatin1String("ollie:switch:symbol")) {
showSymbolSwitcher();
return;
}
QPointer<QAction> action = m_actionsById.value(actionId);
if (action) {
action->trigger();
}
}
QList<PaletteItem> OllieView::buildFileItems(const QString &root,
const QStringList &files) const
{
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> items;
items.reserve(files.size());
for (const QString &rel : files) {
PaletteItem item;
item.id = kFilePrefix + rel;
// Match and display the full project-relative path: FuzzyRanker scores
// against the label (orderless tokens, so "src main" matches
// "src/main.cpp"), and the delegate highlights matched characters across
// the whole path. The base name still ranks well because an exact or
// substring hit on it outscores a scattered subsequence.
item.label = rel;
item.frecency = m_frecency.bonus(
kFilePrefix + root + QLatin1Char('\t') + rel, now);
items.push_back(item);
}
return items;
}
void OllieView::showFileSwitcher()
{
if (!m_filePalette) {
m_filePalette = new PaletteWidget(m_mainWindow->window());
connect(m_filePalette, &PaletteWidget::activatedId, this,
[this](const QString &id) {
if (id.startsWith(kFilePrefix)) {
const QString rel = id.mid(QString(kFilePrefix).size());
// Record under a project-qualified key so frecency does
// not bleed between repos that share relative paths.
recordUsage(kFilePrefix + m_fileSwitcherRoot
+ QLatin1Char('\t') + rel);
openProjectFile(rel);
}
});
// Results that arrive asynchronously repopulate the palette if it is
// still showing the same project. Connected once.
connect(&m_indexer, &ProjectIndexer::filesReady, this,
[this](const QString &root, const QStringList &files) {
if (root == m_fileSwitcherRoot && m_filePalette
&& m_filePalette->isVisible()) {
m_filePalette->setItems(buildFileItems(root, files));
}
});
}
m_fileSwitcherRoot = currentProjectRoot();
// Show immediately so there is no stall; fill from cache if we have it,
// otherwise show a transient placeholder and let the async result populate.
if (m_indexer.hasFiles(m_fileSwitcherRoot)) {
m_filePalette->setItems(
buildFileItems(m_fileSwitcherRoot, m_indexer.cachedFiles(m_fileSwitcherRoot)));
} else {
PaletteItem loading;
loading.id = QStringLiteral("ollie:loading");
loading.label = i18n("Indexing project…");
m_filePalette->setItems({loading});
}
positionPalette(m_filePalette);
m_filePalette->activate();
// Kick off (or serve from cache) the index; filesReady repopulates.
m_indexer.requestFiles(m_fileSwitcherRoot);
}
void OllieView::openProjectFile(const QString &relativePath)
{
if (relativePath.isEmpty() || m_fileSwitcherRoot.isEmpty()) {
return;
}
const QString absolute = QDir(m_fileSwitcherRoot).absoluteFilePath(relativePath);
const QUrl url = QUrl::fromLocalFile(absolute);
if (KTextEditor::View *view = m_mainWindow->openUrl(url)) {
m_mainWindow->activateView(view->document());
}
}
QList<PaletteItem> OllieView::buildSymbolItems(const QString &root,
const QList<Symbol> &symbols) const
{
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> items;
items.reserve(symbols.size());
for (const Symbol &s : symbols) {
PaletteItem item;
// The id encodes the jump target as "relpath\tline"; the location is
// resolved against the switcher root on activation.
const QString loc = s.file + QLatin1Char('\t') + QString::number(s.line);
item.id = kSymbolPrefix + loc;
// Label: scope-qualified name, then kind + file as visible provenance.
// The delegate renders only the label and FuzzyRanker scores against it,
// so folding provenance in keeps it both visible and filterable (e.g.
// "method olliecommands" narrows by name and file at once). The name
// leads, so a name hit scores highest and ranks first.
QString label = s.name;
if (!s.scope.isEmpty()) {
label = s.scope + QStringLiteral("::") + s.name;
}
QString suffix = s.kind;
if (!s.file.isEmpty()) {
suffix = suffix.isEmpty() ? s.file : (s.kind + QStringLiteral(" ") + s.file);
}
if (!suffix.isEmpty()) {
label += QStringLiteral(" — ") + suffix;
}
item.label = label;
item.frecency = m_frecency.bonus(
kSymbolPrefix + root + QLatin1Char('\t') + loc, now);
items.push_back(item);
}
return items;
}
void OllieView::showSymbolSwitcher()
{
if (!m_symbolPalette) {
m_symbolPalette = new PaletteWidget(m_mainWindow->window());
connect(m_symbolPalette, &PaletteWidget::activatedId, this,
[this](const QString &id) {
if (id.startsWith(kSymbolPrefix)) {
const QString loc = id.mid(QString(kSymbolPrefix).size());
// Project-qualified key, as for the file switcher.
recordUsage(kSymbolPrefix + m_symbolSwitcherRoot
+ QLatin1Char('\t') + loc);
goToSymbol(loc);
}
});
connect(&m_indexer, &ProjectIndexer::symbolsReady, this,
[this](const QString &root, const QList<Symbol> &symbols) {
if (root == m_symbolSwitcherRoot && m_symbolPalette
&& m_symbolPalette->isVisible()) {
m_symbolPalette->setItems(buildSymbolItems(root, symbols));
}
});
}
m_symbolSwitcherRoot = currentProjectRoot();
// Open instantly; symbol indexing (ctags) is the slowest stage, so a
// placeholder is important here. The async result repopulates.
if (m_indexer.hasSymbols(m_symbolSwitcherRoot)) {
m_symbolPalette->setItems(
buildSymbolItems(m_symbolSwitcherRoot,
m_indexer.cachedSymbols(m_symbolSwitcherRoot)));
} else {
PaletteItem loading;
loading.id = QStringLiteral("ollie:loading");
loading.label = i18n("Indexing symbols…");
m_symbolPalette->setItems({loading});
}
positionPalette(m_symbolPalette);
m_symbolPalette->activate();
m_indexer.requestSymbols(m_symbolSwitcherRoot);
}
void OllieView::goToSymbol(const QString &locationId)
{
if (m_symbolSwitcherRoot.isEmpty()) {
return;
}
const int tab = locationId.indexOf(QLatin1Char('\t'));
if (tab < 0) {
return;
}
const QString relativePath = locationId.left(tab);
const int line = locationId.mid(tab + 1).toInt();
if (relativePath.isEmpty()) {
return;
}
const QString absolute = QDir(m_symbolSwitcherRoot).absoluteFilePath(relativePath);
const QUrl url = QUrl::fromLocalFile(absolute);
KTextEditor::View *view = m_mainWindow->openUrl(url);
if (!view) {
return;
}
m_mainWindow->activateView(view->document());
if (line > 0) {
// ctags lines are 1-based; KTextEditor cursors are 0-based.
view->setCursorPosition(KTextEditor::Cursor(line - 1, 0));
}
}
void OllieView::plumbAtCursor()
{
KTextEditor::View *view = m_mainWindow->activeView();
if (!view) {
return;
}
KTextEditor::Document *doc = view->document();
if (!doc) {
return;
}
// What to plumb: an explicit selection wins (lets the user plumb a path
// with a line suffix like "foo.cpp:42"), otherwise the word under the
// caret.
QString data;
if (view->selection()) {
data = view->selectionText().trimmed();
}
if (data.isEmpty()) {
data = doc->wordAt(view->cursorPosition());
}
if (data.isEmpty()) {
return;
}
// Working directory: the active document's directory so the plumber can
// resolve relative paths (its rules apply $wdir to bare names).
const QString wdir = activeDocumentDir();
// Send through the plumber. On failure (no plumber / no namespace) fall
// back to a minimal internal resolver so the gesture still does something
// useful.
if (m_plumber.send(data, wdir)) {
return;
}
qWarning("ollie plumb send failed: %s", qUtf8Printable(m_plumber.errorString()));
// Fallback: URL -> external handler; path[:line] -> open here; else treat
// the token as a symbol and open the symbol switcher pre-seeded is overkill
// here, so we just try to open it as a file.
const QUrl asUrl = QUrl::fromUserInput(data);
if (asUrl.isValid() && !asUrl.scheme().isEmpty()
&& asUrl.scheme() != QStringLiteral("file")) {
QDesktopServices::openUrl(asUrl);
return;
}
onPlumbEdit(data, QString(), wdir);
}
void OllieView::onPlumbEdit(const QString &file, const QString &addr, const QString &wdir)
{
if (file.isEmpty()) {
return;
}
// A plumbed "file" may carry a trailing address the rules did not split
// out (e.g. the fallback path above passes the raw token). Prefer the
// explicit addr attribute; if absent, try to peel a ":line[:col]" suffix.
QString path = file;
QString addrStr = addr;
if (addrStr.isEmpty()) {
const int colon = path.lastIndexOf(QLatin1Char(':'));
if (colon > 1) { // keep a Windows drive letter intact
const QString tail = path.mid(colon + 1);
bool numeric = !tail.isEmpty();
for (const QChar c : tail) {
if (!c.isDigit()) {
numeric = false;
break;
}
}
if (numeric) {
addrStr = tail;
path = path.left(colon);
}
}
}
// Resolve a relative path against the message's working directory (the
// plumber sends absolute paths for the edit port, but the fallback may
// not).
QFileInfo fi(path);
if (fi.isRelative() && !wdir.isEmpty()) {
fi = QFileInfo(QDir(wdir).absoluteFilePath(path));
}
const QUrl url = QUrl::fromLocalFile(fi.absoluteFilePath());
KTextEditor::View *view = m_mainWindow->openUrl(url);
if (!view) {
return;
}
m_mainWindow->activateView(view->document());
const PlumbAddr pa = parsePlumbAddr(addrStr);
if (pa.valid) {
view->setCursorPosition(KTextEditor::Cursor(pa.line, pa.column));
}
}
bool OllieView::applySamToDocument(KTextEditor::Document *doc, const QString &program,
int dotStart, int dotEnd, QString *output, QString *error)
{
const QString text = doc->text();
const SamResult res = SamEngine::run(program, text, dotStart, dotEnd);
if (!res.ok) {
if (error) {
*error = res.error;
}
return false;
}
if (output) {
*output = res.output;
}
// Apply all edits inside one transaction so a single undo reverts the whole
// Run. Edits are in ORIGINAL-text offsets, non-overlapping and sorted by
// start; apply back-to-front so earlier offsets remain valid.
if (!res.edits.isEmpty()) {
KTextEditor::Document::EditingTransaction transaction(doc);
for (int i = res.edits.size() - 1; i >= 0; i--) {
const SamEdit &e = res.edits[i];
const KTextEditor::Cursor from = doc->offsetToCursor(e.start);
const KTextEditor::Cursor to = doc->offsetToCursor(e.end);
doc->replaceText(KTextEditor::Range(from, to), e.text);
}
}
return true;
}
void OllieView::runSamProgram(const QString &program)
{
if (!m_samPanel) {
return;
}
const QString trimmed = program.trimmed();
if (trimmed.isEmpty()) {
return;
}
// Multi-file X/Y: drive the sweep over the project's files.
const SamEngine::FileLoop fl = SamEngine::peelFileLoop(trimmed);
if (fl.matched) {
runSamFileLoop(fl.negate, fl.fileRegex, fl.inner);
return;
}
// Single-file: run against the active document.
KTextEditor::View *view = m_mainWindow->activeView();
if (!view || !view->document()) {
m_samPanel->showError(i18n("No active document."));
return;
}
KTextEditor::Document *doc = view->document();
// Dot = current selection, else the whole document (sam's common idioms use
// explicit ranges like `,` anyway; whole-doc is a sensible default dot).
int dotStart = 0;
int dotEnd = doc->text().size();
if (view->selection()) {
const KTextEditor::Range sel = view->selectionRange();
dotStart = int(doc->cursorToOffset(sel.start()));
dotEnd = int(doc->cursorToOffset(sel.end()));
}
QString output;
QString error;
if (!applySamToDocument(doc, program, dotStart, dotEnd, &output, &error)) {
m_samPanel->showError(error);
return;
}
if (!output.isEmpty()) {
m_samPanel->showOutput(output);
} else {
m_samPanel->showOutput(i18n("(done)"));
}
}
void OllieView::runSamFileLoop(bool negate, const QString &fileRegex, const QString &inner)
{
if (!m_samPanel) {
return;
}
const QString root = currentProjectRoot();
if (root.isEmpty()) {
m_samPanel->showError(i18n("X/Y needs a project; none is loaded."));
return;
}
if (inner.trimmed().isEmpty()) {
m_samPanel->showError(i18n("X/Y: no command to run on matched files."));
return;
}
QRegularExpression fileRe(fileRegex);
if (!fileRe.isValid()) {
m_samPanel->showError(i18n("X/Y: bad file regexp: %1", fileRe.errorString()));
return;
}
// Use the cached project file list; request a build if we have none yet.
if (!m_indexer.hasFiles(root)) {
m_samPanel->showOutput(i18n("Indexing project files… run X/Y again in a moment."));
m_indexer.requestFiles(root);
return;
}
const QStringList files = m_indexer.cachedFiles(root);
int matched = 0;
int changed = 0;
int failed = 0;
for (const QString &rel : files) {
const bool hit = fileRe.match(rel).hasMatch();
if (hit == negate) {
continue; // X runs on hits; Y runs on misses
}
matched++;
const QString absolute = QDir(root).absoluteFilePath(rel);
const QUrl url = QUrl::fromLocalFile(absolute);
// Open (or focus) the file so the edit is a normal, undoable Document
// change visible to the user.
KTextEditor::View *view = m_mainWindow->openUrl(url);
if (!view || !view->document()) {
failed++;
m_samPanel->showError(i18n("could not open %1", rel));
continue;
}
KTextEditor::Document *doc = view->document();
QString output;
QString error;
const int dotEnd = doc->text().size();
if (applySamToDocument(doc, inner, 0, dotEnd, &output, &error)) {
changed++;
if (!output.isEmpty()) {
m_samPanel->showOutput(QStringLiteral("%1:\n%2").arg(rel, output));
}
} else {
// A "no match" in a given file is normal during a sweep; report it
// quietly rather than as a hard error.
m_samPanel->showOutput(QStringLiteral("%1: %2").arg(rel, error));
}
}
m_samPanel->showOutput(
i18n("X/Y done: %1 file(s) matched, %2 changed, %3 failed.", matched, changed, failed));
}
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()
{
// Prefer the user's config (~/.config/olliepalette/radials.json); fall back
// to the built-in set when it is absent, empty, or invalid.
m_radialDefs = RadialConfig::load();
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)
{
// 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).
// Handled here rather than via QAction shortcuts so they can never clash
// with Kate's own Alt+<letter> bindings (which produced "ambiguous
// shortcut" and swallowed the key). Scope to this plugin's window so we
// do not hijack the combo in other Kate main windows.
const bool altOnly =
(ke->modifiers() & Qt::KeyboardModifierMask) == Qt::AltModifier;
if (altOnly
&& (ke->key() == Qt::Key_X || ke->key() == Qt::Key_P || ke->key() == Qt::Key_G)) {
auto *w = qobject_cast<QWidget *>(obj);
QWidget *ourWindow = m_mainWindow->window();
const bool ours = w && ourWindow && ourWindow->isAncestorOf(w);
if (ours) {
if (event->type() == QEvent::ShortcutOverride) {
event->accept(); // suppress any registered (ambiguous) shortcut
return true;
}
switch (ke->key()) {
case Qt::Key_X: showCommandPalette(); break;
case Qt::Key_P: showFileSwitcher(); break;
case Qt::Key_G: showSymbolSwitcher(); break;
default: break;
}
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
}
}
}
}
// 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;
m_swallowMiddleRelease = false; // the gesture consumed this release
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) {
// left + middle = Cut the selection (Acme chord). Consume both
// press and release; never a radial.
if (QAction *cut = findActionByName(QStringLiteral("edit_cut"))) {
cut->trigger();
}
m_swallowMiddleRelease = true;
return true;
}
// A plain middle press (no chord): always suppress the X11 primary
// paste that fires on release. If the middle button is bound to a
// radial, fall through to the radial dispatch below; otherwise
// consume the press here too.
m_swallowMiddleRelease = true;
if (!m_buttonToRadial.contains(static_cast<int>(Qt::MiddleButton))) {
return true;
}
// else: do not return — let the radial-trigger dispatch handle it.
}
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 launcher doors are reachable from inside a radial as special slices.
if (actionId == QLatin1String("ollie_command_palette")) {
showCommandPalette();
return;
}
if (actionId == QLatin1String("ollie_goto_file")) {
showFileSwitcher();
return;
}
if (actionId == QLatin1String("ollie_goto_symbol")) {
showSymbolSwitcher();
return;
}
if (QAction *a = findActionByName(actionId)) {
a->trigger();
// If this changed the active pane (focus move, or a split that creates
// and activates a new pane), warp the pointer onto it so the mouse
// follows the focus. Deferred by ~a frame so Kate has finished
// activating — and, for a freshly created split, laying out — the pane.
if (isPaneFocusAction(actionId)) {
QTimer::singleShot(16, this, [this]() { warpCursorToActiveView(); });
}
}
}
void OllieView::warpCursorToActiveView()
{
KTextEditor::View *view = m_mainWindow->activeView();
if (!view) {
return;
}
// Target: the centre of the new active view, in the top-level window's
// coordinates. Kate's split panes are child widgets of one top-level window
// (one wl_surface), so this is the pane centre within that single surface.
QWidget *top = view->window();
if (!top) {
return;
}
const QPoint centerInView = view->rect().center();
const QPoint centerInTop = view->mapTo(top, centerInView);
// Wayland forbids QCursor::setPos; use the pointer-constraints warp against
// the top-level surface. On X11 (or if the Wayland path is unavailable),
// fall back to the direct warp, which works there.
if (WaylandCursorWarp::isWaylandSession()) {
if (QWindow *win = top->windowHandle()) {
if (m_cursorWarp.warp(win, QPointF(centerInTop))) {
return;
}
}
return; // Wayland but warp unavailable — nothing more we can do.
}
QCursor::setPos(top->mapToGlobal(centerInTop));
}
// ---------------------------------------------------------------------------
// 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 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"