From f7f868ce00e01e4026be19437242537967875ce5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 12:53:58 +0200 Subject: 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. --- src/messageview.h | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) (limited to 'src/messageview.h') 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 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 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 &offers); QList 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 m_headerOffers; }; -- cgit v1.2.3 From 8a2e02a3e1a669a738d1de4615720378d5cc2d38 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 12:56:53 +0200 Subject: 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. --- src/messageview.cpp | 42 ++++++++++++++++++++++++++++++++++++++++++ src/messageview.h | 12 ++++++++++++ tests/test_messageview.cpp | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) (limited to 'src/messageview.h') 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 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" -- cgit v1.2.3