diff --git a/docs/PLAN.md b/docs/PLAN.md index 56b45d7..81f25e0 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -74,6 +74,11 @@ Therefore the palette is replaced, not extended, and the replacement is built on - Installs to `kf6/ktexteditor`; metadata verified valid via `KPluginMetaData`. - Not yet wired: frecency (the model supports a bonus, but the plugin currently passes 0 for every action — usage tracking is deferred). +- Duplicate handling: actions are deduplicated by pointer identity (the same + action reachable through several GUI clients is listed once). Distinct actions + that merely share a visible label (e.g. two plugins each offering "Reload") + are kept but disambiguated with their component name, and every entry gets a + unique id so activation always triggers the right action. ### M4 — Radial caret menu (mouse door) — NEXT - Mouse-triggered radial menu positioned at the caret diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index cba929d..c88f506 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -14,7 +14,9 @@ #include #include +#include #include +#include #include namespace katecustom @@ -64,24 +66,35 @@ OllieView::~OllieView() delete m_palette; } -void OllieView::collectActions(QHash> &out) const +QList OllieView::collectActions() const { + QList entries; + KXMLGUIFactory *factory = m_mainWindow->guiFactory(); if (!factory) { - return; + 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; @@ -90,16 +103,45 @@ void OllieView::collectActions(QHash> &out) const if (label.isEmpty()) { continue; } - // Prefer the stable objectName as id; fall back to the label. - QString id = action->objectName(); - if (id.isEmpty()) { - id = label; - } - if (!out.contains(id)) { - out.insert(id, action); - } + 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); } } + + // Two distinct actions can share a visible label (e.g. different plugins + // each offering "Reload"). Those are not duplicates, so keep them, but + // disambiguate by provenance so the user can tell them apart. + QHash labelCounts; + for (const ActionEntry &e : entries) { + ++labelCounts[e.label]; + } + + // Assign unique, reasonably stable ids and disambiguate colliding labels. + QHash idSeq; + for (ActionEntry &e : entries) { + if (labelCounts.value(e.label) > 1 && !e.component.isEmpty()) { + e.label = e.label + QStringLiteral(" — ") + e.component; + } + + 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() @@ -109,22 +151,18 @@ void OllieView::showCommandPalette() connect(m_palette, &PaletteWidget::activatedId, this, &OllieView::runAction); } - m_actionsById.clear(); - collectActions(m_actionsById); + const QList entries = collectActions(); + m_actionsById.clear(); QList items; - items.reserve(m_actionsById.size()); - for (auto it = m_actionsById.cbegin(); it != m_actionsById.cend(); ++it) { - QAction *action = it.value(); - if (!action) { - continue; - } + items.reserve(entries.size()); + for (const ActionEntry &e : entries) { + m_actionsById.insert(e.id, e.action); + PaletteItem item; - item.id = it.key(); - item.label = action->text().remove(QLatin1Char('&')).trimmed(); - // Show the current shortcut as the group column hint, when present. - const QString sc = action->shortcut().toString(QKeySequence::NativeText); - item.group = sc; + item.id = e.id; + item.label = e.label; + item.group = e.shortcut; // shortcut shown as the trailing hint items.push_back(item); } diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 06684e1..dc22736 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -53,9 +53,19 @@ private Q_SLOTS: void runAction(const QString &actionId); private: + // 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. Keyed by a stable id for lookup on accept. - void collectActions(QHash> &out) const; + // window's GUI factory clients, deduplicated by pointer identity. + QList collectActions() const; KTextEditor::MainWindow *m_mainWindow; PaletteWidget *m_palette = nullptr;