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:
parent
95bc9f7ed6
commit
617b526e3f
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
21
docs/PLAN.md
21
docs/PLAN.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
add_subdirectory(fuzzy)
|
||||
add_subdirectory(palette)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue