aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-18 15:37:52 +0200
committerDanilo M. <danix@danix.xyz>2026-09-18 15:37:52 +0200
commit06586027b0f328ba0aa4b2c6c3c77471d72aac52 (patch)
treec18d68bee7edb7d48e13fe31869d7894cfb72f6c
parent9d4fb5444438bdf188a6149a6667788dc3fc7bc0 (diff)
downloadqtmaildir-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.cpp31
-rw-r--r--src/querycompleter.h7
-rw-r--r--tests/test_querycompleter.cpp120
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;