diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.cpp | 20 | ||||
| -rw-r--r-- | src/config.h | 8 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 85 | ||||
| -rw-r--r-- | src/mainwindow.h | 22 |
4 files changed, 135 insertions, 0 deletions
diff --git a/src/config.cpp b/src/config.cpp index 1a8f322..f5d2d15 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -91,6 +91,26 @@ void Config::load(const QString &path) m_completionOnFocus = settings.value(QStringLiteral("completion_on_focus"), false).toBool(); + // Absent is silent, the default being 2000. Present but unparseable warns, + // for the same reason message_zoom does: the user asked for something and + // is not getting it. + // + // Zero and negative are NOT errors and must not be clamped. Zero means mark + // read at once, and any negative value means never, which is how the + // behaviour is turned off. + const QVariant markRead = settings.value(QStringLiteral("mark_read_delay_ms")); + if (markRead.isValid()) { + bool ok = false; + const int value = markRead.toString().toInt(&ok); + if (ok) { + m_markReadDelayMs = value; + } else { + addProblem(QStringLiteral("Mark-read delay '%1' is not a number; " + "using the default.") + .arg(markRead.toString())); + } + } + // [completion] is an ordinary section, so this one DOES take its prefix. // ',' separates entries and '|' separates a value from its description: // two different characters because QSettings splits comma lists itself, diff --git a/src/config.h b/src/config.h index 63fa541..7c6eb63 100644 --- a/src/config.h +++ b/src/config.h @@ -102,6 +102,13 @@ public: /// once it is known. The manual trigger works regardless. bool completionOnFocus() const { return m_completionOnFocus; } + /// How long an opened thread stays unread before it is marked read. + /// + /// Three meanings, all deliberate: a positive value is the delay in + /// milliseconds, 0 marks read immediately, and any negative value disables + /// the behaviour so a thread stays unread until toggled by hand. + int markReadDelayMs() const { return m_markReadDelayMs; } + /// User-supplied mimetype completions, APPENDED to the built-in list. /// Appending rather than replacing means a typo cannot leave completion /// worse off than the defaults. Mimetypes are the only completion list a @@ -135,6 +142,7 @@ private: QString m_notmuchConfig; qreal m_messageZoom = 1.0; bool m_completionOnFocus = false; + int m_markReadDelayMs = 2000; QList<CompletionEntry> m_extraMimetypes; QString m_startupQuery = QStringLiteral("Unread"); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a0ae7c4..45db452 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -42,6 +42,7 @@ #include <QStandardPaths> #include <QStatusBar> #include <QTableView> +#include <QTimer> #include <QToolBar> #include <QVBoxLayout> @@ -265,6 +266,14 @@ void MainWindow::buildUi() m_queryEdit->installEventFilter(this); m_queryCompleter = new QueryCompleter(m_queryEdit, m_config, this); + m_markReadTimer = new QTimer(this); + // Named so a test can observe whether it is armed without the window + // having to expose the timer or the decision that armed it. + m_markReadTimer->setObjectName(QStringLiteral("markReadTimer")); + m_markReadTimer->setSingleShot(true); + connect(m_markReadTimer, &QTimer::timeout, + this, &MainWindow::markCurrentThreadRead); + m_syncLog = new QPlainTextEdit(central); m_syncLog->setReadOnly(true); m_syncLog->setMaximumHeight(120); @@ -445,6 +454,13 @@ void MainWindow::registerActions() if (!current.isValid()) return; const ThreadSummary thread = m_model->threadAt(current.row()); + + // An explicit toggle overrides the automatic one. Without this, marking + // a thread unread by hand would be undone a moment later by a timer + // armed when it was opened, and the key would look broken. + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + if (thread.isUnread()) tagSelected({}, { QStringLiteral("unread") }, tr("Mark read")); else @@ -843,6 +859,7 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, const ThreadSummary thread = m_model->threadAt(current.row()); m_currentThreadId = thread.threadId; m_messageView->setTags(thread.tags); + scheduleMarkRead(thread); QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, Q_ARG(QString, m_currentThreadId), Q_ARG(QString, m_lastQuery), @@ -932,6 +949,74 @@ void MainWindow::onSyncFinished(bool success, int exitCode) } } +void MainWindow::scheduleMarkRead(const ThreadSummary &thread) +{ + // Any pending timer belongs to a thread that is no longer on screen. + // Stopping unconditionally is what makes this a restart rather than a + // stack: arrowing down ten threads must mark only the one still selected + // when the timer finally fires. + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + + // Negative disables the behaviour entirely, per the config key. + const int delay = m_config.markReadDelayMs(); + if (delay < 0) + return; + + // Nothing to do for a thread that is already read. Checked here rather + // than in the handler so no timer is even armed, which keeps a read thread + // from arming one that would fire into a no-op write. + if (!thread.tags.contains(QStringLiteral("unread"))) + return; + + m_markReadThreadId = thread.threadId; + + // Zero means immediately, and a zero-interval timer still fires through + // the event loop rather than reentering the selection handler. + m_markReadTimer->start(delay); +} + +void MainWindow::markCurrentThreadRead() +{ + if (m_markReadThreadId.isEmpty()) + return; + + // The selection can have moved on between the timer being armed and it + // firing, and the thread can have been marked read by hand in that window. + // Both mean this timer has nothing left to do. + if (m_markReadThreadId != m_currentThreadId) { + m_markReadThreadId.clear(); + return; + } + + const QModelIndex current = m_threadView->currentIndex(); + if (!current.isValid()) { + m_markReadThreadId.clear(); + return; + } + + const ThreadSummary thread = m_model->threadAt(current.row()); + if (thread.threadId != m_markReadThreadId + || !thread.tags.contains(QStringLiteral("unread"))) { + m_markReadThreadId.clear(); + return; + } + + const QStringList threadIds = { m_markReadThreadId }; + m_markReadThreadId.clear(); + + // sendThreadTagChange, NOT tagSelected: this deliberately does not go on + // the undo stack. The user never took this action, so hijacking Ctrl+Z to + // reverse it would undo something they did not do, and toggle_unread + // already gives them a direct way to put it back. Decided 2026-08-03. + // + // It still funnels through the one applyTags path, per CLAUDE.md; what + // differs is only whether the inverse is pushed, which is a window-level + // decision above the worker. + sendThreadTagChange(threadIds, {}, { QStringLiteral("unread") }, + tr("Mark read")); +} + void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, const QString &description) { diff --git a/src/mainwindow.h b/src/mainwindow.h index 65f9636..0eea164 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -39,6 +39,7 @@ class QPushButton; class QComboBox; class QPlainTextEdit; class QSplitter; +class QTimer; class ThreadListModel; class MessageView; @@ -117,6 +118,15 @@ private: void tagSelected(const QStringList &add, const QStringList &remove, const QString &description); + /// Starts, restarts or cancels the mark-read timer for a newly opened + /// thread. Cancels outright for a thread that is not unread, so an already + /// read thread never schedules a write that would change nothing. + void scheduleMarkRead(const ThreadSummary &thread); + + /// Removes `unread` from the thread the timer was armed for, if it is still + /// the one on screen. + void markCurrentThreadRead(); + /// Sends a tag change for a set of threads without touching the undo stack. /// Both tagSelected() and ThreadTagCommand route through this. void sendThreadTagChange(const QStringList &threadIds, @@ -168,6 +178,18 @@ private: QString m_lastQuery; QString m_currentThreadId; + /// Marks the open thread read once it has been on screen long enough. + /// + /// Single-shot and RESTARTED on every selection change, never stacked: + /// arrowing down a list must mark only the thread still selected when it + /// fires, not each one passed through. + QTimer *m_markReadTimer = nullptr; + + /// The thread m_markReadTimer will mark read. Compared against the current + /// selection when it fires, so a timer that outlives its thread does + /// nothing rather than marking the wrong one. + QString m_markReadThreadId; + /// The optimistic update awaiting confirmation, kept so a worker error can /// put the model back. Only the most recent one: mutations are sent from /// the UI thread one user action at a time. |
