From 84f5fc8302ef31a272fac534af7587599b98c8f1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 20:17:31 +0200 Subject: docs: spec query bar completion, require translatable strings Design for backlog item 17. Completion covers query prefixes, tag values, date values, path values and mimetype values, each carrying a description so the bar documents the query language while it is typed. A new QueryCompleter class owns it, with the cursor-context tokenizer as a pure function so the parsing rules are testable without a widget or a database. NotmuchWorker gains an all-tags call, which did not exist. Addresses for from:/to: are out of scope: libnotmuch exposes no all-addresses call. Both prefixes still appear so the vocabulary reads complete. Account maildirs belong to path:, not folder:. Account::scopedQuery builds path:"/**", and folder: is a different matcher in notmuch, against the folder name rather than the directory path. Also records the translatable-strings rule in CLAUDE.md. Existing code is not yet audited against it. --- CLAUDE.md | 6 + .../specs/2026-08-03-query-completion-design.md | 240 +++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-query-completion-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 1b6a1bc..ed8faca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,12 @@ combined `thread:a or thread:b` query rather than one query per thread. The only escape hatch is `general/notmuch_config`, pointing at an alternate notmuch config. Per-account subdirectories *are* configured, since notmuch does not model accounts at all. +**Every user-facing string is translatable.** Wrap UI text in `tr()`, including strings +that are only ever shown in passing: status bar messages, tooltips, dialog prose, +completion descriptions. Query syntax itself is not user-facing text — notmuch keywords +like `tag:` and `date:` are wire format and must never be translated, only the prose +describing them. Pre-existing code has not been audited against this rule. + **Config format gotcha:** QSettings treats `/` in a section name as a group separator, so account sections are `[account.work]`, not `[account/work]`. `childKeys` returns keys sorted alphabetically, never in file order. **`[general]` keys are read WITHOUT the diff --git a/docs/superpowers/specs/2026-08-03-query-completion-design.md b/docs/superpowers/specs/2026-08-03-query-completion-design.md new file mode 100644 index 0000000..2ce91fa --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-query-completion-design.md @@ -0,0 +1,240 @@ +# 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`. + +**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:"/**"`, 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 +`` and `/**`, 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. + +`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 one accessor. +- `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 may add prefixes, and this + list will not track them automatically. -- cgit v1.2.3