diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 253392e..0899773 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -7,5 +7,5 @@ add_subdirectory(sam) if(KF6TextEditor_FOUND) add_subdirectory(acme) add_subdirectory(commands) + add_subdirectory(switch) endif() -add_subdirectory(plugin) diff --git a/src/palette/CMakeLists.txt b/src/palette/CMakeLists.txt index 941c000..7a51206 100644 --- a/src/palette/CMakeLists.txt +++ b/src/palette/CMakeLists.txt @@ -9,6 +9,24 @@ add_library(palette STATIC target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Concurrent) target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +# The command-palette plugin ([deft:util] palette). Needs KF6::TextEditor; the +# ":"-verb pack comes from olliecommands_lib, the widget/frecency from palette. +if(KF6TextEditor_FOUND) + kcoreaddons_add_plugin(deft_palette + SOURCES paletteplugin.cpp paletteplugin.h + INSTALL_NAMESPACE "kf6/ktexteditor" + ) + target_link_libraries(deft_palette PRIVATE + palette + olliecommands_lib + KF6::TextEditor + KF6::XmlGui + KF6::CoreAddons + KF6::I18n + Qt6::Widgets + ) +endif() + if(Qt6Test_FOUND) add_executable(test_palettemodel test_palettemodel.cpp) target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets) diff --git a/src/palette/paletteplugin.cpp b/src/palette/paletteplugin.cpp new file mode 100644 index 0000000..caaa904 --- /dev/null +++ b/src/palette/paletteplugin.cpp @@ -0,0 +1,443 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "paletteplugin.h" + +#include "palettewidget.h" +#include "frecencystore.h" +#include "olliecommands.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace deft +{ + +namespace +{ +// Prefix marking a palette id that is a ":" command string to execute rather +// than a QAction to trigger. +const QLatin1String kCmdPrefix("deft:cmd:"); + +// 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")}, + }; +} +} // namespace + +// --------------------------------------------------------------------------- +// PalettePlugin +// --------------------------------------------------------------------------- + +PalettePlugin::PalettePlugin(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); +} + +PalettePlugin::~PalettePlugin() = default; + +QObject *PalettePlugin::createView(KTextEditor::MainWindow *mainWindow) +{ + return new PaletteView(this, mainWindow); +} + +// --------------------------------------------------------------------------- +// PaletteView +// --------------------------------------------------------------------------- + +PaletteView::PaletteView(PalettePlugin *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("deft/palette.frecency.json"))); + m_frecency.load(); + + // The command-palette launcher "door". Its shortcut is NOT registered as a + // 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(), which + // cannot be ambiguous because nothing is registered. The QAction is kept + // only for its objectName/label (radial escape-hatch, discoverability). + auto *openAction = new QAction(i18n("Command Palette (M-x) [Alt+X]"), this); + openAction->setObjectName(QStringLiteral("deft_command_palette")); + connect(openAction, &QAction::triggered, this, &PaletteView::showCommandPalette); + + // Keep it discoverable as a window action (no shortcut attached). + if (QWidget *w = m_mainWindow->window()) { + w->addAction(openAction); + } + + // Application-wide filter for the launcher door (Alt+X), catching the key + // no matter which internal child of the view actually receives it. + qApp->installEventFilter(this); +} + +PaletteView::~PaletteView() +{ + delete m_palette; +} + +QList PaletteView::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; +} + +QAction *PaletteView::findActionByName(const QString &objectName) const +{ + if (objectName.isEmpty()) { + return nullptr; + } + // First: the window's GUI factory clients (plugins exposing XMLGUI actions). + if (KXMLGUIFactory *factory = m_mainWindow->guiFactory()) { + const QList clients = factory->clients(); + for (KXMLGUIClient *client : clients) { + if (KActionCollection *coll = client->actionCollection()) { + if (QAction *a = coll->action(objectName)) { + return a; + } + } + } + } + // Second: window-level actions. Sibling deft plugins register their launcher + // doors with QWidget::addAction (no XMLGUI client), so this is where the + // switch plugin's deft_goto_file / deft_goto_symbol actually live. + if (QWidget *w = m_mainWindow->window()) { + const QList actions = w->actions(); + for (QAction *a : actions) { + if (a && a->objectName() == objectName) { + return a; + } + } + } + return nullptr; +} + +void PaletteView::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 PaletteView::showCommandPalette() +{ + if (!m_palette) { + m_palette = new PaletteWidget(m_mainWindow->window()); + connect(m_palette, &PaletteWidget::activatedId, this, &PaletteView::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). + // Triggered by the sibling switch plugin's window action, by objectName. + { + PaletteItem item; + item.id = QStringLiteral("deft: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("deft: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 PaletteView::recordUsage(const QString &id) +{ + if (id.isEmpty()) { + return; + } + m_frecency.bump(id, QDateTime::currentSecsSinceEpoch()); + m_frecency.save(); +} + +void PaletteView::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, provided by the sibling switch plugin. Trigger + // its window action by objectName; a no-op when switch is not loaded. + if (actionId == QLatin1String("deft:switch:file")) { + if (QAction *a = findActionByName(QStringLiteral("deft_goto_file"))) { + a->trigger(); + } + return; + } + + // The project symbol switcher, as above. + if (actionId == QLatin1String("deft:switch:symbol")) { + if (QAction *a = findActionByName(QStringLiteral("deft_goto_symbol"))) { + a->trigger(); + } + return; + } + + QPointer action = m_actionsById.value(actionId); + if (action) { + action->trigger(); + } +} + +bool PaletteView::eventFilter(QObject *obj, QEvent *event) +{ + // Launcher door: Alt+X (M-x). Handled here rather than via a QAction + // shortcut so it 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 windows. + if (event->type() == QEvent::ShortcutOverride || event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + const bool altOnly = + (ke->modifiers() & Qt::KeyboardModifierMask) == Qt::AltModifier; + if (altOnly && ke->key() == Qt::Key_X) { + 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; + } + showCommandPalette(); + return true; // consume the keypress + } + } + } + + return QObject::eventFilter(obj, event); +} + +} // namespace deft + +// 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(PalettePluginFactory, "paletteplugin.json", + registerPlugin();) + +#include "paletteplugin.moc" diff --git a/src/palette/paletteplugin.h b/src/palette/paletteplugin.h new file mode 100644 index 0000000..fbce29e --- /dev/null +++ b/src/palette/paletteplugin.h @@ -0,0 +1,109 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * The Deft command-palette plugin for Kate / KTextEditor ([deft:util] palette). + * + * PalettePlugin is the KTextEditor::Plugin entry point and also owns the single + * global ":"-verb command pack. For every MainWindow the host creates a + * PaletteView, which registers the "Command Palette (M-x)" door (Alt+X) and, + * when triggered, gathers every QAction currently exposed by the window's GUI + * clients into a PaletteWidget for orderless fuzzy selection. The palette also + * offers the ":"-verbs and — when the sibling switch plugin is loaded — the + * "Go to File / Symbol" switchers, which it triggers by action objectName so no + * cross-plugin link is required. + */ +#ifndef DEFT_PALETTEPLUGIN_H +#define DEFT_PALETTEPLUGIN_H + +#include + +#include "frecencystore.h" +#include "palettemodel.h" // PaletteItem + +#include +#include +#include +#include + +class QAction; +class QEvent; + +namespace KTextEditor +{ +class MainWindow; +class View; +} + +namespace deft +{ + +class PaletteWidget; +class OllieCommands; + +class PalettePlugin : public KTextEditor::Plugin +{ + Q_OBJECT + +public: + explicit PalettePlugin(QObject *parent, const QVariantList &args = {}); + ~PalettePlugin() override; + + QObject *createView(KTextEditor::MainWindow *mainWindow) override; + +private: + OllieCommands *m_commands = nullptr; // ":"-verb pack, registered once globally +}; + +class PaletteView : public QObject +{ + Q_OBJECT + +public: + PaletteView(PalettePlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~PaletteView() override; + +private Q_SLOTS: + void showCommandPalette(); + void runAction(const QString &actionId); + +private: + // Record one activation of \a id and persist. Feeds the frecency bonus so + // habitual choices float up in the palette. + void recordUsage(const QString &id); + + // Size and place a palette: it spans the active window's width (minus a + // small margin), anchored near the top. + void positionPalette(PaletteWidget *palette); + + // Resolve a sibling-plugin action by objectName, searching the window's GUI + // factory clients first and the window's own action list second. Returns + // nullptr when no plugin currently provides it. + QAction *findActionByName(const QString &objectName) const; + + // One harvested, runnable action with the provenance needed to build a + // unique id and to disambiguate labels shared by several commands. + struct ActionEntry { + QPointer action; + QString label; // visible text, ampersands stripped + QString component; // owning collection's display name (provenance) + QString shortcut; // current shortcut, native text + QString id; // unique, stable key used on activation + }; + + // Collect every unique, enabled, visible QAction reachable from the + // window's GUI factory clients, deduplicated by pointer identity. + QList collectActions() const; + + // The application event filter: intercepts the launcher-door key (Alt+X) so + // it can never clash with Kate's own Alt bindings. + bool eventFilter(QObject *obj, QEvent *event) override; + + KTextEditor::MainWindow *m_mainWindow; + PaletteWidget *m_palette = nullptr; + QHash> m_actionsById; + FrecencyStore m_frecency; // persisted usage feeding the palette ranking +}; + +} // namespace deft + +#endif diff --git a/src/palette/paletteplugin.json b/src/palette/paletteplugin.json new file mode 100644 index 0000000..8ccedc6 --- /dev/null +++ b/src/palette/paletteplugin.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Levi Neely" + } + ], + "Description": "Command palette (M-x, Alt+X): orderless fuzzy launcher over every window action, the \":\"-verb text-transform pack, and the project file/symbol switchers, ranked by frecency.", + "Icon": "search", + "License": "LGPL-2.0-or-later", + "Name": "[deft:util] palette", + "Version": "0.1.0" + } +} diff --git a/src/plugin/CMakeLists.txt b/src/plugin/CMakeLists.txt deleted file mode 100644 index d3994e5..0000000 --- a/src/plugin/CMakeLists.txt +++ /dev/null @@ -1,32 +0,0 @@ -find_package(KF6 ${KF_MIN_VERSION} COMPONENTS - TextEditor - XmlGui - CoreAddons - I18n -) - -if(NOT KF6TextEditor_FOUND) - message(STATUS "KF6::TextEditor not found — skipping the Kate plugin target") - return() -endif() - -# The core deft plugin: command palette (M-x), project file/symbol switchers, -# and the Acme line-editing keys + bracket/quote selection. (radial, sam, and -# plumb are now their own plugins; the util features here will split further.) -kcoreaddons_add_plugin(deft - SOURCES ollieplugin.cpp ollieplugin.h - INSTALL_NAMESPACE "kf6/ktexteditor" -) - -target_include_directories(deft PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") - -target_link_libraries(deft PRIVATE - palette - olliecommands_lib - project_lib - KF6::TextEditor - KF6::XmlGui - KF6::CoreAddons - KF6::I18n - Qt6::Widgets -) diff --git a/src/plugin/ollieplugin.json b/src/plugin/ollieplugin.json deleted file mode 100644 index be6fa4b..0000000 --- a/src/plugin/ollieplugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "KPlugin": { - "Authors": [ - { - "Name": "Levi Neely" - } - ], - "Description": "Deft: menu-free, mouse-first editing — orderless fuzzy palette (M-x), radial caret menu, switchers, Plan 9 plumbing, and sam.", - "Icon": "code-context", - "License": "LGPL-2.0-or-later", - "Name": "Deft", - "Version": "0.1.0" - } -} diff --git a/src/switch/CMakeLists.txt b/src/switch/CMakeLists.txt new file mode 100644 index 0000000..a9f190b --- /dev/null +++ b/src/switch/CMakeLists.txt @@ -0,0 +1,21 @@ +# switch — project file/symbol switchers ([deft:util] switch). +# +# No separate library: the plugin is self-contained. The widget/frecency come +# from the palette lib, the index/bridge from project_lib. Needs KF6::TextEditor. +if(NOT KF6TextEditor_FOUND) + return() +endif() + +kcoreaddons_add_plugin(deft_switch + SOURCES switchplugin.cpp switchplugin.h + INSTALL_NAMESPACE "kf6/ktexteditor" +) +target_link_libraries(deft_switch PRIVATE + palette + project_lib + KF6::TextEditor + KF6::XmlGui + KF6::CoreAddons + KF6::I18n + Qt6::Widgets +) diff --git a/src/plugin/ollieplugin.cpp b/src/switch/switchplugin.cpp similarity index 51% rename from src/plugin/ollieplugin.cpp rename to src/switch/switchplugin.cpp index dc69a97..fece8ea 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/switch/switchplugin.cpp @@ -1,24 +1,17 @@ /* * SPDX-License-Identifier: LGPL-2.0-or-later */ -#include "ollieplugin.h" +#include "switchplugin.h" #include "palettewidget.h" #include "frecencystore.h" -#include "olliecommands.h" #include "projectindex.h" #include "symbolindex.h" -#include -#include #include -#include #include #include -#include -#include -#include #include #include @@ -28,10 +21,9 @@ #include #include #include -#include #include -#include -#include +#include +#include #include #include #include @@ -41,43 +33,13 @@ namespace deft namespace { -// 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:"); +const QLatin1String kFilePrefix("deft: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")}, - }; -} +const QLatin1String kSymbolPrefix("deft:sym:"); // 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. @@ -96,63 +58,56 @@ QString activeDocumentDirFor(KTextEditor::MainWindow *mainWindow) } // namespace // --------------------------------------------------------------------------- -// OlliePlugin +// SwitchPlugin // --------------------------------------------------------------------------- -OlliePlugin::OlliePlugin(QObject *parent, const QVariantList &args) +SwitchPlugin::SwitchPlugin(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; +SwitchPlugin::~SwitchPlugin() = default; -QObject *OlliePlugin::createView(KTextEditor::MainWindow *mainWindow) +QObject *SwitchPlugin::createView(KTextEditor::MainWindow *mainWindow) { - return new OllieView(this, mainWindow); + return new SwitchView(this, mainWindow); } // --------------------------------------------------------------------------- -// OllieView +// SwitchView // --------------------------------------------------------------------------- -OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) +SwitchView::SwitchView(SwitchPlugin *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. + // switcher 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"))); + QStringLiteral("deft/switch.frecency.json"))); m_frecency.load(); - // The three launcher "doors". Their shortcuts are NOT registered as + // The two 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("deft_command_palette")); - connect(openAction, &QAction::triggered, this, &OllieView::showCommandPalette); - + // shortcut here and instead intercept the key in eventFilter(), which + // cannot be ambiguous because nothing is registered. The QActions are kept + // for their objectName/label so sibling plugins (palette, radial) can + // trigger them by objectName and so they stay discoverable. auto *gotoFileAction = new QAction(i18n("Go to File (project) [Alt+P]"), this); gotoFileAction->setObjectName(QStringLiteral("deft_goto_file")); - connect(gotoFileAction, &QAction::triggered, this, &OllieView::showFileSwitcher); + connect(gotoFileAction, &QAction::triggered, this, &SwitchView::showFileSwitcher); auto *gotoSymbolAction = new QAction(i18n("Go to Symbol (project) [Alt+G]"), this); gotoSymbolAction->setObjectName(QStringLiteral("deft_goto_symbol")); - connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher); + connect(gotoSymbolAction, &QAction::triggered, this, &SwitchView::showSymbolSwitcher); // Keep them discoverable as window actions (no shortcut attached). if (QWidget *w = m_mainWindow->window()) { - w->addAction(openAction); w->addAction(gotoFileAction); w->addAction(gotoSymbolAction); } @@ -181,140 +136,44 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) [this]() { m_indexer.invalidateAll(); }); } - // Application-wide filter for the Acme line-editing keys, the launcher - // doors (Alt+X/P/G), and the Acme mouse chords, catching events no matter - // which internal child of the view actually receives them. + // Application-wide filter for the launcher doors (Alt+P/Alt+G), catching + // the key no matter which internal child of the view actually receives it. qApp->installEventFilter(this); } -OllieView::~OllieView() +SwitchView::~SwitchView() { - delete m_palette; delete m_filePalette; delete m_symbolPalette; } -QList OllieView::collectActions() const +void SwitchView::recordUsage(const QString &id) { - QList entries; - - KXMLGUIFactory *factory = m_mainWindow->guiFactory(); - if (!factory) { - return entries; + if (id.isEmpty()) { + return; } - - // 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; + m_frecency.bump(id, QDateTime::currentSecsSinceEpoch()); + m_frecency.save(); } -void OllieView::positionPalette(PaletteWidget *palette) +QString SwitchView::activeDocumentDir() const +{ + return activeDocumentDirFor(m_mainWindow); +} + +QString SwitchView::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 SwitchView::positionPalette(PaletteWidget *palette) { QWidget *w = m_mainWindow->window(); if (!w) { @@ -328,133 +187,8 @@ void OllieView::positionPalette(PaletteWidget *palette) 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 +QList SwitchView::buildFileItems(const QString &root, + const QStringList &files) const { const qint64 now = QDateTime::currentSecsSinceEpoch(); QList items; @@ -475,7 +209,7 @@ QList OllieView::buildFileItems(const QString &root, return items; } -void OllieView::showFileSwitcher() +void SwitchView::showFileSwitcher() { if (!m_filePalette) { m_filePalette = new PaletteWidget(m_mainWindow->window()); @@ -510,7 +244,7 @@ void OllieView::showFileSwitcher() buildFileItems(m_fileSwitcherRoot, m_indexer.cachedFiles(m_fileSwitcherRoot))); } else { PaletteItem loading; - loading.id = QStringLiteral("ollie:loading"); + loading.id = QStringLiteral("deft:loading"); loading.label = i18n("Indexing project…"); m_filePalette->setItems({loading}); } @@ -522,7 +256,7 @@ void OllieView::showFileSwitcher() m_indexer.requestFiles(m_fileSwitcherRoot); } -void OllieView::openProjectFile(const QString &relativePath) +void SwitchView::openProjectFile(const QString &relativePath) { if (relativePath.isEmpty() || m_fileSwitcherRoot.isEmpty()) { return; @@ -534,8 +268,8 @@ void OllieView::openProjectFile(const QString &relativePath) } } -QList OllieView::buildSymbolItems(const QString &root, - const QList &symbols) const +QList SwitchView::buildSymbolItems(const QString &root, + const QList &symbols) const { const qint64 now = QDateTime::currentSecsSinceEpoch(); QList items; @@ -572,7 +306,7 @@ QList OllieView::buildSymbolItems(const QString &root, return items; } -void OllieView::showSymbolSwitcher() +void SwitchView::showSymbolSwitcher() { if (!m_symbolPalette) { m_symbolPalette = new PaletteWidget(m_mainWindow->window()); @@ -605,7 +339,7 @@ void OllieView::showSymbolSwitcher() m_indexer.cachedSymbols(m_symbolSwitcherRoot))); } else { PaletteItem loading; - loading.id = QStringLiteral("ollie:loading"); + loading.id = QStringLiteral("deft:loading"); loading.label = i18n("Indexing symbols…"); m_symbolPalette->setItems({loading}); } @@ -616,7 +350,7 @@ void OllieView::showSymbolSwitcher() m_indexer.requestSymbols(m_symbolSwitcherRoot); } -void OllieView::goToSymbol(const QString &locationId) +void SwitchView::goToSymbol(const QString &locationId) { if (m_symbolSwitcherRoot.isEmpty()) { return; @@ -644,19 +378,18 @@ void OllieView::goToSymbol(const QString &locationId) } } -bool OllieView::eventFilter(QObject *obj, QEvent *event) +bool SwitchView::eventFilter(QObject *obj, QEvent *event) { - // 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. + // Launcher doors: 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. if (event->type() == QEvent::ShortcutOverride || event->type() == QEvent::KeyPress) { auto *ke = static_cast(event); 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)) { + if (altOnly && (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); @@ -666,7 +399,6 @@ bool OllieView::eventFilter(QObject *obj, QEvent *event) 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; @@ -683,7 +415,7 @@ bool OllieView::eventFilter(QObject *obj, QEvent *event) // 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();) +K_PLUGIN_FACTORY_WITH_JSON(SwitchPluginFactory, "switchplugin.json", + registerPlugin();) -#include "ollieplugin.moc" +#include "switchplugin.moc" diff --git a/src/plugin/ollieplugin.h b/src/switch/switchplugin.h similarity index 53% rename from src/plugin/ollieplugin.h rename to src/switch/switchplugin.h index f4be721..1ad1432 100644 --- a/src/plugin/ollieplugin.h +++ b/src/switch/switchplugin.h @@ -1,15 +1,19 @@ /* * SPDX-License-Identifier: LGPL-2.0-or-later * - * The Ollie command-palette plugin for Kate / KTextEditor. + * The Deft project switcher plugin for Kate / KTextEditor ([deft:util] switch). * - * OlliePlugin is the KTextEditor::Plugin entry point. For every MainWindow the - * host creates an OllieView, which registers the "Command Palette (M-x)" action - * (Alt+X) and, when triggered, gathers every QAction currently exposed by the - * window's GUI clients into a PaletteWidget for orderless fuzzy selection. + * SwitchPlugin is the KTextEditor::Plugin entry point. For every MainWindow the + * host creates a SwitchView, which registers two launcher "doors": + * - Go to File (Alt+P, objectName "deft_goto_file") + * - Go to Symbol (Alt+G, objectName "deft_goto_symbol") + * Each opens a PaletteWidget populated from an asynchronous, cached project + * index (files via VCS/discovery, symbols via ctags), scoped to Kate's loaded + * project when present. The doors are exposed as window actions so sibling deft + * plugins (palette, radial) can trigger them by objectName. */ -#ifndef DEFT_OLLIEPLUGIN_H -#define DEFT_OLLIEPLUGIN_H +#ifndef DEFT_SWITCHPLUGIN_H +#define DEFT_SWITCHPLUGIN_H #include @@ -19,51 +23,42 @@ #include "palettemodel.h" // PaletteItem #include -#include -#include #include +#include -class QAction; -class QKeyEvent; +class QEvent; namespace KTextEditor { class MainWindow; class View; -class Document; } namespace deft { class PaletteWidget; -class OllieCommands; -class OlliePlugin : public KTextEditor::Plugin +class SwitchPlugin : public KTextEditor::Plugin { Q_OBJECT public: - explicit OlliePlugin(QObject *parent, const QVariantList &args = {}); - ~OlliePlugin() override; + explicit SwitchPlugin(QObject *parent, const QVariantList &args = {}); + ~SwitchPlugin() override; QObject *createView(KTextEditor::MainWindow *mainWindow) override; - -private: - OllieCommands *m_commands = nullptr; // ":"-verb pack, registered once globally }; -class OllieView : public QObject +class SwitchView : public QObject { Q_OBJECT public: - OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow); - ~OllieView() override; + SwitchView(SwitchPlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~SwitchView() override; private Q_SLOTS: - void showCommandPalette(); - void runAction(const QString &actionId); void showFileSwitcher(); void openProjectFile(const QString &relativePath); void showSymbolSwitcher(); @@ -71,7 +66,7 @@ private Q_SLOTS: private: // Record one activation of \a id and persist. Feeds the frecency bonus so - // habitual choices float up in every palette. + // habitual choices float up in the switchers. void recordUsage(const QString &id); // The effective project root: Kate's loaded project (via the bridge) when @@ -89,32 +84,16 @@ private: // small margin), anchored near the top. void positionPalette(PaletteWidget *palette); - // One harvested, runnable action with the provenance needed to build a - // unique id and to disambiguate labels shared by several commands. - struct ActionEntry { - QPointer action; - QString label; // visible text, ampersands stripped - QString component; // owning collection's display name (provenance) - QString shortcut; // current shortcut, native text - QString id; // unique, stable key used on activation - }; - - // Collect every unique, enabled, visible QAction reachable from the - // window's GUI factory clients, deduplicated by pointer identity. - QList collectActions() const; - // The application event filter: intercepts the launcher-door keys - // (Alt+X/P/G) so they can never clash with Kate's own Alt bindings. + // (Alt+P/Alt+G) so they can never clash with Kate's own Alt bindings. bool eventFilter(QObject *obj, QEvent *event) override; KTextEditor::MainWindow *m_mainWindow; - PaletteWidget *m_palette = nullptr; PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher QString m_fileSwitcherRoot; // project root for the open switcher PaletteWidget *m_symbolPalette = nullptr; // project "Go to Symbol" switcher QString m_symbolSwitcherRoot; // project root for the symbol switcher - QHash> m_actionsById; - FrecencyStore m_frecency; // persisted usage feeding the palette ranking + FrecencyStore m_frecency; // persisted usage feeding the switcher ranking KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin ProjectIndexer m_indexer; // async, cached file/symbol indexing }; diff --git a/src/switch/switchplugin.json b/src/switch/switchplugin.json new file mode 100644 index 0000000..1480073 --- /dev/null +++ b/src/switch/switchplugin.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Authors": [ + { + "Name": "Levi Neely" + } + ], + "Description": "Project switchers: Go to File (Alt+P) and Go to Symbol (Alt+G), fuzzy-matched over an async, cached index of the active Kate project (VCS/discovery for files, ctags for symbols), ranked by frecency.", + "Icon": "go-jump", + "License": "LGPL-2.0-or-later", + "Name": "[deft:util] switch", + "Version": "0.1.0" + } +}