aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 16:33:13 +0200
committerDanilo M. <danix@danix.xyz>2026-08-03 16:33:13 +0200
commitea90e69d5e974960c653e65a5bb9ca1359f2d52c (patch)
tree87410a035212f0e8e5997722662803c17a3320d8
parentec390fb46e1a36d8406dde487228bbb0f348a20a (diff)
parent2c33529fb665cb54c31e54230fcb7b2491cf8565 (diff)
downloadqtmaildir-ea90e69d5e974960c653e65a5bb9ca1359f2d52c.tar.gz
qtmaildir-ea90e69d5e974960c653e65a5bb9ca1359f2d52c.zip
Merge branch 'feature/ui-state-persistence'
Persistence cluster from the post-0.1.0 usability backlog: window, splitter and column geometry survive restart, the message pane owns its zoom and remembers it, and the startup query is chosen by name instead of by alphabetical accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md17
-rw-r--r--README.md11
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md69
-rw-r--r--src/config.cpp62
-rw-r--r--src/config.h24
-rw-r--r--src/keymap.cpp12
-rw-r--r--src/mainwindow.cpp122
-rw-r--r--src/mainwindow.h17
-rw-r--r--src/messageview.cpp93
-rw-r--r--src/messageview.h24
-rw-r--r--tests/test_config.cpp123
-rw-r--r--tests/test_keymap.cpp20
-rw-r--r--tests/test_mainwindow.cpp64
-rw-r--r--tests/test_messageview.cpp55
14 files changed, 701 insertions, 12 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index fd1283e..1b6a1bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,9 +77,24 @@ Per-account subdirectories *are* configured, since notmuch does not model accoun
**Config format gotcha:** QSettings treats `/` in a section name as a group separator, so
account sections are `[account.work]`, not `[account/work]`. `childKeys` returns keys
-sorted alphabetically, never in file order. Config lives at
+sorted alphabetically, never in file order. **`[general]` keys are read WITHOUT the
+`general/` prefix** — QSettings' INI backend treats a section literally named `[general]`
+as its own fallback section and strips it, so a `general/<key>` lookup silently matches
+nothing (this is how `notmuch_config` went unnoticed as broken). Config lives at
`~/.config/qtmaildir/qtmaildir.conf`.
+Machine-written UI state is a **separate** file, `~/.local/state/qtmaildir/uistate.conf`
+via `MainWindow::uiStatePath()`. Never write window blobs into the hand-edited config.
+Build the path from `QStandardPaths::GenericStateLocation`, not `StateLocation`: the
+latter appends both the organization and the application name, and both are `qtmaildir`.
+
+**Do not conclude a key binding is dead from `QTest::keyClick()`.** Whether a symbol needs
+Shift is a layout property, not a Qt one. `Ctrl++` is the shipped `zoom_in` default and is
+exactly what the `+` key emits on an Italian layout, while synthetic input never delivers
+it. Verify against a real keyboard before changing a default on reachability grounds. The
+separate, real trap `normalizeSequence()` handles is a **bare capital** (`N` parses to
+unshifted Key_N, which no keystroke emits).
+
## Web view security
The most security-sensitive area: a browser engine pointed at input from strangers. Do not
diff --git a/README.md b/README.md
index 37ea3c9..55b4f50 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,14 @@ identity.
; Optional. Omit to let notmuch resolve its own config, which is what keeps
; the GUI and the CLI pointed at one database.
; notmuch_config = /home/you/.notmuch-config
+; Optional. Starting zoom of the message pane, 0.5 to 3.0. Only the starting
+; point: once you zoom with Ctrl+wheel or Ctrl+/Ctrl-, that is remembered
+; separately and this value no longer applies.
+; message_zoom = 1.0
+; Optional. Which [queries] entry to open at startup, by name. Defaults to
+; Unread. Falls back to the first saved query if no query by this name
+; exists, and warns if you named one explicitly.
+; startup_query = Unread
[sync]
; Optional. Omit and the Sync button disables itself with a tooltip.
@@ -121,7 +129,8 @@ k = prev_thread
Saved-query buttons appear in alphabetical order rather than file order:
QSettings returns keys sorted, and preserving file order would mean
-hand-rolling an INI parser.
+hand-rolling an INI parser. Which query opens at startup is therefore a
+separate setting, `[general] startup_query`, rather than "the first one".
## Tags
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 915c8cb..02bf7c4 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -34,10 +34,10 @@ taking that too literally.
| # | Item | Cluster | Size | Status |
|---|------|---------|------|--------|
-| 1 | Splitter/column widths do not survive restart | persistence | S | open |
+| 1 | Splitter/column widths do not survive restart | persistence | S | **done** |
| 2 | No way to see full message details (From/To/Cc/Subject) | information | M | open |
| 3 | Too few clickable affordances, shortcuts are the only route | discoverability | M | **done** |
-| 4 | Message-pane font size does not survive restart | persistence | S | open |
+| 4 | Message-pane font size does not survive restart | persistence | S | **done** |
| 5 | Thread list is cramped, poor readability | presentation | S | open |
| 6 | Opened message stays unread | behavior | S | open |
| 7 | HTML view should be default for HTML messages | behavior | XS | **verify first, may already be done** |
@@ -89,6 +89,24 @@ as well. Establish it once, in whichever lands first.
confirm both held. Then delete the state file and confirm the app still starts
with the 1200x800 default rather than a zero-size window.
+### Outcome (done)
+
+Built as described. `MainWindow::uiStatePath()` establishes the state file the
+plan calls for, so items 4 and 10 inherit it. Two things worth recording:
+
+- **`QStandardPaths::StateLocation` is the wrong enum here.** It appends both
+ the organization and the application name, and this app sets both to
+ `qtmaildir`, so it yields `~/.local/state/qtmaildir/qtmaildir/`. The path is
+ built from `GenericStateLocation` plus an explicit `/qtmaildir`, the same
+ shape as `Config::defaultPath()`. A test pins the component count.
+- **`restoreUiState()` runs after `buildMenus()`, not at the end of
+ `buildUi()`** as the plan proposed. `QMainWindow::restoreState()` matches
+ toolbars by object name, so a toolbar that does not exist yet has its
+ position silently dropped.
+
+Every restore is guarded on a non-empty blob, so absent state leaves the
+`buildUi()` defaults rather than producing a zero-size window.
+
## 2. No way to see full message details
**Observed:** From, To, Cc, Subject and the rest are not visible for the
@@ -243,6 +261,38 @@ already exists.
- Route the actions through item 3's `QAction` conversion so they appear in the
View menu, which also makes the reset discoverable.
+### Outcome (done)
+
+Built as described, and both of the plan's stated risks turned out not to
+exist. Probed rather than assumed:
+
+- **The application `QAction` wins over Chromium's native zoom key.** The plan
+ called this "the one real risk in the item". It is not one: the action fires
+ and the web view's own handling never runs, so the tracked factor cannot
+ diverge from what is on screen.
+- **Zoom survives `setHtml()`.** The plan expected the view might reset it on
+ navigation and asked for a reapply per render. Not needed; the web view keeps
+ the factor, so it is the single source of truth and there is no second copy.
+- **Do not test key reachability with synthetic input.** A probe using
+ `QTest::keyClick()` reported `Ctrl++` as a dead binding, and a test was
+ written asserting it. 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.
+- `Ctrl+=` is a second binding for reset, skipped when `[keys]` gives `Ctrl+=`
+ to something else. Ctrl+wheel zooms and Ctrl+middle-click resets, both
+ filtered by ancestry from an application-level filter: the events land on an
+ internal `QQuickWidget` the web view creates lazily, so a filter installed on
+ the view itself never sees them.
+
+**A pre-existing bug surfaced while adding the config key.** `[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 the prefix; the file format the user writes is
+unchanged. Regression test in `test_config`.
+
## 5. Thread list is cramped
**Observed:** rows are tightly packed, everything is uniform, the UI reads as
@@ -342,6 +392,21 @@ stays blocked and per-render.
Do not build a full account sidebar for this. Persisting the selection may
resolve the complaint entirely, and it is a fraction of the work. Reassess after.
+### Partly done
+
+**The startup query is now chosen by name**, not by sort order. `[queries]` is
+read through `childKeys()`, which sorts alphabetically, so the old
+`savedQueries().first()` opened whichever entry happened to sort first, which
+is why the app came up on Inbox. `[general] startup_query` names the entry,
+defaults to `Unread`, and falls back to the first saved query when the name
+matches nothing. Only a name the user wrote is worth a warning: the built-in
+default naming a query they never created is not something they got wrong.
+
+Neither half of item 10 proper is done: the account selection still resets on
+restart, and reaching an account's inbox is still two steps. Persisting the
+selection remains the next cheap step, and the reassessment the item calls for
+should happen after that rather than now.
+
## 11. Icon, `.desktop` file, SlackBuild
Packaging, independent of everything above, and can proceed in parallel.
diff --git a/src/config.cpp b/src/config.cpp
index f21bba9..e92c7f3 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -52,8 +52,40 @@ 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.
+ // Empty is treated as unset rather than as "a query named nothing".
+ const QString startup =
+ settings.value(QStringLiteral("startup_query")).toString().trimmed();
+ if (!startup.isEmpty()) {
+ m_startupQuery = startup;
+ m_startupQueryWasSet = true;
+ }
+
+ 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()) {
@@ -125,6 +157,34 @@ void Config::load(const QString &path)
for (const QString &name : settings.childKeys())
m_savedQueries.append({ name, settings.value(name).toString() });
settings.endGroup();
+
+ // Checked here rather than where startup_query is read: [queries] is not
+ // parsed until now. Only a name the user actually wrote is worth a
+ // problem; the built-in default naming a query they never created is not
+ // something they got wrong.
+ if (m_startupQueryWasSet && !m_savedQueries.isEmpty()
+ && startupSavedQuery().name.compare(m_startupQuery,
+ Qt::CaseInsensitive) != 0) {
+ addProblem(QStringLiteral("Startup query '%1' is not a saved query; "
+ "opening '%2' instead.")
+ .arg(m_startupQuery, startupSavedQuery().name));
+ }
+}
+
+SavedQuery Config::startupSavedQuery() const
+{
+ if (m_savedQueries.isEmpty())
+ return {};
+
+ for (const SavedQuery &query : m_savedQueries) {
+ if (query.name.compare(m_startupQuery, Qt::CaseInsensitive) == 0)
+ return query;
+ }
+
+ // Named a query that does not exist. Not worth a warning: the default is
+ // a name the user never wrote, so an install with no [queries] Unread
+ // entry would warn on every launch about a key it never set.
+ return m_savedQueries.first();
}
Account Config::account(const QString &key) const
diff --git a/src/config.h b/src/config.h
index 943cbd8..3b7fc48 100644
--- a/src/config.h
+++ b/src/config.h
@@ -78,6 +78,23 @@ public:
/// Optional alternate notmuch config file. Empty means "let notmuch decide".
QString notmuchConfig() const { return m_notmuchConfig; }
+ /// The saved query to open at startup, by name. Falls back to "Unread"
+ /// when unset, and to the first saved query when no query by that name
+ /// exists: [queries] is read through childKeys(), which sorts
+ /// alphabetically, so "first" would otherwise mean whatever happens to
+ /// sort first rather than anything the user chose.
+ QString startupQuery() const { return m_startupQuery; }
+
+ /// The saved query startupQuery() names, or the first one when it names
+ /// nothing that exists. A default-constructed SavedQuery when there are
+ /// none at all.
+ SavedQuery startupSavedQuery() const;
+
+ /// 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 +118,13 @@ private:
QList<SavedQuery> m_savedQueries;
QString m_syncCommand;
QString m_notmuchConfig;
+ qreal m_messageZoom = 1.0;
+ QString m_startupQuery = QStringLiteral("Unread");
+
+ /// Whether startup_query came from the config rather than being the
+ /// built-in default. Only a name the user wrote is worth reporting when
+ /// it matches no saved query.
+ bool m_startupQueryWasSet = false;
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 48b475c..2a79988 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -19,9 +19,12 @@
#include "mainwindow.h"
#include <QAction>
+#include <QCloseEvent>
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
+#include <QDir>
+#include <QFileInfo>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
@@ -33,6 +36,7 @@
#include <QPushButton>
#include <QSettings>
#include <QSplitter>
+#include <QStandardPaths>
#include <QStatusBar>
#include <QTableView>
#include <QToolBar>
@@ -61,6 +65,73 @@ QString MainWindow::cidPrefixForIndex(int index)
return QStringLiteral("m%1").arg(index);
}
+QString MainWindow::uiStatePath()
+{
+ // GenericStateLocation, not StateLocation: the latter appends both the
+ // organization and the application name, and both are "qtmaildir" here,
+ // so it yields ~/.local/state/qtmaildir/qtmaildir. Built the same way
+ // Config::defaultPath() builds its own.
+ const QString base =
+ QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
+ return base + QStringLiteral("/qtmaildir/uistate.conf");
+}
+
+void MainWindow::restoreUiState()
+{
+ QSettings state(uiStatePath(), QSettings::IniFormat);
+
+ // Every restore is conditional: an absent or rejected blob must leave the
+ // buildUi() defaults alone rather than produce a zero-size window.
+ const QByteArray geometry = state.value(QStringLiteral("window/geometry"))
+ .toByteArray();
+ if (!geometry.isEmpty()) {
+ restoreGeometry(geometry);
+ }
+
+ const QByteArray windowState = state.value(QStringLiteral("window/state"))
+ .toByteArray();
+ if (!windowState.isEmpty()) {
+ restoreState(windowState);
+ }
+
+ const QByteArray splitter = state.value(QStringLiteral("window/splitter"))
+ .toByteArray();
+ if (!splitter.isEmpty()) {
+ m_splitter->restoreState(splitter);
+ }
+
+ const QByteArray header = state.value(QStringLiteral("threadlist/header"))
+ .toByteArray();
+ 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
+{
+ QDir().mkpath(QFileInfo(uiStatePath()).absolutePath());
+ QSettings state(uiStatePath(), QSettings::IniFormat);
+ state.setValue(QStringLiteral("window/geometry"), saveGeometry());
+ state.setValue(QStringLiteral("window/state"), saveState());
+ 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)
+{
+ saveUiState();
+ QMainWindow::closeEvent(event);
+}
+
MainWindow::MainWindow(const Config &config, QWidget *parent)
: QMainWindow(parent), m_config(config)
{
@@ -87,6 +158,9 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
buildUi();
registerActions();
buildMenus();
+ // After buildMenus(): QMainWindow::restoreState() matches toolbars by
+ // object name, so they must already exist or their position is dropped.
+ restoreUiState();
wireWorker();
showWarnings();
@@ -97,8 +171,12 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
// modifier shortcuts such as Ctrl+Q still work there, which the old
// filter blocked.
- if (!m_config.savedQueries().isEmpty()) {
- m_queryEdit->setText(m_config.savedQueries().first().query);
+ // Not savedQueries().first(): [queries] is read through childKeys(), which
+ // sorts alphabetically, so "first" means whatever happens to sort first
+ // rather than anything the user chose. Config resolves the name.
+ const SavedQuery startup = m_config.startupSavedQuery();
+ if (!startup.query.isEmpty()) {
+ m_queryEdit->setText(startup.query);
runCurrentQuery();
}
}
@@ -210,11 +288,11 @@ void MainWindow::buildUi()
connect(m_messageView, &MessageView::statusMessage,
this, [this](const QString &text) { m_statusLabel->setText(text); });
- auto *splitter = new QSplitter(Qt::Horizontal, central);
- splitter->addWidget(m_threadView);
- splitter->addWidget(m_messageView);
- splitter->setStretchFactor(1, 2);
- layout->addWidget(splitter, 1);
+ m_splitter = new QSplitter(Qt::Horizontal, central);
+ m_splitter->addWidget(m_threadView);
+ m_splitter->addWidget(m_messageView);
+ m_splitter->setStretchFactor(1, 2);
+ layout->addWidget(m_splitter, 1);
layout->addWidget(m_syncLog);
@@ -315,6 +393,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())
@@ -362,6 +466,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/mainwindow.h b/src/mainwindow.h
index 4445894..f4186ff 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -38,6 +38,7 @@ class QLabel;
class QPushButton;
class QComboBox;
class QPlainTextEdit;
+class QSplitter;
class ThreadListModel;
class MessageView;
@@ -63,6 +64,15 @@ public:
/// cid: references from resolving to another's.
static QString cidPrefixForIndex(int index);
+ /// Path of the machine-written UI state file. Deliberately not
+ /// Config::defaultPath(): the config is hand-edited and must never gain a
+ /// base64 geometry blob, nor be rewritten on exit (QSettings does not
+ /// preserve comments or key order).
+ static QString uiStatePath();
+
+protected:
+ void closeEvent(QCloseEvent *event) override;
+
private slots:
void runCurrentQuery();
void onThreadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
@@ -74,6 +84,12 @@ private slots:
private:
void buildUi();
+
+ /// Restores window geometry, splitter and thread-list header widths.
+ /// A missing or rejected blob leaves the buildUi() defaults in place.
+ void restoreUiState();
+ void saveUiState() const;
+
void registerActions();
void buildMenus();
void wireWorker();
@@ -116,6 +132,7 @@ private:
QLineEdit *m_queryEdit = nullptr;
QTableView *m_threadView = nullptr;
+ QSplitter *m_splitter = nullptr;
QComboBox *m_accountBox = nullptr;
QPushButton *m_syncButton = nullptr;
QLabel *m_statusLabel = nullptr;
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();
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index e492480..24f6afb 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -34,6 +34,11 @@ private slots:
void brokenSyncCommandIsAProblem();
void malformedAccountIsAProblem();
void validConfigHasNoProblems();
+ void startupQueryDefaultsToUnread();
+ void startupQueryHonoursTheConfiguredName();
+ void unknownStartupQueryFallsBackAndReports();
+ void generalSectionKeysAreActuallyRead();
+ void messageZoomDefaultsAndValidates();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -222,5 +227,123 @@ void TestConfig::validConfigHasNoProblems()
QVERIFY(config.warnings().isEmpty());
}
+void TestConfig::startupQueryDefaultsToUnread()
+{
+ // [queries] is read through childKeys(), which sorts alphabetically, so
+ // savedQueries().first() is "Flagged" here. The startup query must be
+ // chosen by name, not by sort order.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[queries]\n"
+ "Inbox=tag:inbox\n"
+ "Unread=tag:unread\n"
+ "Flagged=tag:flagged\n")));
+
+ QCOMPARE(config.savedQueries().first().name, QStringLiteral("Flagged"));
+ QCOMPARE(config.startupSavedQuery().name, QStringLiteral("Unread"));
+ QCOMPARE(config.startupSavedQuery().query, QStringLiteral("tag:unread"));
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::startupQueryHonoursTheConfiguredName()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "startup_query=Flagged\n"
+ "\n"
+ "[queries]\n"
+ "Inbox=tag:inbox\n"
+ "Unread=tag:unread\n"
+ "Flagged=tag:flagged\n")));
+
+ QCOMPARE(config.startupSavedQuery().name, QStringLiteral("Flagged"));
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::unknownStartupQueryFallsBackAndReports()
+{
+ // A name the user wrote that matches nothing is a problem: they asked for
+ // something and are not getting it. Startup still works, on the fallback.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "startup_query=Nonexistent\n"
+ "\n"
+ "[queries]\n"
+ "Inbox=tag:inbox\n")));
+
+ QCOMPARE(config.startupSavedQuery().name, QStringLiteral("Inbox"));
+ QCOMPARE(config.problems().size(), 1);
+
+ // The built-in default naming a query the user never created is NOT a
+ // problem: they did not get it wrong, they simply have no Unread entry.
+ QTemporaryDir quiet;
+ Config silent;
+ silent.load(writeIni(quiet, QStringLiteral(
+ "[queries]\n"
+ "Inbox=tag:inbox\n")));
+
+ QCOMPARE(silent.startupSavedQuery().name, QStringLiteral("Inbox"));
+ QVERIFY(silent.problems().isEmpty());
+}
+
+void TestConfig::generalSectionKeysAreActuallyRead()
+{
+ // QSettings' INI backend treats a section literally named [general] as its
+ // own fallback section and strips the prefix, so a "general/<key>" lookup
+ // matches nothing. notmuch_config was read that way and had never worked.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "notmuch_config=/somewhere/notmuch-config\n"
+ "\n"
+ "[sync]\n"
+ "command=/bin/true\n")));
+
+ QCOMPARE(config.notmuchConfig(),
+ QStringLiteral("/somewhere/notmuch-config"));
+}
+
+void TestConfig::messageZoomDefaultsAndValidates()
+{
+ // A QTemporaryDir per case, not one shared: writeIni() always uses the
+ // same file name, and QSettings caches by path, so a second load of the
+ // same path would return the first case's contents.
+
+ // Absent: 1.0, silently. Nothing the user asked for is being ignored.
+ {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+ QCOMPARE(config.messageZoom(), 1.0);
+ QVERIFY(config.problems().isEmpty());
+ }
+
+ {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "message_zoom=1.25\n")));
+ QCOMPARE(config.messageZoom(), 1.25);
+ QVERIFY(config.problems().isEmpty());
+ }
+
+ // Present but unparseable is a problem: the user asked for something and
+ // is not getting it, which is the line addProblem() draws.
+ {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "message_zoom=huge\n")));
+ QCOMPARE(config.messageZoom(), 1.0);
+ QCOMPARE(config.problems().size(), 1);
+ }
+}
+
QTEST_MAIN(TestConfig)
#include "test_config.moc"
diff --git a/tests/test_keymap.cpp b/tests/test_keymap.cpp
index c81eeb0..0fb4f57 100644
--- a/tests/test_keymap.cpp
+++ b/tests/test_keymap.cpp
@@ -36,8 +36,28 @@ private slots:
void userBindingWinsOverDefaultInMenus();
void defaultsDoNotCollide();
void everyDefaultIsAKnownAction();
+ void everyDefaultParses();
};
+void TestKeyMap::everyDefaultParses()
+{
+ // A default that does not parse is a dead binding, the failure mode
+ // bareCapitalMatchesShiftedPress() covers for user-written keys.
+ //
+ // This deliberately does NOT try to decide which keys a keyboard can
+ // deliver. Whether a symbol needs Shift is a layout property, not a Qt
+ // one: Ctrl++ is exactly what the '+' key emits on an Italian layout and
+ // is unreachable on a US one, and QTest::keyClick() cannot reproduce
+ // either faithfully. A test asserting reachability from synthetic input
+ // would encode one layout's habits as a rule for all of them.
+ for (const auto &binding : KeyMap::defaultBindings()) {
+ const QKeySequence sequence = KeyMap::normalizeSequence(binding.first);
+ QVERIFY2(!sequence.isEmpty(),
+ qPrintable(QStringLiteral("default '%1' for %2 does not parse")
+ .arg(binding.first, binding.second)));
+ }
+}
+
void TestKeyMap::defaultsAreLoaded()
{
KeyMap map;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 6bfa925..b4d9e4a 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -20,12 +20,15 @@
#include <QAction>
#include <QDir>
+#include <QFile>
#include <QSettings>
+#include <QStandardPaths>
#include <QTemporaryDir>
#include "config.h"
#include "keymap.h"
#include "mainwindow.h"
+#include "messageview.h"
/// MainWindow is mostly wiring, and the parts that need a real database are
/// still verified manually. What is checked here is the action registry: the
@@ -41,6 +44,9 @@ private slots:
void configuredBindingReachesTheAction();
void cidPrefixesAreBangFree();
void cidPrefixesAreDistinctPerMessage();
+ void uiStateIsNotWrittenIntoTheUserConfig();
+ void uiStateSurvivesARestart();
+ void missingUiStateLeavesTheDefaults();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -158,6 +164,64 @@ void TestMainWindow::cidPrefixesAreDistinctPerMessage()
}
}
+void TestMainWindow::uiStateIsNotWrittenIntoTheUserConfig()
+{
+ // The config file is hand-edited and must never gain a base64 geometry
+ // blob, nor be rewritten on exit: QSettings preserves neither comments nor
+ // key order, so writing it would quietly destroy the user's formatting.
+ QVERIFY(MainWindow::uiStatePath() != Config::defaultPath());
+
+ // One qtmaildir component, not two. QStandardPaths::StateLocation appends
+ // both the organization and the application name, and here both are
+ // "qtmaildir", so using it nests the directory inside itself.
+ QCOMPARE(MainWindow::uiStatePath().count(QStringLiteral("/qtmaildir/")), 1);
+ QVERIFY(MainWindow::uiStatePath().endsWith(
+ QStringLiteral("/qtmaildir/uistate.conf")));
+}
+
+void TestMainWindow::uiStateSurvivesARestart()
+{
+ // Test mode redirects QStandardPaths at the process level, so the state
+ // file lands in a scratch directory rather than the real ~/.local/state.
+ QStandardPaths::setTestModeEnabled(true);
+ QFile::remove(MainWindow::uiStatePath());
+
+ const QSize resized(940, 620);
+ {
+ const Config config;
+ MainWindow window(config);
+ window.resize(resized);
+ window.findChild<MessageView *>()->setZoomFactor(1.4);
+ window.close(); // closeEvent() is what persists the state
+ }
+
+ QVERIFY2(QFile::exists(MainWindow::uiStatePath()),
+ qPrintable(QStringLiteral("no state file at %1")
+ .arg(MainWindow::uiStatePath())));
+
+ const Config config;
+ MainWindow reopened(config);
+ QCOMPARE(reopened.size(), resized);
+ QCOMPARE(reopened.findChild<MessageView *>()->zoomFactor(), 1.4);
+
+ QFile::remove(MainWindow::uiStatePath());
+ QStandardPaths::setTestModeEnabled(false);
+}
+
+void TestMainWindow::missingUiStateLeavesTheDefaults()
+{
+ // A restore that silently succeeded on an empty blob would give a
+ // zero-size window on first launch. Absent state must be a no-op.
+ QStandardPaths::setTestModeEnabled(true);
+ QFile::remove(MainWindow::uiStatePath());
+
+ const Config config;
+ MainWindow window(config);
+ QCOMPARE(window.size(), QSize(1200, 800));
+
+ QStandardPaths::setTestModeEnabled(false);
+}
+
// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index 0c3353d..4b6bc4c 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -36,6 +36,8 @@ private slots:
void documentActuallyLoads();
void threadContentReachesThePage();
void dataUrlSubResourceStillBlocked();
+ void zoomIsClampedToARenderableRange();
+ void zoomSurvivesANewDocument();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -175,5 +177,58 @@ void TestMessageView::dataUrlSubResourceStillBlocked()
QVERIFY(text.contains(QStringLiteral("visible-text")));
}
+void TestMessageView::zoomIsClampedToARenderableRange()
+{
+ // A factor outside the range leaves the pane unreadable, and the only way
+ // back is a menu entry the user can no longer read. A corrupt state file
+ // reaching setZoomFactor() must not be able to do that.
+ QCOMPARE(MessageView::clampZoom(100.0), MessageView::kMaxZoom);
+ QCOMPARE(MessageView::clampZoom(0.01), MessageView::kMinZoom);
+
+ // A missing or non-numeric state value converts to 0.0, and a hand-edited
+ // one can hold NaN or an infinity. None of those may reach the web view.
+ QCOMPARE(MessageView::clampZoom(0.0), MessageView::kDefaultZoom);
+ QCOMPARE(MessageView::clampZoom(-2.0), MessageView::kDefaultZoom);
+ QCOMPARE(MessageView::clampZoom(qQNaN()), MessageView::kDefaultZoom);
+ QCOMPARE(MessageView::clampZoom(qInf()), MessageView::kDefaultZoom);
+
+ // In-range values pass through untouched.
+ QCOMPARE(MessageView::clampZoom(1.4), 1.4);
+
+ MessageView view;
+ view.setZoomFactor(50.0);
+ QCOMPARE(view.zoomFactor(), MessageView::kMaxZoom);
+}
+
+void TestMessageView::zoomSurvivesANewDocument()
+{
+ // MainWindow persists whatever zoomFactor() reports and never reapplies it
+ // per render, which is only correct if the web view keeps the factor
+ // across setHtml(). Verified rather than assumed.
+ MessageView view;
+ QWebEngineView *web = webViewOf(&view);
+ QVERIFY(web);
+
+ view.setZoomFactor(1.5);
+
+ QSignalSpy loaded(web, &QWebEngineView::loadFinished);
+
+ ParsedMessage message;
+ message.ok = true;
+ message.from = QStringLiteral("Sender <sender@example.org>");
+ message.subject = QStringLiteral("Zoom");
+ message.plainBody = QStringLiteral("body text");
+
+ ThreadRenderItem item;
+ item.message = message;
+ item.cidPrefix = QStringLiteral("m0");
+ item.expanded = true;
+
+ view.showThread({ item });
+ QVERIFY(loaded.wait(15000));
+
+ QCOMPARE(view.zoomFactor(), 1.5);
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"