diff options
Diffstat (limited to 'docs/superpowers/plans/2026-08-03-query-completion.md')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-query-completion.md | 1655 |
1 files changed, 1655 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-03-query-completion.md b/docs/superpowers/plans/2026-08-03-query-completion.md new file mode 100644 index 0000000..815eb72 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-query-completion.md @@ -0,0 +1,1655 @@ +# Query Bar Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the query bar complete notmuch prefixes, tag names, dates, paths and mimetypes with inline descriptions, so the bar teaches the query language while it is typed. + +**Architecture:** A new `QueryCompleter` class owns a `QCompleter` installed on the existing `QLineEdit`, swapping its model as the cursor moves between contexts. A pure `completionContext()` function decides the context from the text and cursor offset, so the parsing rules are testable without a widget or a database. `NotmuchWorker` gains an all-tags call following the existing generation-counter pattern. + +**Tech Stack:** C++17, Qt 6.11 (Widgets), libnotmuch, QTest. + +**Spec:** `docs/superpowers/specs/2026-08-03-query-completion-design.md` + +--- + +## Background for the implementer + +Read these before starting. They are project rules that this plan depends on. + +- **Every code block in a plan document in this repo is a draft, not verified + code.** Nine defects were found in the previous plan's code blocks. Run the + tests; do not assume a block compiles. +- **`NmTags` already exists** in `src/nmraii.h`. The spec says it must be added; + that is wrong. Do not re-add it. +- **`[general]` keys are read WITHOUT the `general/` prefix.** QSettings' INI + backend treats a section literally named `[general]` as its own fallback + section and strips it. `settings.value("completion_on_focus")` is correct; + `settings.value("general/completion_on_focus")` silently matches nothing. + Other sections DO take the prefix: `settings.value("sync/command")`. +- **No `notmuch_*` pointer crosses the thread boundary.** Data crosses as plain + value types over queued signals. +- **gmime headers before Qt headers** in any translation unit including both. + Not relevant to this plan's files, but it is why include order looks odd + elsewhere. +- Build: `cmake --build build`. Test one binary: `ctest --test-dir build -R <name>`. +- Commits are GPG-signed: `git commit -S`. Never disable signing. + +## File Structure + +**Created:** +- `src/completionentry.h` — the `CompletionEntry` value type alone. It has its + own header because both `Config` and `QueryCompleter` need it, and + `QueryCompleter` needs `Config`: putting it in `querycompleter.h` would make + the two headers include each other. +- `src/querycompleter.h` — the `CompletionContext` struct, the + `completionContext()` free function declaration, and the `QueryCompleter` + class. +- `src/querycompleter.cpp` — tokenizer, vocabulary tables, model construction, + the description delegate, and the popup footer. +- `tests/test_querycompleter.cpp` — tokenizer rules and model selection. + +**Modified:** +- `src/notmuchworker.h` / `.cpp` — `requestAllTags` slot, `allTagsReady` signal. +- `src/config.h` / `.cpp` — `completionOnFocus()` and `extraMimetypes()`. +- `src/keymap.cpp` — the `complete_query` default binding. +- `src/mainwindow.h` / `.cpp` — construct `QueryCompleter`, wire tag refresh. +- `src/CMakeLists.txt` — add `querycompleter.cpp` to `qtmaildir_lib`. +- `tests/CMakeLists.txt` — `add_qtmaildir_test(querycompleter)`. +- `tests/test_notmuchworker.cpp` — cover `requestAllTags`. +- `tests/test_config.cpp` — cover the two new accessors. + +The tokenizer, the vocabulary and the widget wiring live in one class because +they change together: adding a prefix means adding its model and its trigger in +the same edit. Splitting them across files would spread one change over three. + +--- + +## Task 1: The completion context struct and prefix tokenizing + +**Files:** +- Create: `src/querycompleter.h` +- Create: `src/querycompleter.cpp` +- Create: `tests/test_querycompleter.cpp` +- Modify: `src/CMakeLists.txt:16` +- Modify: `tests/CMakeLists.txt:20` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_querycompleter.cpp`: + +```cpp +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include <QtTest> + +#include "querycompleter.h" + +class TestQueryCompleter : public QObject +{ + Q_OBJECT + +private slots: + void emptyTextCompletesPrefix(); + void bareWordCompletesPrefix(); + void wordAfterOperatorCompletesPrefix(); + void prefixReplaceSpanCoversTheWord(); +}; + +void TestQueryCompleter::emptyTextCompletesPrefix() +{ + const CompletionContext ctx = completionContext(QString(), 0); + QCOMPARE(ctx.kind, CompletionContext::Prefix); + QCOMPARE(ctx.stem, QString()); +} + +void TestQueryCompleter::bareWordCompletesPrefix() +{ + const CompletionContext ctx = completionContext(QStringLiteral("su"), 2); + QCOMPARE(ctx.kind, CompletionContext::Prefix); + QCOMPARE(ctx.stem, QStringLiteral("su")); +} + +void TestQueryCompleter::wordAfterOperatorCompletesPrefix() +{ + // The token boundary is whitespace, not the start of the line. + const QString text = QStringLiteral("tag:inbox and su"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::Prefix); + QCOMPARE(ctx.stem, QStringLiteral("su")); +} + +void TestQueryCompleter::prefixReplaceSpanCoversTheWord() +{ + const QString text = QStringLiteral("tag:inbox and su"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.replaceFrom, 14); + QCOMPARE(ctx.replaceLength, 2); +} + +QTEST_MAIN(TestQueryCompleter) +#include "test_querycompleter.moc" +``` + +Add to `tests/CMakeLists.txt` after line 20 (`add_qtmaildir_test(messageview)`): + +```cmake +add_qtmaildir_test(querycompleter) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build 2>&1 | tail -20` +Expected: FAIL, `querycompleter.h: No such file or directory`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/querycompleter.h` (GPL header as above, then): + +```cpp +#pragma once + +#include <QString> + +/// 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 +/// tested without a widget or a database. +struct CompletionContext +{ + enum Kind { + None, ///< Complete nothing: inside a quoted literal, for instance. + Prefix, ///< Complete a query keyword: tag:, date:, and, or, not. + Value, ///< Complete a value for `prefix`. + }; + + Kind kind = None; + + /// For Value, the keyword left of ':', lowercased. Empty for Prefix. + QString prefix; + + /// The text being matched against the candidates. + QString stem; + + /// The exact span an accepted completion overwrites. Covers only the text + /// being completed, so accepting never disturbs neighbouring text. + int replaceFrom = 0; + int replaceLength = 0; +}; + +/// Decides what the cursor position implies about completion. +/// +/// `cursor` is an offset into `text`, as QLineEdit::cursorPosition() returns. +CompletionContext completionContext(const QString &text, int cursor); +``` + +Create `src/querycompleter.cpp` (GPL header, then): + +```cpp +#include "querycompleter.h" + +namespace { + +/// Start of the token the cursor sits in. The boundary is whitespace or '(', +/// so "tag:inbox and su" has its last token starting at 14, not at 0. +int tokenStart(const QString &text, int cursor) +{ + int start = cursor; + while (start > 0) { + const QChar c = text.at(start - 1); + if (c.isSpace() || c == QLatin1Char('(')) + break; + --start; + } + return start; +} + +} // namespace + +CompletionContext completionContext(const QString &text, int cursor) +{ + CompletionContext ctx; + + if (cursor < 0 || cursor > text.size()) + return ctx; + + const int start = tokenStart(text, cursor); + const QString token = text.mid(start, cursor - start); + + ctx.kind = CompletionContext::Prefix; + ctx.stem = token; + ctx.replaceFrom = start; + ctx.replaceLength = token.size(); + return ctx; +} +``` + +Add `querycompleter.cpp` to the `qtmaildir_lib` list in `src/CMakeLists.txt`, +after `mainwindow.cpp` on line 16. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/querycompleter.h src/querycompleter.cpp tests/test_querycompleter.cpp \ + src/CMakeLists.txt tests/CMakeLists.txt +git commit -S -m "feat(completion): add the query cursor-context tokenizer + +Prefix completion only so far: the token under the cursor, bounded by +whitespace or an opening parenthesis rather than by the start of the line." +``` + +--- + +## Task 2: Value context after a prefix + +**Files:** +- Modify: `src/querycompleter.cpp` +- Test: `tests/test_querycompleter.cpp` + +- [ ] **Step 1: Write the failing test** + +Add to the `private slots:` block and the file body: + +```cpp + void colonSwitchesToValue(); + void valueReplaceSpanExcludesThePrefix(); + void prefixIsLowercased(); + void emptyValueAfterColonStillCompletes(); +``` + +```cpp +void TestQueryCompleter::colonSwitchesToValue() +{ + const QString text = QStringLiteral("tag:sho"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.prefix, QStringLiteral("tag")); + QCOMPARE(ctx.stem, QStringLiteral("sho")); +} + +void TestQueryCompleter::valueReplaceSpanExcludesThePrefix() +{ + // Accepting must overwrite "sho" only, never "tag:". + const QString text = QStringLiteral("tag:sho"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.replaceFrom, 4); + QCOMPARE(ctx.replaceLength, 3); +} + +void TestQueryCompleter::prefixIsLowercased() +{ + const QString text = QStringLiteral("TAG:sho"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.prefix, QStringLiteral("tag")); +} + +void TestQueryCompleter::emptyValueAfterColonStillCompletes() +{ + // "tag:" with the cursor at the end offers every tag. + const QString text = QStringLiteral("tag:"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.prefix, QStringLiteral("tag")); + QCOMPARE(ctx.stem, QString()); + QCOMPARE(ctx.replaceFrom, 4); + QCOMPARE(ctx.replaceLength, 0); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: FAIL, `colonSwitchesToValue` compares `Prefix` to `Value`. + +- [ ] **Step 3: Write minimal implementation** + +Replace the body of `completionContext()` after the `token` line: + +```cpp + const int colon = token.indexOf(QLatin1Char(':')); + if (colon < 0) { + ctx.kind = CompletionContext::Prefix; + ctx.stem = token; + ctx.replaceFrom = start; + ctx.replaceLength = token.size(); + return ctx; + } + + ctx.kind = CompletionContext::Value; + ctx.prefix = token.left(colon).toLower(); + ctx.stem = token.mid(colon + 1); + ctx.replaceFrom = start + colon + 1; + ctx.replaceLength = ctx.stem.size(); + return ctx; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/querycompleter.cpp tests/test_querycompleter.cpp +git commit -S -m "feat(completion): recognise value context after a prefix + +The replace span covers the value only, so accepting a completion never +overwrites the prefix that selected it." +``` + +--- + +## Task 3: Quoted literals complete nothing + +**Files:** +- Modify: `src/querycompleter.cpp` +- Test: `tests/test_querycompleter.cpp` + +- [ ] **Step 1: Write the failing test** + +```cpp + void insideQuotesCompletesNothing(); + void afterClosedQuotesCompletesAgain(); +``` + +```cpp +void TestQueryCompleter::insideQuotesCompletesNothing() +{ + // subject:"foo bar| is a literal, not a keyword position. + const QString text = QStringLiteral("subject:\"foo bar"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::None); +} + +void TestQueryCompleter::afterClosedQuotesCompletesAgain() +{ + const QString text = QStringLiteral("subject:\"foo bar\" and ta"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::Prefix); + QCOMPARE(ctx.stem, QStringLiteral("ta")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: FAIL. `insideQuotesCompletesNothing` gets `Value`, because the space +inside the quotes is treated as a token boundary. + +- [ ] **Step 3: Write minimal implementation** + +Add above `tokenStart()` in the anonymous namespace: + +```cpp +/// Whether the cursor sits inside a double-quoted literal. Counts quotes from +/// the start: an odd count before the cursor means the quote is still open. +bool insideQuotes(const QString &text, int cursor) +{ + int quotes = 0; + for (int i = 0; i < cursor; ++i) { + if (text.at(i) == QLatin1Char('"')) + ++quotes; + } + return (quotes % 2) != 0; +} +``` + +Add to `completionContext()` immediately after the bounds check: + +```cpp + if (insideQuotes(text, cursor)) + return ctx; // kind stays None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/querycompleter.cpp tests/test_querycompleter.cpp +git commit -S -m "feat(completion): complete nothing inside a quoted literal + +subject:\"foo bar has the cursor in free text, where offering keywords +would be wrong." +``` + +--- + +## Task 4: Date ranges complete on both bounds + +**Files:** +- Modify: `src/querycompleter.h` +- Modify: `src/querycompleter.cpp` +- Test: `tests/test_querycompleter.cpp` + +This is the subtlest rule in the plan. `date:today..yes` must complete +`yesterday` as the upper bound without disturbing `today`. + +- [ ] **Step 1: Write the failing test** + +```cpp + void rangeUpperBoundCompletes(); + void rangeLowerBoundCompletes(); + void bareValueAllowsRelativeEntries(); + void rangeSuppressesRelativeEntries(); +``` + +```cpp +void TestQueryCompleter::rangeUpperBoundCompletes() +{ + const QString text = QStringLiteral("date:today..yes"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.prefix, QStringLiteral("date")); + QCOMPARE(ctx.stem, QStringLiteral("yes")); + // Overwrites "yes" only: "date:today.." must survive. + QCOMPARE(ctx.replaceFrom, 12); + QCOMPARE(ctx.replaceLength, 3); +} + +void TestQueryCompleter::rangeLowerBoundCompletes() +{ + // Cursor sits at offset 8, before the "..". + const QString text = QStringLiteral("date:las..today"); + const CompletionContext ctx = completionContext(text, 8); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.stem, QStringLiteral("las")); + QCOMPARE(ctx.replaceFrom, 5); + QCOMPARE(ctx.replaceLength, 3); +} + +void TestQueryCompleter::bareValueAllowsRelativeEntries() +{ + const QString text = QStringLiteral("date:1w"); + const CompletionContext ctx = completionContext(text, text.size()); + QVERIFY(ctx.allowRangeEntries); +} + +void TestQueryCompleter::rangeSuppressesRelativeEntries() +{ + // "1week.." offered here would produce date:1week....today. + const QString text = QStringLiteral("date:1w..today"); + const CompletionContext ctx = completionContext(text, 7); + QVERIFY(!ctx.allowRangeEntries); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build 2>&1 | tail -20` +Expected: FAIL to compile, `'allowRangeEntries' has no member`. + +- [ ] **Step 3: Write minimal implementation** + +Add to `CompletionContext` in `src/querycompleter.h`, after `replaceLength`: + +```cpp + /// Whether candidates that are themselves ranges may be offered. + /// + /// The relative date entries ("1week..") are complete open-ended ranges. + /// Offering one inside an existing range yields date:1week....today, which + /// is malformed, so they are withheld once a range is underway. + bool allowRangeEntries = true; +``` + +In `completionContext()`, replace the Value block: + +```cpp + ctx.kind = CompletionContext::Value; + ctx.prefix = token.left(colon).toLower(); + + const QString value = token.mid(colon + 1); + const int valueStart = start + colon + 1; + + // A range is two independent values. Complete whichever side the cursor + // is in, leaving the other untouched. + const int separator = value.indexOf(QStringLiteral("..")); + if (separator < 0) { + ctx.stem = value; + ctx.replaceFrom = valueStart; + ctx.replaceLength = value.size(); + return ctx; + } + + ctx.allowRangeEntries = false; + + const int cursorInValue = cursor - valueStart; + if (cursorInValue <= separator) { + ctx.stem = value.left(cursorInValue); + ctx.replaceFrom = valueStart; + ctx.replaceLength = separator; + } else { + const int upperStart = separator + 2; + ctx.stem = value.mid(upperStart, cursorInValue - upperStart); + ctx.replaceFrom = valueStart + upperStart; + ctx.replaceLength = value.size() - upperStart; + } + return ctx; +``` + +Note the `stem` uses the cursor offset while `replaceLength` uses the whole +side. That is deliberate: matching uses what has been typed so far, but +accepting replaces the entire bound, so completing mid-word does not leave a +tail behind. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: PASS, 14 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/querycompleter.h src/querycompleter.cpp tests/test_querycompleter.cpp +git commit -S -m "feat(completion): complete both bounds of a date range + +Each side of '..' is an independent value against the same model. Entries +that are themselves ranges are withheld once a range exists, since +date:1week....today is malformed." +``` + +--- + +## Task 5: The vocabulary tables + +**Files:** +- Create: `src/completionentry.h` +- Modify: `src/querycompleter.h` +- Modify: `src/querycompleter.cpp` +- Test: `tests/test_querycompleter.cpp` + +- [ ] **Step 1: Write the failing test** + +```cpp + void prefixVocabularyCoversNotmuchKeywords(); + void dateVocabularySeparatesRelativeEntries(); +``` + +```cpp +void TestQueryCompleter::prefixVocabularyCoversNotmuchKeywords() +{ + const QList<CompletionEntry> entries = prefixVocabulary(); + + QStringList values; + for (const CompletionEntry &entry : entries) + values.append(entry.value); + + QVERIFY(values.contains(QStringLiteral("tag:"))); + QVERIFY(values.contains(QStringLiteral("date:"))); + QVERIFY(values.contains(QStringLiteral("and"))); + + // Every entry carries a description; a blank column teaches nothing. + for (const CompletionEntry &entry : entries) + QVERIFY(!entry.description.isEmpty()); +} + +void TestQueryCompleter::dateVocabularySeparatesRelativeEntries() +{ + const QList<CompletionEntry> entries = dateVocabulary(); + + bool sawSymbolic = false; + bool sawRelative = false; + for (const CompletionEntry &entry : entries) { + if (entry.value == QStringLiteral("today")) + sawSymbolic = true; + if (entry.value.contains(QStringLiteral(".."))) + sawRelative = true; + } + QVERIFY(sawSymbolic); + QVERIFY(sawRelative); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build 2>&1 | tail -20` +Expected: FAIL, `'CompletionEntry' was not declared`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/completionentry.h` (GPL header, then): + +```cpp +#pragma once + +#include <QString> + +/// One completion candidate and the prose describing it. +/// +/// This lives in its own header because both Config and QueryCompleter need +/// it, and QueryCompleter needs Config. Declaring it in querycompleter.h +/// would make the two headers include each other. +struct CompletionEntry +{ + QString value; ///< Inserted verbatim. Query syntax, never translated. + QString description; ///< Shown beside it. Prose, always translated. +}; +``` + +Add `#include <QList>` and `#include "completionentry.h"` to +`src/querycompleter.h`, then after `completionContext()`: + +```cpp +/// The notmuch query keywords, with descriptions. Hardcoded: notmuch exposes +/// no way to enumerate its own prefixes, so this list must track releases by +/// hand. See the spec's Consequences section. +QList<CompletionEntry> prefixVocabulary(); + +/// Symbolic and relative date values. Absolute dates are not enumerable and +/// are covered by the free-form hint in the popup footer instead. +QList<CompletionEntry> dateVocabulary(); + +/// The built-in mimetypes, before the user's extra_mimetypes are appended. +QList<CompletionEntry> mimetypeVocabulary(); +``` + +Add to `src/querycompleter.cpp`, after the anonymous namespace. Note `QObject::tr()` +requires `#include <QObject>`: + +```cpp +QList<CompletionEntry> prefixVocabulary() +{ + return { + { QStringLiteral("tag:"), QObject::tr("messages with a tag") }, + { QStringLiteral("is:"), QObject::tr("same as tag:") }, + { QStringLiteral("from:"), QObject::tr("sender address or name") }, + { QStringLiteral("to:"), QObject::tr("recipient, including Cc") }, + { QStringLiteral("subject:"), QObject::tr("words in the subject") }, + { QStringLiteral("date:"), QObject::tr("a date or a range") }, + { QStringLiteral("attachment:"), QObject::tr("attachment filename") }, + { QStringLiteral("mimetype:"), QObject::tr("attachment content type") }, + { QStringLiteral("folder:"), QObject::tr("Maildir folder name") }, + { QStringLiteral("path:"), QObject::tr("directory below the Maildir root") }, + { QStringLiteral("thread:"), QObject::tr("a thread id") }, + { QStringLiteral("id:"), QObject::tr("a single message id") }, + { QStringLiteral("and"), QObject::tr("both conditions") }, + { QStringLiteral("or"), QObject::tr("either condition") }, + { QStringLiteral("not"), QObject::tr("exclude what follows") }, + }; +} + +QList<CompletionEntry> dateVocabulary() +{ + return { + { QStringLiteral("today"), QObject::tr("since midnight") }, + { QStringLiteral("yesterday"), QObject::tr("the previous day") }, + { QStringLiteral("this_week"), QObject::tr("the current week") }, + { QStringLiteral("last_week"), QObject::tr("the week before this one") }, + { QStringLiteral("this_month"), QObject::tr("the current month") }, + { QStringLiteral("last_month"), QObject::tr("the month before this one") }, + { QStringLiteral("this_year"), QObject::tr("the current year") }, + { QStringLiteral("1week.."), QObject::tr("the last seven days") }, + { QStringLiteral("1month.."), QObject::tr("the last month") }, + }; +} + +QList<CompletionEntry> mimetypeVocabulary() +{ + return { + { QStringLiteral("application/pdf"), QObject::tr("PDF document") }, + { QStringLiteral("image/jpeg"), QObject::tr("JPEG image") }, + { QStringLiteral("image/png"), QObject::tr("PNG image") }, + { QStringLiteral("text/html"), QObject::tr("HTML document") }, + { QStringLiteral("application/zip"), QObject::tr("ZIP archive") }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: PASS, 16 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/completionentry.h src/querycompleter.h src/querycompleter.cpp \ + tests/test_querycompleter.cpp +git commit -S -m "feat(completion): add the prefix, date and mimetype vocabularies + +Keywords are syntax and stay untranslated; the descriptions beside them are +prose and go through tr()." +``` + +--- + +## Task 6: Config accessors + +**Files:** +- Modify: `src/config.h:95` (after `messageZoom()`) +- Modify: `src/config.cpp:77-88` (after the `message_zoom` block) +- Test: `tests/test_config.cpp` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_config.cpp`. Follow the existing fixture style in that file +for writing a temporary config; the assertions are: + +```cpp + void completionOnFocusDefaultsToFalse(); + void extraMimetypesAppendToBuiltins(); + void extraMimetypeDescriptionMayContainComma(); + void malformedExtraMimetypeIsSkipped(); +``` + +```cpp +void TestConfig::completionOnFocusDefaultsToFalse() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral("[general]\n"))); + QCOMPARE(config.completionOnFocus(), false); +} + +void TestConfig::extraMimetypesAppendToBuiltins() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[completion]\n" + "extra_mimetypes = application/epub+zip|EPUB book, message/rfc822\n"))); + + const QList<CompletionEntry> extra = config.extraMimetypes(); + QCOMPARE(extra.size(), 2); + QCOMPARE(extra.at(0).value, QStringLiteral("application/epub+zip")); + QCOMPARE(extra.at(0).description, QStringLiteral("EPUB book")); + QCOMPARE(extra.at(1).value, QStringLiteral("message/rfc822")); + QVERIFY(extra.at(1).description.isEmpty()); +} + +void TestConfig::extraMimetypeDescriptionMayContainComma() +{ + // '|' separates value from description precisely so a description can + // contain a comma without QSettings tearing the entry in two. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[completion]\n" + "extra_mimetypes = \"application/epub+zip|EPUB, an ebook format\"\n"))); + + const QList<CompletionEntry> extra = config.extraMimetypes(); + QCOMPARE(extra.size(), 1); + QCOMPARE(extra.at(0).description, QStringLiteral("EPUB, an ebook format")); +} + +void TestConfig::malformedExtraMimetypeIsSkipped() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[completion]\n" + "extra_mimetypes = |no value here, message/rfc822\n"))); + + const QList<CompletionEntry> extra = config.extraMimetypes(); + QCOMPARE(extra.size(), 1); + QCOMPARE(extra.at(0).value, QStringLiteral("message/rfc822")); + QVERIFY(!config.problems().isEmpty()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build 2>&1 | tail -20` +Expected: FAIL, `'class Config' has no member named 'completionOnFocus'`. + +- [ ] **Step 3: Write minimal implementation** + +`src/config.h`: add `#include "completionentry.h"` and `#include <QList>`, then +after `messageZoom()`: + +```cpp + /// Whether focusing an empty query bar opens the completion popup. Off by + /// default: it is helpful when learning the query language and intrusive + /// once it is known. The manual trigger works regardless. + bool completionOnFocus() const { return m_completionOnFocus; } + + /// User-supplied mimetype completions, APPENDED to the built-in list. + /// Appending rather than replacing means a typo cannot leave completion + /// worse off than the defaults. + QList<CompletionEntry> extraMimetypes() const { return m_extraMimetypes; } +``` + +and the members: + +```cpp + bool m_completionOnFocus = false; + QList<CompletionEntry> m_extraMimetypes; +``` + +`src/config.cpp`, after the `message_zoom` block. **`[general]` keys take no +prefix; `[completion]` keys do:** + +```cpp + m_completionOnFocus = + settings.value(QStringLiteral("completion_on_focus"), false).toBool(); + + const QStringList rawMimetypes = + settings.value(QStringLiteral("completion/extra_mimetypes")).toStringList(); + for (const QString &raw : rawMimetypes) { + const QString entry = raw.trimmed(); + if (entry.isEmpty()) + continue; + + const int bar = entry.indexOf(QLatin1Char('|')); + const QString value = + (bar < 0 ? entry : entry.left(bar)).trimmed(); + const QString description = + (bar < 0 ? QString() : entry.mid(bar + 1)).trimmed(); + + if (value.isEmpty()) { + addProblem(QStringLiteral( + "completion/extra_mimetypes: entry with no mimetype: %1").arg(entry)); + continue; + } + m_extraMimetypes.append({ value, description }); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R config --output-on-failure` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/config.h src/config.cpp tests/test_config.cpp +git commit -S -m "feat(config): add completion_on_focus and extra_mimetypes + +Mimetypes are the one completion list with no enumerator, so the user can +extend it. Entries append to the built-ins and a malformed one is skipped +with a problem recorded rather than dropping the whole list." +``` + +--- + +## Task 7: The all-tags worker call + +**Files:** +- Modify: `src/notmuchworker.h:66` (slots) and `:76` (signals) +- Modify: `src/notmuchworker.cpp` +- Test: `tests/test_notmuchworker.cpp` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_notmuchworker.cpp`, following the existing fixture that +builds a throwaway Maildir and runs `notmuch new`: + +```cpp + void requestAllTagsReturnsSortedTags(); +``` + +```cpp +void TestNotmuchWorker::requestAllTagsReturnsSortedTags() +{ + NotmuchWorker worker(m_configPath); + QSignalSpy spy(&worker, &NotmuchWorker::allTagsReady); + + worker.requestAllTags(7); + + QCOMPARE(spy.count(), 1); + const QStringList tags = spy.at(0).at(0).toStringList(); + const quint64 generation = spy.at(0).at(1).value<quint64>(); + + QCOMPARE(generation, quint64(7)); + QVERIFY(tags.contains(QStringLiteral("inbox"))); + + QStringList sorted = tags; + sorted.sort(); + QCOMPARE(tags, sorted); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build 2>&1 | tail -20` +Expected: FAIL, `'class NotmuchWorker' has no member named 'requestAllTags'`. + +- [ ] **Step 3: Write minimal implementation** + +`src/notmuchworker.h`, in `public slots:`: + +```cpp + /// Every tag in the database, sorted. Feeds query bar completion, which + /// cannot offer tag names it has no way to enumerate. Called at startup, + /// after a sync, and after a tag mutation introduces an unknown tag. + void requestAllTags(quint64 generation); +``` + +in `signals:`: + +```cpp + void allTagsReady(const QStringList &tags, quint64 generation); +``` + +`src/notmuchworker.cpp`. `NmTags` already exists in `nmraii.h`: + +```cpp +void NotmuchWorker::requestAllTags(quint64 generation) +{ + if (!openReadOnly()) + return; + + NmTags tags(notmuch_database_get_all_tags(m_db)); + if (!tags) { + emit errorOccurred(QStringLiteral("Cannot list tags")); + return; + } + + QStringList result; + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + + result.sort(); + emit allTagsReady(result, generation); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R notmuchworker --output-on-failure` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/notmuchworker.h src/notmuchworker.cpp tests/test_notmuchworker.cpp +git commit -S -m "feat(worker): list every tag in the database + +Query bar completion cannot offer tag names without a way to enumerate +them, and libnotmuch had no call wired up for it. Follows the existing +generation-counter pattern; the result crosses the thread boundary as a +QStringList." +``` + +--- + +## Task 8: Model selection + +**Files:** +- Modify: `src/querycompleter.h` +- Modify: `src/querycompleter.cpp` +- Test: `tests/test_querycompleter.cpp` + +- [ ] **Step 1: Write the failing test** + +```cpp + void tagAndIsShareTheTagModel(); + void pathOffersAccountMaildirsBothForms(); + void folderOffersNothing(); + void mimetypeAppendsConfiguredEntries(); + void rangeContextDropsRelativeDates(); +``` + +```cpp +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.."))); +} +``` + +`writeIni()` is the helper already used in `tests/test_config.cpp:44`. Copy it +verbatim into this file rather than sharing it, so the two tests stay +independent: + +```cpp +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; +} +``` + +It needs `#include <QTemporaryDir>` and `#include "config.h"`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build 2>&1 | tail -20` +Expected: FAIL, `'QueryCompleter' was not declared in this scope`. + +- [ ] **Step 3: Write minimal implementation** + +Add to `src/querycompleter.h`: + +```cpp +#include <QObject> +#include <QStringList> + +class Config; +class QCompleter; +class QLineEdit; +class QStandardItemModel; + +/// Completion for the notmuch query bar. +/// +/// Installs a QCompleter on a QLineEdit and swaps its model as the cursor +/// moves between contexts, so `tag:` offers tag names while `date:` offers +/// dates. The tokenizer above decides which; this class owns the candidates. +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. + /// Exposed for testing; the widget path goes through the models directly. + 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; +}; +``` + +Add to `src/querycompleter.cpp`: + +```cpp +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; + for (const QString &tag : m_tags) + entries.append({ tag, QString() }); + return entries; + } + + if (context.prefix == QStringLiteral("date")) { + QList<CompletionEntry> entries; + for (const CompletionEntry &entry : dateVocabulary()) { + // 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; + for (const Account &account : m_config.accounts()) { + if (account.maildir.isEmpty()) + continue; + entries.append({ account.maildir, QObject::tr("account directory") }); + entries.append({ account.maildir + QStringLiteral("/**"), + QObject::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; the + // rest are free text. + return {}; +} + +QStringList QueryCompleter::candidatesFor(const CompletionContext &context) const +{ + QStringList values; + for (const CompletionEntry &entry : entriesFor(context)) + values.append(entry.value); + return values; +} +``` + +`src/querycompleter.cpp` needs `#include "config.h"`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R querycompleter --output-on-failure` +Expected: PASS, 21 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/querycompleter.h src/querycompleter.cpp tests/test_querycompleter.cpp +git commit -S -m "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." +``` + +--- + +## Task 9: The popup, with descriptions and the free-form date hint + +**Files:** +- Modify: `src/querycompleter.h` +- Modify: `src/querycompleter.cpp` + +No new unit test: this is widget rendering, verified by running the app. The +selection logic underneath is already covered by Task 8. + +- [ ] **Step 1: Build the model and the completer** + +Add to `QueryCompleter`'s private section in the header: + +```cpp + void rebuildModel(const CompletionContext &context); + void updateContext(); + + QCompleter *m_completer = nullptr; + QStandardItemModel *m_model = nullptr; + QLabel *m_hint = nullptr; + CompletionContext m_context; +``` + +Forward-declare `class QLabel;` alongside the others. + +- [ ] **Step 2: Implement** + +In the constructor, after the member init: + +```cpp + 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_edit->setCompleter(m_completer); + + auto *view = new QListView; + view->setItemDelegate(new CompletionDelegate(view)); + m_completer->setPopup(view); + + 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) { + // 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. + const QString value = index.data(Qt::DisplayRole).toString(); + QString text = m_edit->text(); + text.replace(m_context.replaceFrom, m_context.replaceLength, value); + m_edit->setText(text); + m_edit->setCursorPosition(m_context.replaceFrom + value.size()); + }); +``` + +`updateContext()` and `rebuildModel()`: + +```cpp +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) +{ + m_model->clear(); + + for (const CompletionEntry &entry : entriesFor(context)) { + auto *value = new QStandardItem(entry.value); + auto *description = new QStandardItem(entry.description); + m_model->appendRow({ value, description }); + } + + // The free-form hint is a footer, not a row: a row would be filtered away + // by the first keystroke that did not match it, and could be selected and + // inserted, producing a broken query. + if (m_hint) { + m_hint->setVisible(context.kind == CompletionContext::Value + && context.prefix == QStringLiteral("date")); + } +} +``` + +The delegate, in the anonymous namespace: + +```cpp +/// Draws the description greyed and right-aligned beside the value. +class CompletionDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override + { + QStyledItemDelegate::paint(painter, option, index); + + const QModelIndex sibling = index.sibling(index.row(), 1); + const QString description = sibling.data(Qt::DisplayRole).toString(); + if (description.isEmpty()) + return; + + painter->save(); + QColor grey = option.palette.color(QPalette::Disabled, QPalette::Text); + painter->setPen(grey); + painter->drawText(option.rect.adjusted(0, 0, -6, 0), + Qt::AlignRight | Qt::AlignVCenter, description); + painter->restore(); + } +}; +``` + +The hint label, created after the popup view: + +```cpp + m_hint = new QLabel( + tr("also accepts free-form dates, e.g. 2026-01-15 or 15/01/2026..today"), + view); + QFont hintFont = m_hint->font(); + hintFont.setItalic(true); + hintFont.setPointSizeF(hintFont.pointSizeF() * 0.9); + m_hint->setFont(hintFont); + m_hint->hide(); +``` + +**Verify empirically rather than from memory:** the hint must sit below the +list and must not be selectable. If parenting it to the view does not place it +correctly, wrap the view and the label in a `QWidget` with a `QVBoxLayout` and +set that as the popup instead. Do not assume either approach works without +running it. + +- [ ] **Step 3: Build and run the app** + +Run: `cmake --build build && ./build/src/qtmaildir` +Verify by hand: typing `tag:` shows tag names with no descriptions; typing +`date:` shows dates with descriptions and the italic footer; typing +`subject:"foo ` shows nothing. + +- [ ] **Step 4: Commit** + +```bash +git add src/querycompleter.h src/querycompleter.cpp +git commit -S -m "feat(completion): render the popup with descriptions + +The free-form date hint is a footer label rather than a model row: a row +would be filtered away by the first non-matching keystroke and could be +selected and inserted, producing a query that errors." +``` + +--- + +## Task 10: The manual trigger and the focus toggle + +**Files:** +- Modify: `src/keymap.cpp:38` (known actions) and `:75` (default bindings) +- Modify: `src/querycompleter.h` / `.cpp` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_keymap.cpp`: + +```cpp +void TestKeyMap::completeQueryIsBoundByDefault() +{ + QVERIFY(KeyMap::knownActions().contains(QStringLiteral("complete_query"))); + QCOMPARE(KeyMap::defaultSequenceFor(QStringLiteral("complete_query")), + QKeySequence(QStringLiteral("Ctrl+Space"))); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cmake --build build && ctest --test-dir build -R keymap --output-on-failure` +Expected: FAIL, `knownActions()` does not contain it. + +- [ ] **Step 3: Write minimal implementation** + +`src/keymap.cpp`: add `QStringLiteral("complete_query"),` to the +`knownActions()` list, and to `defaultBindings()`: + +```cpp + { QStringLiteral("Ctrl+Space"), QStringLiteral("complete_query") }, +``` + +Going through `KeyMap` rather than hardcoding means it appears in the shortcut +reference automatically and is rebindable in `[keys]`. + +Add the public slot to `QueryCompleter`: + +```cpp +public slots: + /// Opens the popup regardless of what has been typed. Bound to + /// complete_query, and the only trigger when completion_on_focus is off. + void triggerCompletion(); +``` + +```cpp +void QueryCompleter::triggerCompletion() +{ + if (!m_edit || !m_completer) + return; + updateContext(); + m_completer->setCompletionPrefix(m_context.stem); + m_completer->complete(); +} +``` + +For the focus toggle, install an event filter in the constructor when +`m_config.completionOnFocus()` is true: + +```cpp + if (m_config.completionOnFocus()) + m_edit->installEventFilter(this); +``` + +```cpp +bool QueryCompleter::eventFilter(QObject *watched, QEvent *event) +{ + // Only the empty-bar case: once there is text, ordinary typing has + // already driven completion. + if (watched == m_edit && event->type() == QEvent::FocusIn + && m_edit->text().isEmpty()) { + triggerCompletion(); + } + return QObject::eventFilter(watched, event); +} +``` + +Declare `bool eventFilter(QObject *watched, QEvent *event) override;` in the +header. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cmake --build build && ctest --test-dir build -R keymap --output-on-failure` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/keymap.cpp src/querycompleter.h src/querycompleter.cpp \ + tests/test_keymap.cpp +git commit -S -m "feat(completion): add Ctrl+Space and the focus toggle + +The manual trigger goes through KeyMap so it reaches the shortcut reference +and stays rebindable. completion_on_focus governs only the empty-bar case." +``` + +--- + +## Task 11: Wire it into MainWindow + +**Files:** +- Modify: `src/mainwindow.h` (member, slot) +- Modify: `src/mainwindow.cpp:220-223` (construction), `:825` (sync finished), + and the `applyTags` result path. + +- [ ] **Step 1: Construct the completer** + +After the `m_queryEdit` block at `mainwindow.cpp:220`: + +```cpp + m_queryCompleter = new QueryCompleter(m_queryEdit, m_config, this); +``` + +Add to `mainwindow.h`: `class QueryCompleter;` and +`QueryCompleter *m_queryCompleter = nullptr;`. + +- [ ] **Step 2: Wire the worker signal** + +In `wireWorker()`, alongside the existing connects: + +```cpp + connect(m_worker, &NotmuchWorker::allTagsReady, + this, &MainWindow::onAllTagsReady); +``` + +```cpp +void MainWindow::onAllTagsReady(const QStringList &tags, quint64 generation) +{ + Q_UNUSED(generation); // Not a query result; no stale generation to discard. + m_knownTags = tags; + m_queryCompleter->setTags(tags); +} +``` + +Add `void onAllTagsReady(const QStringList &tags, quint64 generation);` and +`QStringList m_knownTags;` to the header. + +- [ ] **Step 3: Request tags at startup and after sync** + +After the worker thread starts, and inside `onSyncFinished()`: + +```cpp + QMetaObject::invokeMethod(m_worker, "requestAllTags", Qt::QueuedConnection, + Q_ARG(quint64, 0)); +``` + +- [ ] **Step 4: Refresh after a tag mutation introduces an unknown tag** + +Wherever `tagsApplied` is handled, before the existing body: + +```cpp + // A tag the user has just created is the one they are most likely to type + // again, so do not wait for the next sync to offer it. A set membership + // test, not a query. + for (const QString &tag : change.add) { + if (!m_knownTags.contains(tag)) { + QMetaObject::invokeMethod(m_worker, "requestAllTags", + Qt::QueuedConnection, Q_ARG(quint64, 0)); + break; + } + } +``` + +- [ ] **Step 5: Connect the shortcut** + +Where the other `KeyMap` actions are dispatched, add the `complete_query` case +calling `m_queryCompleter->triggerCompletion()`. + +- [ ] **Step 6: Build, test, run** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: PASS, 13 test binaries. + +Then `./build/src/qtmaildir` and verify by hand: `tag:` offers real tags from +the database; Ctrl+Space opens the popup on an empty bar; creating a new tag +makes it available without a sync. + +- [ ] **Step 7: Commit** + +```bash +git add src/mainwindow.h src/mainwindow.cpp +git commit -S -m "feat(completion): wire query completion into the main window + +Tags refresh at startup, after a sync, and after a mutation introduces a tag +not already known, since that is the tag most likely to be typed again." +``` + +--- + +## Task 12: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md:58` + +- [ ] **Step 1: Document the config keys** + +Add `completion_on_focus` to the `[general]` documentation and a `[completion]` +section documenting `extra_mimetypes`, including the `|` separator and why it +differs from the `,` entry separator. + +- [ ] **Step 2: Document the shortcut** + +Add `Ctrl+Space` to the README's key binding table. + +- [ ] **Step 3: Mark the backlog item done** + +Change item 17's status from `open` to `done` in the status table. + +- [ ] **Step 4: Commit** + +```bash +git add README.md docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +git commit -S -m "docs: document query completion config and shortcut" +``` + +--- + +## Verification + +Before calling this complete: + +```bash +cmake --build build +ctest --test-dir build --output-on-failure +``` + +All 13 test binaries must pass. Then run the app and confirm by hand, since +none of this is reachable from a unit test: + +1. `tag:` offers real tag names, hierarchies included. +2. `is:` offers the same list. +3. `date:` offers dates with descriptions and the italic free-form footer. +4. `date:today..` offers `yesterday` but not `1week..`. +5. `path:` offers each account maildir in both forms. +6. `subject:"foo ` offers nothing. +7. Ctrl+Space opens the popup on an empty bar. +8. Accepting a completion mid-query leaves the surrounding text intact. + +Item 8 is the one most likely to be wrong, and it is the reason the tokenizer +reports an explicit replace span rather than relying on `QCompleter`'s own +insertion. |
