aboutsummaryrefslogtreecommitdiffstats
path: root/src/messageview.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 16:23:43 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:53:37 +0200
commit0364f7d48813a350e9c02d78e652f6b58c31abec (patch)
tree36241bbef89577ac07dce128be318e9556bbd489 /src/messageview.cpp
parent98c1c615d147bfee3f17d4f420e1512af5a79436 (diff)
downloadqtmaildir-0364f7d48813a350e9c02d78e652f6b58c31abec.tar.gz
qtmaildir-0364f7d48813a350e9c02d78e652f6b58c31abec.zip
feat: own the message-pane zoom and persist it
Zoom was Chromium's, not the application's: the web view handled the keys natively and never told anyone, so there was no value to save. qtmaildir now owns it. Zoom in, out and reset are real actions, in the View menu and rebindable through [keys], and the factor is persisted to the UI state file. Ctrl+wheel over the body zooms and Ctrl+middle-click resets, both filtered by ancestry from an application-level filter: the events are delivered to an internal QQuickWidget the web view creates lazily, so a filter on the view itself never sees them. The factor is clamped to 0.5 - 3.0, and NaN, infinity, zero and negative values fall back to 1.0, since a corrupt state file must not be able to leave the pane unreadable with no visible way back. Both risks the plan flagged turned out not to exist, verified by probe rather than assumed. The application QAction wins over the web view's native zoom key, so the tracked factor cannot diverge from what is on screen. And the factor survives setHtml(), so the web view is the single source of truth and needs no reapply per render. A third finding is worth recording because it produced a wrong fix first. A probe using QTest::keyClick() reported Ctrl++ as a dead binding, and a test was written asserting that. Both were wrong: Ctrl++ is exactly what the '+' key emits on an Italian layout, confirmed against the real keyboard, and it is the shipped default. Whether a symbol needs Shift is a property of the layout, not of Qt, and keyClick() reproduces neither. The test now only checks that every default parses, and the comment in defaultBindings() says not to re-derive this from synthetic input. Ctrl+= is a second binding for reset, skipped when [keys] gives it to something else. Also fixes a pre-existing bug the new config key exposed. [general] entries were read as "general/<key>", which matches nothing: QSettings' INI backend treats a section literally named [general] as its own fallback section and strips the prefix. notmuch_config had therefore never worked. Both keys are now read without it; the file format is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/messageview.cpp')
-rw-r--r--src/messageview.cpp93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp
index aebb81b..9a1d3e4 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -21,8 +21,12 @@
#include <QDesktopServices>
#include <QHBoxLayout>
#include <QLabel>
+#include <QApplication>
+#include <QMouseEvent>
#include <QPushButton>
+#include <QtNumeric>
#include <QTimer>
+#include <QWheelEvent>
#include <QVBoxLayout>
#include <QWebEnginePage>
#include <QWebEngineProfile>
@@ -101,6 +105,13 @@ MessageView::MessageView(QWidget *parent)
settings->setAttribute(QWebEngineSettings::PluginsEnabled, false);
settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false);
+ // 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
+ // filter on m_view itself would never see it. eventFilter() narrows by
+ // ancestry, so no event outside this pane is touched.
+ qApp->installEventFilter(this);
+
m_headerLabel = new QLabel(this);
m_headerLabel->setTextFormat(Qt::RichText);
m_headerLabel->setWordWrap(true);
@@ -281,6 +292,88 @@ void MessageView::toggleHtml()
render();
}
+bool MessageView::eventFilter(QObject *watched, QEvent *event)
+{
+ const QEvent::Type type = event->type();
+ if (type != QEvent::Wheel && type != QEvent::MouseButtonPress)
+ return QWidget::eventFilter(watched, event);
+
+ // Application-wide filter: only events inside this pane are ours. Anything
+ // else, including a Ctrl+wheel over the thread list, passes untouched.
+ // isAncestorOf() is false for the widget itself, so test that separately.
+ auto *widget = qobject_cast<QWidget *>(watched);
+ if (!widget || (widget != m_view && !m_view->isAncestorOf(widget)))
+ return QWidget::eventFilter(watched, event);
+
+ if (type == QEvent::Wheel) {
+ auto *wheel = static_cast<QWheelEvent *>(event);
+ if (!(wheel->modifiers() & Qt::ControlModifier))
+ return QWidget::eventFilter(watched, event);
+
+ // angleDelta is in eighths of a degree; one detent is 120. A high
+ // resolution wheel sends smaller steps, so scale rather than treating
+ // every event as one full step.
+ const int delta = wheel->angleDelta().y();
+ if (delta != 0)
+ setZoomFactor(zoomFactor() + 0.1 * delta / 120.0);
+
+ // Consumed, or Chromium's own Ctrl+wheel zoom would run on top of
+ // ours and the factor we track would no longer be what is on screen.
+ return true;
+ }
+
+ // Ctrl+middle-click resets: the same hand that just zoomed with the wheel
+ // puts it back, without reaching for the keyboard.
+ auto *mouse = static_cast<QMouseEvent *>(event);
+ if (mouse->button() != Qt::MiddleButton
+ || !(mouse->modifiers() & Qt::ControlModifier)) {
+ return QWidget::eventFilter(watched, event);
+ }
+
+ zoomReset();
+
+ // Consumed: a plain middle click is paste-on-X11 in some contexts, and
+ // this gesture must do one thing only.
+ return true;
+}
+
+qreal MessageView::clampZoom(qreal factor)
+{
+ // qIsFinite rejects the NaN and infinity a corrupt or hand-edited state
+ // file can produce; qFuzzyIsNull rejects the 0.0 that a missing or
+ // non-numeric value converts to, which would render nothing at all.
+ if (!qIsFinite(factor) || factor <= 0.0)
+ return kDefaultZoom;
+ return qBound(kMinZoom, factor, kMaxZoom);
+}
+
+qreal MessageView::zoomFactor() const
+{
+ // The web view is the single source of truth. It keeps the factor across
+ // setHtml(), verified on Qt 6.11, so there is no second copy to drift.
+ return m_view->zoomFactor();
+}
+
+void MessageView::setZoomFactor(qreal factor)
+{
+ m_view->setZoomFactor(clampZoom(factor));
+}
+
+void MessageView::zoomIn()
+{
+ setZoomFactor(zoomFactor() + 0.1);
+}
+
+void MessageView::zoomOut()
+{
+ setZoomFactor(zoomFactor() - 0.1);
+}
+
+void MessageView::zoomReset()
+{
+ setZoomFactor(kDefaultZoom);
+}
+
void MessageView::loadRemoteContent()
{
// Applies to this thread only and is cleared by the next showThread().