diff options
| -rw-r--r-- | src/querycompleter.cpp | 226 | ||||
| -rw-r--r-- | src/querycompleter.h | 20 | ||||
| -rw-r--r-- | tests/test_querycompleter.cpp | 55 |
3 files changed, 301 insertions, 0 deletions
diff --git a/src/querycompleter.cpp b/src/querycompleter.cpp index 6b4c69a..8457776 100644 --- a/src/querycompleter.cpp +++ b/src/querycompleter.cpp @@ -20,7 +20,16 @@ #include "config.h" +#include <QCompleter> #include <QCoreApplication> +#include <QFontMetrics> +#include <QLabel> +#include <QLineEdit> +#include <QListView> +#include <QPainter> +#include <QResizeEvent> +#include <QStandardItemModel> +#include <QStyledItemDelegate> namespace { @@ -76,8 +85,136 @@ int tokenEnd(const QString &text, int cursor) return end; } +/// Draws the description greyed and right-aligned beside the value. +/// +/// The two are drawn into disjoint halves of the row rather than simply +/// painted on top of each other: a long value ("application/vnd.oasis..." +/// exceeds the popup width on its own) would otherwise run underneath the +/// description and render both unreadable. +class CompletionDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override + { + const QModelIndex sibling = index.sibling(index.row(), 1); + const QString description = sibling.data(Qt::DisplayRole).toString(); + if (description.isEmpty()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + const QFontMetrics metrics(option.font); + const int gap = 12; + const int rightMargin = 6; + + // The description never takes more than its share, so a long value + // keeps room to be legible and a long description gets elided too. + const int available = option.rect.width() - gap - rightMargin; + int descriptionWidth = qMin(metrics.horizontalAdvance(description), + available / 2); + descriptionWidth = qMax(descriptionWidth, 0); + + // Let the base class draw the selection background and the value, but + // only into the part of the row the description does not claim. + QStyleOptionViewItem valueOption = option; + valueOption.rect = option.rect.adjusted( + 0, 0, -(descriptionWidth + gap + rightMargin), 0); + QStyledItemDelegate::paint(painter, valueOption, index); + + // The background belongs to the whole row, so repaint the strip the + // base class just left untouched before drawing the description. + painter->save(); + QRect descriptionRect = option.rect; + descriptionRect.setLeft(valueOption.rect.right() + 1); + if (option.state & QStyle::State_Selected) + painter->fillRect(descriptionRect, option.palette.highlight()); + + painter->setPen(option.palette.color(QPalette::Disabled, QPalette::Text)); + painter->drawText(descriptionRect.adjusted(0, 0, -rightMargin, 0), + Qt::AlignRight | Qt::AlignVCenter, + metrics.elidedText(description, Qt::ElideRight, + descriptionWidth)); + painter->restore(); + } +}; + } // namespace +/// A completion list with a non-selectable footer strip below the items. +/// +/// The footer is a child label sitting in space reserved by +/// setViewportMargins, not a model row. A row would be filtered away by +/// QCompleter's filter model on the first keystroke that did not match it, +/// and could be selected and inserted, producing a query that errors. +/// +/// QCompleter::setPopup takes a QAbstractItemView, so the label cannot simply +/// be laid out beside the view in a container widget: the container would not +/// be accepted. Reserving margin inside the view is what fits that signature. +class CompletionPopup : public QListView +{ +public: + explicit CompletionPopup(QWidget *parent = nullptr) + : QListView(parent), m_hint(new QLabel(this)) + { + // Illustrative, not a promise: notmuch's date parser is permissive and + // the set it accepts varies between builds. + m_hintText = QueryCompleter::tr( + "also accepts free-form dates, e.g. 2026-01-15 or 15/01/2026..today"); + m_hint->setText(m_hintText); + m_hint->setTextInteractionFlags(Qt::NoTextInteraction); + m_hint->setMargin(4); + + QFont hintFont = m_hint->font(); + hintFont.setItalic(true); + hintFont.setPointSizeF(hintFont.pointSizeF() * 0.9); + m_hint->setFont(hintFont); + + m_hint->hide(); + } + + void setHintVisible(bool visible) + { + if (visible == !m_hint->isHidden()) + return; + m_hint->setVisible(visible); + setViewportMargins(0, 0, 0, + visible ? m_hint->sizeHint().height() : 0); + layoutHint(); + } + +protected: + void resizeEvent(QResizeEvent *event) override + { + QListView::resizeEvent(event); + layoutHint(); + } + +private: + void layoutHint() + { + // isHidden, not isVisible: while the popup window is still unmapped + // every child reports invisible, which would skip the only layout pass + // that runs before the popup appears. + if (m_hint->isHidden()) + return; + + // The popup is only as wide as the line edit, which is routinely + // narrower than the hint. Elide rather than let it clip mid-word. + const int textWidth = width() - 2 * m_hint->margin(); + m_hint->setText(QFontMetrics(m_hint->font()) + .elidedText(m_hintText, Qt::ElideRight, textWidth)); + + const int height = m_hint->sizeHint().height(); + m_hint->setGeometry(0, this->height() - height, width(), height); + } + + QLabel *m_hint; + QString m_hintText; +}; + QList<CompletionEntry> prefixVocabulary() { return { @@ -193,6 +330,95 @@ QueryCompleter::QueryCompleter(QLineEdit *edit, const Config &config, QObject *parent) : QObject(parent), m_edit(edit), m_config(config) { + if (!m_edit) + return; + + m_model = new QStandardItemModel(this); + + m_completer = new QCompleter(m_model, this); + m_completer->setCaseSensitivity(Qt::CaseInsensitive); + m_completer->setCompletionColumn(0); + m_completer->setCompletionMode(QCompleter::PopupCompletion); + // Tag hierarchies are the reason completion exists here, and a user who + // types "amazon" means shopping/amazon. + m_completer->setFilterMode(Qt::MatchContains); + + m_popup = new CompletionPopup; + m_completer->setPopup(m_popup); + // After setPopup, never before: setPopup installs a plain + // QStyledItemDelegate of its own and discards whatever was set already, + // which silently drops the description column. + m_popup->setItemDelegate(new CompletionDelegate(m_popup)); + + m_edit->setCompleter(m_completer); + + connect(m_edit, &QLineEdit::textEdited, this, &QueryCompleter::updateContext); + connect(m_edit, &QLineEdit::cursorPositionChanged, + this, [this]() { updateContext(); }); + + connect(m_completer, QOverload<const QModelIndex &>::of(&QCompleter::activated), + this, [this](const QModelIndex &index) { + acceptCompletion(index.data(Qt::DisplayRole).toString()); + }); +} + +void QueryCompleter::acceptCompletion(const QString &value) +{ + if (!m_edit) + return; + + // Replace exactly the span the tokenizer identified. QCompleter's own + // insertion replaces the whole "completion prefix", which is not the same + // span once a prefix or a range bound is involved. + QString text = m_edit->text(); + if (m_context.replaceFrom < 0 || m_context.replaceLength < 0 + || m_context.replaceFrom + m_context.replaceLength > text.size()) + return; + + text.replace(m_context.replaceFrom, m_context.replaceLength, value); + + // Setting the text re-emits cursorPositionChanged, which would recompute + // the context from a caret Qt has not moved yet. Block that so the caret + // lands past the insertion first. + const QSignalBlocker blocker(m_edit); + m_edit->setText(text); + m_edit->setCursorPosition(m_context.replaceFrom + value.size()); + + // The context is now stale in every field; recompute it from the caret we + // just placed so a second accept without an intervening keystroke is sane. + m_context = completionContext(m_edit->text(), m_edit->cursorPosition()); +} + +void QueryCompleter::updateContext() +{ + if (!m_edit) + return; + m_context = completionContext(m_edit->text(), m_edit->cursorPosition()); + rebuildModel(m_context); +} + +void QueryCompleter::rebuildModel(const CompletionContext &context) +{ + if (!m_model) + return; + + m_model->clear(); + + const QList<CompletionEntry> entries = entriesFor(context); + for (const CompletionEntry &entry : entries) { + // Matching runs on column 0 only, so a description never influences + // which candidates are offered. + auto *value = new QStandardItem(entry.value); + auto *description = new QStandardItem(entry.description); + value->setEditable(false); + description->setEditable(false); + m_model->appendRow({ value, description }); + } + + if (m_popup) { + m_popup->setHintVisible(context.kind == CompletionContext::Value + && context.prefix == QStringLiteral("date")); + } } void QueryCompleter::setTags(const QStringList &tags) diff --git a/src/querycompleter.h b/src/querycompleter.h index e9ffc64..374fe84 100644 --- a/src/querycompleter.h +++ b/src/querycompleter.h @@ -26,7 +26,12 @@ #include "completionentry.h" class Config; +class QCompleter; class QLineEdit; +class QStandardItemModel; + +/// The completion popup, defined in the .cpp: a list view with a footer strip. +class CompletionPopup; /// Where the cursor sits in a query, and therefore what should be offered. /// @@ -96,10 +101,25 @@ public: /// The candidate values for a context, in the order they are offered. QStringList candidatesFor(const CompletionContext &context) const; + /// Recomputes the context from the line edit and refills the popup model. + void updateContext(); + + /// Inserts `value` over the span the last updateContext() identified. + /// + /// Public so a test can drive the accept path directly. Going through + /// synthetic key events instead would test the keyboard layout, not this. + void acceptCompletion(const QString &value); + private: QList<CompletionEntry> entriesFor(const CompletionContext &context) const; + void rebuildModel(const CompletionContext &context); QLineEdit *m_edit = nullptr; const Config &m_config; QStringList m_tags; + + QCompleter *m_completer = nullptr; + QStandardItemModel *m_model = nullptr; + CompletionPopup *m_popup = nullptr; + CompletionContext m_context; }; diff --git a/tests/test_querycompleter.cpp b/tests/test_querycompleter.cpp index bd1e50f..56b5343 100644 --- a/tests/test_querycompleter.cpp +++ b/tests/test_querycompleter.cpp @@ -19,6 +19,8 @@ #include <QtTest> #include <QTemporaryDir> +#include <QLineEdit> + #include "config.h" #include "querycompleter.h" @@ -48,6 +50,10 @@ private slots: void folderOffersNothing(); void mimetypeAppendsConfiguredEntries(); void rangeContextDropsRelativeDates(); + void acceptReplacesOnlyThePrefixToken(); + void acceptReplacesOnlyTheValueAfterThePrefix(); + void acceptReplacesOnlyTheEditedRangeBound(); + void acceptReplacesTheWholeBoundWhenCompletingMidWord(); }; // Copied from tests/test_config.cpp rather than shared, so the two test files @@ -298,5 +304,54 @@ void TestQueryCompleter::rangeContextDropsRelativeDates() QVERIFY(!inRange.contains(QStringLiteral("1week.."))); } +// The accept path is driven directly rather than through synthetic key +// events: whether a key needs Shift is a keyboard-layout property, so +// QTest::keyClick could never decide whether this logic is right. +static QString acceptInto(const QString &text, int cursor, const QString &value) +{ + Config config; + QLineEdit edit; + QueryCompleter completer(&edit, config); + + edit.setText(text); + edit.setCursorPosition(cursor); + completer.updateContext(); + completer.acceptCompletion(value); + + return edit.text(); +} + +void TestQueryCompleter::acceptReplacesOnlyThePrefixToken() +{ + // The neighbouring token must survive untouched. + QCOMPARE(acceptInto(QStringLiteral("tag:inbox su"), 12, + QStringLiteral("subject:")), + QStringLiteral("tag:inbox subject:")); +} + +void TestQueryCompleter::acceptReplacesOnlyTheValueAfterThePrefix() +{ + // QCompleter's own insertion would overwrite "date:tod" whole, because + // that is the token it matched on. Only "tod" may be replaced. + QCOMPARE(acceptInto(QStringLiteral("date:tod"), 8, QStringLiteral("today")), + QStringLiteral("date:today")); +} + +void TestQueryCompleter::acceptReplacesOnlyTheEditedRangeBound() +{ + const QString text = QStringLiteral("date:yesterday..to"); + QCOMPARE(acceptInto(text, text.size(), QStringLiteral("today")), + QStringLiteral("date:yesterday..today")); +} + +void TestQueryCompleter::acceptReplacesTheWholeBoundWhenCompletingMidWord() +{ + // Caret sits after "yest" but the bound runs to the "..", so accepting + // must leave no "erday" tail behind. + QCOMPARE(acceptInto(QStringLiteral("date:yesterday..today"), 9, + QStringLiteral("this_week")), + QStringLiteral("date:this_week..today")); +} + QTEST_MAIN(TestQueryCompleter) #include "test_querycompleter.moc" |
