1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
|
# Query bar completion
Design for backlog item 17 of `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md`.
## Problem
The query bar is a bare `QLineEdit` (`mainwindow.cpp:220`) with the placeholder
`notmuch query, e.g. tag:inbox`. Writing a query means knowing notmuch's
vocabulary from memory, and recalling tag hierarchies exactly:
`shopping/amazon` is not guessable.
Two distinct users are underserved:
- Someone who has never used notmuch or neomutt cannot discover that prefixes
exist at all. An empty box teaches nothing.
- Someone fluent in notmuch still cannot recall every tag they have created.
The goal is a bar that documents the query language while being typed, without
slowing down a user who already knows it.
## Scope
In scope: completion of query prefixes, tag values, date values, path values
and mimetype values, each with a human description shown alongside.
Out of scope, deliberately:
- **Address completion** for `from:` and `to:`. libnotmuch exposes no
all-addresses call; it would require shelling out to `notmuch address` or
scanning messages. Both prefixes still appear in the prefix list so the
vocabulary reads complete, but they complete no values. Deferred to its own
backlog item.
- **Saved query names.** A saved query name is not valid notmuch syntax, so
completing one into the bar would produce a query that errors. Making it
useful would mean expanding the name to its query text, which is
substitution rather than completion, and a different feature. The saved
query buttons already sit directly below the bar.
- **Absolute date completion.** `2026-01-15` is not enumerable. See the
free-form hint below.
## Architecture
A new class owns all of it. `mainwindow.cpp` is already 35K and gains only
construction plus two connections.
```
MainWindow
└ QueryCompleter src/querycompleter.{h,cpp}
├ completionContext() free function, pure, unit-tested
├ static vocabulary prefixes, dates, mimetypes (+ descriptions)
├ config-derived path values from Account::maildir
└ dynamic tag list, supplied by MainWindow from the worker
NotmuchWorker
├ slot requestAllTags(quint64 generation)
└ signal allTagsReady(const QStringList &tags, quint64 generation)
```
`QueryCompleter` is constructed with the `QLineEdit` it manages and a `const
Config &`. It installs a `QCompleter` on the line edit, swaps that completer's
model as the cursor moves between contexts, and exposes `setTags(const
QStringList &)` for the worker result. It emits no signals.
The separation matters for testing: `completionContext()` is where the defects
will be, and it needs neither a widget nor a database to exercise.
## The tokenizer
`completionContext(const QString &text, int cursor)` returns:
```cpp
struct CompletionContext {
enum Kind { None, Prefix, Value };
Kind kind = None;
QString prefix; ///< For Value: the keyword left of ':', lowercased.
QString stem; ///< The text being matched.
int replaceFrom = 0;
int replaceLength = 0;
};
```
`replaceFrom`/`replaceLength` describe exactly the span an accepted completion
overwrites, so accepting never disturbs neighbouring text.
Rules:
1. Scan back from the cursor to the nearest unquoted whitespace, `(` or the
start of the string. That span is the current token.
2. Within the token, the first `:` splits it. Text before is the prefix, text
after is the value being typed.
3. A token with no `:` is a prefix completion.
4. Inside double quotes, return `None`. In `subject:"foo bar|` the cursor sits
in a literal, not a keyword position.
5. A value containing `..` is a **range of two independent values**. The cursor
before the separator completes the lower bound, after it the upper bound;
both draw on the same model, and the replace span covers only the side being
edited. `date:today..yes|` completes `yesterday` as the upper bound without
touching `today`.
Matching is case-insensitive; insertion is always lowercase, since notmuch's
prefixes are lowercase.
`is:` and `tag:` resolve to the same model. notmuch treats `is:x` as a synonym
for `tag:x`, so offering tags for both is correct rather than a convenience.
### Range entries and the `..` trap
The relative date entries (`1week..`, `1month..`) contain `..` themselves: each
is already a complete open-ended range meaning "from then until now". Offering
one as a bound inside an existing range yields `date:1week....today`, which is
malformed.
Therefore: **entries containing `..` are offered only when the value has no
`..` yet.** Once a range is underway, only the bare symbolic values are
candidates on either side. This is a required test case, not an optimisation.
## Vocabulary
A `QStandardItemModel` per context: completion text in column 0, description in
column 1. `QCompleter` is set to match on column 0 only, so descriptions never
influence matching. A `QStyledItemDelegate` draws the description greyed and
right-aligned.
Every description passes through `tr()`. Keywords do not: they are syntax.
**Prefixes** — `tag:`, `is:`, `from:`, `to:`, `subject:`, `date:`,
`attachment:`, `mimetype:`, `folder:`, `path:`, `thread:`, `id:`, and the
operators `and`, `or`, `not`.
**Dates** — symbolic: `today`, `yesterday`, `this_week`, `last_week`,
`this_month`, `last_month`, `this_year`. Relative (contain `..`, subject to the
rule above): `1week..`, `1month..`.
**Mimetypes** — `application/pdf`, `image/jpeg`, `image/png`, `text/html`,
`application/zip`.
Mimetypes are the one list a user can extend, via `[completion]
extra_mimetypes` in the config. Entries **append to** the built-ins rather than
replacing them, so a typo or a short list can never leave completion worse off
than the defaults, and there is no way to lose a built-in by adding one entry.
Each entry accepts an optional description after a `|`:
```ini
[completion]
extra_mimetypes = application/epub+zip|EPUB book, message/rfc822
```
`,` separates entries and `|` separates a value from its description. They are
different characters deliberately: QSettings splits comma lists itself, so a
description containing a comma would otherwise be torn into two entries.
Neither character is legal in a mimetype, so nothing is ambiguous.
An entry without a description shows a blank right column rather than being
rejected. An entry that is empty, or whose value is empty, is skipped and
recorded through `Config::addProblem()`: the user configured something that is
not being honoured.
The other value lists are deliberately **not** configurable, because each has a
real source and a user-editable copy would only drift from it:
- **Prefixes** come from notmuch. Adding one to a config list would not teach
notmuch the keyword; it would complete happily and then error on Enter.
- **Paths** are derived from the configured accounts.
- **Dates** are closed once symbolic and relative forms are covered. Everything
beyond them is literal and handled by the free-form hint.
- **Tags** are read from the database and are never config.
**Paths** — `Account::maildir` for each configured account, offered after
`path:`. Config-derived rather than scanned: the Maildir root is deliberately
not duplicated in this project's config, and the configured accounts are the
directories actually queried.
`Account::scopedQuery()` (`config.cpp:27`) builds `path:"<maildir>/**"`, so
`path:` is the prefix these values genuinely belong to. `folder:` is a
different matcher in notmuch, against the Maildir folder name rather than the
directory path, and its values are not enumerable from config. `folder:`
therefore completes as a prefix only, like `from:` and `to:`.
The recursive form is worth offering directly: each account contributes both
`<maildir>` and `<maildir>/**`, the latter described as including
subdirectories, since `/**` is the form the application itself uses and is not
guessable.
**Tags** — supplied at runtime, see below.
### Free-form date hint
Date values cannot be enumerated, so the completion list cannot reveal that
notmuch accepts free-form dates at all. When the date model is active the popup
carries a footer label:
> also accepts free-form dates, e.g. `2026-01-15` or `15/01/2026..today`
It is a label beneath the popup's list view, **not a model row**. A row would
be dropped by `QCompleter`'s filter model on the first keystroke that did not
match it, and could in principle be selected and inserted, producing a broken
query. A footer sidesteps both: it cannot be filtered away and cannot be
accepted.
The wording is illustrative, not a specification. notmuch's date parser is
permissive and its exact accepted set varies by build, so the hint must not
read as a promise.
## Tag fetching and freshness
`NotmuchWorker` gains `requestAllTags(quint64 generation)`, wrapping
`notmuch_database_get_all_tags()` (notmuch.h:942) and emitting a sorted
`QStringList`. A `NmTags` RAII alias is added to `nmraii.h` alongside the
existing ones. No notmuch pointer crosses the thread boundary; the generation
counter follows the existing pattern.
The list refreshes:
- at startup,
- after each sync completes, since a sync can introduce tags,
- after an `applyTags` that added a tag not already in the list.
The third case is a set membership test in `MainWindow`, not a query. Without
it, a tag the user has just created would be missing from completion until the
next sync, which is precisely the tag they are most likely to type again.
Never per keystroke.
## Triggering
Config gains `[general] completion_on_focus`, a bool defaulting to **false**.
When true, focusing an empty query bar opens the popup showing the full prefix
list. `QCompleter` does not do this unaided: it requires an explicit
`complete()` call on the focus event.
A manual trigger always works, independent of that toggle: a new
`complete_query` action added to `KeyMap::defaultBindings()`, default
`Ctrl+Space`. Going through `KeyMap` rather than hardcoding means it appears in
the shortcut reference automatically and is rebindable in `[keys]`, consistent
with every other binding.
Ordinary typing completes as soon as a token is underway. That is `QCompleter`'s
normal behaviour and is not gated by the toggle; the toggle governs only the
empty-bar-on-focus case.
## Testing
TDD, tokenizer first. `tests/test_querycompleter.cpp`:
- Every tokenizer rule, including the quote escape and both sides of a range.
- The `..` trap: relative entries offered on a bare value, withheld once a
range exists.
- Replace spans, asserted by applying the completion and comparing the
resulting string.
- Model selection through the public API against a stub `Config`: `date:`
selects dates, `path:` selects the configured maildirs in both bare and
`/**` forms, `is:` selects the same model as `tag:`, and `folder:` selects
nothing.
- `extra_mimetypes` appends: configured entries are present **and** every
built-in survives. An entry with `|description` carries it; one without shows
blank rather than being dropped. A description containing a comma survives
intact. A malformed entry is skipped, the rest of the list still loads, and a
problem is recorded.
`requestAllTags` is tested in the existing `tests/test_notmuchworker.cpp`
against its throwaway notmuch database, asserting the tags generated into that
fixture come back sorted.
Register with `add_qtmaildir_test(querycompleter)` in `tests/CMakeLists.txt`,
and add `querycompleter.cpp` to the `qtmaildir_lib` list in
`src/CMakeLists.txt`.
## Consequences
- `mainwindow.cpp` grows by construction and two connections only.
- `Config` gains two accessors: `completionOnFocus()` and `extraMimetypes()`.
- `KeyMap` gains one default binding, which the shortcut reference picks up
without further change.
- `NotmuchWorker` gains one slot and one signal.
- The prefix list is a maintenance point: notmuch adds prefixes across releases
(`mimetype:` and `thread:` were not always present), and a hardcoded list
will not track them. Config would not fix this, it would only let a user
guess at keywords notmuch may not accept. The real upgrade path is deriving
the list from the installed notmuch rather than making it editable. Revisit
if the list goes visibly stale.
|