M3: loadable KTextEditor plugin with M-x command palette

- OlliePlugin (KTextEditor::Plugin) + OllieView, built as olliepalette.so
  via kcoreaddons_add_plugin, installed under kf6/ktexteditor.
- M-x bound to Alt+X (single modifier, adjacent keys on a compact layout,
  ApplicationShortcut so it is always live).
- On trigger, aggregates every enabled/visible QAction from the window's
  guiFactory()->clients() action collections (keyed by objectName) and
  shows them in PaletteWidget; the chosen row triggers the QAction. The
  action's current shortcut is surfaced in the group column.
- Enable position-independent code for linking the static palette lib into
  the shared plugin module.
- Plugin metadata verified valid via KPluginMetaData.

Frecency wiring deferred (model supports it; plugin passes 0). All three
ctest suites remain green.
This commit is contained in:
Levi Neely 2026-10-07 15:59:05 +02:00
parent 617b526e3f
commit 91a0e2d260
8 changed files with 302 additions and 7 deletions

View File

@ -16,6 +16,8 @@ include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Widgets)
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test)

View File

@ -51,6 +51,20 @@ QT_QPA_PLATFORM=offscreen ./build/bin/test_fuzzyranker # run the matcher tests
ctest --test-dir build # or via ctest
```
## Install the plugin into Kate
The plugin builds to `build/bin/kf6/ktexteditor/olliepalette.so`. Drop it where
Kate scans for KTextEditor plugins (a user path on `QT_PLUGIN_PATH`):
```sh
install -D build/bin/kf6/ktexteditor/olliepalette.so \
~/.local/lib/x86_64-linux-gnu/qt6/plugins/kf6/ktexteditor/olliepalette.so
```
Then in Kate: Settings → Configure Kate → Plugins → enable
**Ollie Command Palette**. Press **Alt+X** (M-x) to open the palette; type to
filter, Enter or click to run.
## Status
See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
@ -61,7 +75,9 @@ See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
- **Milestone 2 — custom palette widget: DONE.** `PaletteModel` +
`PaletteWidget` (frameless popup, live filtering, highlighted matches,
keyboard + mouse activation). 10 model unit tests, all green.
- Milestone 3 — KTextEditor plugin + M-x + action registry: next.
- Milestone 3 — KTextEditor plugin + M-x: **DONE.** Loadable `olliepalette.so`;
`Alt+X` opens the palette over every action in the window's GUI clients.
- Milestone 4 — radial caret menu (mouse door): next.
## Layout
@ -75,6 +91,9 @@ src/
palettemodel.{h,cpp} ranked, filterable list model (headless)
palettewidget.{h,cpp} frameless command-palette popup
test_palettemodel.cpp QTest suite
plugin/
ollieplugin.{h,cpp} KTextEditor::Plugin + M-x action registry
ollieplugin.json plugin metadata
docs/
PLAN.md goal, constraints, roadmap
```

View File

@ -61,13 +61,21 @@ Therefore the palette is replaced, not extended, and the replacement is built on
- `src/palette/test_palettemodel.cpp`: 10 QTest cases, all green (filtering,
orderless, typo, best-first ordering, frecency tie-break, highlight ranges).
### M3 — KTextEditor plugin + M-x + action registry — NEXT
- `KTextEditor::Plugin` skeleton producing a loadable `.so`.
- Central action registry aggregating Kate built-in actions + plugin actions.
- `M-x` (`Alt+x`) opens the M2 palette over the registry.
- Frecency ranking: usage history feeds a score bonus.
### M3 — KTextEditor plugin + M-x + action registry — DONE
- `src/plugin/ollieplugin.{h,cpp}` + `ollieplugin.json`: a loadable
`KTextEditor::Plugin` (`olliepalette.so`) built with `kcoreaddons_add_plugin`.
- `OllieView` (one per MainWindow) registers the **M-x** action, `Alt+X`, a
single modifier with the keys adjacent on a compact layout; `ApplicationShortcut`
context so it is always live.
- On trigger it aggregates every enabled, visible `QAction` from the window's
`guiFactory()->clients()` action collections, keyed by stable `objectName`,
and feeds them to `PaletteWidget`. The action's current shortcut is shown in
the group column. Accepting a row triggers the underlying `QAction`.
- 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).
### M4 — Radial caret menu (mouse door)
### M4 — Radial caret menu (mouse door) — NEXT
- Mouse-triggered radial menu positioned at the caret
(`View::cursorToCoordinate`), flick-to-select by angle.
- Slices invoke registry actions; one slice opens the palette / command line, so

View File

@ -1,2 +1,3 @@
add_subdirectory(fuzzy)
add_subdirectory(palette)
add_subdirectory(plugin)

25
src/plugin/CMakeLists.txt Normal file
View File

@ -0,0 +1,25 @@
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()
kcoreaddons_add_plugin(olliepalette
SOURCES ollieplugin.cpp ollieplugin.h
INSTALL_NAMESPACE "kf6/ktexteditor"
)
target_link_libraries(olliepalette PRIVATE
palette
KF6::TextEditor
KF6::XmlGui
KF6::CoreAddons
KF6::I18n
Qt6::Widgets
)

159
src/plugin/ollieplugin.cpp Normal file
View File

@ -0,0 +1,159 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "ollieplugin.h"
#include "palettewidget.h"
#include <KTextEditor/MainWindow>
#include <KActionCollection>
#include <KXMLGUIClient>
#include <KXMLGUIFactory>
#include <KLocalizedString>
#include <KPluginFactory>
#include <QAction>
#include <QKeySequence>
#include <QWidget>
namespace katecustom
{
// ---------------------------------------------------------------------------
// 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);
}
}
OllieView::~OllieView()
{
delete m_palette;
}
void OllieView::collectActions(QHash<QString, QPointer<QAction>> &out) const
{
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
if (!factory) {
return;
}
const QList<KXMLGUIClient *> clients = factory->clients();
for (KXMLGUIClient *client : clients) {
KActionCollection *coll = client->actionCollection();
if (!coll) {
continue;
}
const QList<QAction *> actions = coll->actions();
for (QAction *action : actions) {
if (!action || action->isSeparator()) {
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;
}
// 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);
}
}
}
}
void OllieView::showCommandPalette()
{
if (!m_palette) {
m_palette = new PaletteWidget(m_mainWindow->window());
connect(m_palette, &PaletteWidget::activatedId, this, &OllieView::runAction);
}
m_actionsById.clear();
collectActions(m_actionsById);
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;
}
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;
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<QAction> action = m_actionsById.value(actionId);
if (action) {
action->trigger();
}
}
} // 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<katecustom::OlliePlugin>();)
#include "ollieplugin.moc"

67
src/plugin/ollieplugin.h Normal file
View File

@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* The Ollie command-palette plugin for Kate / KTextEditor.
*
* 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.
*/
#ifndef KATECUSTOM_OLLIEPLUGIN_H
#define KATECUSTOM_OLLIEPLUGIN_H
#include <KTextEditor/Plugin>
#include <QObject>
#include <QPointer>
#include <QHash>
class QAction;
namespace KTextEditor
{
class MainWindow;
}
namespace katecustom
{
class PaletteWidget;
class OlliePlugin : public KTextEditor::Plugin
{
Q_OBJECT
public:
explicit OlliePlugin(QObject *parent, const QVariantList &args = {});
~OlliePlugin() override;
QObject *createView(KTextEditor::MainWindow *mainWindow) override;
};
class OllieView : public QObject
{
Q_OBJECT
public:
OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow);
~OllieView() override;
private Q_SLOTS:
void showCommandPalette();
void runAction(const QString &actionId);
private:
// 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;
KTextEditor::MainWindow *m_mainWindow;
PaletteWidget *m_palette = nullptr;
QHash<QString, QPointer<QAction>> m_actionsById;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,14 @@
{
"KPlugin": {
"Authors": [
{
"Name": "Levi Neely"
}
],
"Description": "Menu-free, mouse-first command access: an orderless fuzzy command palette (M-x).",
"Icon": "code-context",
"License": "LGPL-2.0-or-later",
"Name": "Ollie Command Palette",
"Version": "0.1.0"
}
}