aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp26
-rw-r--r--src/config.h6
-rw-r--r--src/keymap.cpp12
-rw-r--r--src/mainwindow.cpp38
-rw-r--r--src/messageview.cpp93
-rw-r--r--src/messageview.h24
6 files changed, 198 insertions, 1 deletions
diff --git a/src/config.cpp b/src/config.cpp
index f21bba9..a2caaee 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -52,8 +52,32 @@ void Config::load(const QString &path)
{
QSettings settings(path, QSettings::IniFormat);
+ // Keys of [general] are read WITHOUT the "general/" prefix. QSettings'
+ // INI backend treats a section literally named [general] as its own
+ // fallback section and strips the prefix, so "general/notmuch_config"
+ // never matches anything, in any section arrangement (verified on
+ // Qt 6.11). The file still reads as [general] to the user; only the
+ // lookup differs. Same family of trap as the [account.work] dot and the
+ // childKeys() ordering already documented in CLAUDE.md.
m_notmuchConfig =
- settings.value(QStringLiteral("general/notmuch_config")).toString();
+ settings.value(QStringLiteral("notmuch_config")).toString();
+
+ // Absent is fine and silent: the default is 1.0. Present but unparseable
+ // is a problem, since the user asked for something and is not getting it.
+ // The range check lives in MessageView::clampZoom(), the one place that
+ // knows what the web view can render.
+ const QVariant zoom = settings.value(QStringLiteral("message_zoom"));
+ if (zoom.isValid()) {
+ bool ok = false;
+ const double value = zoom.toString().toDouble(&ok);
+ if (ok) {
+ m_messageZoom = value;
+ } else {
+ addProblem(QStringLiteral("Message zoom '%1' is not a number; "
+ "using the default.")
+ .arg(zoom.toString()));
+ }
+ }
m_syncCommand = settings.value(QStringLiteral("sync/command")).toString();
if (m_syncCommand.isEmpty()) {
diff --git a/src/config.h b/src/config.h
index cfb046e..8089d67 100644
--- a/src/config.h
+++ b/src/config.h
@@ -78,6 +78,11 @@ public:
/// Optional alternate notmuch config file. Empty means "let notmuch decide".
QString notmuchConfig() const { return m_notmuchConfig; }
+ /// Starting message-pane zoom for a profile with no saved UI state. Once
+ /// the user zooms, the state file remembers that instead, so this is only
+ /// ever the default. Clamped by MessageView::clampZoom() on use.
+ qreal messageZoom() const { return m_messageZoom; }
+
/// Every non-fatal problem, both kinds below. Shown in the status bar.
QStringList warnings() const { return m_warnings; }
@@ -101,6 +106,7 @@ private:
QList<SavedQuery> m_savedQueries;
QString m_syncCommand;
QString m_notmuchConfig;
+ qreal m_messageZoom = 1.0;
QStringList m_warnings;
QStringList m_problems;
};
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 42ccd40..22c8da1 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -35,6 +35,9 @@ QStringList KeyMap::knownActions()
QStringLiteral("focus_query"),
QStringLiteral("toggle_html"),
QStringLiteral("load_remote"),
+ QStringLiteral("zoom_in"),
+ QStringLiteral("zoom_out"),
+ QStringLiteral("zoom_reset"),
QStringLiteral("undo"),
QStringLiteral("sync"),
QStringLiteral("quit"),
@@ -63,6 +66,15 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Ctrl+L"), QStringLiteral("focus_query") },
{ QStringLiteral("Ctrl+H"), QStringLiteral("toggle_html") },
{ QStringLiteral("Ctrl+M"), QStringLiteral("load_remote") },
+ // Ctrl++ is what the '+' key really delivers on a layout where '+' is
+ // unshifted, an Italian one among them, confirmed against the actual
+ // keyboard. QTest::keyClick() cannot reproduce it, so a synthetic-input
+ // probe wrongly reports this binding as dead; do not "fix" it on that
+ // evidence. A US layout, where '+' is Shift+'=', wants Ctrl+Shift+= in
+ // [keys] instead.
+ { QStringLiteral("Ctrl++"), QStringLiteral("zoom_in") },
+ { QStringLiteral("Ctrl+-"), QStringLiteral("zoom_out") },
+ { QStringLiteral("Ctrl+0"), QStringLiteral("zoom_reset") },
{ QStringLiteral("Ctrl+Z"), QStringLiteral("undo") },
{ QStringLiteral("Ctrl+G"), QStringLiteral("sync") },
{ QStringLiteral("Ctrl+Q"), QStringLiteral("quit") },
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 081984d..c4955b6 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -105,6 +105,13 @@ void MainWindow::restoreUiState()
if (!header.isEmpty()) {
m_threadView->horizontalHeader()->restoreState(header);
}
+
+ // The config value is the starting point for a profile that has never
+ // zoomed; once the user does, the state file is what they last had.
+ // clampZoom() rejects the garbage a hand-edited file can hold.
+ m_messageView->setZoomFactor(
+ state.value(QStringLiteral("message/zoom"), m_config.messageZoom())
+ .toDouble());
}
void MainWindow::saveUiState() const
@@ -116,6 +123,7 @@ void MainWindow::saveUiState() const
state.setValue(QStringLiteral("window/splitter"), m_splitter->saveState());
state.setValue(QStringLiteral("threadlist/header"),
m_threadView->horizontalHeader()->saveState());
+ state.setValue(QStringLiteral("message/zoom"), m_messageView->zoomFactor());
}
void MainWindow::closeEvent(QCloseEvent *event)
@@ -381,6 +389,32 @@ void MainWindow::registerActions()
tr("Load remote images for the current thread"), [this]() {
m_messageView->loadRemoteContent();
});
+ addAction(QStringLiteral("zoom_in"), tr("Zoom &in"),
+ tr("Enlarge the message text"), [this]() {
+ m_messageView->zoomIn();
+ });
+ addAction(QStringLiteral("zoom_out"), tr("Zoom &out"),
+ tr("Shrink the message text"), [this]() {
+ m_messageView->zoomOut();
+ });
+ auto *zoomReset =
+ addAction(QStringLiteral("zoom_reset"), tr("&Actual size"),
+ tr("Return the message text to its default size"), [this]() {
+ m_messageView->zoomReset();
+ });
+
+ // Ctrl+= alongside the configured binding: '=' reads as "back to normal",
+ // and on a layout where '+' is Shift+'=' it is the unshifted key next to
+ // zoom in. Appended rather than assigned, so a [keys] override of
+ // zoom_reset keeps working and simply gains this as a second way in.
+ // A user who bound Ctrl+= to something else in [keys] keeps their binding.
+ const QKeySequence altReset(QStringLiteral("Ctrl+="));
+ if (m_keyMap.actionFor(altReset).isEmpty()) {
+ QList<QKeySequence> shortcuts = zoomReset->shortcuts();
+ shortcuts.append(altReset);
+ zoomReset->setShortcuts(shortcuts);
+ }
+
addAction(QStringLiteral("undo"), tr("&Undo"),
tr("Undo the last tag change"), [this]() {
if (m_undoStack.canUndo())
@@ -428,6 +462,10 @@ void MainWindow::buildMenus()
viewMenu->addSeparator();
viewMenu->addAction(m_actions.value(QStringLiteral("toggle_html")));
viewMenu->addAction(m_actions.value(QStringLiteral("load_remote")));
+ viewMenu->addSeparator();
+ viewMenu->addAction(m_actions.value(QStringLiteral("zoom_in")));
+ viewMenu->addAction(m_actions.value(QStringLiteral("zoom_out")));
+ viewMenu->addAction(m_actions.value(QStringLiteral("zoom_reset")));
auto *helpMenu = menuBar()->addMenu(tr("&Help"));
auto *shortcuts = helpMenu->addAction(tr("&Keyboard shortcuts"));
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().
diff --git a/src/messageview.h b/src/messageview.h
index 9570db5..09e7d71 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -65,13 +65,37 @@ public:
/// Tags of the thread on display, shown as chips along the bottom.
void setTags(const QStringList &tags);
+ /// The body zoom factor. Chromium's own range is roughly 0.25 to 5.0;
+ /// these are tighter, since a pane at either extreme is unusable and the
+ /// only visible way back is a menu entry the user cannot read.
+ static constexpr qreal kMinZoom = 0.5;
+ static constexpr qreal kMaxZoom = 3.0;
+ static constexpr qreal kDefaultZoom = 1.0;
+
+ /// Clamps to [kMinZoom, kMaxZoom]. A non-finite or non-positive value,
+ /// which is what a corrupt state file yields, falls back to kDefaultZoom.
+ static qreal clampZoom(qreal factor);
+
+ qreal zoomFactor() const;
+ void setZoomFactor(qreal factor);
+
public slots:
void toggleHtml();
void loadRemoteContent();
+ void zoomIn();
+ void zoomOut();
+ void zoomReset();
signals:
void statusMessage(const QString &text);
+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
+ /// focus proxy, not to the view itself, so this filters the whole subtree
+ /// rather than one widget.
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
private:
void render();
void updateHeader();