/* * SPDX-License-Identifier: LGPL-2.0-or-later */ #include "ollieplugin.h" #include "palettewidget.h" #include "radialmenu.h" #include "radialconfig.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace katecustom { namespace { // Bracket pairs understood by the double-click selection feature. QChar matchingBracket(QChar c, bool &isOpener) { switch (c.unicode()) { case '(': isOpener = true; return QLatin1Char(')'); case '[': isOpener = true; return QLatin1Char(']'); case '{': isOpener = true; return QLatin1Char('}'); case ')': isOpener = false; return QLatin1Char('('); case ']': isOpener = false; return QLatin1Char('['); case '}': isOpener = false; return QLatin1Char('{'); default: isOpener = false; return QChar(); } } // Advance a cursor by one character, wrapping across line ends. Returns false // at end of document. bool stepForward(KTextEditor::Document *doc, KTextEditor::Cursor &c) { if (c.column() < doc->lineLength(c.line())) { c.setColumn(c.column() + 1); } else if (c.line() < doc->documentEnd().line()) { c.setLine(c.line() + 1); c.setColumn(0); } else { return false; } return true; } // Retreat a cursor by one character, wrapping across line starts. Returns false // at start of document. bool stepBackward(KTextEditor::Document *doc, KTextEditor::Cursor &c) { if (c.column() > 0) { c.setColumn(c.column() - 1); } else if (c.line() > 0) { c.setLine(c.line() - 1); c.setColumn(doc->lineLength(c.line())); } else { return false; } return true; } // Scan forward from the position just after an opener for its matching closer, // honouring nesting. \a pos must point at the first char after the opener. // Returns the closer's cursor, or an invalid cursor if unbalanced. KTextEditor::Cursor findCloser(KTextEditor::Document *doc, KTextEditor::Cursor pos, QChar opener, QChar closer) { int depth = 1; while (true) { const QChar ch = doc->characterAt(pos); if (ch == opener) { ++depth; } else if (ch == closer) { if (--depth == 0) { return pos; } } if (!stepForward(doc, pos)) { break; } } return KTextEditor::Cursor::invalid(); } // Scan backward from the position just before a closer for its matching opener. // \a pos must point at the char immediately before the closer. KTextEditor::Cursor findOpener(KTextEditor::Document *doc, KTextEditor::Cursor pos, QChar closer, QChar opener) { int depth = 1; while (true) { const QChar ch = doc->characterAt(pos); if (ch == closer) { ++depth; } else if (ch == opener) { if (--depth == 0) { return pos; } } if (!stepBackward(doc, pos)) { break; } } return KTextEditor::Cursor::invalid(); } } // namespace // --------------------------------------------------------------------------- // OlliePlugin // --------------------------------------------------------------------------- OlliePlugin::OlliePlugin(QObject *parent, const QVariantList &args) : KTextEditor::Plugin(parent) { Q_UNUSED(args); } OlliePlugin::~OlliePlugin() = default; QObject *OlliePlugin::createView(KTextEditor::MainWindow *mainWindow) { return new OllieView(this, mainWindow); } // --------------------------------------------------------------------------- // OllieView // --------------------------------------------------------------------------- OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) : QObject(plugin) , m_mainWindow(mainWindow) { // The one keyboard door: Alt+X (M-x). A single modifier, no chord, and the // keys sit together on a compact layout. auto *openAction = new QAction(i18n("Command Palette (M-x)"), this); openAction->setObjectName(QStringLiteral("ollie_command_palette")); openAction->setShortcut(QKeySequence(Qt::ALT | Qt::Key_X)); openAction->setShortcutContext(Qt::ApplicationShortcut); connect(openAction, &QAction::triggered, this, &OllieView::showCommandPalette); // Attach the shortcut to the window so it is always live. if (QWidget *w = m_mainWindow->window()) { w->addAction(openAction); } setupRadials(); setupAcmeKeys(); // One application-wide filter serves both the mouse-button radial gesture // and the Acme-style left+middle chord, catching presses no matter which // internal child of the view actually receives them. qApp->installEventFilter(this); } OllieView::~OllieView() { delete m_palette; delete m_radial; } QList OllieView::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::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()); for (const ActionEntry &e : entries) { m_actionsById.insert(e.id, e.action); PaletteItem item; item.id = e.id; item.label = e.label; item.group = e.shortcut; // shortcut shown as the trailing hint items.push_back(item); } m_palette->setItems(items); // Center the palette near the top of the active window. if (QWidget *w = m_mainWindow->window()) { const QSize sz = m_palette->size(); const QPoint topLeft = w->mapToGlobal(QPoint((w->width() - sz.width()) / 2, w->height() / 6)); m_palette->move(topLeft); } m_palette->activate(); } void OllieView::runAction(const QString &actionId) { QPointer action = m_actionsById.value(actionId); if (action) { action->trigger(); } } QAction *OllieView::findActionByName(const QString &nameOrText) const { KXMLGUIFactory *factory = m_mainWindow->guiFactory(); if (!factory || nameOrText.isEmpty()) { return nullptr; } const QList 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() { // User config could be loaded here later; for now ship the built-in set. m_radialDefs = RadialConfig::builtinDefault(); QWidget *w = m_mainWindow->window(); for (int i = 0; i < m_radialDefs.size(); ++i) { const RadialDef &def = m_radialDefs.at(i); // Key trigger (weapon-wheel style). if (!def.trigger.isEmpty()) { auto *trigger = new QAction(this); trigger->setObjectName(QStringLiteral("ollie_radial_") + def.name); trigger->setText(i18n("Radial Menu: %1", def.name)); trigger->setShortcut(QKeySequence(def.trigger)); trigger->setShortcutContext(Qt::ApplicationShortcut); connect(trigger, &QAction::triggered, this, [this, i]() { showRadial(i); }); if (w) { w->addAction(trigger); } } // Mouse-button gesture (the natural radial trigger). if (!def.mouseButton.isEmpty()) { Qt::MouseButton btn = Qt::NoButton; if (def.mouseButton == QLatin1String("RightButton")) { btn = Qt::RightButton; } else if (def.mouseButton == QLatin1String("MiddleButton")) { btn = Qt::MiddleButton; } if (btn != Qt::NoButton) { m_buttonToRadial.insert(static_cast(btn), i); } } } } bool OllieView::selectBracketPairAt(KTextEditor::View *view, const QPoint &globalPos) { if (!view) { return false; } KTextEditor::Document *doc = view->document(); if (!doc) { return false; } const QPoint local = view->mapFromGlobal(globalPos); const KTextEditor::Cursor at = view->coordinatesToCursor(local); if (!at.isValid()) { return false; } // Characters immediately to the right and left of the click position. const QChar rightCh = doc->characterAt(at); KTextEditor::Cursor leftCur = at; const bool haveLeft = stepBackward(doc, leftCur); const QChar leftCh = haveLeft ? doc->characterAt(leftCur) : QChar(); bool rOpener = false; const QChar rMatch = matchingBracket(rightCh, rOpener); bool lOpener = false; const QChar lMatch = matchingBracket(leftCh, lOpener); KTextEditor::Cursor opener = KTextEditor::Cursor::invalid(); KTextEditor::Cursor closer = KTextEditor::Cursor::invalid(); // Prefer an opener to the right (select its contents forward), then a closer // to the left (select its contents backward), then the remaining adjacency // cases. "Everything between" excludes the bracket characters themselves. if (!rMatch.isNull() && rOpener) { KTextEditor::Cursor after = at; if (stepForward(doc, after)) { opener = at; closer = findCloser(doc, after, rightCh, rMatch); } } else if (!lMatch.isNull() && !lOpener) { KTextEditor::Cursor before = leftCur; if (stepBackward(doc, before)) { closer = leftCur; opener = findOpener(doc, before, leftCh, lMatch); } } else if (!rMatch.isNull() && !rOpener) { // Closer to the right: match backward from just before it. KTextEditor::Cursor before = at; if (stepBackward(doc, before)) { closer = at; opener = findOpener(doc, before, rightCh, rMatch); } } else if (!lMatch.isNull() && lOpener) { // Opener to the left: match forward from the click position. opener = leftCur; closer = findCloser(doc, at, leftCh, lMatch); } else { return false; // no bracket adjacent — let Kate's default run } if (!opener.isValid() || !closer.isValid()) { return false; } // Select the content strictly between the pair. The inner range starts just // after the opener and ends at the closer. KTextEditor::Cursor innerStart = opener; if (!stepForward(doc, innerStart)) { return false; } const KTextEditor::Range inner(innerStart, closer); view->setSelection(inner); view->setCursorPosition(closer); return true; } bool OllieView::eventFilter(QObject *obj, QEvent *event) { // Bracket-pair selection: double-clicking next to a bracket selects the // text between the matching pair (multi-line). Falls through to Kate's // default double-click (word select) when no bracket is adjacent. The // second click of a double-click arrives as DblClick (not a plain press) // and leaves the left button held, so it also arms the chord anchor — // letting the user go straight from a double-click into a mouse chord. if (event->type() == QEvent::MouseButtonDblClick) { auto *mev = static_cast(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; 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) { if (QAction *cut = findActionByName(QStringLiteral("edit_cut"))) { cut->trigger(); } } // Consume press (and its release) to suppress middle-click primary // paste entirely inside the editor. m_swallowMiddleRelease = true; return true; } if (mev->button() == Qt::LeftButton && inView) { m_leftDownInView = true; // anchor set; selection proceeds normally } } // Suppress the context menu that would otherwise follow an RMB gesture. if (event->type() == QEvent::ContextMenu && m_buttonToRadial.contains(static_cast(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 palette is reachable from inside a radial as a special slice. if (actionId == QLatin1String("ollie_command_palette")) { showCommandPalette(); return; } if (QAction *a = findActionByName(actionId)) { a->trigger(); } } // --------------------------------------------------------------------------- // Acme-style line-editing keys // // Semantics ported from plan9port acme text.c (textbswidth / Ktype): // ^A move to beginning of line (stop at the preceding newline) // ^E move to end of line (stop at the next newline) // ^H erase one character back // ^U erase back to the beginning of line // ^W erase back one word: skip trailing non-alphanumerics, then the // alphanumeric run (Acme uses C isalnum, which excludes '_') // --------------------------------------------------------------------------- void OllieView::setupAcmeKeys() { QWidget *w = m_mainWindow->window(); struct Binding { const char *name; QKeySequence seq; void (OllieView::*slot)(); }; const Binding bindings[] = { {"ollie_acme_bol", QKeySequence(Qt::CTRL | Qt::Key_A), &OllieView::acmeBeginningOfLine}, {"ollie_acme_eol", QKeySequence(Qt::CTRL | Qt::Key_E), &OllieView::acmeEndOfLine}, {"ollie_acme_erase_char", QKeySequence(Qt::CTRL | Qt::Key_H), &OllieView::acmeEraseChar}, {"ollie_acme_erase_line", QKeySequence(Qt::CTRL | Qt::Key_U), &OllieView::acmeEraseLine}, {"ollie_acme_erase_word", QKeySequence(Qt::CTRL | Qt::Key_W), &OllieView::acmeEraseWord}, }; for (const Binding &b : bindings) { auto *action = new QAction(this); action->setObjectName(QString::fromLatin1(b.name)); action->setShortcut(b.seq); // Override the Kate/KDE defaults (Select All, Close, …) inside the app. action->setShortcutContext(Qt::ApplicationShortcut); connect(action, &QAction::triggered, this, b.slot); if (w) { w->addAction(action); } m_acmeSequences.push_back(b.seq); m_acmeActions.push_back(action); } // Remove our sequences from any pre-existing Kate/KDE action so Qt does not // report an ambiguous shortcut. New views bring their own copies of these // actions, so re-run the sweep whenever a view is created. Also defer one // sweep to the event loop, after the GUI factory has merged all clients. clearConflictingShortcuts(); QMetaObject::invokeMethod(this, &OllieView::clearConflictingShortcuts, Qt::QueuedConnection); connect(m_mainWindow, &KTextEditor::MainWindow::viewCreated, this, [this](KTextEditor::View *) { clearConflictingShortcuts(); }); } void OllieView::clearConflictingShortcuts() { KXMLGUIFactory *factory = m_mainWindow->guiFactory(); if (!factory) { return; } // Build the quick-lookup set of sequences we own. const QList clients = factory->clients(); for (KXMLGUIClient *client : clients) { KActionCollection *coll = client->actionCollection(); if (!coll) { continue; } const QList actions = coll->actions(); for (QAction *a : actions) { if (!a || m_acmeActions.contains(a)) { continue; // never strip our own actions } const QList scs = a->shortcuts(); if (scs.isEmpty()) { continue; } QList kept; kept.reserve(scs.size()); for (const QKeySequence &s : scs) { if (!m_acmeSequences.contains(s)) { kept.push_back(s); } } if (kept.size() != scs.size()) { a->setShortcuts(kept); // Also clear any KActionCollection default so it is not restored. KActionCollection::setDefaultShortcuts(a, kept); } } } } void OllieView::acmeBeginningOfLine() { KTextEditor::View *view = m_mainWindow->activeView(); if (!view) { return; } const KTextEditor::Cursor c = view->cursorPosition(); view->setCursorPosition(KTextEditor::Cursor(c.line(), 0)); } void OllieView::acmeEndOfLine() { KTextEditor::View *view = m_mainWindow->activeView(); if (!view) { return; } KTextEditor::Document *doc = view->document(); const KTextEditor::Cursor c = view->cursorPosition(); view->setCursorPosition(KTextEditor::Cursor(c.line(), doc->lineLength(c.line()))); } void OllieView::acmeEraseChar() { KTextEditor::View *view = m_mainWindow->activeView(); if (!view) { return; } KTextEditor::Document *doc = view->document(); KTextEditor::Cursor q0 = view->cursorPosition(); KTextEditor::Cursor from = q0; if (!stepBackward(doc, from)) { return; // at start of document } doc->removeText(KTextEditor::Range(from, q0)); } void OllieView::acmeEraseLine() { KTextEditor::View *view = m_mainWindow->activeView(); if (!view) { return; } KTextEditor::Document *doc = view->document(); const KTextEditor::Cursor q0 = view->cursorPosition(); // Erase back to the beginning of the line; if already at column 0, eat the // preceding newline (join with the previous line), matching Acme's ^U. if (q0.column() > 0) { doc->removeText(KTextEditor::Range(KTextEditor::Cursor(q0.line(), 0), q0)); } else if (q0.line() > 0) { const int prevLen = doc->lineLength(q0.line() - 1); doc->removeText(KTextEditor::Range(KTextEditor::Cursor(q0.line() - 1, prevLen), q0)); } } void OllieView::acmeEraseWord() { KTextEditor::View *view = m_mainWindow->activeView(); if (!view) { return; } KTextEditor::Document *doc = view->document(); const KTextEditor::Cursor q0 = view->cursorPosition(); // Replicate textbswidth(^W): walk back, stopping at a newline (eating it // only if we are standing on it); otherwise skip non-alphanumerics, then // consume the following alphanumeric run. KTextEditor::Cursor q = q0; bool skipping = true; while (true) { KTextEditor::Cursor prev = q; if (!stepBackward(doc, prev)) { break; // start of document } // Crossing to a previous line is Acme's newline stop. Eat the newline // only if we are standing on it (nothing erased yet on this line). if (prev.line() != q.line()) { if (q == q0) { q = prev; } break; } const QChar r = doc->characterAt(prev); const bool alnum = r.isLetterOrNumber(); if (alnum && skipping) { skipping = false; } else if (!alnum && !skipping) { break; } q = prev; } if (q != q0) { doc->removeText(KTextEditor::Range(q, q0)); } } } // namespace katecustom // K_PLUGIN_FACTORY generates the factory and the moc-level registration. The // matching .moc include is required in this translation unit. K_PLUGIN_FACTORY_WITH_JSON(OlliePluginFactory, "ollieplugin.json", registerPlugin();) #include "ollieplugin.moc"