diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 15:37:52 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 15:37:52 +0200 |
| commit | 06586027b0f328ba0aa4b2c6c3c77471d72aac52 (patch) | |
| tree | c18d68bee7edb7d48e13fe31869d7894cfb72f6c | |
| parent | 9d4fb5444438bdf188a6149a6667788dc3fc7bc0 (diff) | |
| download | qtmaildir-06586027b0f328ba0aa4b2c6c3c77471d72aac52.tar.gz qtmaildir-06586027b0f328ba0aa4b2c6c3c77471d72aac52.zip | |
feat: complete contacts in the query bar
from: and to: now offer the vCard store's addresses, each quoted via
SearchTerm::quote() with the contact's name as the description. The store
is the enumerator libnotmuch does not expose, which is what the old
complete-nothing comment said was missing; it keeps that role for folder:,
subject:, attachment:, thread: and id:.
With no store configured the branch returns {} and the behaviour is
unchanged.
| -rw-r--r-- | src/querycompleter.cpp | 31 | ||||
| -rw-r--r-- | src/querycompleter.h | 7 | ||||
| -rw-r--r-- | tests/test_querycompleter.cpp | 120 |
3 files changed, 153 insertions, 5 deletions
diff --git a/src/querycompleter.cpp b/src/querycompleter.cpp index 9848faf..1b4c2fc 100644 --- a/src/querycompleter.cpp +++ b/src/querycompleter.cpp @@ -19,6 +19,7 @@ #include "querycompleter.h" #include "config.h" +#include "searchterm.h" #include <QCompleter> #include <QCoreApplication> @@ -597,6 +598,11 @@ void QueryCompleter::setTags(const QStringList &tags) m_tags = tags; } +void QueryCompleter::setContacts(const QList<Contact> &contacts) +{ + m_contacts = contacts; +} + QList<CompletionEntry> QueryCompleter::entriesFor( const CompletionContext &context) const { @@ -649,10 +655,27 @@ QList<CompletionEntry> QueryCompleter::entriesFor( return entries; } - // from:, to:, folder:, subject:, attachment:, thread:, id: complete no - // values. Addresses need an enumerator libnotmuch does not expose; - // folder: matches a Maildir folder name that config cannot enumerate, and - // the rest are free text. + // The addresses live in the vCard store, which is the enumerator + // libnotmuch does not expose. The VALUE is the bare address because that + // is what notmuch matches on; the contact's name is the description, so + // the popup says who owns it. + if (context.prefix == QStringLiteral("from") + || context.prefix == QStringLiteral("to")) { + QList<CompletionEntry> entries; + entries.reserve(m_contacts.size()); + for (const Contact &contact : m_contacts) { + const QString address = SearchTerm::quote(contact.email); + if (address.isEmpty()) + continue; + entries.append({ address, contact.name }); + } + return entries; + } + + // folder:, subject:, attachment:, thread:, id: complete no values. folder: + // matches a Maildir folder name that config cannot enumerate, and the rest + // are free text. from: and to: no longer belong in this list: the vCards + // are the enumerator they lacked. return {}; } diff --git a/src/querycompleter.h b/src/querycompleter.h index 27161bc..147909c 100644 --- a/src/querycompleter.h +++ b/src/querycompleter.h @@ -24,6 +24,7 @@ #include <QStringList> #include "completionentry.h" +#include "contactstore.h" class Config; class QCompleter; @@ -98,6 +99,11 @@ public: /// Replaces the tag candidates. Called with the worker's allTagsReady. void setTags(const QStringList &tags); + /// Replaces the address candidates. Called by MainWindow after the vCard + /// store is read, and REPLACES rather than merges for the same reason + /// setTags does: the caller owns the list. + void setContacts(const QList<Contact> &contacts); + /// The candidate values for a context, in the order they are offered. QStringList candidatesFor(const CompletionContext &context) const; @@ -134,6 +140,7 @@ private: QLineEdit *m_edit = nullptr; const Config &m_config; QStringList m_tags; + QList<Contact> m_contacts; /// Set while the filter is redelivering a key to the popup. The filter is /// installed on the application and sendEvent re-runs application filters, diff --git a/tests/test_querycompleter.cpp b/tests/test_querycompleter.cpp index 4c1e866..ca08d7b 100644 --- a/tests/test_querycompleter.cpp +++ b/tests/test_querycompleter.cpp @@ -19,13 +19,19 @@ #include <QtTest> #include <QTemporaryDir> +#include <QHash> #include <QLineEdit> #include <QImage> #include <QListView> #include <QPixmap> #include "config.h" +#include "contactstore.h" #include "querycompleter.h" +#include "searchterm.h" + +// Defined further down, beside the popup tests that also need it. +static QListView *findPopup(); class TestQueryCompleter : public QObject { @@ -51,6 +57,11 @@ private slots: void tagAndIsShareTheTagModel(); void pathOffersAccountMaildirsBothForms(); void folderOffersNothing(); + void fromOffersQuotedAddressesWithNamesAsDescriptions(); + void toOffersTheSameContactsAsFrom(); + void anEmptyContactStoreOffersNoAddresses(); + void anAddressValueIsTheBareAddressNotAName(); + void addressesTakeTheValueBranchNotThePrefixVocabulary(); void mimetypeAppendsConfiguredEntries(); void rangeContextDropsRelativeDates(); void acceptReplacesOnlyThePrefixToken(); @@ -294,7 +305,8 @@ void TestQueryCompleter::pathOffersAccountMaildirsBothForms() void TestQueryCompleter::folderOffersNothing() { // folder: matches a Maildir folder name, not a path, and its values are - // not enumerable from config. Prefix-only, like from: and to:. + // not enumerable from config. Prefix-only, unlike from: and to:, whose + // values come from the vCard store. Config config; QueryCompleter completer(nullptr, config); const QStringList candidates = completer.candidatesFor( @@ -302,6 +314,112 @@ void TestQueryCompleter::folderOffersNothing() QVERIFY(candidates.isEmpty()); } +void TestQueryCompleter::fromOffersQuotedAddressesWithNamesAsDescriptions() +{ + // The popup shows who an address belongs to: the value is the address, + // the description is the contact's name. Read through the real popup model + // rather than candidatesFor(), which carries the values alone. + Config config; + QLineEdit edit; + edit.show(); + QueryCompleter completer(&edit, config); + completer.setContacts({ + { QStringLiteral("Alice Example"), QStringLiteral("alice@example.org") }, + { QStringLiteral("Bob Example"), QStringLiteral("bob@example.org") }, + }); + + QTest::keyClicks(&edit, QStringLiteral("from:")); + QListView *popup = findPopup(); + QVERIFY(popup && popup->isVisible()); + + QStringList values; + QHash<QString, QString> namesByValue; + for (int row = 0; row < popup->model()->rowCount(); ++row) { + const QString value = popup->model()->index(row, 0).data().toString(); + values << value; + namesByValue.insert( + value, popup->model()->index(row, 1).data().toString()); + } + + const QString alice = SearchTerm::quote(QStringLiteral("alice@example.org")); + QVERIFY(values.contains(alice)); + QCOMPARE(namesByValue.value(alice), QStringLiteral("Alice Example")); + QCOMPARE(namesByValue.value(SearchTerm::quote(QStringLiteral("bob@example.org"))), + QStringLiteral("Bob Example")); +} + +void TestQueryCompleter::toOffersTheSameContactsAsFrom() +{ + Config config; + QueryCompleter completer(nullptr, config); + completer.setContacts({ + { QStringLiteral("Alice Example"), QStringLiteral("alice@example.org") }, + }); + + const QStringList from = completer.candidatesFor( + completionContext(QStringLiteral("from:"), 5)); + const QStringList to = completer.candidatesFor( + completionContext(QStringLiteral("to:"), 3)); + + QCOMPARE(to, from); + QCOMPARE(to, QStringList({ SearchTerm::quote(QStringLiteral("alice@example.org")) })); +} + +void TestQueryCompleter::anEmptyContactStoreOffersNoAddresses() +{ + // No store configured is today's behaviour: from: and to: offer nothing, + // and the other addressless keywords are unchanged. + Config config; + QueryCompleter completer(nullptr, config); + + QVERIFY(completer.candidatesFor( + completionContext(QStringLiteral("from:"), 5)).isEmpty()); + QVERIFY(completer.candidatesFor( + completionContext(QStringLiteral("to:"), 3)).isEmpty()); + QVERIFY(completer.candidatesFor( + completionContext(QStringLiteral("folder:"), 7)).isEmpty()); +} + +void TestQueryCompleter::anAddressValueIsTheBareAddressNotAName() +{ + // notmuch matches on the address, so "Name <addr>" would be one token that + // never matches. The display name is the description, never the value. + Config config; + QueryCompleter completer(nullptr, config); + completer.setContacts({ + { QStringLiteral("Alice Example"), QStringLiteral("alice@example.org") }, + }); + + const QStringList candidates = completer.candidatesFor( + completionContext(QStringLiteral("from:"), 5)); + + QCOMPARE(candidates, + QStringList({ SearchTerm::quote(QStringLiteral("alice@example.org")) })); + QVERIFY(!candidates.first().contains(QStringLiteral("Alice"))); +} + +void TestQueryCompleter::addressesTakeTheValueBranchNotThePrefixVocabulary() +{ + // A from: value is a Value context, resolved by the same branch that serves + // path: and tag:, never the prefix vocabulary. If the kind were misjudged + // the popup would offer "from:" itself back at the user. + const CompletionContext ctx = + completionContext(QStringLiteral("from:"), 5); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.prefix, QStringLiteral("from")); + + Config config; + QueryCompleter completer(nullptr, config); + completer.setContacts({ + { QStringLiteral("Alice Example"), QStringLiteral("alice@example.org") }, + }); + + const QStringList candidates = completer.candidatesFor(ctx); + QVERIFY(candidates.contains( + SearchTerm::quote(QStringLiteral("alice@example.org")))); + QVERIFY(!candidates.contains(QStringLiteral("tag:"))); +} + void TestQueryCompleter::mimetypeAppendsConfiguredEntries() { QTemporaryDir dir; |
