summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 20:56:59 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:19 +0200
commita6f73f77890d801ecd7209e97ba948c06fdd35c2 (patch)
treebc3254e448f02c08d218b91246ee0233dab68334
parent45e5887f5b33e5f30d3c16187bb5d6e707983804 (diff)
downloadqtmaildir-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.cpp82
-rw-r--r--src/querycompleter.h31
-rw-r--r--tests/test_querycompleter.cpp96
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"