diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 21:07:03 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-04 12:54:21 +0200 |
| commit | 1e587846a255592e5aab022c4e6e134d4ff64ed2 (patch) | |
| tree | c3dd9bc24b3a7010a1edbed6cf56991619442e71 | |
| parent | a6f73f77890d801ecd7209e97ba948c06fdd35c2 (diff) | |
| download | qtmaildir-1e587846a255592e5aab022c4e6e134d4ff64ed2.tar.gz qtmaildir-1e587846a255592e5aab022c4e6e134d4ff64ed2.zip | |
feat(completion): render the popup with descriptions
The free-form date hint is a footer label rather than a model row: a row
would be filtered away by the first non-matching keystroke and could be
selected and inserted, producing a query that errors.
QCompleter::setPopup takes a QAbstractItemView, so the label cannot be laid
out beside the view in a container widget. The footer sits in space reserved
with setViewportMargins inside the list view instead.
setItemDelegate must run after setPopup, not before: setPopup installs a
plain QStyledItemDelegate of its own and discards whatever was already set,
which silently drops the description column.
Accepting replaces exactly the span the tokenizer identified rather than
QCompleter's own completion prefix, which is a different span once a prefix
or a range bound is involved. Four tests drive that path directly instead of
through synthetic key events, since whether a key needs Shift is a
keyboard-layout property and could not decide the question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -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" |
