aboutsummaryrefslogtreecommitdiffstats
path: root/src/senddialog.cpp
blob: 4a36b7b4a0432ce74fdd380b8a33076b2c7c8e70 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
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);
}