M3: dedup palette actions by identity, disambiguate shared labels

The same QAction is often reachable through multiple GUI clients; those
are true duplicates and are now collapsed by pointer identity. Distinct
actions that merely share a visible label (common across plugins) are kept
but disambiguated by appending the owning component's display name, and
every entry is assigned a unique, stable id so activating a row always
triggers the intended action rather than whichever one won a hash key.
This commit is contained in:
Levi Neely 2026-10-07 16:05:22 +02:00
parent 91a0e2d260
commit 485d395d4d
3 changed files with 78 additions and 25 deletions

View File

@ -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

View File

@ -14,7 +14,9 @@
#include <KPluginFactory>
#include <QAction>
#include <QHash>
#include <QKeySequence>
#include <QSet>
#include <QWidget>
namespace katecustom
@ -64,24 +66,35 @@ OllieView::~OllieView()
delete m_palette;
}
void OllieView::collectActions(QHash<QString, QPointer<QAction>> &out) const
QList<OllieView::ActionEntry> OllieView::collectActions() const
{
QList<ActionEntry> 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<QAction *> seen;
const QList<KXMLGUIClient *> clients = factory->clients();
for (KXMLGUIClient *client : clients) {
KActionCollection *coll = client->actionCollection();
if (!coll) {
continue;
}
const QString component = coll->componentDisplayName();
const QList<QAction *> actions = coll->actions();
for (QAction *action : actions) {
if (!action || action->isSeparator()) {
continue;
}
if (seen.contains(action)) {
continue;
}
// Only offer things the user could actually invoke now.
if (!action->isEnabled() || !action->isVisible()) {
continue;
@ -90,16 +103,45 @@ void OllieView::collectActions(QHash<QString, QPointer<QAction>> &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<QString, int> labelCounts;
for (const ActionEntry &e : entries) {
++labelCounts[e.label];
}
// Assign unique, reasonably stable ids and disambiguate colliding labels.
QHash<QString, int> 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<ActionEntry> entries = collectActions();
m_actionsById.clear();
QList<PaletteItem> 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);
}

View File

@ -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<QAction> 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<QString, QPointer<QAction>> &out) const;
// window's GUI factory clients, deduplicated by pointer identity.
QList<ActionEntry> collectActions() const;
KTextEditor::MainWindow *m_mainWindow;
PaletteWidget *m_palette = nullptr;