/* * 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 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+ 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+ 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::collectActions() const { QList 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 seen; const QList clients = factory->clients(); for (KXMLGUIClient *client : clients) { KActionCollection *coll = client->actionCollection(); if (!coll) { continue; } const QString component = coll->componentDisplayName(); const QList 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 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 stillColliding; for (const ActionEntry &e : entries) { ++stillColliding[e.label]; } QHash 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 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 entries = collectActions(); m_actionsById.clear(); QList 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 action = m_actionsById.value(actionId); if (action) { action->trigger(); } } QList OllieView::buildFileItems(const QString &root, const QStringList &files) const { const qint64 now = QDateTime::currentSecsSinceEpoch(); QList 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 OllieView::buildSymbolItems(const QString &root, const QList &symbols) const { const qint64 now = QDateTime::currentSecsSinceEpoch(); QList 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 &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 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 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(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(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(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+ 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(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(event); if (mev->button() == Qt::LeftButton) { KTextEditor::View *v = m_mainWindow->activeView(); auto *w = qobject_cast(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(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(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(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(event); KTextEditor::View *v = m_mainWindow->activeView(); auto *w = qobject_cast(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(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(Qt::RightButton))) { KTextEditor::View *view = m_mainWindow->activeView(); auto *widget = qobject_cast(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(event); const int button = static_cast(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(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();) #include "ollieplugin.moc"