From c72d96d6428f99f06c04868a70cb09c4a9180f98 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 21:17:34 +0200 Subject: feat(compose): the send popup and its undo window, item 123 Three rows in every state so nothing reflows and the window never jumps. The bar changes MODE rather than place: determinate while the countdown drains, because a countdown has measurable progress, and indeterminate once the command starts, because a send does not. That is the pairing item 134's widget was extracted to serve. 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, which is worse than either clean outcome. Undo therefore disables itself the moment the command starts, and stays visible while disabled: 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 looks like it was never offered. The test for this asserts the NEGATIVE property, that committed() never fires after Undo, including after the original countdown would have elapsed. Asserting only that undone() fired would pass against a design that ran the command and threw the result away, which is the whole failure the delay exists to prevent. Removing the close BUTTON is not the same as closing the code path, and the first draft did only the former while its comments claimed otherwise. Escape still reached QDialog::reject(), and close() during the countdown hid the window while leaving the timer running, so the send committed with nothing on screen and the only cancel control destroyed: measured, committed=1 on a dialog the user had dismissed. A never-shown dialog did the same, since close() returns early without reaching done(). That is CLAUDE.md's done(int) trap in the one place it costs mail rather than state. Dismissal is REFUSED before commit rather than treated as an implicit Undo, at the user's decision: a close that silently means cancel overloads one gesture with two meanings, while a refusal leaves Undo as the only way out, which is what the popup's single control already says. done(int) refuses pre-commit and forces Accepted after, closeEvent covers the never-shown route done() cannot see, and Undo passes through both. Task 12 needs no special entry point, since it closes after the send finishes and that is post-commit by definition. A refusal must not read as a hang, so the label says how to leave. Making the hint silent was a mutation that SURVIVED, because the text was written in two places and neutering one was masked by the other; extracting it to one function exposed a real defect behind the wrong green, in that the next tick overwrote the hint 100ms later and the refusal was effectively silent anyway. It is held for 1500ms now, with a test that it survives a tick and still releases. setStage is public and Task 12 passes values into it, so it refuses to wind back to CountingDown after commit rather than trusting its caller with an invariant this class documents as inviolable; the label read "Sending in 0..." and the bar returned to determinate. Both m_committed guards carry tests: removing them left the suite green, so two deliberate safety additions rested on reasoning alone. Every route out is asserted, per the rule that a test using close() while the user uses Cancel covers one route of three: close() shown, close() never-shown, Escape bare and with Shift and Ctrl, reject() direct, and Undo, which must still work or the popup is a trap. The status label is sized to the longest string it can hold in the current language rather than to its content: Italian 'Rimozione della bozza...' is longer than 'Removing draft...', and a label sized to content resizes the popup between stages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FXF741wz4SY7j5dqvAxMU5 --- src/senddialog.h | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/senddialog.h (limited to 'src/senddialog.h') 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. + * + * 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 +#include + +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; +}; -- cgit v1.2.3