1628 lines
60 KiB
C++
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"
|