aboutsummaryrefslogtreecommitdiffstats
path: root/src/senddialog.h
blob: a930c3d85d8f344224508b2c846f0dc9ed2a0514 (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
/*
 * 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;
};