aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/senddialog.cpp318
-rw-r--r--src/senddialog.h145
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_senddialog.cpp468
-rw-r--r--translations/qtmaildir_it_IT.ts31
6 files changed, 964 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 501c276..83981b2 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -19,6 +19,7 @@ add_library(qtmaildir_lib STATIC
formattoolbar.cpp
tagchip.cpp
tagcolors.cpp
+ senddialog.cpp
savequerydialog.cpp
tagdialog.cpp
tagrules.cpp
diff --git a/src/senddialog.cpp b/src/senddialog.cpp
new file mode 100644
index 0000000..4a36b7b
--- /dev/null
+++ b/src/senddialog.cpp
@@ -0,0 +1,318 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "senddialog.h"
+
+#include <QDateTime>
+#include <QFontMetrics>
+#include <QHBoxLayout>
+#include <QCloseEvent>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QPushButton>
+#include <QTimer>
+#include <QVBoxLayout>
+
+#include "busyindicator.h"
+
+namespace {
+
+// How often the countdown repaints: smooth enough for a draining bar without
+// being a busy loop. It is also the resolution of the countdown itself, since
+// tick() subtracts exactly this much rather than consulting a clock. Two
+// consequences, both deliberate: a delay that is not a multiple of 100 rounds
+// UP to one (250 runs for 300ms), and timer slack accumulates rather than
+// being corrected against a clock. Drift is irrelevant at this scale, where
+// the number is a courtesy pause and nothing downstream measures it.
+constexpr int kTickMs = 100;
+
+// How long the refused-dismissal hint holds the status label. Longer than a
+// tick, or the countdown would overwrite it before it could be read and the
+// refusal would be silent in practice; short enough that the countdown the
+// user is waiting on is not hidden for any meaningful part of its life.
+constexpr qint64 kHintMs = 1500;
+
+} // namespace
+
+SendDialog::SendDialog(int delayMs, QWidget *parent)
+ : QDialog(parent)
+ , m_remainingMs(qMax(0, delayMs))
+ , m_totalMs(qMax(0, delayMs))
+{
+ setWindowTitle(tr("Sending"));
+
+ // Modal to the composer, not to the application. Sending from one composer
+ // must not freeze a second composer or the main window.
+ setWindowModality(Qt::WindowModal);
+
+ // No close button: during the countdown a bare dismissal is ambiguous,
+ // since it could equally mean "cancel" or "send now", so Undo is the only
+ // control that states which. This removes the AFFORDANCE only. Escape,
+ // close() and the window manager all still reach done(), and that override
+ // is what actually makes a dismissal safe; keyPressEvent() below merely
+ // spares the user an Escape that would silently undo. Reasoning about this
+ // flag alone is what left close() committing a send with no window up.
+ setWindowFlags((windowFlags() | Qt::CustomizeWindowHint)
+ & ~Qt::WindowCloseButtonHint);
+
+ auto *layout = new QVBoxLayout(this);
+
+ m_status = new QLabel(this);
+ m_status->setObjectName(QStringLiteral("sendStatus"));
+
+ // Sized to the LONGEST string it can hold in the current language, not to
+ // its content. Italian "Rimozione della bozza..." is longer than "Removing
+ // draft...", so a label sized to whatever it happens to be showing resizes
+ // the popup between stages. Computed from tr() results at construction, so
+ // it is correct in whatever language is loaded AT THAT MOMENT. That is
+ // sufficient here and not in general: main() installs the QTranslator on
+ // its own stack before any window exists, so no dialog can outlive a
+ // language change. A runtime language switch would need this recomputed.
+ const QFontMetrics metrics(m_status->font());
+ // The refusal hint is in this list too. It replaces the countdown text in
+ // the same label, so leaving it out would resize the popup at exactly the
+ // moment the user is being told the window will not close, which is the
+ // worst possible time for it to jump.
+ const QStringList candidates{
+ tr("Sending in %1...").arg(99),
+ tr("Sending..."),
+ tr("Filing sent copy..."),
+ tr("Removing draft..."),
+ tr("Press Undo to stop sending."),
+ };
+ int widest = 0;
+ for (const QString &candidate : candidates)
+ widest = qMax(widest, metrics.horizontalAdvance(candidate));
+ m_status->setMinimumWidth(widest);
+ layout->addWidget(m_status);
+
+ m_indicator = new BusyIndicator(this);
+ m_indicator->setObjectName(QStringLiteral("sendProgress"));
+ layout->addWidget(m_indicator);
+
+ // Three rows in every state, so nothing reflows: Undo keeps its place and
+ // its size after it disables rather than vanishing.
+ auto *buttons = new QHBoxLayout;
+ buttons->addStretch();
+ m_undo = new QPushButton(tr("Undo"), this);
+ m_undo->setObjectName(QStringLiteral("undoSend"));
+ buttons->addWidget(m_undo);
+ layout->addLayout(buttons);
+
+ // Built BEFORE the Undo connection below, which stops it. The lambda would
+ // read a null m_timer otherwise, and only because nothing can click a
+ // button mid-constructor does the reverse order happen to survive.
+ m_timer = new QTimer(this);
+ m_timer->setObjectName(QStringLiteral("sendCountdown"));
+ m_timer->setInterval(kTickMs);
+ connect(m_timer, &QTimer::timeout, this, &SendDialog::tick);
+
+ // Both the button and done() funnel into one place, so the two dismissal
+ // routes cannot drift into disagreeing about what a cancel does.
+ connect(m_undo, &QPushButton::clicked, this, [this] { undo(); });
+
+ if (m_totalMs == 0) {
+ // Queued rather than immediate, so a caller that connects to
+ // committed() AFTER constructing the dialog still hears it. Emitting
+ // from the constructor would send to nobody.
+ QTimer::singleShot(0, this, &SendDialog::commit);
+ } else {
+ setStage(Stage::CountingDown);
+ m_timer->start();
+ }
+}
+
+bool SendDialog::undo()
+{
+ // Undo is disabled at commit, but a disabled button is a UI property and
+ // not an invariant. This is the ONE place that can report "nothing was
+ // sent", so it refuses outright once the command is running rather than
+ // trusting the button's state.
+ //
+ // m_undone is the second half and is NOT redundant: it makes undone()
+ // fire exactly once however many times this is reached.
+ if (m_committed || m_undone)
+ return false;
+ m_undone = true;
+
+ // The timer stops FIRST. A timer left running commits after the dialog has
+ // already reported that nothing was sent, which is the one outcome the
+ // whole delay exists to make impossible.
+ m_timer->stop();
+ m_undo->setEnabled(false);
+ emit undone();
+
+ // Undo is the ONE route out before commit, so it is the one caller allowed
+ // through done()'s refusal. The flag is what distinguishes it from every
+ // other reject(); it is never cleared, because the dialog is finished.
+ m_undoing = true;
+ reject();
+ return true;
+}
+
+void SendDialog::refuseDismissal()
+{
+ // A window that ignores a close reads as a hang, so the refusal says where
+ // the exit is rather than doing nothing at all. One function because both
+ // done() and closeEvent() refuse, and two copies of this meant neutering
+ // either one left the other still setting the text, hiding the regression.
+ //
+ // Held for kHintMs, because the countdown's next tick is only kTickMs away
+ // and would otherwise overwrite the hint before it could be read, leaving
+ // the refusal effectively silent after all. setStage() honours the hold
+ // rather than this scheduling a restore, so the countdown keeps running
+ // underneath and there is no second timer to get out of step.
+ m_hintUntil = QDateTime::currentMSecsSinceEpoch() + kHintMs;
+ m_status->setText(tr("Press Undo to stop sending."));
+ m_undo->setFocus();
+}
+
+void SendDialog::keyPressEvent(QKeyEvent *event)
+{
+ // QDialog maps Escape to reject(). Swallowed WITH ANY MODIFIER: Shift and
+ // Ctrl variants are the same keystroke as far as intent goes, and letting
+ // one through would be an undocumented back door to the same dismissal.
+ // done() would treat it safely as an Undo either way; this just spares the
+ // user a cancel they did not ask for by reflex.
+ if (event->key() == Qt::Key_Escape) {
+ event->accept();
+ return;
+ }
+ QDialog::keyPressEvent(event);
+}
+
+void SendDialog::done(int result)
+{
+ // Every dismissal route arrives here, which is the point: close(), the
+ // window manager, Escape and QDialog's own reject() all converge on
+ // done(), and guarding any one of them individually leaves the others
+ // open. Which routes are permitted, and when:
+ //
+ // BEFORE COMMIT, nothing closes the dialog except Undo. A close is
+ // REFUSED, not silently reinterpreted as a cancel: "close means undo" is
+ // confusing, because the user cannot tell whether dismissing the window
+ // stopped the send or merely hid it, and the two answers differ by whether
+ // their mail goes out. The popup carries exactly one control and it says
+ // what it does. Undo reaches QDialog::done() through m_undoing below.
+ //
+ // AFTER COMMIT, the send is in flight and there is nothing left to cancel,
+ // so any close is honoured. It is forced to Accepted so a caller reading
+ // result() cannot mistake a running send for a cancelled one.
+ //
+ // TASK 12 closes this dialog when the send finishes, and it does so after
+ // commit by definition, so the ordinary accept()/close() works and needs
+ // no special entry point. A stray reject() cannot reach the pre-commit
+ // state at all, which is the property this refusal buys.
+ if (m_committed) {
+ QDialog::done(QDialog::Accepted);
+ return;
+ }
+
+ if (m_undoing) {
+ QDialog::done(QDialog::Rejected);
+ return;
+ }
+
+ refuseDismissal();
+}
+
+void SendDialog::closeEvent(QCloseEvent *event)
+{
+ // Measured against a standalone Qt program, not assumed: close() on a
+ // dialog that was NEVER SHOWN reaches closeEvent() but returns BEFORE
+ // done(), so done()'s refusal alone would let that one route through. A
+ // shown dialog reaches both, and ignoring the event here stops it before
+ // done() is consulted.
+ if (!m_committed && !m_undoing) {
+ event->ignore();
+ refuseDismissal();
+ return;
+ }
+ QDialog::closeEvent(event);
+}
+
+void SendDialog::tick()
+{
+ m_remainingMs -= kTickMs;
+ if (m_remainingMs <= 0) {
+ commit();
+ return;
+ }
+ setStage(Stage::CountingDown);
+}
+
+void SendDialog::commit()
+{
+ // Idempotent: a stray tick racing the singleShot must not emit twice.
+ if (m_committed)
+ return;
+
+ m_timer->stop();
+ m_committed = true;
+
+ // Disabled, never hidden. A greyed Undo says why cancelling is no longer
+ // possible; an absent one only looks like it was never offered.
+ m_undo->setEnabled(false);
+
+ setStage(Stage::Sending);
+ emit committed();
+}
+
+void SendDialog::setStage(Stage stage)
+{
+ // The enum is documented "in order", so the class enforces that rather
+ // than trusting its caller: Task 12 passes values from this public enum,
+ // and winding back would relabel a running send "Sending in 0..." and
+ // redraw a full countdown bar under it, offering a cancel that no longer
+ // exists. Only the backwards step is refused; the forward stages are the
+ // caller's to drive.
+ if (m_committed && stage == Stage::CountingDown)
+ return;
+
+ // The refusal hint outranks the countdown text for as long as it is held.
+ // Only the countdown is suppressed: a stage change is a real event and
+ // must always be shown, and commit() clears the hold anyway.
+ if (stage == Stage::CountingDown
+ && QDateTime::currentMSecsSinceEpoch() < m_hintUntil) {
+ m_indicator->setProgress(m_remainingMs, m_totalMs);
+ return;
+ }
+
+ switch (stage) {
+ case Stage::CountingDown:
+ // Rounded up, so a countdown with 1ms left still reads "1" rather than
+ // sitting on "0" for a tick.
+ m_status->setText(tr("Sending in %1...")
+ .arg((m_remainingMs + 999) / 1000));
+ m_indicator->setProgress(m_remainingMs, m_totalMs);
+ return;
+ case Stage::Sending:
+ m_status->setText(tr("Sending..."));
+ break;
+ case Stage::FilingSentCopy:
+ m_status->setText(tr("Filing sent copy..."));
+ break;
+ case Stage::RemovingDraft:
+ m_status->setText(tr("Removing draft..."));
+ break;
+ }
+
+ // Everything past the countdown: the duration stops being knowable, so the
+ // same widget switches from a fraction to an animation.
+ m_indicator->setBusy(true);
+}
diff --git a/src/senddialog.h b/src/senddialog.h
new file mode 100644
index 0000000..a930c3d
--- /dev/null
+++ b/src/senddialog.h
@@ -0,0 +1,145 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QtGlobal>
+
+class BusyIndicator;
+class QLabel;
+class QCloseEvent;
+class QKeyEvent;
+class QPushButton;
+class QTimer;
+
+/// Owns a send from the cancellable countdown through to completion.
+///
+/// The delay is where cancelling is SAFE and it is the only place it is.
+/// Nothing has reached a server during the countdown, so Undo means genuinely
+/// nothing happened. Killing send_command once it runs leaves an UNKNOWN send:
+/// the message may have reached the server in full before the kill, which is
+/// worse than either clean outcome. So there is no cancel after commit, and
+/// isCommitted() is the line between the two.
+///
+/// Three rows in every state, so nothing reflows and the window never jumps:
+/// a status label, the bar, and Undo.
+///
+/// The bar CHANGES MODE, it does not change place. Determinate while the
+/// countdown drains, because a countdown has measurable progress;
+/// indeterminate once the command starts, because a send does not.
+///
+/// Modal to the composer, NOT to the application: sending from one composer
+/// must not freeze a second composer or the main window.
+///
+/// DISMISSAL IS A THIRD ROUTE TO THE SAME FAILURE, and removing the close
+/// button only removes the affordance. Escape, the window manager, close() and
+/// QDialog's own machinery all still reach done(); see done() and closeEvent()
+/// below, which are the two places that cover them. An earlier revision
+/// reasoned about Escape and the titlebar button alone and left close()
+/// committing a send with no window on screen.
+///
+/// Before commit, Undo is the ONLY way out and every other route is refused.
+class SendDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ /// \p delayMs of zero skips the countdown and sends at once.
+ explicit SendDialog(int delayMs, QWidget *parent = nullptr);
+
+ /// The stages, in order. Each sets the label; every stage after the
+ /// countdown leaves the bar indeterminate.
+ enum class Stage { CountingDown, Sending, FilingSentCopy, RemovingDraft };
+ Q_ENUM(Stage)
+
+ void setStage(Stage stage);
+
+ /// True once the countdown has elapsed and the command has started, after
+ /// which cancelling is no longer possible.
+ bool isCommitted() const { return m_committed; }
+
+signals:
+ /// The countdown elapsed or was skipped: the caller should start sending.
+ void committed();
+
+ /// Undo was pressed during the countdown. NOTHING has been sent.
+ void undone();
+
+protected:
+ /// Swallows Escape, with any modifiers. QDialog maps it to reject(), and
+ /// during the countdown a bare dismissal is ambiguous in exactly the way
+ /// the constructor describes; Undo is the control that says which it means.
+ void keyPressEvent(QKeyEvent *event) override;
+
+ /// The single choke point for every dismissal route, which is why the
+ /// close button's removal was not enough on its own: QDialog reaches
+ /// reject() from the window manager, from close(), and from its own
+ /// machinery, and all of them arrive here.
+ ///
+ /// During the countdown a close is REFUSED. "Close means undo" is
+ /// confusing: the user cannot tell whether dismissing the window stopped
+ /// the send or merely hid it, and the two answers differ by whether their
+ /// mail goes out. Undo is the only way out, which is what the popup's
+ /// single control already says. After commit any close is honoured, since
+ /// there is nothing left to cancel, and it is forced to Accepted so a
+ /// caller reading result() cannot mistake a running send for a cancelled
+ /// one. Task 12 closes the dialog after the send finishes, which is
+ /// post-commit by definition and so needs no special entry point.
+ void done(int result) override;
+
+ /// CLAUDE.md's companion trap: close() on a widget that was never shown
+ /// returns early WITHOUT reaching done(), so done()'s refusal alone would
+ /// let exactly that one route through. Refuses on the same terms.
+ void closeEvent(QCloseEvent *event) override;
+
+private:
+ /// The one place that can report "nothing was sent". Returns false, and
+ /// does nothing at all, once the send has committed. Both the Undo button
+ /// and every dismissal route funnel through it.
+ bool undo();
+
+ /// Shows the hint that Undo is the only way out, and holds it long enough
+ /// to be read. One function because both refusal sites call it.
+ void refuseDismissal();
+
+ void tick();
+ void commit();
+
+ QLabel *m_status = nullptr;
+ BusyIndicator *m_indicator = nullptr;
+ QPushButton *m_undo = nullptr;
+ QTimer *m_timer = nullptr;
+
+ int m_remainingMs = 0;
+ int m_totalMs = 0;
+ bool m_committed = false;
+
+ /// Set by the first undo(), so undone() is emitted exactly once however
+ /// many dismissal routes fire. A shown dialog's close() reaches BOTH
+ /// closeEvent() and done().
+ bool m_undone = false;
+
+ /// Deadline until which the refusal hint holds the status label against
+ /// the countdown's own text. Zero when no hint is showing.
+ qint64 m_hintUntil = 0;
+
+ /// Set only by undo(), and what lets that one route through done()'s
+ /// pre-commit refusal. Every other reject() is turned away.
+ bool m_undoing = false;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index b28bf6f..fc19b01 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -75,6 +75,7 @@ add_qtmaildir_test(draftstore)
add_qtmaildir_test(messagesender)
add_qtmaildir_test(composecontext)
add_qtmaildir_test(formattoolbar)
+add_qtmaildir_test(senddialog)
add_qtmaildir_test(translations)
# Asserts on the tracked .ts rather than the generated .qm: an untranslated
# string is dropped by lrelease, so it is invisible in the .qm and shows up
diff --git a/tests/test_senddialog.cpp b/tests/test_senddialog.cpp
new file mode 100644
index 0000000..ac8c234
--- /dev/null
+++ b/tests/test_senddialog.cpp
@@ -0,0 +1,468 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+
+#include <QLabel>
+#include <QPushButton>
+#include <QSet>
+
+#include "busyindicator.h"
+#include "senddialog.h"
+
+class TestSendDialog : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void theBarIsDeterminateWhileCountingDown();
+ void theCountdownCommitsWhenItElapses();
+ void aZeroDelayCommitsImmediately();
+ void undoDuringTheCountdownEmitsUndoneAndNeverCommits();
+ void undoDisablesItselfOnceTheCommandStarts();
+ void theBarBecomesIndeterminateWhenSending();
+ void undoStaysVisibleAfterItDisables();
+ void theStatusLabelIsWideEnoughForEveryStage();
+ void closingDuringTheCountdownIsRefused();
+ void closingADialogThatWasNeverShownIsAlsoRefused();
+ void theRefusalHintSurvivesTheNextCountdownTick();
+ void rejectDuringTheCountdownIsRefused();
+ void escapeDuringTheCountdownIsRefused();
+ void undoIsTheOneRouteThatClosesBeforeCommit();
+ void closingAfterCommitReportsAcceptedAndDoesNotUndo();
+ void undoAfterCommitIsRefused();
+ void everyStageSetsItsOwnLabelAndLeavesTheBarBusy();
+ void windingBackToCountingDownAfterCommitIsRefused();
+};
+
+void TestSendDialog::theBarIsDeterminateWhileCountingDown()
+{
+ // A countdown has measurable progress, so the bar drains rather than
+ // animating. This is the half of BusyIndicator MainWindow never uses: the
+ // status bar's sync indicator is indeterminate for its whole life.
+ //
+ // A generous delay so the assertion cannot race the countdown's own end,
+ // which would flip the bar to indeterminate for a legitimate reason and
+ // report a defect that is not there.
+ SendDialog dialog(5000);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY2(indicator, "the dialog has no BusyIndicator named sendProgress");
+ QVERIFY2(indicator->isDeterminate(),
+ "the bar was animating during a countdown that has a known end");
+}
+
+void TestSendDialog::theCountdownCommitsWhenItElapses()
+{
+ // A short delay rather than waiting out the shipped default: what is being
+ // tested is that the countdown ends in a commit, not how long it is.
+ SendDialog dialog(150);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+ QCOMPARE(spy.count(), 1);
+ QVERIFY(dialog.isCommitted());
+}
+
+void TestSendDialog::aZeroDelayCommitsImmediately()
+{
+ // send_delay_ms = 0 sends at once, for anyone who finds the delay
+ // irritating. It must still be a queued commit rather than one inside the
+ // constructor, or a caller connecting to committed() after constructing the
+ // dialog would never hear it.
+ SendDialog dialog(0);
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(spy.wait(1000), "a zero delay never committed");
+ QCOMPARE(spy.count(), 1);
+ QVERIFY(dialog.isCommitted());
+}
+
+void TestSendDialog::undoDuringTheCountdownEmitsUndoneAndNeverCommits()
+{
+ // THE test for this feature, and the property that matters is the NEGATIVE
+ // one. A test asserting only that undone() fired would pass against a
+ // design that started the send anyway and threw the result away, which is
+ // the whole failure the delay exists to prevent. Nothing has reached a
+ // server during the countdown, so Undo must mean that nothing happened.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY2(undo, "the dialog has no button named undoSend");
+ QVERIFY2(undo->isEnabled(), "Undo was dead during the countdown");
+
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QCOMPARE(committedSpy.count(), 0);
+
+ // Past the original deadline. A timer left running would commit here, after
+ // the dialog has already reported that nothing was sent.
+ QTest::qWait(2500);
+ QVERIFY2(committedSpy.count() == 0,
+ "the countdown committed after Undo was pressed");
+}
+
+void TestSendDialog::undoDisablesItselfOnceTheCommandStarts()
+{
+ // There is no cancel after commit. Killing send_command once it runs leaves
+ // an UNKNOWN send: the message may have reached the server in full before
+ // the kill, which is worse than either clean outcome.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+
+ QVERIFY2(!undo->isEnabled(),
+ "Undo was still live after the send command started");
+}
+
+void TestSendDialog::theBarBecomesIndeterminateWhenSending()
+{
+ // The bar CHANGES MODE, it does not change place: a send has no measurable
+ // progress, so the same widget stops drawing a fraction and starts
+ // animating, and nothing in the popup reflows.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY(indicator);
+ QVERIFY(indicator->isDeterminate());
+
+ dialog.setStage(SendDialog::Stage::Sending);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar kept the countdown's fraction while sending");
+}
+
+void TestSendDialog::undoStaysVisibleAfterItDisables()
+{
+ // A control that vanishes re-lays out the popup mid-operation, and a greyed
+ // Undo says WHY cancelling is no longer possible where an absent one only
+ // looks like it was never offered.
+ SendDialog dialog(100);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ QSignalSpy spy(&dialog, &SendDialog::committed);
+ QVERIFY2(spy.wait(3000), "the countdown never committed");
+
+ QVERIFY2(undo->isVisibleTo(&dialog),
+ "Undo disappeared instead of greying out");
+}
+
+void TestSendDialog::theStatusLabelIsWideEnoughForEveryStage()
+{
+ // The label is sized to the LONGEST string it can hold in the current
+ // language, not to its content, so the popup does not resize between
+ // stages. Asserted against the metrics of the strings themselves rather
+ // than a constant, so it holds in whatever language is loaded.
+ SendDialog dialog(2000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY2(status, "the dialog has no label named sendStatus");
+
+ const QFontMetrics metrics(status->font());
+ const QStringList candidates{
+ SendDialog::tr("Sending in %1...").arg(99),
+ SendDialog::tr("Sending..."),
+ SendDialog::tr("Filing sent copy..."),
+ SendDialog::tr("Removing draft..."),
+ SendDialog::tr("Press Undo to stop sending."),
+ };
+ int widest = 0;
+ for (const QString &candidate : candidates)
+ widest = qMax(widest, metrics.horizontalAdvance(candidate));
+
+ QVERIFY2(status->minimumWidth() >= widest,
+ "the status label was sized to its content, so the popup will "
+ "resize when a longer stage name arrives");
+}
+
+void TestSendDialog::closingDuringTheCountdownIsRefused()
+{
+ // The same failure as the Undo test, reached by a different door. Removing
+ // the close BUTTON removes the visual affordance, not the code path: the
+ // window manager, close() and QDialog's own machinery all still reach
+ // done(). Left unguarded, close() hides the window and leaves the timer
+ // running, so the send starts with no window on screen and the only cancel
+ // control destroyed.
+ //
+ // The close is REFUSED rather than reinterpreted as an Undo, at the user's
+ // call: "close means undo is confusing", because a dismissed window cannot
+ // tell you whether it stopped the send or merely hid it. So the dialog
+ // stays up, the send stays scheduled, and Undo remains the only way out.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QVERIFY2(!dialog.close(), "close() during the countdown was accepted");
+
+ QVERIFY2(dialog.isVisible(),
+ "the dialog vanished on a close it was supposed to refuse");
+ QVERIFY2(undoneSpy.count() == 0,
+ "a refused close silently undid the send anyway");
+
+ // Refusing must not be silent: a window that ignores a close reads as a
+ // hang, so the popup has to say where the exit is.
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY(status);
+ QVERIFY2(status->text().contains(QStringLiteral("Undo")),
+ "a refused close gave the user no hint that Undo is the way out");
+
+ // The send was never cancelled, so it still goes out. That is the whole
+ // point of refusing rather than undoing.
+ QVERIFY2(committedSpy.wait(3000),
+ "the refused close cancelled the send after all");
+}
+
+void TestSendDialog::closingADialogThatWasNeverShownIsAlsoRefused()
+{
+ // CLAUDE.md's documented companion trap: close() on a widget that was
+ // never shown returns early WITHOUT reaching done(), so a refusal written
+ // only in done() would miss this one route entirely. The countdown is
+ // running either way, because it starts in the constructor rather than on
+ // show(). Refused on the same terms as the shown case.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+
+ QVERIFY2(!dialog.close(),
+ "close() on an unshown dialog slipped past the refusal");
+ QVERIFY2(undoneSpy.count() == 0,
+ "closing an unshown dialog undid the send");
+
+ QVERIFY2(committedSpy.wait(3000),
+ "the unshown dialog's send was cancelled by a refused close");
+}
+
+void TestSendDialog::theRefusalHintSurvivesTheNextCountdownTick()
+{
+ // Without a hold the hint lives for one tick, which is 100ms, and the
+ // countdown text overwrites it before it can be read. A refusal the user
+ // cannot see is a window that ignores them, which reads as a hang, so the
+ // hold is what makes the refusal honest rather than decorative.
+ SendDialog dialog(5000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ QVERIFY(status);
+
+ dialog.close();
+ const QString hint = status->text();
+ QVERIFY2(hint.contains(QStringLiteral("Undo")), "no hint on refusal");
+
+ // Several ticks later, well past the point the countdown would have
+ // reclaimed the label.
+ QTest::qWait(500);
+ QCOMPARE(status->text(), hint);
+
+ // And it does eventually give the label back, or the countdown would be
+ // hidden for the rest of its life.
+ QTest::qWait(1500);
+ QVERIFY2(status->text() != hint,
+ "the hint never released the label back to the countdown");
+}
+
+void TestSendDialog::rejectDuringTheCountdownIsRefused()
+{
+ // reject() is the route neither close() nor Escape goes through directly,
+ // and it is the one a caller reaches for. CLAUDE.md's rule is that every
+ // route out gets asserted: "a test used close() and the user used Cancel"
+ // is the documented way one of three gets missed.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ dialog.reject();
+
+ QVERIFY2(dialog.isVisible(), "reject() dismissed the countdown");
+ QVERIFY2(undoneSpy.count() == 0, "reject() undid the send");
+ QVERIFY2(committedSpy.wait(3000), "reject() cancelled the send after all");
+}
+
+void TestSendDialog::escapeDuringTheCountdownIsRefused()
+{
+ // Escape is QDialog's built-in reject(), and swallowing it in
+ // keyPressEvent is only the first line: done() refuses it too, so the
+ // dialog is safe even if the key handler is ever removed.
+ SendDialog dialog(2000);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QTest::keyClick(&dialog, Qt::Key_Escape);
+ QVERIFY2(dialog.isVisible(), "Escape dismissed the countdown");
+
+ // With modifiers too, so neither is an undocumented back door.
+ QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ShiftModifier);
+ QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ControlModifier);
+ QVERIFY2(dialog.isVisible(), "a modified Escape dismissed the countdown");
+
+ QVERIFY2(undoneSpy.count() == 0, "Escape undid the send");
+ QVERIFY2(committedSpy.wait(3000), "Escape cancelled the send after all");
+}
+
+void TestSendDialog::undoIsTheOneRouteThatClosesBeforeCommit()
+{
+ // The counterpart to the four refusals above: having refused every other
+ // way out, the one remaining control must actually work, or the popup is
+ // a trap with no exit at all.
+ SendDialog dialog(2000);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+ undo->click();
+
+ QCOMPARE(undoneSpy.count(), 1);
+ QVERIFY2(!dialog.isVisible(), "Undo did not close the dialog");
+ QCOMPARE(dialog.result(), int(QDialog::Rejected));
+}
+
+void TestSendDialog::closingAfterCommitReportsAcceptedAndDoesNotUndo()
+{
+ // After commit there is nothing to undo, so closing is permitted. What it
+ // must NOT do is report Rejected: a caller inspecting result() would read
+ // a send that is running as one that was cancelled, and undone() must stay
+ // silent because the message is on its way.
+ SendDialog dialog(100);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+ QVERIFY(dialog.isCommitted());
+
+ dialog.close();
+
+ QCOMPARE(undoneSpy.count(), 0);
+ QVERIFY2(dialog.result() != QDialog::Rejected,
+ "closing a committed dialog reported the send as cancelled");
+}
+
+void TestSendDialog::undoAfterCommitIsRefused()
+{
+ // Undo is disabled at commit, but a disabled button is a UI property, not
+ // an invariant. This asserts the handler's own guard, so a future change
+ // that re-enables the button cannot turn it back into a claim that nothing
+ // was sent while send_command is already running.
+ SendDialog dialog(100);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ QSignalSpy undoneSpy(&dialog, &SendDialog::undone);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+
+ auto *undo = dialog.findChild<QPushButton *>(QStringLiteral("undoSend"));
+ QVERIFY(undo);
+
+ // Deliberately re-enabled, to reach the handler that the disabled state
+ // would otherwise hide. This is the mutation a future edit could make by
+ // accident; the guard behind it is what this test is for.
+ undo->setEnabled(true);
+ undo->click();
+
+ QVERIFY2(undoneSpy.count() == 0,
+ "Undo claimed nothing was sent after the send command started");
+}
+
+void TestSendDialog::everyStageSetsItsOwnLabelAndLeavesTheBarBusy()
+{
+ // Walks all four, because a break accidentally deleted from one case would
+ // fall through to the next and nothing else would notice. FilingSentCopy
+ // and RemovingDraft are also the two whose Italian strings drove the whole
+ // label-width design, so leaving them unexercised would test the sizing of
+ // strings nothing ever displays.
+ SendDialog dialog(2000);
+ dialog.show();
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ QVERIFY(status);
+ QVERIFY(indicator);
+
+ const QString countingDown = status->text();
+ QVERIFY2(!countingDown.isEmpty(), "the countdown showed no text");
+ QVERIFY(indicator->isDeterminate());
+
+ QStringList seen;
+ const QVector<SendDialog::Stage> stages{
+ SendDialog::Stage::Sending,
+ SendDialog::Stage::FilingSentCopy,
+ SendDialog::Stage::RemovingDraft,
+ };
+ for (SendDialog::Stage stage : stages) {
+ dialog.setStage(stage);
+ QVERIFY2(!status->text().isEmpty(), "a stage set no text at all");
+ QVERIFY2(!indicator->isDeterminate(),
+ "a post-countdown stage left the bar drawing a fraction");
+ seen << status->text();
+ }
+
+ // Distinct from each other and from the countdown: a fallthrough would
+ // show the following stage's text and collapse two of these into one.
+ seen << countingDown;
+ QCOMPARE(QSet<QString>(seen.begin(), seen.end()).size(), seen.size());
+}
+
+void TestSendDialog::windingBackToCountingDownAfterCommitIsRefused()
+{
+ // setStage() is public and Task 12 passes values from the public enum. The
+ // enum is documented "in order", so the class enforces that itself rather
+ // than trusting its caller: winding back would relabel a running send
+ // "Sending in 0..." and redraw a full countdown bar under it, offering a
+ // cancel that no longer exists.
+ SendDialog dialog(100);
+ QSignalSpy committedSpy(&dialog, &SendDialog::committed);
+ dialog.show();
+
+ QVERIFY2(committedSpy.wait(3000), "the countdown never committed");
+
+ auto *status = dialog.findChild<QLabel *>(QStringLiteral("sendStatus"));
+ auto *indicator = dialog.findChild<BusyIndicator *>(
+ QStringLiteral("sendProgress"));
+ const QString sending = status->text();
+
+ dialog.setStage(SendDialog::Stage::CountingDown);
+
+ QCOMPARE(status->text(), sending);
+ QVERIFY2(!indicator->isDeterminate(),
+ "the bar drew a countdown fraction over a running send");
+}
+
+QTEST_MAIN(TestSendDialog)
+#include "test_senddialog.moc"
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index a45b4f2..83ac087 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -1381,6 +1381,37 @@
</message>
</context>
<context>
+ <name>SendDialog</name>
+ <message>
+ <source>Sending</source>
+ <translation>Invio in corso</translation>
+ </message>
+ <message>
+ <source>Sending in %1...</source>
+ <translation>Invio tra %1...</translation>
+ </message>
+ <message>
+ <source>Sending...</source>
+ <translation>Invio in corso...</translation>
+ </message>
+ <message>
+ <source>Filing sent copy...</source>
+ <translation>Archiviazione della copia inviata...</translation>
+ </message>
+ <message>
+ <source>Removing draft...</source>
+ <translation>Rimozione della bozza...</translation>
+ </message>
+ <message>
+ <source>Press Undo to stop sending.</source>
+ <translation>Premi Annulla per fermare l&apos;invio.</translation>
+ </message>
+ <message>
+ <source>Undo</source>
+ <translation>Annulla</translation>
+ </message>
+</context>
+<context>
<name>SyncPhaseTracker</name>
<message>
<source>Reindexing (notmuch)...</source>