aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 12:56:53 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 12:56:53 +0200
commit8a2e02a3e1a669a738d1de4615720378d5cc2d38 (patch)
treec1dc8fb4cad01987fb192869887e28d7e37fb4f1
parentf7f868ce00e01e4026be19437242537967875ce5 (diff)
downloadqtmaildir-8a2e02a3e1a669a738d1de4615720378d5cc2d38.tar.gz
qtmaildir-8a2e02a3e1a669a738d1de4615720378d5cc2d38.zip
feat(search): search for the selected body text
selectedText() reads the selection with no script injection; JavaScript stays disabled in the profile. The page's standard menu is kept and the entries are added to it below a separator. The quoting is tested through a function taking the text, so it needs no live web engine: a selection is arbitrary prose and can carry quotes, newlines and query syntax, none of which notmuch reports as an error.
-rw-r--r--src/messageview.cpp42
-rw-r--r--src/messageview.h12
-rw-r--r--tests/test_messageview.cpp34
3 files changed, 88 insertions, 0 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 8a1976d..5f7c9d2 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -153,6 +153,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
@@ -569,6 +575,42 @@ void MessageView::showHeaderContextMenu(const QPoint &pos)
menu.exec(m_headerLabel->mapToGlobal(pos));
}
+SearchOffer MessageView::selectionSearchOffer(const QString &selectedText) const
+{
+ const QString query = SearchTerm::quote(selectedText);
+ if (query.isEmpty())
+ return {};
+
+ constexpr int kMaxLabel = 40;
+ const QString shown = selectedText.simplified();
+ return { shown.size() > kMaxLabel
+ ? shown.left(kMaxLabel) + QStringLiteral("...")
+ : shown,
+ query };
+}
+
+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 });
+ }
+
+ // popup() rather than exec(): the menu owns itself via WA_DeleteOnClose and
+ // must not block this handler.
+ menu->popup(m_view->mapToGlobal(pos));
+}
+
void MessageView::showDetailsDialog()
{
if (m_items.isEmpty())
diff --git a/src/messageview.h b/src/messageview.h
index 26ed216..3e600f0 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -140,6 +140,15 @@ public:
/// 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();
@@ -235,6 +244,9 @@ private:
/// 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.
///
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index 6403308..f83a377 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -52,6 +52,7 @@ private slots:
void headerOffersSubjectDateAndSenderForOneMessage();
void headerOffersNoSenderForARealThread();
void headerOffersNothingForAnAbsentField();
+ void bodySelectionBecomesAQuotedSearch();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -663,5 +664,38 @@ void TestMessageView::headerOffersNothingForAnAbsentField()
QVERIFY(!query.startsWith(QStringLiteral("cc:")));
}
+void TestMessageView::bodySelectionBecomesAQuotedSearch()
+{
+ // The selection reaches the query as ONE quoted term. Asserted on the
+ // constructed string: a query that lost its quoting is not an error to
+ // notmuch, it simply matches nothing, so nothing downstream would report
+ // this being wrong.
+ //
+ // Takes the text as an argument rather than reading the page, so the
+ // quoting is testable without a live web engine and a rendered document.
+ MessageView view;
+
+ QCOMPARE(view.selectionSearchOffer(QStringLiteral("invoice 4471")).query,
+ QStringLiteral("\"invoice 4471\""));
+
+ // A selection spanning paragraphs arrives full of newlines.
+ QCOMPARE(view.selectionSearchOffer(
+ QStringLiteral("first line\n\nsecond line")).query,
+ QStringLiteral("\"first line second line\""));
+
+ // Query syntax in the selection is data, not syntax: it is quoted, not
+ // interpreted, so a selection reading "a or b" searches for that phrase.
+ QCOMPARE(view.selectionSearchOffer(QStringLiteral("tag:inbox or x")).query,
+ QStringLiteral("\"tag:inbox or x\""));
+
+ // Nothing selected means no entry, rather than an entry searching for "".
+ QVERIFY(view.selectionSearchOffer(QString()).query.isEmpty());
+ QVERIFY(view.selectionSearchOffer(QStringLiteral(" \n ")).query.isEmpty());
+
+ // A usable offer always carries a label for the menu to show.
+ QVERIFY(!view.selectionSearchOffer(QStringLiteral("invoice 4471"))
+ .label.isEmpty());
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"