aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/querycompleter.cpp226
-rw-r--r--src/querycompleter.h20
-rw-r--r--tests/test_querycompleter.cpp55
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"