summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 12:53:58 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 12:53:58 +0200
commitf7f868ce00e01e4026be19437242537967875ce5 (patch)
tree77900377b2d355bc6340fbc80cae4682825fa2bf
parent811bea0640dbfd27ca2c47b0288716ca608e5abb (diff)
downloadqtmaildir-f7f868ce00e01e4026be19437242537967875ce5.tar.gz
qtmaildir-f7f868ce00e01e4026be19437242537967875ce5.zip
feat(search): offer the header's fields for searching
The menu lists what is searchable rather than hit-testing which line of a rich-text label was clicked, which breaks as soon as the label wraps. The values are collected by the pass that renders the header, so nothing parses the markup back into structure. From, To and Cc appear only for a single-message thread, sharing the condition with the header's own display: a thread's recipient differs message to message, and the menu must not offer what the header is not stating. The test fixture's Date: header named the wrong weekday, which Qt::RFC2822Date rejects outright, so no date offer would have been produced from it.
-rw-r--r--src/messageview.cpp72
-rw-r--r--src/messageview.h37
-rw-r--r--tests/test_messageview.cpp99
3 files changed, 207 insertions, 1 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp
index d2be380..8a1976d 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -30,6 +30,7 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
+#include <QMenu>
#include <QMouseEvent>
#include <QPushButton>
#include <QStandardPaths>
@@ -50,6 +51,7 @@
#include "cidschemehandler.h"
#include "htmlbuilder.h"
#include "requestinterceptor.h"
+#include "searchterm.h"
#include "tagstrip.h"
#include "threadcidmap.h"
#include "version.h"
@@ -163,6 +165,12 @@ MessageView::MessageView(QWidget *parent)
m_headerLabel->setWordWrap(true);
m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ // Item 85: the header's values are searchable. CustomContextMenu rather
+ // than an action list, since the entries depend on what is displayed.
+ m_headerLabel->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(m_headerLabel, &QWidget::customContextMenuRequested,
+ this, &MessageView::showHeaderContextMenu);
+
// To the right of the header, per the user's decision: the summary answers
// "who is this from", this answers "what actually happened to it". A button
// and not only a shortcut, since "everything needs a memorized key" is the
@@ -428,6 +436,11 @@ QString MessageView::headerMark(Marks::Mark mark) const
void MessageView::updateHeader()
{
+ // A stale offer list must not survive either exit: the early return below
+ // leaves nothing on screen to search, and the normal path rebuilds it from
+ // scratch a few lines down.
+ m_headerOffers.clear();
+
if (m_items.isEmpty()) {
m_headerLabel->clear();
m_detailsButton->hide();
@@ -440,6 +453,30 @@ void MessageView::updateHeader()
// Re: prefixes that add nothing.
const QString subject = m_items.first().message.subject;
+ // Collected by the pass that renders the label, from the same values, so
+ // nothing has to parse the rendered markup back into structure.
+ auto elided = [](const QString &value) {
+ constexpr int kMaxLabel = 40;
+ return value.size() > kMaxLabel
+ ? value.left(kMaxLabel) + QStringLiteral("...")
+ : value;
+ };
+
+ auto offer = [this](const QString &label, const QString &query) {
+ if (query.isEmpty())
+ return;
+ m_headerOffers.append({ label, query });
+ };
+
+ offer(tr("subject \"%1\"").arg(elided(subject)),
+ SearchTerm::field(QStringLiteral("subject"), subject));
+
+ const QDateTime sent = MimeParser::parseDate(m_items.first().message.date);
+ if (sent.isValid()) {
+ offer(tr("mail from %1").arg(sent.date().toString(Qt::ISODate)),
+ SearchTerm::onDate(sent.date()));
+ }
+
// Item 70's marks, beside the subject and OUTSIDE the message area. The
// user asked for these two only: whether the thread is flagged and whether
// it carries an attachment, which are the two states worth knowing before
@@ -489,6 +526,16 @@ void MessageView::updateHeader()
row(tr("From:"), message.from);
row(tr("To:"), message.to);
row(tr("Cc:"), message.cc);
+
+ // Only here, sharing the condition with the header's own display: for
+ // a real thread these differ message to message, and the details
+ // dialog is where they are unambiguous.
+ offer(tr("sender %1").arg(elided(message.from)),
+ SearchTerm::field(QStringLiteral("from"), message.from));
+ offer(tr("recipient %1").arg(elided(message.to)),
+ SearchTerm::field(QStringLiteral("to"), message.to));
+ offer(tr("copied to %1").arg(elided(message.cc)),
+ SearchTerm::field(QStringLiteral("cc"), message.cc));
} else {
text += QStringLiteral("<br><small>%1</small>")
.arg(tr("%n message(s) in thread", "", m_items.size()));
@@ -497,6 +544,31 @@ void MessageView::updateHeader()
m_headerLabel->setText(text);
}
+void MessageView::addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers)
+{
+ for (const SearchOffer &entry : offers) {
+ auto *sub = menu->addMenu(tr("Search for %1").arg(entry.label));
+
+ auto *replace = sub->addAction(tr("Search for this"));
+ connect(replace, &QAction::triggered, this,
+ [this, entry]() { emit searchRequested(entry.query, false); });
+
+ auto *narrow = sub->addAction(tr("Add to search"));
+ connect(narrow, &QAction::triggered, this,
+ [this, entry]() { emit searchRequested(entry.query, true); });
+ }
+}
+
+void MessageView::showHeaderContextMenu(const QPoint &pos)
+{
+ if (m_headerOffers.isEmpty())
+ return;
+
+ QMenu menu(this);
+ addSearchEntries(&menu, m_headerOffers);
+ menu.exec(m_headerLabel->mapToGlobal(pos));
+}
+
void MessageView::showDetailsDialog()
{
if (m_items.isEmpty())
diff --git a/src/messageview.h b/src/messageview.h
index 422869a..26ed216 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -25,8 +25,10 @@
#include "htmlbuilder.h"
#include "marks.h"
#include "mimeparser.h"
+#include "searchterm.h"
class QLabel;
+class QMenu;
class QPushButton;
class QWebEngineView;
class QWebEngineProfile;
@@ -127,6 +129,17 @@ public:
/// is rendered or the notice is hidden.
QString staleMessageId() const { return m_staleMessageId; }
+ /// What the header can be searched for, given what it is currently showing.
+ ///
+ /// From, To and Cc appear only for a single-message thread, which is
+ /// exactly when the header displays them: for a real thread the recipient
+ /// differs message to message and the header says only the subject and the
+ /// count. The menu must never offer a value the header is not stating.
+ ///
+ /// The values come from the same pass that renders the label, never from
+ /// parsing it back: rich text does not survive a second parse.
+ QList<SearchOffer> headerSearchOffers() const { return m_headerOffers; }
+
public slots:
void toggleHtml();
void loadRemoteContent();
@@ -158,6 +171,18 @@ signals:
void staleThreadRecoveryRequested(const QString &threadId,
const QString &messageId);
+ /// The user chose a search from one of the pane's context menus.
+ ///
+ /// `extend` narrows the current query rather than replacing it. The view
+ /// does not know what the query bar holds and must not: the window owns
+ /// that field and does the combining.
+ ///
+ /// Separate from queryRequested(), which carries a gate against a link in
+ /// a rendered document driving the thread list. These menus are chrome
+ /// built by our own code from values we extracted, so they need no gate,
+ /// and widening the existing signal would change what that gate protects.
+ void searchRequested(const QString &query, bool extend);
+
protected:
/// Turns Ctrl+wheel over the body into zoom, and Ctrl+middle-click into a
/// reset. Both events are delivered to the web view's internal QQuickWidget
@@ -207,6 +232,15 @@ private:
/// Every attachment in the thread, in the order the messages render.
QList<Attachment> allAttachments() const;
+ /// Builds and pops the header's menu at `pos`, in the label's coordinates.
+ void showHeaderContextMenu(const QPoint &pos);
+
+ /// Appends a "Search for ..." submenu per offer, each holding the replace
+ /// and the narrow operation.
+ ///
+ /// Shared with the web view's menu in a later task so the two cannot grow
+ /// different wording or a different pair of operations.
+ void addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers);
QList<ThreadRenderItem> m_items;
bool m_preferHtml = true;
@@ -232,4 +266,7 @@ private:
QPushButton *m_detailsButton = nullptr;
QWidget *m_attachmentBar = nullptr;
TagStrip *m_tagStrip = nullptr;
+
+ /// Populated by updateHeader(), consumed by the header's context menu.
+ QList<SearchOffer> m_headerOffers;
};
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index e94fb86..6403308 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -49,6 +49,9 @@ private slots:
void detailsDialogIsOfferedForEveryThread();
void placeholderRendersAndReportsItself();
void aMessageBodyCannotRunAQuery();
+ void headerOffersSubjectDateAndSenderForOneMessage();
+ void headerOffersNoSenderForARealThread();
+ void headerOffersNothingForAnAbsentField();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -372,7 +375,7 @@ static ThreadRenderItem oneMessage()
message.to = QStringLiteral("Recipient <recipient@example.org>");
message.cc = QStringLiteral("Copied <copied@example.org>");
message.subject = QStringLiteral("Quarterly report");
- message.date = QStringLiteral("Mon, 4 Aug 2026 09:00:00 +0200");
+ message.date = QStringLiteral("Tue, 4 Aug 2026 09:00:00 +0200");
message.plainBody = QStringLiteral("body");
ThreadRenderItem item;
@@ -566,5 +569,99 @@ void TestMessageView::aMessageBodyCannotRunAQuery()
QVERIFY(queries.isEmpty());
}
+void TestMessageView::headerOffersSubjectDateAndSenderForOneMessage()
+{
+ MessageView view;
+ view.showThread({ oneMessage() });
+
+ const QList<SearchOffer> offers = view.headerSearchOffers();
+
+ QStringList queries;
+ for (const SearchOffer &offer : offers)
+ queries << offer.query;
+ const QString shown = queries.join(QStringLiteral(" | "));
+
+ QVERIFY2(queries.contains(QStringLiteral("subject:\"Quarterly report\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("from:\"Sender <sender@example.org>\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("to:\"Recipient <recipient@example.org>\"")),
+ qPrintable(shown));
+ QVERIFY2(queries.contains(
+ QStringLiteral("cc:\"Copied <copied@example.org>\"")),
+ qPrintable(shown));
+
+ // The date is offered as a one-day range. Qt::RFC2822Date checks the
+ // weekday against the date, so a fixture with the wrong day silently
+ // produces no offer at all: 2026-08-04 is a Tuesday.
+ QVERIFY2(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")),
+ qPrintable(shown));
+
+ // Every offer carries a label the menu shows, naming the value so the user
+ // can see what they are about to search for.
+ for (const SearchOffer &offer : offers) {
+ QVERIFY(!offer.label.isEmpty());
+ QVERIFY(!offer.query.isEmpty());
+ }
+}
+
+void TestMessageView::headerOffersNoSenderForARealThread()
+{
+ // The header shows From/To/Cc only for a single-message thread, because a
+ // thread's recipient differs message to message. The menu shares that
+ // condition: it must never offer a value the header is not stating.
+ ThreadRenderItem first = oneMessage();
+ ThreadRenderItem second = oneMessage();
+ second.message.from = QStringLiteral("Recipient <recipient@example.org>");
+ second.message.to = QStringLiteral("Sender <sender@example.org>");
+
+ MessageView view;
+ view.showThread({ first, second });
+
+ QStringList queries;
+ for (const SearchOffer &offer : view.headerSearchOffers())
+ queries << offer.query;
+
+ // THE GUARD. A test asserting only that something is absent passes against
+ // no implementation whatever. Subject and date must still be offered,
+ // which proves the list was built before the absences below mean anything.
+ QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built");
+ QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\"")));
+ QVERIFY(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")));
+
+ for (const QString &query : queries) {
+ QVERIFY2(!query.startsWith(QStringLiteral("from:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ QVERIFY2(!query.startsWith(QStringLiteral("to:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ QVERIFY2(!query.startsWith(QStringLiteral("cc:")),
+ qPrintable(QStringLiteral("thread offered %1").arg(query)));
+ }
+}
+
+void TestMessageView::headerOffersNothingForAnAbsentField()
+{
+ // cc:"" parses cleanly and matches nothing, so an entry built from an
+ // empty header would look enabled and silently do nothing.
+ ThreadRenderItem item = oneMessage();
+ item.message.cc.clear();
+
+ MessageView view;
+ view.showThread({ item });
+
+ QStringList queries;
+ for (const SearchOffer &offer : view.headerSearchOffers())
+ queries << offer.query;
+
+ // Guard first, then the absence.
+ QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built");
+ QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\"")));
+
+ for (const QString &query : queries)
+ QVERIFY(!query.startsWith(QStringLiteral("cc:")));
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"