diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 20:56:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-04 12:54:19 +0200 |
| commit | a6f73f77890d801ecd7209e97ba948c06fdd35c2 (patch) | |
| tree | bc3254e448f02c08d218b91246ee0233dab68334 | |
| parent | 45e5887f5b33e5f30d3c16187bb5d6e707983804 (diff) | |
| download | qtmaildir-a6f73f77890d801ecd7209e97ba948c06fdd35c2.tar.gz qtmaildir-a6f73f77890d801ecd7209e97ba948c06fdd35c2.zip | |
feat(completion): select candidates per context
tag: and is: share the tag list, since notmuch aliases them. path: offers
each account maildir in both bare and recursive forms, the latter being
what Account::scopedQuery builds and not something a user would guess.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | src/querycompleter.cpp | 82 | ||||
| -rw-r--r-- | src/querycompleter.h | 31 | ||||
| -rw-r--r-- | tests/test_querycompleter.cpp | 96 |
3 files changed, 209 insertions, 0 deletions
diff --git a/src/querycompleter.cpp b/src/querycompleter.cpp index 77c62c1..6b4c69a 100644 --- a/src/querycompleter.cpp +++ b/src/querycompleter.cpp @@ -18,6 +18,8 @@ #include "querycompleter.h" +#include "config.h" + #include <QCoreApplication> namespace { @@ -186,3 +188,83 @@ CompletionContext completionContext(const QString &text, int cursor) } return ctx; } + +QueryCompleter::QueryCompleter(QLineEdit *edit, const Config &config, + QObject *parent) + : QObject(parent), m_edit(edit), m_config(config) +{ +} + +void QueryCompleter::setTags(const QStringList &tags) +{ + m_tags = tags; +} + +QList<CompletionEntry> QueryCompleter::entriesFor( + const CompletionContext &context) const +{ + if (context.kind == CompletionContext::None) + return {}; + + if (context.kind == CompletionContext::Prefix) + return prefixVocabulary(); + + // notmuch treats is:x as a synonym for tag:x, so both take the tag list. + if (context.prefix == QStringLiteral("tag") + || context.prefix == QStringLiteral("is")) { + QList<CompletionEntry> entries; + entries.reserve(m_tags.size()); + for (const QString &tag : m_tags) + entries.append({ tag, QString() }); + return entries; + } + + if (context.prefix == QStringLiteral("date")) { + QList<CompletionEntry> entries; + const QList<CompletionEntry> vocabulary = dateVocabulary(); + for (const CompletionEntry &entry : vocabulary) { + // Entries that are themselves ranges cannot go inside a range. + if (!context.allowRangeEntries + && entry.value.contains(QStringLiteral(".."))) + continue; + entries.append(entry); + } + return entries; + } + + if (context.prefix == QStringLiteral("mimetype")) { + QList<CompletionEntry> entries = mimetypeVocabulary(); + entries.append(m_config.extraMimetypes()); + return entries; + } + + if (context.prefix == QStringLiteral("path")) { + QList<CompletionEntry> entries; + const QList<Account> accounts = m_config.accounts(); + for (const Account &account : accounts) { + if (account.maildir.isEmpty()) + continue; + entries.append({ account.maildir, + VocabularyStrings::tr("account directory") }); + entries.append({ account.maildir + QStringLiteral("/**"), + VocabularyStrings::tr("and everything below it") }); + } + 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. + return {}; +} + +QStringList QueryCompleter::candidatesFor(const CompletionContext &context) const +{ + QStringList values; + const QList<CompletionEntry> entries = entriesFor(context); + values.reserve(entries.size()); + for (const CompletionEntry &entry : entries) + values.append(entry.value); + return values; +} diff --git a/src/querycompleter.h b/src/querycompleter.h index 7c00ce2..e9ffc64 100644 --- a/src/querycompleter.h +++ b/src/querycompleter.h @@ -19,10 +19,15 @@ #pragma once #include <QList> +#include <QObject> #include <QString> +#include <QStringList> #include "completionentry.h" +class Config; +class QLineEdit; + /// Where the cursor sits in a query, and therefore what should be offered. /// /// A plain value type produced by a pure function so the parsing rules can be @@ -72,3 +77,29 @@ QList<CompletionEntry> dateVocabulary(); /// The built-in mimetypes, before the user's extra_mimetypes are appended. QList<CompletionEntry> mimetypeVocabulary(); + +/// Completion for the notmuch query bar. +/// +/// completionContext() above decides which context the cursor sits in; this +/// class owns the candidates offered for that context. +class QueryCompleter : public QObject +{ + Q_OBJECT +public: + /// `edit` may be null in tests that exercise candidate selection only. + QueryCompleter(QLineEdit *edit, const Config &config, + QObject *parent = nullptr); + + /// Replaces the tag candidates. Called with the worker's allTagsReady. + void setTags(const QStringList &tags); + + /// The candidate values for a context, in the order they are offered. + QStringList candidatesFor(const CompletionContext &context) const; + +private: + QList<CompletionEntry> entriesFor(const CompletionContext &context) const; + + QLineEdit *m_edit = nullptr; + const Config &m_config; + QStringList m_tags; +}; diff --git a/tests/test_querycompleter.cpp b/tests/test_querycompleter.cpp index fb14adf..bd1e50f 100644 --- a/tests/test_querycompleter.cpp +++ b/tests/test_querycompleter.cpp @@ -17,7 +17,9 @@ */ #include <QtTest> +#include <QTemporaryDir> +#include "config.h" #include "querycompleter.h" class TestQueryCompleter : public QObject @@ -41,8 +43,25 @@ private slots: void rangeSuppressesRelativeEntries(); void prefixVocabularyCoversNotmuchKeywords(); void dateVocabularySeparatesRelativeEntries(); + void tagAndIsShareTheTagModel(); + void pathOffersAccountMaildirsBothForms(); + void folderOffersNothing(); + void mimetypeAppendsConfiguredEntries(); + void rangeContextDropsRelativeDates(); }; +// Copied from tests/test_config.cpp rather than shared, so the two test files +// stay independent. +static QString writeIni(const QTemporaryDir &dir, const QString &body) +{ + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + QFile f(path); + f.open(QIODevice::WriteOnly | QIODevice::Text); + f.write(body.toUtf8()); + f.close(); + return path; +} + void TestQueryCompleter::emptyTextCompletesPrefix() { const CompletionContext ctx = completionContext(QString(), 0); @@ -202,5 +221,82 @@ void TestQueryCompleter::dateVocabularySeparatesRelativeEntries() QVERIFY(sawRelative); } +void TestQueryCompleter::tagAndIsShareTheTagModel() +{ + Config config; + QueryCompleter completer(nullptr, config); + completer.setTags({ QStringLiteral("inbox"), QStringLiteral("shopping/amazon") }); + + const QStringList forTag = completer.candidatesFor( + completionContext(QStringLiteral("tag:"), 4)); + const QStringList forIs = completer.candidatesFor( + completionContext(QStringLiteral("is:"), 3)); + + QVERIFY(forTag.contains(QStringLiteral("shopping/amazon"))); + QCOMPARE(forTag, forIs); +} + +void TestQueryCompleter::pathOffersAccountMaildirsBothForms() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\n" + "maildir = work\n" + "address = you@example.org\n"))); + + QueryCompleter completer(nullptr, config); + const QStringList candidates = completer.candidatesFor( + completionContext(QStringLiteral("path:"), 5)); + + QVERIFY(candidates.contains(QStringLiteral("work"))); + // The recursive form is what scopedQuery() itself builds and is not + // guessable, so it is offered directly. + QVERIFY(candidates.contains(QStringLiteral("work/**"))); +} + +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:. + Config config; + QueryCompleter completer(nullptr, config); + const QStringList candidates = completer.candidatesFor( + completionContext(QStringLiteral("folder:"), 7)); + QVERIFY(candidates.isEmpty()); +} + +void TestQueryCompleter::mimetypeAppendsConfiguredEntries() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[completion]\n" + "extra_mimetypes = application/epub+zip|EPUB book\n"))); + + QueryCompleter completer(nullptr, config); + const QStringList candidates = completer.candidatesFor( + completionContext(QStringLiteral("mimetype:"), 9)); + + QVERIFY(candidates.contains(QStringLiteral("application/epub+zip"))); + QVERIFY(candidates.contains(QStringLiteral("application/pdf"))); +} + +void TestQueryCompleter::rangeContextDropsRelativeDates() +{ + Config config; + QueryCompleter completer(nullptr, config); + + const QStringList bare = completer.candidatesFor( + completionContext(QStringLiteral("date:"), 5)); + QVERIFY(bare.contains(QStringLiteral("1week.."))); + + const QString ranged = QStringLiteral("date:today.."); + const QStringList inRange = completer.candidatesFor( + completionContext(ranged, ranged.size())); + QVERIFY(inRange.contains(QStringLiteral("yesterday"))); + QVERIFY(!inRange.contains(QStringLiteral("1week.."))); +} + QTEST_MAIN(TestQueryCompleter) #include "test_querycompleter.moc" |
