From 617b526e3fd165b4fc5cebfc3116d6e7bd144696 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 15:49:48 +0200 Subject: [PATCH] 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). --- CMakeLists.txt | 3 +- README.md | 9 +- docs/PLAN.md | 21 ++- src/CMakeLists.txt | 1 + src/fuzzy/fuzzyranker.cpp | 20 +-- src/palette/CMakeLists.txt | 14 ++ src/palette/palettemodel.cpp | 106 ++++++++++++++ src/palette/palettemodel.h | 79 +++++++++++ src/palette/palettewidget.cpp | 220 ++++++++++++++++++++++++++++++ src/palette/palettewidget.h | 63 +++++++++ src/palette/test_palettemodel.cpp | 131 ++++++++++++++++++ 11 files changed, 649 insertions(+), 18 deletions(-) create mode 100644 src/palette/CMakeLists.txt create mode 100644 src/palette/palettemodel.cpp create mode 100644 src/palette/palettemodel.h create mode 100644 src/palette/palettewidget.cpp create mode 100644 src/palette/palettewidget.h create mode 100644 src/palette/test_palettemodel.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6562ad8..2bdea73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md index 64fb500..dd0328a 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/PLAN.md b/docs/PLAN.md index e156668..72f8cf2 100644 --- a/docs/PLAN.md +++ b/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. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 69f8470..4c9d8b1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1 +1,2 @@ add_subdirectory(fuzzy) +add_subdirectory(palette) diff --git a/src/fuzzy/fuzzyranker.cpp b/src/fuzzy/fuzzyranker.cpp index 451f28e..f0aca5e 100644 --- a/src/fuzzy/fuzzyranker.cpp +++ b/src/fuzzy/fuzzyranker.cpp @@ -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; } } diff --git a/src/palette/CMakeLists.txt b/src/palette/CMakeLists.txt new file mode 100644 index 0000000..708f174 --- /dev/null +++ b/src/palette/CMakeLists.txt @@ -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() diff --git a/src/palette/palettemodel.cpp b/src/palette/palettemodel.cpp new file mode 100644 index 0000000..69fba99 --- /dev/null +++ b/src/palette/palettemodel.cpp @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "palettemodel.h" + +#include + +#include + +namespace katecustom +{ + +PaletteModel::PaletteModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +void PaletteModel::setItems(QList 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 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(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(v.ranges.size())); + for (const MatchRange &mr : v.ranges) { + out.push_back(QPoint(mr.start, mr.length)); + } + return out; + } + default: + return {}; + } +} + +} // namespace katecustom diff --git a/src/palette/palettemodel.h b/src/palette/palettemodel.h new file mode 100644 index 0000000..7a203fd --- /dev/null +++ b/src/palette/palettemodel.h @@ -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 +#include +#include +#include + +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 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 ranges; + }; + + void rebuild(); + + QList m_items; + QList m_visible; + QString m_query; +}; + +} // namespace katecustom + +#endif diff --git a/src/palette/palettewidget.cpp b/src/palette/palettewidget.cpp new file mode 100644 index 0000000..9a76055 --- /dev/null +++ b/src/palette/palettewidget.cpp @@ -0,0 +1,220 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "palettewidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 &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(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 diff --git a/src/palette/palettewidget.h b/src/palette/palettewidget.h new file mode 100644 index 0000000..b6b8174 --- /dev/null +++ b/src/palette/palettewidget.h @@ -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 +#include + +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 &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 diff --git a/src/palette/test_palettemodel.cpp b/src/palette/test_palettemodel.cpp new file mode 100644 index 0000000..273e95b --- /dev/null +++ b/src/palette/test_palettemodel.cpp @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "palettemodel.h" + +#include +#include +#include + +using namespace katecustom; + +namespace +{ +QList 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 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"