M2: command palette widget + ranked list model

- PaletteModel: QAbstractListModel ranking items via FuzzyRanker, with
  id/label/group/score and highlight-range roles plus a frecency bonus.
  Headless, unit-tested (10 cases).
- PaletteWidget: frameless popup with a filter line and results list.
  Live company-style updates, top hit preselected, custom delegate bolds
  matched characters. Keyboard (Up/Down/PageUp/PageDown/Enter/Esc, focus
  kept in the filter) and mouse (click to activate). No chords.
- Tighten FuzzyRanker typo tolerance: single adjacent transposition instead
  of deletion. Still recovers 'gti'->'git' but no longer false-matches
  'ren' against 'Open File'.

All tests green: fuzzyranker (14) + palettemodel (10).
This commit is contained in:
Levi Neely 2026-10-07 15:49:48 +02:00
parent 95bc9f7ed6
commit 617b526e3f
11 changed files with 649 additions and 18 deletions

View File

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

View File

@ -58,7 +58,10 @@ See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
- **Milestone 1 — FuzzyRanker (keystone): DONE.** Orderless, layered scoring
(exact / substring / word-initials / subsequence / bounded typo), merged
highlight ranges. 14 unit tests, all green.
- Milestone 2 — custom palette widget: next.
- **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.
## Layout
@ -68,6 +71,10 @@ src/
fuzzy/
fuzzyranker.{h,cpp} orderless + layered fuzzy matcher
test_fuzzyranker.cpp QTest suite
palette/
palettemodel.{h,cpp} ranked, filterable list model (headless)
palettewidget.{h,cpp} frameless command-palette popup
test_palettemodel.cpp QTest suite
docs/
PLAN.md goal, constraints, roadmap
```

View File

@ -36,7 +36,8 @@ Therefore the palette is replaced, not extended, and the replacement is built on
2. substring
3. word-boundary initials (`rs` -> **R**ename **S**ymbol)
4. in-order subsequence
5. bounded typo (one deletion; recovers transpositions like `gti` -> `git`)
5. bounded typo via a single adjacent transposition (`gti` -> `git`;
deliberately narrow to avoid false positives like `ren` -> "Open File")
- **merged highlight ranges** for the UI
- AND semantics across needles, case-folded, shorter-candidate preference
@ -48,13 +49,19 @@ Therefore the palette is replaced, not extended, and the replacement is built on
- `src/fuzzy/test_fuzzyranker.cpp`: QTest suite, 14 cases, all green,
including `gti -> git` and `ren sym -> Rename Symbol`.
### M2 — Palette widget (replaces KCommandBar) — NEXT
- Custom `QFrame`: filter `QLineEdit` + results list (model/view).
- Live, company-style updates on every keystroke; top hit preselected; `Enter`
executes; matched characters highlighted via `MatchResult::ranges`.
- Consumes `FuzzyRanker`. Pure Qt widget, independent of Kate for testability.
### M2 — Palette widget (replaces KCommandBar) — DONE
- `PaletteModel` (`src/palette/palettemodel.{h,cpp}`): `QAbstractListModel`,
re-ranks via `FuzzyRanker` on `setQuery`, exposes id/label/group/score and
highlight ranges per row, frecency bonus. No widget dependency.
- `PaletteWidget` (`src/palette/palettewidget.{h,cpp}`): frameless popup, filter
line + results list, live company-style updates, top hit preselected, custom
delegate bolds matched ranges. Keyboard: Up/Down/PageUp/PageDown, Enter
activates, Esc cancels — cursor stays in the filter line, no chords. Mouse:
click a row to activate. Emits `activatedId(id)` / `cancelled()`.
- `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
### 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.

View File

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

View File

@ -155,25 +155,27 @@ NeedleMatch trySubsequence(const QString &cand, const QString &needle)
}
// --- strategy 4: bounded typo tolerance ------------------------------------
// Allow up to one deletion in the needle (handles transposition/insert typos
// like "gti" -> "git"): if dropping one needle char yields a subsequence, it
// matches at the lowest tier. We try each single-char deletion.
// Recover a single ADJACENT TRANSPOSITION (the most common typo class), e.g.
// "gti" -> "git". We generate each adjacent-swap variant of the needle and
// accept the first that matches as an in-order subsequence. Transposition is
// deliberately narrow: unlike deletion-based fuzzing it will not match a needle
// against an unrelated candidate that merely shares a shorter subsequence.
NeedleMatch tryTypo(const QString &cand, const QString &needle)
{
NeedleMatch m;
if (needle.size() < 2) {
return m;
}
for (int drop = 0; drop < needle.size(); ++drop) {
QString reduced = needle;
reduced.remove(drop, 1);
NeedleMatch sub = trySubsequence(cand, reduced);
for (int i = 0; i + 1 < needle.size(); ++i) {
QString swapped = needle;
swapped[i] = needle[i + 1];
swapped[i + 1] = needle[i];
NeedleMatch sub = trySubsequence(cand, swapped);
if (sub.matched) {
m.matched = true;
m.tier = MatchTier::Typo;
m.indices = sub.indices;
m.score = tierWeight(m.tier) + reduced.size() * 4;
m.score = tierWeight(m.tier) + needle.size() * 4;
return m;
}
}

View File

@ -0,0 +1,14 @@
add_library(palette STATIC
palettemodel.cpp
palettemodel.h
palettewidget.cpp
palettewidget.h
)
target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets)
target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
if(Qt6Test_FOUND)
add_executable(test_palettemodel test_palettemodel.cpp)
target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets)
add_test(NAME palettemodel COMMAND test_palettemodel)
endif()

View File

@ -0,0 +1,106 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "palettemodel.h"
#include <QPoint>
#include <algorithm>
namespace katecustom
{
PaletteModel::PaletteModel(QObject *parent)
: QAbstractListModel(parent)
{
}
void PaletteModel::setItems(QList<PaletteItem> items)
{
beginResetModel();
m_items = std::move(items);
rebuild();
endResetModel();
}
void PaletteModel::setQuery(const QString &query)
{
if (query == m_query) {
return;
}
beginResetModel();
m_query = query;
rebuild();
endResetModel();
}
void PaletteModel::rebuild()
{
m_visible.clear();
const QList<QString> needles = FuzzyRanker::tokenize(m_query);
for (int i = 0; i < m_items.size(); ++i) {
const PaletteItem &item = m_items.at(i);
const MatchResult r = FuzzyRanker::score(item.label, needles);
if (!r.matched) {
continue;
}
// Frecency nudges well-used commands up without overriding a clearly
// stronger textual match.
m_visible.push_back({i, r.score + item.frecency, r.ranges});
}
// Highest score first; stable so equal scores keep input order.
std::stable_sort(m_visible.begin(), m_visible.end(),
[](const Visible &a, const Visible &b) { return a.score > b.score; });
}
const PaletteItem *PaletteModel::itemAt(int row) const
{
if (row < 0 || row >= m_visible.size()) {
return nullptr;
}
return &m_items.at(m_visible.at(row).sourceIndex);
}
int PaletteModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return static_cast<int>(m_visible.size());
}
QVariant PaletteModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_visible.size()) {
return {};
}
const Visible &v = m_visible.at(index.row());
const PaletteItem &item = m_items.at(v.sourceIndex);
switch (role) {
case Qt::DisplayRole:
case LabelRole:
return item.label;
case IdRole:
return item.id;
case GroupRole:
return item.group;
case ScoreRole:
return v.score;
case HighlightRangesRole: {
QVariantList out;
out.reserve(static_cast<int>(v.ranges.size()));
for (const MatchRange &mr : v.ranges) {
out.push_back(QPoint(mr.start, mr.length));
}
return out;
}
default:
return {};
}
}
} // namespace katecustom

View File

@ -0,0 +1,79 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* PaletteModel — a ranked, filterable list model for the command palette.
*
* Holds a flat list of PaletteItem and, for a given query, exposes only the
* matching items sorted best-score-first (via FuzzyRanker). Highlight ranges
* for each visible row are available through HighlightRangesRole so the view
* can emphasise matched characters.
*
* This class is intentionally free of any widget dependency so it can be unit
* tested without an event loop or display.
*/
#ifndef KATECUSTOM_PALETTEMODEL_H
#define KATECUSTOM_PALETTEMODEL_H
#include "fuzzyranker.h"
#include <QAbstractListModel>
#include <QString>
#include <QVariant>
#include <QList>
namespace katecustom
{
/*! One selectable entry in the palette. */
struct PaletteItem {
QString id; // stable identifier returned on activation
QString label; // text shown and matched against
QString group; // optional category (e.g. "File", "Edit")
int frecency = 0; // usage-derived ranking bonus (higher = more used)
};
class PaletteModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
IdRole = Qt::UserRole + 1,
LabelRole,
GroupRole,
ScoreRole,
HighlightRangesRole, // QVariantList of QPoint(start, length)
};
explicit PaletteModel(QObject *parent = nullptr);
/*! Replace the full item set. Re-applies the current query. */
void setItems(QList<PaletteItem> items);
/*! Set the filter query and re-rank. Empty query shows all items. */
void setQuery(const QString &query);
QString query() const { return m_query; }
/*! The item at a visible row, or nullptr if out of range. */
const PaletteItem *itemAt(int row) const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
private:
struct Visible {
int sourceIndex;
int score;
std::vector<MatchRange> ranges;
};
void rebuild();
QList<PaletteItem> m_items;
QList<Visible> m_visible;
QString m_query;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,220 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "palettewidget.h"
#include <QAbstractTextDocumentLayout>
#include <QApplication>
#include <QKeyEvent>
#include <QLineEdit>
#include <QListView>
#include <QStyledItemDelegate>
#include <QTextCharFormat>
#include <QTextDocument>
#include <QVBoxLayout>
#include <QPainter>
#include <QPoint>
namespace katecustom
{
namespace
{
// Renders a palette row, emboldening the matched character ranges supplied via
// PaletteModel::HighlightRangesRole.
class HighlightDelegate : public QStyledItemDelegate
{
public:
using QStyledItemDelegate::QStyledItemDelegate;
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
QStyleOptionViewItem opt = option;
initStyleOption(&opt, index);
// Draw selection background / focus via the style, but suppress the
// style's own text so we can render rich text ourselves.
const QString text = opt.text;
opt.text.clear();
QStyle *style = opt.widget ? opt.widget->style() : QApplication::style();
style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, opt.widget);
const QColor baseColor = (opt.state & QStyle::State_Selected)
? opt.palette.color(QPalette::HighlightedText)
: opt.palette.color(QPalette::Text);
QTextDocument doc;
QTextCharFormat plain;
plain.setForeground(baseColor);
QTextCharFormat strong = plain;
strong.setFontWeight(QFont::Bold);
const QVariantList ranges =
index.data(PaletteModel::HighlightRangesRole).toList();
// Build a per-character bold mask.
QList<bool> boldMask(text.size(), false);
for (const QVariant &v : ranges) {
const QPoint r = v.toPoint(); // x=start, y=length
for (int i = r.x(); i < r.x() + r.y() && i < text.size(); ++i) {
if (i >= 0) {
boldMask[i] = true;
}
}
}
QTextCursor cur(&doc);
int i = 0;
while (i < text.size()) {
const bool bold = boldMask[i];
int j = i;
while (j < text.size() && boldMask[j] == bold) {
++j;
}
cur.insertText(text.mid(i, j - i), bold ? strong : plain);
i = j;
}
painter->save();
const QRect textRect = style->subElementRect(QStyle::SE_ItemViewItemText, &opt, opt.widget);
painter->translate(textRect.topLeft());
QRect clip(0, 0, textRect.width(), textRect.height());
// Vertically center the single text line.
const int dy = (textRect.height() - doc.size().toSize().height()) / 2;
painter->translate(0, dy > 0 ? dy : 0);
QAbstractTextDocumentLayout::PaintContext ctx;
ctx.clip = clip;
doc.documentLayout()->draw(painter, ctx);
painter->restore();
}
};
} // namespace
PaletteWidget::PaletteWidget(QWidget *parent)
: QFrame(parent, Qt::Popup)
, m_model(new PaletteModel(this))
, m_filter(new QLineEdit(this))
, m_list(new QListView(this))
{
setFrameShape(QFrame::StyledPanel);
setFrameShadow(QFrame::Raised);
m_filter->setPlaceholderText(tr("Type to filter commands…"));
m_filter->setClearButtonEnabled(true);
m_list->setModel(m_model);
m_list->setItemDelegate(new HighlightDelegate(m_list));
m_list->setSelectionMode(QAbstractItemView::SingleSelection);
m_list->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_list->setUniformItemSizes(true);
m_list->setFocusPolicy(Qt::NoFocus); // keep focus/typing in the filter line
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(6, 6, 6, 6);
layout->setSpacing(4);
layout->addWidget(m_filter);
layout->addWidget(m_list);
// Live, company-style updates: re-rank and keep the top row selected.
connect(m_filter, &QLineEdit::textChanged, this, [this](const QString &text) {
m_model->setQuery(text);
if (m_model->rowCount() > 0) {
m_list->setCurrentIndex(m_model->index(0, 0));
}
});
// Mouse: a click activates immediately.
connect(m_list, &QListView::clicked, this, [this](const QModelIndex &) {
acceptCurrent();
});
m_filter->installEventFilter(this);
resize(480, 320);
}
void PaletteWidget::setItems(const QList<PaletteItem> &items)
{
m_model->setItems(items);
if (m_model->rowCount() > 0) {
m_list->setCurrentIndex(m_model->index(0, 0));
}
}
void PaletteWidget::activate()
{
m_filter->clear();
m_model->setQuery(QString());
if (m_model->rowCount() > 0) {
m_list->setCurrentIndex(m_model->index(0, 0));
}
show();
m_filter->setFocus();
}
void PaletteWidget::moveSelection(int delta)
{
const int count = m_model->rowCount();
if (count == 0) {
return;
}
int row = m_list->currentIndex().row();
if (row < 0) {
row = 0;
}
row = qBound(0, row + delta, count - 1);
m_list->setCurrentIndex(m_model->index(row, 0));
}
void PaletteWidget::acceptCurrent()
{
const int row = m_list->currentIndex().row();
const PaletteItem *item = m_model->itemAt(row);
if (!item) {
return;
}
const QString id = item->id;
hide();
Q_EMIT activatedId(id);
}
void PaletteWidget::dismiss()
{
hide();
Q_EMIT cancelled();
}
bool PaletteWidget::eventFilter(QObject *obj, QEvent *event)
{
if (obj == m_filter && event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent *>(event);
switch (ke->key()) {
case Qt::Key_Down:
moveSelection(1);
return true;
case Qt::Key_Up:
moveSelection(-1);
return true;
case Qt::Key_PageDown:
moveSelection(8);
return true;
case Qt::Key_PageUp:
moveSelection(-8);
return true;
case Qt::Key_Return:
case Qt::Key_Enter:
acceptCurrent();
return true;
case Qt::Key_Escape:
dismiss();
return true;
default:
break;
}
}
return QFrame::eventFilter(obj, event);
}
} // namespace katecustom

View File

@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* PaletteWidget — a frameless command palette popup.
*
* A filter line on top and a results list below. Results update live on every
* keystroke (company-style), the best match is preselected, Enter activates the
* selection, Esc cancels, Up/Down move the selection while the cursor stays in
* the filter line. Matched characters are emphasised using the highlight ranges
* supplied by PaletteModel.
*
* The widget owns a PaletteModel. Feed it items with setItems(); listen to
* activated(id) for the chosen command.
*/
#ifndef KATECUSTOM_PALETTEWIDGET_H
#define KATECUSTOM_PALETTEWIDGET_H
#include "palettemodel.h"
#include <QFrame>
#include <QList>
class QLineEdit;
class QListView;
namespace katecustom
{
class PaletteWidget : public QFrame
{
Q_OBJECT
public:
explicit PaletteWidget(QWidget *parent = nullptr);
/*! Replace the command set shown by the palette. */
void setItems(const QList<PaletteItem> &items);
/*! Clear the filter, reset selection to the top, show and focus. */
void activate();
Q_SIGNALS:
/*! Emitted with PaletteItem::id when the user activates a result. */
void activatedId(const QString &id);
/*! Emitted when the palette is dismissed without a choice. */
void cancelled();
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private:
void moveSelection(int delta);
void acceptCurrent();
void dismiss();
PaletteModel *m_model;
QLineEdit *m_filter;
QListView *m_list;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,131 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "palettemodel.h"
#include <QTest>
#include <QPoint>
#include <QSignalSpy>
using namespace katecustom;
namespace
{
QList<PaletteItem> sampleCommands()
{
return {
{QStringLiteral("edit.rename"), QStringLiteral("Rename Symbol"), QStringLiteral("Edit"), 0},
{QStringLiteral("edit.reindent"), QStringLiteral("Reindent Lines"), QStringLiteral("Edit"), 0},
{QStringLiteral("file.open"), QStringLiteral("Open File"), QStringLiteral("File"), 0},
{QStringLiteral("git.blame"), QStringLiteral("Git Blame"), QStringLiteral("Git"), 0},
{QStringLiteral("edit.sort"), QStringLiteral("Sort Lines"), QStringLiteral("Edit"), 0},
};
}
QStringList labelsOf(const PaletteModel &m)
{
QStringList out;
for (int i = 0; i < m.rowCount(); ++i) {
out << m.index(i, 0).data(PaletteModel::LabelRole).toString();
}
return out;
}
}
class TestPaletteModel : public QObject
{
Q_OBJECT
private Q_SLOTS:
void emptyQueryShowsAll()
{
PaletteModel m;
m.setItems(sampleCommands());
QCOMPARE(m.rowCount(), 5);
}
void filtersToMatches()
{
PaletteModel m;
m.setItems(sampleCommands());
m.setQuery(QStringLiteral("ren"));
const QStringList got = labelsOf(m);
QVERIFY(got.contains(QStringLiteral("Rename Symbol")));
QVERIFY(!got.contains(QStringLiteral("Open File")));
}
void orderlessThroughModel()
{
PaletteModel m;
m.setItems(sampleCommands());
m.setQuery(QStringLiteral("sym ren"));
QVERIFY(labelsOf(m).contains(QStringLiteral("Rename Symbol")));
}
void typoThroughModel()
{
PaletteModel m;
m.setItems(sampleCommands());
m.setQuery(QStringLiteral("gti"));
QVERIFY(labelsOf(m).contains(QStringLiteral("Git Blame")));
}
void bestMatchIsFirstRow()
{
PaletteModel m;
m.setItems(sampleCommands());
m.setQuery(QStringLiteral("sort"));
QCOMPARE(m.index(0, 0).data(PaletteModel::LabelRole).toString(),
QStringLiteral("Sort Lines"));
}
void frecencyBreaksTies()
{
// Two items that match 'o' as a plain subsequence; frecency should lift
// the favoured one to the top.
QList<PaletteItem> items = {
{QStringLiteral("a"), QStringLiteral("Open Folder"), {}, 0},
{QStringLiteral("b"), QStringLiteral("Open Folder"), {}, 500},
};
PaletteModel m;
m.setItems(items);
m.setQuery(QStringLiteral("open"));
QCOMPARE(m.index(0, 0).data(PaletteModel::IdRole).toString(), QStringLiteral("b"));
}
void highlightRangesExposed()
{
PaletteModel m;
m.setItems(sampleCommands());
m.setQuery(QStringLiteral("sort"));
// Top row is "Sort Lines"; 'sort' is a 4-char substring at index 0.
const QVariantList ranges =
m.index(0, 0).data(PaletteModel::HighlightRangesRole).toList();
QCOMPARE(ranges.size(), 1);
QCOMPARE(ranges.first().toPoint(), QPoint(0, 4));
}
void itemAtMapsToSource()
{
PaletteModel m;
m.setItems(sampleCommands());
m.setQuery(QStringLiteral("blame"));
const PaletteItem *it = m.itemAt(0);
QVERIFY(it != nullptr);
QCOMPARE(it->id, QStringLiteral("git.blame"));
QVERIFY(m.itemAt(99) == nullptr);
}
void modelResetsOnQueryChange()
{
PaletteModel m;
m.setItems(sampleCommands());
QSignalSpy spy(&m, &QAbstractItemModel::modelReset);
m.setQuery(QStringLiteral("ren"));
QCOMPARE(spy.count(), 1);
}
};
QTEST_MAIN(TestPaletteModel)
#include "test_palettemodel.moc"