diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.cpp | 26 | ||||
| -rw-r--r-- | src/config.h | 6 | ||||
| -rw-r--r-- | src/keymap.cpp | 12 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 38 | ||||
| -rw-r--r-- | src/messageview.cpp | 93 | ||||
| -rw-r--r-- | src/messageview.h | 24 |
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 943cbd8..3e94249 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(); |
