aboutsummaryrefslogtreecommitdiffstats
path: root/src/composewindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 09:54:10 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 09:54:10 +0200
commit01ea9e9d7df04dc771430e4c378202b8ef37b8db (patch)
treefdca0a7328acf86f7999c89bcfd3638fe0f67ef9 /src/composewindow.cpp
parent671e76f691b4027c3c9707fdae6ea6d4b4690832 (diff)
downloadqtmaildir-01ea9e9d7df04dc771430e4c378202b8ef37b8db.tar.gz
qtmaildir-01ea9e9d7df04dc771430e4c378202b8ef37b8db.zip
feat(compose): report autosave state in a status bar
Autosave worked and said nothing on success. The only feedback was m_banner, which is the failure channel and whose persistence is load-bearing for the quit path, so success got its own channel rather than sharing one. The fix is a funnel, not a label. m_dirty had seven writers, four of which clear it and only two of those are a save: the constructor clears it because seeding is not an edit, and the send handler clears it because the message is gone. A cue hung off saveDraftNow() would have been silently wrong in both. setDirty() is the only writer now, and it refreshes the status cue and setWindowModified() together so neither display can drift from the flag. The age line needs a tick of its own, since it moves with no edit to drive it. Five seconds against a label that reads in tens of them. Two defects found by probing rather than by reading. The %n plural rendered as "2 minute(s) ago" for every English user, because Qt picks a plural form only when a translation supplies the forms and there is no English .ts; it uses %1 and "min" now, which Italian substitutes identically. And the status mark was inside the translatable string, where a translator could drop it; it is concatenated outside tr(). Presentation reworked after the user looked at it. The first version reused item 151's yellow ribbon treatment, which reads as a misplaced widget on a bare status label rather than as a warning, and put both labels in the permanent widget area, which is the right-hand tray. They are ordinary status text on the left now. onlyTheSetterWritesTheDirtyFlag() asserts the funnel structurally, by reading composewindow.cpp: the first test for the send path called markClean() directly and a mutation restoring a direct assignment left the whole suite green. Four mutations now fail. The suite still cannot see the presentation, which is why that half needed a hand test. lrelease reports 487 finished, 0 unfinished. Closes item 160, and unblocks 161. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
Diffstat (limited to 'src/composewindow.cpp')
-rw-r--r--src/composewindow.cpp119
1 files changed, 113 insertions, 6 deletions
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index c35bb5d..2b0b9d8 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -45,6 +45,7 @@
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
+#include <QStatusBar>
#include <QStandardPaths>
#include <QTextCursor>
#include <QTimer>
@@ -109,7 +110,9 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
// parent still makes Qt treat it as a window because of Qt::Window, which
// QMainWindow carries.
setAttribute(Qt::WA_DeleteOnClose);
- setWindowTitle(tr("Compose"));
+ // The [*] is Qt's placeholder for the modified marker, substituted with
+ // the platform's own convention by setWindowModified().
+ setWindowTitle(tr("Compose[*]"));
// A sensible default. NOT restored and NOT saved; see the header.
resize(760, 640);
@@ -150,7 +153,7 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
// either, must show what the message is addressed to.
revealCcBccIfUsed();
- m_dirty = false;
+ setDirty(false);
m_autosaveTimer->stop();
// The body, whenever there is already a recipient: a Reply or a Forward
@@ -406,6 +409,8 @@ void ComposeWindow::buildUi()
for (QLineEdit *field : { m_to, m_cc, m_bcc, m_subject })
connect(field, &QLineEdit::textChanged, this, &ComposeWindow::markDirty);
connect(m_sendHtml, &QCheckBox::toggled, this, &ComposeWindow::markDirty);
+
+ buildDraftStatusBar();
connect(m_from, &QComboBox::currentIndexChanged, this, [this]() {
markDirty();
// The account SEEDS the signature, so a change to it re-seeds. It
@@ -953,9 +958,104 @@ void ComposeWindow::applyFormat(const QString &token)
cursor.selectionEnd(), token));
}
+void ComposeWindow::buildDraftStatusBar()
+{
+ // The success channel item 160 added. Deliberately NOT the banner: that
+ // one reports FAILURE and its persistence is load-bearing, since the quit
+ // path's honesty depends on a failed save still being visible.
+ QStatusBar *bar = statusBar();
+
+ m_draftAge = new QLabel(bar);
+ m_draftAge->setObjectName(QStringLiteral("draftAge"));
+ m_draftAge->setTextFormat(Qt::PlainText);
+
+ // A ring, not a chip. Item 151's yellow ground belongs to the message
+ // pane's ribbons, which are bars spanning the pane and have something to
+ // be a ground OF; the same treatment on a bare status label reads as a
+ // misplaced widget rather than as a warning, which is what the user saw.
+ // Ordinary status text with a mark carries the same meaning quietly.
+ // The mark is NOT part of the translatable string: a translator cannot
+ // drop or mangle what they are never handed.
+ m_unsavedCue = new QLabel(
+ QStringLiteral("%1 %2").arg(QChar(0x25CB), tr("unsaved content")), bar);
+ m_unsavedCue->setObjectName(QStringLiteral("unsavedCue"));
+ m_unsavedCue->setTextFormat(Qt::PlainText);
+ m_unsavedCue->hide();
+
+ // addWidget, not addPermanentWidget: the permanent tray is the RIGHT hand
+ // end, and both of these belong on the left, the age first with the cue
+ // beside it.
+ bar->addWidget(m_draftAge);
+ bar->addWidget(m_unsavedCue);
+
+ // The age moves with no edit to drive it, so it needs a tick of its own.
+ // Five seconds against a label that reads in tens of them: a per-second
+ // tick would wake the window sixty times a minute to redraw the same
+ // string.
+ m_draftAgeTick = new QTimer(this);
+ m_draftAgeTick->setObjectName(QStringLiteral("draftAgeTick"));
+ m_draftAgeTick->setInterval(5000);
+ connect(m_draftAgeTick, &QTimer::timeout, this,
+ &ComposeWindow::refreshDraftStatus);
+ m_draftAgeTick->start();
+}
+
+void ComposeWindow::setDirty(bool dirty)
+{
+ m_dirty = dirty;
+
+ // Both cues, from the one flag. The status label is what the user reads
+ // while typing; the title marker is what they see when the composer sits
+ // behind another window. Qt substitutes the [*] placeholder in the title
+ // with the platform's own convention, so this is the native gesture
+ // rather than an invented one.
+ if (m_unsavedCue)
+ m_unsavedCue->setVisible(dirty);
+ setWindowModified(dirty);
+}
+
+void ComposeWindow::markClean()
+{
+ // No draft is written: the message has been sent, so there is nothing
+ // left to save. Only the state and its two displays move.
+ setDirty(false);
+}
+
+void ComposeWindow::refreshDraftStatus()
+{
+ if (!m_draftAge)
+ return;
+ if (!m_lastSavedAt.isValid()) {
+ m_draftAge->clear();
+ return;
+ }
+ reportDraftAgeFor(m_lastSavedAt.secsTo(QDateTime::currentDateTime()));
+}
+
+void ComposeWindow::reportDraftAgeFor(qint64 seconds)
+{
+ if (!m_draftAge)
+ return;
+
+ // Coarse on purpose. The label reads in tens of seconds and the tick is
+ // slower than a second, so a precise count would advertise an accuracy
+ // the refresh does not have.
+ if (seconds < 10)
+ m_draftAge->setText(tr("Draft autosaved"));
+ else if (seconds < 60)
+ m_draftAge->setText(tr("Last autosave %1s ago").arg(seconds));
+ else
+ // Minutes as a number rather than as a %n plural. There is no English
+ // .ts, so an untranslated %n string falls back to its SOURCE text and
+ // renders literally as "2 minute(s) ago" for every English user;
+ // measured. Italian keeps its own plural forms through this same
+ // string, since %1 is substituted either way.
+ m_draftAge->setText(tr("Last autosave %1 min ago").arg(seconds / 60));
+}
+
void ComposeWindow::markDirty()
{
- m_dirty = true;
+ setDirty(true);
// Debounced: the timer restarts on every keystroke, so a write happens
// once the user has paused, not once per character. Every autosave
// produces a Maildir write that mbsync uploads, which is what the debounce
@@ -996,7 +1096,7 @@ bool ComposeWindow::saveDraftNow()
// blocking build entirely for the no-change case, which is the common one.
const QString fingerprint = fingerprintOf(message);
if (!m_savedFingerprint.isEmpty() && fingerprint == m_savedFingerprint) {
- m_dirty = false;
+ setDirty(false);
return true;
}
@@ -1036,10 +1136,17 @@ bool ComposeWindow::saveDraftNow()
m_draftPath = written.path;
m_savedFingerprint = fingerprint;
- m_dirty = false;
+ setDirty(false);
m_saveFailed = false;
m_banner->hide();
+ // The success half of item 160: an autosave used to be entirely silent,
+ // so the only sign a draft had been written was the file appearing in the
+ // Drafts view. The banner is deliberately NOT reused; it is the failure
+ // channel and its persistence is load-bearing for the quit path.
+ m_lastSavedAt = QDateTime::currentDateTime();
+ refreshDraftStatus();
+
// The write is done and the previous revision already unlinked; hand both
// paths up so the owner indexes the new one and drops the old (item 158).
emit draftSaved(written.path, previousPath);
@@ -1233,7 +1340,7 @@ void ComposeWindow::send()
// twice. m_finished stops closeEvent() saving a draft for a
// message that is gone, and stops it refusing the close.
m_finished = true;
- m_dirty = false;
+ markClean();
close();
}, Qt::SingleShotConnection);