aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 12:28:49 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 12:28:49 +0200
commit183ed3f087146614270fb0253374aa94aa9e4775 (patch)
treed95e5e877a9f5fede40e5003e8205eddd6b5e43b
parent74c1b90e287d5128249ec0e3390123a7147e8659 (diff)
downloadqtmaildir-183ed3f087146614270fb0253374aa94aa9e4775.tar.gz
qtmaildir-183ed3f087146614270fb0253374aa94aa9e4775.zip
docs: design searching from the message pane as item 85
Item 78 asked for a tagging rule built from something visible in a message. Brainstorming narrowed it: a saved query can already be promoted to a rule, so the road from "I see something interesting" to "a rule tags it" exists as search, save, promote. Searching is the missing step and the safe one, since a query costs nothing when it is wrong while a rule runs unattended against real mail. The search half splits out as item 85. Five surfaces gain a context menu with Search for this and Add to search: subject, date and From/To/Cc in the header, tag chips, body selection, and every header per message in the details dialog, which is rebuilt as rows rather than one text box. Item 78 stays open carrying the rule shortcut alone.
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md40
-rw-r--r--docs/superpowers/specs/2026-08-14-search-from-message-design.md224
2 files changed, 263 insertions, 1 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index ac830fa..f562e97 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -142,13 +142,14 @@ taking that too literally.
| 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13, shipped in 0.17.0. The window-kind question is left open, see the closed-items file |
| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13, shipped in 0.17.0. See `specs/2026-08-13-rule-builder-design.md` |
| 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13, shipped in 0.17.0 |
-| 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it |
+| 78 | No way to build a rule from something visible in a message | workflow | M | open, narrowed 2026-08-14; the search half split out as item 85, which is the road a rule is made from. Revisit once 85 has been used |
| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 |
| 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand |
| 81 | No way to turn a saved query into a tagging rule | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-query-to-rule-design.md` |
| 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete |
| 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair |
| 84 | A config problem blocks `test_mainwindow` on a modal nobody can dismiss | testing | S | open; measured 2026-08-14, `showWarnings()` calls `QMessageBox::warning` from the constructor |
+| 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | open; designed 2026-08-14, see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilds the details dialog as rows |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -557,6 +558,43 @@ here.
**Size: S.** The diagnosis is the expensive part and it is already done.
+## 85. Nothing on screen can be searched for by right-clicking it
+
+**Observed (2026-08-14).** The user asked, while discussing item 78, to be able
+to right-click a sender, a subject, a date, a tag chip or a piece of selected
+body text and be offered a search built from it, both replacing the query bar
+and narrowing what is already in it. The narrowing case is the one that decides
+the shape: a query returning a thousand threads is refined by adding a second
+condition, and today that means retyping a query the user never typed.
+
+**Cause.** Not a defect, unbuilt. `MessageView` has no context menu on any
+surface, and `TagStrip` paints chips with no hit test and no signals.
+
+**Approach.** Designed 2026-08-14. **Read
+`specs/2026-08-14-search-from-message-design.md` rather than planning from this
+entry.**
+
+Three constraints decide whether to open the spec. **This is search only, and
+that is the point**: item 78's rule shortcut is deliberately left out, because a
+saved query can already be promoted to a rule and searching is the missing step
+on that road, as well as the safe one. **The details dialog is rebuilt as rows**,
+which the user wanted anyway, and its value labels must stay explicitly
+`Qt::PlainText`: the current `QPlainTextEdit` is a deliberate protection against
+markup in header values that come from strangers. And **Add to search
+parenthesises both sides**, because the bar may hold a hand-written `or` and
+`a or b AND c` binds the wrong way, silently widening a search meant to narrow.
+
+**Constraints.** The thread list is out of scope and the reason is worth
+keeping: a row's `authors` comes from `notmuch_thread_get_authors` and is a
+display summary reading `Alice, Bob`, so a `from:` built from it matches
+nothing. Item 78's own approach line assumed otherwise.
+
+A mis-quoted query is not an error to notmuch, it matches zero, so the quoting
+helper is tested against the constructed string rather than against a provoked
+failure.
+
+**Size: M.**
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
diff --git a/docs/superpowers/specs/2026-08-14-search-from-message-design.md b/docs/superpowers/specs/2026-08-14-search-from-message-design.md
new file mode 100644
index 0000000..50b3a6c
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-14-search-from-message-design.md
@@ -0,0 +1,224 @@
+# Searching from what is on screen
+
+Resolves the first half of backlog item **78**. Depends on nothing unshipped.
+
+## What this is
+
+Five surfaces in the message pane become right-clickable. Each offers two
+entries: **Search for this**, which replaces the query bar, and **Add to
+search**, which narrows what is already there. The value under the cursor
+becomes a notmuch query and runs.
+
+Nothing here writes a rule.
+
+## What this is not, and why
+
+Item 78 as filed asks for a **tagging rule** built from something visible in a
+message. That is deliberately not this. The user's reasoning, taken as the
+decision: a saved query can already be promoted to a rule (item 81), so the
+road from "I see something interesting" to "a rule tags it" already exists as
+search, save the query, build the rule from it. Three steps, each reversible,
+each showing its result before the next.
+
+Searching is the step that is missing, and it is the safe one: a query costs
+nothing if it is wrong, while a rule is handed to a `post-new` hook that runs
+unattended against real mail every ten minutes. Building the shortcut before
+the road works would put the dangerous end first.
+
+**Item 78 therefore stays open** after this ships, carrying the rule shortcut
+alone. Revisit it once the search actions have been used enough to know which
+values are worth promoting directly.
+
+## The surfaces
+
+| Surface | Widget | Query built | Offered when |
+|---|---|---|---|
+| Subject | header `QLabel` | `subject:"..."` | always |
+| Date | header `QLabel` | `date:YYYY-MM-DD..YYYY-MM-DD` | always |
+| From, To, Cc | header `QLabel` | `from:` / `to:` / `cc:` | single-message thread |
+| Tag chip | `TagStrip` | `tag:x` | per chip |
+| Body selection | `QWebEngineView` | `"..."` | selection non-empty |
+| Every header, per message | details dialog | all of the above | always |
+
+**From, To and Cc are offered exactly when the header displays them**, which is
+when the thread holds one message. The header already adapts this way
+(`messageview.cpp:470-489`) and the reason is recorded there: for a real thread
+the recipient differs message to message and neither the union nor the
+intersection is "the" recipient. The menu must not offer a value the header is
+not stating, so it shares the condition rather than restating it.
+
+For a multi-message thread those three live in the details dialog, where they
+are per-message and unambiguous. That is the fallback, not the primary route: a
+single-message thread is the common case and reaching its sender should take one
+click, not a trip through a dialog.
+
+**The `+N` overflow chip offers nothing.** It stands for a list, not a tag.
+
+**The header menu lists the fields; it does not hit-test them.** The header is
+one rich-text `QLabel` holding up to four rendered lines, and working out which
+line the cursor is on means mapping a point through laid-out rich text, which is
+fiddly and breaks as soon as the label wraps. A right-click anywhere on the
+header instead offers every field available for that message, each entry naming
+its value:
+
+```
+Search for subject "Invoice 4471" > Search for this
+Search for from foo@example.org > Add to search
+Search for mail from 2026-08-14
+```
+
+The values are kept beside the label as a small list, populated by the same pass
+that renders it, rather than parsed back out of the markup. Building the display
+string and the searchable value in one place is what stops them disagreeing.
+
+Listing is also the better affordance: the user sees which fields are searchable
+without discovering it by clicking in the right spot. A long value is elided in
+the entry's text; the query uses the full one.
+
+The tag strip is hit-tested rather than listed, because a chip is a discrete
+widget-like thing with its own rect and the user is aiming at one.
+
+## Two operations
+
+**Search for this** sets the query bar to the term and runs it.
+
+**Add to search** combines with what the bar already holds:
+
+```
+(existing) AND (new)
+```
+
+**Both sides are parenthesised, and this is load-bearing.** The bar may hold a
+hand-written query containing `or`, and `a or b AND c` binds as `a or (b AND c)`,
+which silently widens the search instead of narrowing it. This is the same trap
+the `post-new` hook already handles when it scopes a rule with `tag:new`, and it
+is recorded in CLAUDE.md for that reason.
+
+When the bar is empty, **Add to search** behaves as **Search for this** rather
+than producing `() AND (new)`.
+
+The account scope is not touched. `MainWindow::runQuery()` wraps the bar's text
+in the selected account's scope on every run (`mainwindow.cpp:1981-1983`), so a
+scope applied here would be applied twice.
+
+## Quoting
+
+One helper, `searchTermFor(const QString &)`, used by every surface:
+
+- trim, and collapse runs of whitespace and newlines to single spaces
+- escape embedded `"`
+- wrap in double quotes
+- cap the length; a multi-kilobyte selection is a mis-drag, not a query
+- an empty or whitespace-only result yields no menu entry at all
+
+**This gets its own tests, and they matter more than they look.** notmuch's
+parser rejects almost nothing: CLAUDE.md records that `from:((((` parses cleanly
+and matches zero. A mis-quoted query therefore does not error, it silently
+returns nothing, and the feature looks broken with no clue why. Body selections
+are arbitrary user-chosen prose and can contain quotes, colons, parentheses,
+`AND`, newlines, or a pasted log.
+
+Assert on the constructed string, never on a provoked parser failure.
+
+## Wiring
+
+`MessageView` already has the right signal:
+
+```cpp
+void queryRequested(const QString &query);
+```
+
+It exists for the placeholder pane's helper lines and carries a documented
+security gate: a message body is attacker-controlled HTML and can hold a
+`qtmaildir-query:` link, so the signal is emitted only when the placeholder is
+what is displayed (`messageview.h:140-149`).
+
+**Reuse it rather than adding a parallel signal.** The menus are application
+chrome built by our own code from values we extracted, not links in a rendered
+document, so they do not weaken that gate. The gate stays exactly as it is for
+links; these are a separate emitter of the same signal.
+
+`TagStrip` and the details dialog emit their own equivalent. Every one of them
+carries a finished query string and nothing else; the panes never touch the
+query bar. `MainWindow` receives, sets `m_queryEdit`, and calls the existing
+runner, which keeps the account scope and the generation counter working.
+
+**Extend needs the bar's current text**, which only `MainWindow` has. The signal
+therefore carries the term plus which operation was chosen, and `MainWindow`
+does the combining. A pane that built `(existing) AND (new)` itself would have
+to read the query bar, which is exactly the coupling the existing signal's
+comment says to avoid.
+
+## The details dialog is rebuilt as rows
+
+Today it is one `QPlainTextEdit` holding every message's headers as flat text,
+built inline in a 40-line `showDetailsDialog()` and shown with `exec()`
+(`messageview.cpp:500-552`). The user does not like that it appears as a text
+box, and a flat text box cannot carry a per-value context menu without parsing
+displayed text back into structure.
+
+It becomes a scrollable column of labelled rows, one row per header per message,
+each row holding its own value and its message index. The context menu then
+carries the real value with no parsing.
+
+**One property of the current dialog must survive.** It uses `setPlainText` and
+a `QPlainTextEdit` deliberately, and the comment says why: header values come
+from strangers, and plain text cannot interpret markup, so there is nothing to
+escape and nothing that could render. Row widgets reintroduce that risk, because
+a `QLabel` interprets rich text by default and Qt will guess when the format is
+`Qt::AutoText`.
+
+Every value label is therefore **explicitly `Qt::PlainText`**. Not escaped
+input into a rich-text label, which is the same protection one mistake away from
+failing.
+
+The dialog moves out of `MessageView` into its own `MessageDetailsDialog` class.
+A 40-line inline builder that now grows rows, menus and signals is past what
+belongs inline, and the class is separately testable.
+
+## Testing
+
+- `searchTermFor` against quotes, newlines, colons, parentheses, `AND`, empty
+ and whitespace-only input, and an over-long selection.
+- Term construction per surface: a tag chip yields `tag:x`, a date yields the
+ day's range, a subject is quoted.
+- **Add to search** parenthesises both sides, and an `or` in the existing query
+ survives it. Empty bar falls back to replace.
+- `TagStrip::chipAt` against the painted rects, including a miss in the gap
+ between two chips and the `+N` chip yielding nothing. The hit test and the
+ paint take their geometry from one function so they cannot drift, as
+ `CardDelegate::expanderRectFor` already does for the expander.
+- Header menu contents: From/To/Cc entries present for a single-message thread,
+ absent for a multi-message one, with subject and date present in both. **The
+ absence half needs a guard proving the menu was built at all**; item 82 records
+ that a test asserting only the absence of a widget passes against no
+ implementation whatever. Asserting that subject and date are still there is
+ that guard.
+- An empty header field yields no entry. A message with no Cc must not offer
+ `cc:""`, which parses cleanly and matches nothing.
+- Details dialog rows carry the right value and message index, and value labels
+ report `Qt::PlainText`.
+- One `test_mainwindow` case that a triggered action leaves the expected text in
+ the query bar.
+
+Three traps from CLAUDE.md apply and are not re-derived here: a rendering probe
+proves nothing about which widget is clickable, visible and clickable and working
+are three separate properties, and a hit test asserted against `visualRect` can
+endorse a broken layout.
+
+## Out of scope
+
+**Creating a rule directly from a value.** The remainder of item 78, above.
+
+**Searching from the thread list.** Its context menu is tag actions today and
+the row's `authors` is a display summary from
+`notmuch_thread_get_authors`, not an address: it reads `Alice, Bob` or
+`Alice| Bob`, so `from:` built from it matches nothing. Any thread-list search
+needs a real address resolved from a message first. Worth recording because the
+backlog's own approach line for item 78 assumed the row held a usable sender.
+
+**Refining by anything not on screen.** No date-range picker, no "from anyone at
+this domain". Both are reasonable and neither is this.
+
+**The web view's standard context menu entries.** Copy and the rest stay as
+they are; the search entry is added alongside.