aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt2
-rw-r--r--src/mainwindow.cpp18
-rw-r--r--src/mainwindow.h7
-rw-r--r--src/messagedetailsdialog.cpp153
-rw-r--r--src/messagedetailsdialog.h87
-rw-r--r--src/messageview.cpp189
-rw-r--r--src/messageview.h49
-rw-r--r--src/mimeparser.cpp15
-rw-r--r--src/mimeparser.h11
-rw-r--r--src/searchterm.cpp93
-rw-r--r--src/searchterm.h94
-rw-r--r--src/tagstrip.cpp59
-rw-r--r--src/tagstrip.h23
13 files changed, 746 insertions, 54 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 0945f65..ee1f621 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -22,9 +22,11 @@ add_library(qtmaildir_lib STATIC
syncmonitor.cpp
threadcidmap.cpp
messageview.cpp
+ messagedetailsdialog.cpp
mainwindow.cpp
querycompleter.cpp
rulequery.cpp
+ searchterm.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index bb6aa43..115646e 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -55,6 +55,7 @@
#include "querycompleter.h"
#include "carddelegate.h"
#include "cardlayout.h"
+#include "searchterm.h"
#include "tagchip.h"
#include "tagdialog.h"
#include "savequerydialog.h"
@@ -672,6 +673,8 @@ void MainWindow::buildUi()
this, &MainWindow::onPlaceholderQueryRequested);
connect(m_messageView, &MessageView::staleThreadRecoveryRequested,
this, &MainWindow::recoverStaleThread);
+ connect(m_messageView, &MessageView::searchRequested,
+ this, &MainWindow::runSearchFromPane);
m_splitter = new QSplitter(Qt::Horizontal, central);
m_splitter->addWidget(m_threadView);
@@ -1648,6 +1651,21 @@ void MainWindow::onPlaceholderQueryRequested(const QString &query)
runCurrentQuery();
}
+void MainWindow::runSearchFromPane(const QString &query, bool extend)
+{
+ if (query.isEmpty())
+ return;
+
+ const QString next =
+ extend ? SearchTerm::extend(m_queryEdit->text(), query) : query;
+
+ // Through the query bar and the existing runner, so the account scope, the
+ // generation counter and the flat-mode reset all behave exactly as they do
+ // for a typed query. Nothing here builds a second query path.
+ m_queryEdit->setText(next);
+ runCurrentQuery();
+}
+
void MainWindow::showWarnings()
{
const QStringList warnings = m_config.warnings() + m_keyMap.warnings();
diff --git a/src/mainwindow.h b/src/mainwindow.h
index a68fcd9..f54a889 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -373,6 +373,13 @@ private slots:
/// Runs a query the user clicked on the placeholder pane.
void onPlaceholderQueryRequested(const QString &query);
+ /// Runs a search asked for from the message pane.
+ ///
+ /// `extend` narrows the current query rather than replacing it. The panes
+ /// carry a finished query and no knowledge of the bar; the combining
+ /// happens here, because only the window can see what the bar holds.
+ void runSearchFromPane(const QString &query, bool extend);
+
/// Runs one tagging rule's query in the thread list, so the user can see
/// which mail it collects. The rules dialog stays open; the point is to
/// compare the rule against its results.
diff --git a/src/messagedetailsdialog.cpp b/src/messagedetailsdialog.cpp
new file mode 100644
index 0000000..058814b
--- /dev/null
+++ b/src/messagedetailsdialog.cpp
@@ -0,0 +1,153 @@
+/*
+ * 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 "messagedetailsdialog.h"
+
+#include <QDialogButtonBox>
+#include <QFontDatabase>
+#include <QGridLayout>
+#include <QLabel>
+#include <QMenu>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+#include "mimeparser.h"
+#include "searchterm.h"
+
+MessageDetailsDialog::MessageDetailsDialog(const QList<ThreadRenderItem> &items,
+ QWidget *parent)
+ : QDialog(parent)
+{
+ setWindowTitle(tr("Message details"));
+ setObjectName(QStringLiteral("messageDetailsDialog"));
+
+ buildRows(items);
+
+ auto *layout = new QVBoxLayout(this);
+
+ // Scrollable: a long thread has many rows, and the dialog must not grow
+ // past the screen to show them.
+ auto *scroll = new QScrollArea(this);
+ scroll->setWidgetResizable(true);
+ auto *content = new QWidget(scroll);
+ auto *grid = new QGridLayout(content);
+
+ // A monospaced value keeps a long id or address readable as the record it
+ // is, which is what the text box did well and is worth carrying over.
+ const QFont fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+
+ int gridRow = 0;
+ int lastMessage = -1;
+ for (const HeaderRow &row : std::as_const(m_rows)) {
+ if (items.size() > 1 && row.messageIndex != lastMessage) {
+ lastMessage = row.messageIndex;
+ auto *heading = new QLabel(
+ tr("Message %1 of %2").arg(row.messageIndex + 1)
+ .arg(items.size()),
+ content);
+ heading->setTextFormat(Qt::PlainText);
+ QFont headingFont = heading->font();
+ headingFont.setBold(true);
+ heading->setFont(headingFont);
+ grid->addWidget(heading, gridRow, 0, 1, 2);
+ ++gridRow;
+ }
+
+ auto *label = new QLabel(row.label, content);
+ label->setTextFormat(Qt::PlainText);
+ label->setAlignment(Qt::AlignTop | Qt::AlignLeft);
+ grid->addWidget(label, gridRow, 0);
+
+ // PlainText stated, not inferred. A QLabel guesses under AutoText, and
+ // this value came from a stranger.
+ auto *value = new QLabel(row.value, content);
+ value->setTextFormat(Qt::PlainText);
+ value->setFont(fixed);
+ value->setWordWrap(true);
+ value->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ if (!row.query.isEmpty()) {
+ value->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(value, &QWidget::customContextMenuRequested, this,
+ [this, value, row](const QPoint &pos) {
+ QMenu menu(this);
+ auto *replace = menu.addAction(tr("Search for this"));
+ connect(replace, &QAction::triggered, this,
+ [this, row]() { requestSearch(row, false); });
+ auto *narrow = menu.addAction(tr("Add to search"));
+ connect(narrow, &QAction::triggered, this,
+ [this, row]() { requestSearch(row, true); });
+ menu.exec(value->mapToGlobal(pos));
+ });
+ }
+
+ grid->addWidget(value, gridRow, 1);
+ ++gridRow;
+ }
+
+ grid->setColumnStretch(1, 1);
+ grid->setRowStretch(gridRow, 1);
+ scroll->setWidget(content);
+ layout->addWidget(scroll);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ resize(700, 400);
+}
+
+void MessageDetailsDialog::buildRows(const QList<ThreadRenderItem> &items)
+{
+ for (int i = 0; i < items.size(); ++i) {
+ const ParsedMessage &message = items.at(i).message;
+
+ auto add = [this, i](const QString &field, const QString &label,
+ const QString &value, const QString &query) {
+ if (value.isEmpty())
+ return; // An empty row reads as a rendering fault.
+ m_rows.append({ field, label, value, query, i });
+ };
+
+ add(QStringLiteral("subject"), tr("Subject:"), message.subject,
+ SearchTerm::field(QStringLiteral("subject"), message.subject));
+ add(QStringLiteral("from"), tr("From:"), message.from,
+ SearchTerm::field(QStringLiteral("from"), message.from));
+ add(QStringLiteral("to"), tr("To:"), message.to,
+ SearchTerm::field(QStringLiteral("to"), message.to));
+ add(QStringLiteral("cc"), tr("Cc:"), message.cc,
+ SearchTerm::field(QStringLiteral("cc"), message.cc));
+
+ // The raw header is shown, but the query is a one-day range: a text
+ // match on an RFC 2822 string would match almost nothing.
+ const QDateTime sent = MimeParser::parseDate(message.date);
+ add(QStringLiteral("date"), tr("Date:"), message.date,
+ sent.isValid() ? SearchTerm::onDate(sent.date()) : QString());
+
+ // Shown but not searchable: an id names one message, and the thread
+ // holding it is already on screen.
+ add(QString(), tr("Message-Id:"), message.messageId, QString());
+ }
+}
+
+void MessageDetailsDialog::requestSearch(const HeaderRow &row, bool extend)
+{
+ if (row.query.isEmpty())
+ return;
+ emit searchRequested(row.query, extend);
+}
diff --git a/src/messagedetailsdialog.h b/src/messagedetailsdialog.h
new file mode 100644
index 0000000..f865913
--- /dev/null
+++ b/src/messagedetailsdialog.h
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QList>
+#include <QString>
+
+#include "htmlbuilder.h"
+
+/// One header of one message, as shown and as searched for.
+///
+/// The query is built when the row is, from the parsed value, so nothing has
+/// to parse displayed text back into structure. That is the whole reason this
+/// dialog stopped being a text box.
+struct HeaderRow
+{
+ /// notmuch's field name, or empty for a header with no searchable form.
+ /// Wire format, never translated.
+ QString field;
+
+ /// Translated label shown at the start of the row, e.g. "From:".
+ QString label;
+
+ /// The header's value, verbatim and untrusted.
+ QString value;
+
+ /// The finished query, empty when the header has no searchable form.
+ QString query;
+
+ /// Which message of the thread this row belongs to, zero-based.
+ int messageIndex = 0;
+};
+
+/// The full headers of every message in a thread, read-only.
+///
+/// Rows rather than one text box, so a value can carry its own context menu
+/// without anything parsing rendered text back into structure. The user also
+/// asked not to be shown a text box.
+///
+/// **Every value label is explicitly `Qt::PlainText`.** This replaced a
+/// `QPlainTextEdit` whose plain-textness was a security property rather than a
+/// style: header values come from strangers, and plain text cannot interpret
+/// markup, so there is nothing to escape and nothing that can render. A QLabel
+/// guesses under `Qt::AutoText`, so stating the format is what preserves that.
+class MessageDetailsDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ explicit MessageDetailsDialog(const QList<ThreadRenderItem> &items,
+ QWidget *parent = nullptr);
+
+ /// The rows on display, in order. Exposed for testing without rendering.
+ QList<HeaderRow> rows() const { return m_rows; }
+
+ /// Emits searchRequested for `row`, or nothing when the row carries no
+ /// searchable query. The menu entries call this; a test can too, without
+ /// popping a menu.
+ void requestSearch(const HeaderRow &row, bool extend);
+
+signals:
+ /// The user chose a search from a row's menu. `extend` narrows the current
+ /// query rather than replacing it.
+ void searchRequested(const QString &query, bool extend);
+
+private:
+ /// Builds the rows from the thread, one group per message.
+ void buildRows(const QList<ThreadRenderItem> &items);
+
+ QList<HeaderRow> m_rows;
+};
diff --git a/src/messageview.cpp b/src/messageview.cpp
index d2be380..2e7e694 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -22,14 +22,13 @@
#include <QDesktopServices>
#include <QDialog>
#include <QDialogButtonBox>
-#include <QFontDatabase>
-#include <QPlainTextEdit>
#include <QDir>
#include <QBuffer>
#include <QFileDialog>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
+#include <QMenu>
#include <QMouseEvent>
#include <QPushButton>
#include <QStandardPaths>
@@ -49,7 +48,9 @@
#include "cidschemehandler.h"
#include "htmlbuilder.h"
+#include "messagedetailsdialog.h"
#include "requestinterceptor.h"
+#include "searchterm.h"
#include "tagstrip.h"
#include "threadcidmap.h"
#include "version.h"
@@ -151,6 +152,12 @@ MessageView::MessageView(QWidget *parent)
settings->setAttribute(QWebEngineSettings::PluginsEnabled, false);
settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false);
+ // Item 85: a selection in the body is searchable. CustomContextMenu so the
+ // page's standard entries survive and the search is added to them.
+ m_view->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(m_view, &QWidget::customContextMenuRequested,
+ this, &MessageView::showBodyContextMenu);
+
// Ctrl+wheel zoom. The filter goes on the application rather than on
// m_view: the wheel event is delivered to an internal QQuickWidget the
// view creates lazily, so there is no child to filter at this point and a
@@ -163,6 +170,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
@@ -239,6 +252,20 @@ MessageView::MessageView(QWidget *parent)
m_tagStrip = new TagStrip(this);
m_tagStrip->hide();
+ // Item 85: a tag chip is searchable. The strip reports which chip was hit
+ // and where; what a tag can do is decided here, beside the other menus, so
+ // all three surfaces offer the same pair of operations.
+ connect(m_tagStrip, &TagStrip::tagContextMenuRequested, this,
+ [this](const QString &tag, const QPoint &globalPos) {
+ const QString query = SearchTerm::tag(tag);
+ if (query.isEmpty())
+ return;
+
+ QMenu menu(this);
+ addSearchEntries(&menu, { { tr("tag %1").arg(tag), query } });
+ menu.exec(globalPos);
+ });
+
auto *layout = new QVBoxLayout(this);
layout->addLayout(headerRow);
layout->addLayout(blockedRow);
@@ -428,6 +455,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 +472,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 +545,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,58 +563,93 @@ void MessageView::updateHeader()
m_headerLabel->setText(text);
}
-void MessageView::showDetailsDialog()
+void MessageView::addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers)
{
- if (m_items.isEmpty())
+ 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;
- QDialog dialog(this);
- dialog.setWindowTitle(tr("Message details"));
+ QMenu menu(this);
+ addSearchEntries(&menu, m_headerOffers);
+ menu.exec(m_headerLabel->mapToGlobal(pos));
+}
- auto *layout = new QVBoxLayout(&dialog);
+SearchOffer MessageView::selectionSearchOffer(const QString &selectedText) const
+{
+ const QString query = SearchTerm::quote(selectedText);
+ if (query.isEmpty())
+ return {};
- auto *details = new QPlainTextEdit(&dialog);
- details->setReadOnly(true);
- // A monospaced font keeps a long Received chain readable as the wrapped
- // record it is.
- details->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
- details->setLineWrapMode(QPlainTextEdit::NoWrap);
-
- // setPlainText, and a QPlainTextEdit rather than a label: this dialog shows
- // header values verbatim, and those come from strangers. Plain text cannot
- // interpret markup, so there is nothing here to escape and nothing that
- // could render.
- QString text;
- for (int i = 0; i < m_items.size(); ++i) {
- const ParsedMessage &message = m_items.at(i).message;
-
- if (i > 0)
- text += QLatin1Char('\n');
- if (m_items.size() > 1)
- text += tr("--- Message %1 of %2 ---")
- .arg(i + 1).arg(m_items.size()) + QLatin1Char('\n');
-
- auto line = [&text](const QString &label, const QString &value) {
- if (!value.isEmpty())
- text += label + QLatin1Char(' ') + value + QLatin1Char('\n');
- };
+ constexpr int kMaxLabel = 40;
+ const QString shown = selectedText.simplified();
+ return { shown.size() > kMaxLabel
+ ? shown.left(kMaxLabel) + QStringLiteral("...")
+ : shown,
+ query };
+}
- line(tr("Subject:"), message.subject);
- line(tr("From:"), message.from);
- line(tr("To:"), message.to);
- line(tr("Cc:"), message.cc);
- line(tr("Date:"), message.date);
- line(tr("Message-Id:"), message.messageId);
+void MessageView::showBodyContextMenu(const QPoint &pos)
+{
+ // The page's own menu first: copy, select all and the rest stay exactly as
+ // they were. This adds to that menu rather than replacing it.
+ QMenu *menu = m_view->createStandardContextMenu();
+ if (!menu)
+ menu = new QMenu(this);
+ menu->setAttribute(Qt::WA_DeleteOnClose);
+
+ // selectedText() reads the selection out of the render process with no
+ // script injection. JavaScript is disabled in this profile and stays so.
+ const SearchOffer offer = selectionSearchOffer(m_view->page()->selectedText());
+ if (!offer.query.isEmpty()) {
+ menu->addSeparator();
+ addSearchEntries(menu, { offer });
}
- details->setPlainText(text);
- layout->addWidget(details);
+ // popup() rather than exec(): the menu owns itself via WA_DeleteOnClose and
+ // must not block this handler.
+ menu->popup(m_view->mapToGlobal(pos));
+}
- auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog);
- connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
- layout->addWidget(buttons);
+void MessageView::showDetailsDialog()
+{
+ if (m_items.isEmpty())
+ return;
+
+ MessageDetailsDialog dialog(m_items, this);
+
+ // The dialog's searches are the pane's searches: one signal reaches the
+ // window whichever surface the user used.
+ //
+ // It CLOSES on the way out, and that is not tidiness. The dialog is modal,
+ // so without this the query runs and the thread list repaints behind a
+ // window the user still has to dismiss, making the search look like it did
+ // nothing.
+ //
+ // accept() BEFORE the emit, not after. The connection is direct, so the
+ // emit runs the query synchronously: the model clears and this pane blanks
+ // while the modal dialog is still up, holding the m_items it was built
+ // from. Closing first leaves no window in which the dialog describes a
+ // thread the pane has already dropped.
+ connect(&dialog, &MessageDetailsDialog::searchRequested, this,
+ [this, &dialog](const QString &query, bool extend) {
+ dialog.accept();
+ emit searchRequested(query, extend);
+ });
- dialog.resize(700, 400);
dialog.exec();
}
diff --git a/src/messageview.h b/src/messageview.h
index 422869a..3e600f0 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,26 @@ 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; }
+
+ /// The offer for a body selection, its query empty when there is nothing
+ /// usable selected.
+ ///
+ /// Takes the text rather than reading the page, so the quoting is testable
+ /// without a live web engine and a rendered document. A selection is
+ /// arbitrary prose and can carry quotes, newlines and query syntax, none
+ /// of which notmuch reports as an error.
+ SearchOffer selectionSearchOffer(const QString &selectedText) const;
+
public slots:
void toggleHtml();
void loadRemoteContent();
@@ -158,6 +180,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 +241,18 @@ 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);
+
+ /// Builds and pops the web view's menu, keeping its standard entries.
+ void showBodyContextMenu(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 +278,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/src/mimeparser.cpp b/src/mimeparser.cpp
index ecf56f1..2782a4a 100644
--- a/src/mimeparser.cpp
+++ b/src/mimeparser.cpp
@@ -272,20 +272,25 @@ QString recipientSummary(const QString &rawTo, int maxNames)
return summary;
}
-QString attachmentFolderName(const QString &rfc822Date, const QString &subject)
+QDateTime MimeParser::parseDate(const QString &rfc822Date)
{
- // The date prefix sorts chronologically in a file manager. A Date: header
- // that does not parse is simply dropped rather than guessed at.
// A trailing timezone comment, "... +0200 (CEST)", is legal per RFC 5322
// and common in the wild, but Qt::RFC2822Date rejects the whole string
// when one is present (verified on Qt 6.11). Strip comments before
- // parsing, or every such message silently loses its date prefix.
+ // parsing, or every such message silently loses its date.
QString cleaned = rfc822Date;
cleaned.remove(QRegularExpression(QStringLiteral("\\s*\\([^)]*\\)")));
cleaned = cleaned.trimmed();
+ return QDateTime::fromString(cleaned, Qt::RFC2822Date);
+}
+
+QString attachmentFolderName(const QString &rfc822Date, const QString &subject)
+{
+ // The date prefix sorts chronologically in a file manager. A Date: header
+ // that does not parse is simply dropped rather than guessed at.
QString prefix;
- const QDateTime parsed = QDateTime::fromString(cleaned, Qt::RFC2822Date);
+ const QDateTime parsed = MimeParser::parseDate(rfc822Date);
if (parsed.isValid())
prefix = parsed.toString(QStringLiteral("yyyy-MM-dd"));
diff --git a/src/mimeparser.h b/src/mimeparser.h
index 9fceb2f..da54434 100644
--- a/src/mimeparser.h
+++ b/src/mimeparser.h
@@ -19,6 +19,7 @@
#pragma once
#include <QByteArray>
+#include <QDateTime>
#include <QHash>
#include <QList>
#include <QString>
@@ -143,4 +144,14 @@ public:
MimeParser();
ParsedMessage parse(const QString &filePath) const;
+
+ /// Parses an RFC 2822 `Date:` header, returning an invalid QDateTime when
+ /// nothing usable is there.
+ ///
+ /// **Strips comments before parsing**, because `Qt::RFC2822Date` rejects
+ /// the entire string when a trailing timezone comment is present, and
+ /// `... +0200 (CEST)` is legal per RFC 5322 and common in the wild
+ /// (verified on Qt 6.11). A parser without this silently loses the date on
+ /// a large share of real mail.
+ static QDateTime parseDate(const QString &rfc822Date);
};
diff --git a/src/searchterm.cpp b/src/searchterm.cpp
new file mode 100644
index 0000000..acc64c6
--- /dev/null
+++ b/src/searchterm.cpp
@@ -0,0 +1,93 @@
+/*
+ * 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 "searchterm.h"
+
+#include <algorithm>
+
+namespace SearchTerm {
+
+QString quote(const QString &value)
+{
+ // simplified() collapses every run of whitespace, newlines and tabs
+ // included, and trims the ends. A selection spanning paragraphs arrives
+ // full of newlines, which would otherwise reach the query bar verbatim.
+ QString cleaned = value.simplified();
+ if (cleaned.isEmpty())
+ return {};
+
+ if (cleaned.size() > kMaxValueLength)
+ cleaned.truncate(kMaxValueLength);
+
+ // Backslashes first: escaping the quotes first would then escape the
+ // backslashes this step adds, doubling them.
+ cleaned.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
+ cleaned.replace(QLatin1Char('"'), QStringLiteral("\\\""));
+
+ return QLatin1Char('"') + cleaned + QLatin1Char('"');
+}
+
+QString field(const QString &name, const QString &value)
+{
+ const QString quoted = quote(value);
+ if (quoted.isEmpty())
+ return {};
+ return name + QLatin1Char(':') + quoted;
+}
+
+QString onDate(const QDate &day)
+{
+ if (!day.isValid())
+ return {};
+ const QString text = day.toString(QStringLiteral("yyyy-MM-dd"));
+ return QStringLiteral("date:%1..%1").arg(text);
+}
+
+QString tag(const QString &name)
+{
+ const QString trimmed = name.simplified();
+ if (trimmed.isEmpty())
+ return {};
+
+ // A tag is a token from a vocabulary the user chose, so it reads better
+ // unquoted in the bar they are about to edit. Quoted only when it holds
+ // something that would not survive.
+ const bool needsQuoting =
+ std::any_of(trimmed.cbegin(), trimmed.cend(), [](QChar ch) {
+ return !(ch.isLetterOrNumber() || ch == QLatin1Char('-')
+ || ch == QLatin1Char('_') || ch == QLatin1Char('.')
+ || ch == QLatin1Char('/'));
+ });
+
+ return QStringLiteral("tag:") + (needsQuoting ? quote(trimmed) : trimmed);
+}
+
+QString extend(const QString &existing, const QString &addition)
+{
+ const QString left = existing.trimmed();
+ const QString right = addition.trimmed();
+
+ if (right.isEmpty())
+ return left;
+ if (left.isEmpty())
+ return right;
+
+ return QStringLiteral("(%1) AND (%2)").arg(left, right);
+}
+
+} // namespace SearchTerm
diff --git a/src/searchterm.h b/src/searchterm.h
new file mode 100644
index 0000000..d596676
--- /dev/null
+++ b/src/searchterm.h
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QDate>
+#include <QList>
+#include <QString>
+
+/// Builds the notmuch query strings behind the right-click search actions.
+///
+/// Free functions and no widget, so the whole query grammar is testable
+/// without a painter, a web engine or a window. Every surface that offers a
+/// search goes through here, which is what stops five surfaces growing five
+/// slightly different quoting rules.
+///
+/// **notmuch rejects almost nothing.** `from:((((` parses cleanly and matches
+/// zero, so a malformed query produces an empty result rather than an error
+/// the user could act on. Correctness here cannot be checked by asking notmuch;
+/// it is checked against the constructed string.
+namespace SearchTerm {
+
+/// Longest quoted value. A selection longer than this is a mis-drag rather
+/// than a search, and the query bar is an editable line the user has to be
+/// able to read.
+inline constexpr int kMaxValueLength = 200;
+
+/// Quotes an arbitrary value for use as a notmuch term.
+///
+/// Whitespace and newlines collapse to single spaces, embedded quotes are
+/// escaped, the value is capped at kMaxValueLength, and an empty or
+/// whitespace-only value yields an EMPTY STRING rather than `""`. Callers
+/// test for empty to decide whether to offer a menu entry at all.
+QString quote(const QString &value);
+
+/// `field:"value"`, or empty when the value is empty.
+///
+/// The field name is a notmuch keyword and is never translated: it is wire
+/// format, not user-facing text.
+QString field(const QString &name, const QString &value);
+
+/// `date:YYYY-MM-DD..YYYY-MM-DD` for a single day, empty for an invalid date.
+///
+/// The day twice rather than the day and its successor: notmuch's range is
+/// inclusive at both ends, so the naive `..next-day` form silently includes a
+/// second day of mail.
+QString onDate(const QDate &day);
+
+/// `tag:name`, quoted only when the name needs it.
+QString tag(const QString &name);
+
+/// Narrows `existing` by `addition`, as `(existing) AND (addition)`.
+///
+/// **Both sides are parenthesised and that is load-bearing.** The query bar
+/// may hold a hand-written disjunction, and `a or b AND c` binds as
+/// `a or (b AND c)`: the result WIDENS a search the user asked to narrow, and
+/// nothing reports an error. The same trap is why the post-new hook
+/// parenthesises a rule's query before scoping it with `tag:new`.
+///
+/// An empty `existing` yields `addition` alone rather than `() AND (x)`, which
+/// matches nothing; an empty `addition` leaves `existing` untouched.
+QString extend(const QString &existing, const QString &addition);
+
+} // namespace SearchTerm
+
+/// One entry a context menu can offer: a finished query and the text naming it.
+///
+/// Carried rather than rebuilt at menu-construction time, so the value a menu
+/// entry searches for is the value the pane extracted, with no second parse of
+/// anything already rendered.
+struct SearchOffer
+{
+ /// Shown in the menu. Already translated, and elides a long value: the
+ /// query keeps the full one.
+ QString label;
+
+ /// The finished notmuch query. Never empty in a constructed offer.
+ QString query;
+};
diff --git a/src/tagstrip.cpp b/src/tagstrip.cpp
index bad116a..565a054 100644
--- a/src/tagstrip.cpp
+++ b/src/tagstrip.cpp
@@ -18,6 +18,7 @@
#include "tagstrip.h"
+#include <QContextMenuEvent>
#include <QFontMetrics>
#include <QPainter>
@@ -109,6 +110,50 @@ void TagStrip::resizeEvent(QResizeEvent *event)
relayout();
}
+QRect TagStrip::chipRectAt(int index) const
+{
+ if (index < 0 || index >= m_visible.size())
+ return {};
+
+ const QFontMetrics metrics(font());
+
+ // Reproduces paintEvent's own vertical placement exactly, which is derived
+ // from the font's height rather than from the chip's, so a chip whose text
+ // is shorter than the line still lands on the same baseline.
+ const int top = (height() - (metrics.height() + TagChip::kPaddingY * 2)) / 2;
+
+ int x = 0;
+ for (int i = 0; i < index; ++i)
+ x += TagChip::sizeFor(metrics, m_visible.at(i)).width() + TagChip::kSpacing;
+
+ return QRect(QPoint(x, top), TagChip::sizeFor(metrics, m_visible.at(index)));
+}
+
+QString TagStrip::chipAt(const QPoint &point) const
+{
+ for (int i = 0; i < m_visible.size(); ++i) {
+ if (chipRectAt(i).contains(point))
+ return m_visible.at(i);
+ }
+ // Deliberately nothing for the overflow chip and for empty space: the +N
+ // chip names a list, not a tag.
+ return {};
+}
+
+void TagStrip::contextMenuEvent(QContextMenuEvent *event)
+{
+ const QString tag = chipAt(event->pos());
+ if (tag.isEmpty()) {
+ // Ignored rather than accepted, so a parent that offers its own menu
+ // still gets the chance to show it.
+ event->ignore();
+ return;
+ }
+
+ event->accept();
+ emit tagContextMenuRequested(tag, event->globalPos());
+}
+
void TagStrip::paintEvent(QPaintEvent *)
{
if (m_visible.isEmpty())
@@ -118,16 +163,20 @@ void TagStrip::paintEvent(QPaintEvent *)
const QFontMetrics metrics(font());
const int top = (height() - (metrics.height() + TagChip::kPaddingY * 2)) / 2;
- int x = 0;
- for (const QString &tag : m_visible) {
- const QSize size = TagChip::sizeFor(metrics, tag);
+ for (int i = 0; i < m_visible.size(); ++i) {
+ const QString &tag = m_visible.at(i);
const QColor colour = m_tagColors ? m_tagColors->colourFor(tag)
: TagColors().colourFor(tag);
- TagChip::paint(&painter, QRect(QPoint(x, top), size), tag, colour);
- x += size.width() + TagChip::kSpacing;
+ TagChip::paint(&painter, chipRectAt(i), tag, colour);
}
if (!m_hidden.isEmpty()) {
+ // After the last visible chip. right() is inclusive, so +1 makes it an
+ // exclusive edge before the gap is added. The overflow chip is not in
+ // m_visible and so has no chipRectAt() of its own.
+ const QRect last = chipRectAt(m_visible.size() - 1);
+ const int x = last.right() + 1 + TagChip::kSpacing;
+
const QString text = overflowText(m_hidden.size());
const QSize size = TagChip::sizeFor(metrics, text);
TagChip::paint(&painter, QRect(QPoint(x, top), size), text,
diff --git a/src/tagstrip.h b/src/tagstrip.h
index 4102bed..f59233b 100644
--- a/src/tagstrip.h
+++ b/src/tagstrip.h
@@ -18,10 +18,12 @@
#pragma once
+#include <QRect>
#include <QStringList>
#include <QWidget>
class TagColors;
+class QContextMenuEvent;
/// One row of tag chips under the message pane.
///
@@ -48,9 +50,30 @@ public:
QStringList visibleTags() const { return m_visible; }
QStringList hiddenTags() const { return m_hidden; }
+ /// The rect of the visible chip at `index`, empty when out of range.
+ ///
+ /// The SAME function paintEvent lays out from, so what is drawn and what
+ /// is clickable cannot drift. `CardDelegate::expanderRectFor` exists for
+ /// this reason and this follows it.
+ QRect chipRectAt(int index) const;
+
+ /// The tag under `point`, empty when the point is on no chip.
+ ///
+ /// The trailing "+N" chip yields an empty string: it stands for a list of
+ /// tags rather than for one, so there is nothing a search could name.
+ QString chipAt(const QPoint &point) const;
+
+signals:
+ /// A visible chip was right-clicked. `globalPos` is where to pop a menu.
+ ///
+ /// The strip does not build the menu itself: what a tag can do belongs to
+ /// the window, which owns the query bar and the actions.
+ void tagContextMenuRequested(const QString &tag, const QPoint &globalPos);
+
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
+ void contextMenuEvent(QContextMenuEvent *event) override;
private:
/// Recomputes the visible/hidden split for the current width.