From 882bb1b36fd777ec5fd5f331f2d589d48b5af5c1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 24 Aug 2026 20:52:25 +0200 Subject: feat(compose): a signature switch on the editor bar A QToolButton with a checkable menu at the right end of the editor bar, where item 142 put the controls of the editor. Not registered in KeyMap: parented to the composer like the formatting actions, so its scope is this window. The signature is applied through a QTextCursor rather than setPlainText(), which destroys the undo stack, and the seeded one is cleared from that stack for the reason the seeded quote already is: one Ctrl+Z must not wipe content the user never typed. A resumed draft seeds nothing. Its body already carries the signature it was written with, and seeding again would put a second one on a message written once. Part of item 152. --- src/composewindow.cpp | 136 +++++++++++++++++++++++++ src/composewindow.h | 33 ++++++ tests/CMakeLists.txt | 1 + tests/test_composewindow.cpp | 220 ++++++++++++++++++++++++++++++++++++++++ translations/qtmaildir_it_IT.ts | 16 +++ 5 files changed, 406 insertions(+) create mode 100644 tests/test_composewindow.cpp diff --git a/src/composewindow.cpp b/src/composewindow.cpp index a64736f..8236d79 100644 --- a/src/composewindow.cpp +++ b/src/composewindow.cpp @@ -25,6 +25,7 @@ #include "mimeparser.h" #include "messagesender.h" #include "senddialog.h" +#include "signatures.h" #include #include @@ -40,9 +41,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -128,6 +131,7 @@ ComposeWindow::ComposeWindow(const ComposeContext &context, buildFormatToolbar(); seedFields(); seedBody(); + seedSignature(); // AFTER buildUi(), which creates m_banner, and BEFORE // refreshAttachmentList(), which renders m_attachments: extraction appends @@ -536,6 +540,29 @@ void ComposeWindow::buildFormatToolbar() m_sendHtml->setIcon(htmlIcon); m_formatToolbar->addWidget(m_sendHtml); + // The signature switch rides at the right end with Attach and the HTML + // toggle: item 142 put the controls OF THE EDITOR on this side, as against + // the formatting buttons on the left, and choosing a signature is one of + // those. + // + // A QToolButton with a menu rather than a QComboBox, matching the bar's + // other controls; a combo would read as a different class of thing. Not + // registered in KeyMap: it is parented to this window, exactly as the + // formatting actions are, so its scope is the composer and item 132's + // reachability rule does not apply. + m_signatureSwitch = new QToolButton(m_formatToolbar); + m_signatureSwitch->setObjectName(QStringLiteral("signatureSwitch")); + m_signatureSwitch->setText(tr("Signature")); + m_signatureSwitch->setToolTip( + tr("Chooses the signature added to this message.")); + m_signatureSwitch->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + m_signatureSwitch->setPopupMode(QToolButton::InstantPopup); + const QIcon signatureIcon = QIcon::fromTheme(QStringLiteral("insert-text")); + if (!signatureIcon.isNull()) + m_signatureSwitch->setIcon(signatureIcon); + m_signatureSwitch->setMenu(new QMenu(m_signatureSwitch)); + m_formatToolbar->addWidget(m_signatureSwitch); + // Send is NOT on this row: it is the terminal action, and it lives on the // button beside the headers. The QAction survives because it carries the // shortcut and is what the button triggers. @@ -664,6 +691,114 @@ void ComposeWindow::seedBody() m_body->document()->clearUndoRedoStacks(); } +void ComposeWindow::setSignatureDir(const QString &dir) +{ + m_signatureDir = dir; +} + +QStringList ComposeWindow::knownSignatures() const +{ + QStringList known; + const QStringList names = Signatures::names(m_signatureDir); + known.reserve(names.size()); + for (const QString &name : names) + known.append(Signatures::text(m_signatureDir, name)); + return known; +} + +QString ComposeWindow::seededSignatureName() const +{ + // The account SEEDS, it does not bind: this is a starting value, and the + // switch keeps every signature reachable whichever account is selected. + const Account account = m_config.account(m_context.accountKey); + if (!account.signature.isEmpty()) + return account.signature; + return m_config.compose().signature; +} + +void ComposeWindow::applySignature(const QString &name) +{ + const QString text = + name.isEmpty() ? QString() : Signatures::text(m_signatureDir, name); + + // A QTextCursor replacement rather than setPlainText(), for the reason + // recorded at applyEdit(): setPlainText() destroys the document's undo + // stack, so a switch would make everything typed before it unrecoverable. + const QString replaced = Signatures::replace( + m_body->toPlainText(), text, knownSignatures(), + m_config.compose().signaturePosition); + + QTextCursor cursor(m_body->document()); + cursor.select(QTextCursor::Document); + cursor.insertText(replaced); + + m_signatureName = name; + + for (QAction *action : m_signatureSwitch->menu()->actions()) + action->setChecked(action->data().toString() == name); +} + +void ComposeWindow::seedSignature() +{ + if (m_signatureDir.isEmpty()) { + const QString base = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); + m_signatureDir = base + QStringLiteral("/qtmaildir/signatures"); + } + + QMenu *menu = m_signatureSwitch->menu(); + menu->clear(); + + auto *none = menu->addAction(tr("None")); + none->setCheckable(true); + none->setData(QString()); + connect(none, &QAction::triggered, this, [this]() { + m_signatureChosen = true; + applySignature(QString()); + markDirty(); + }); + + const QStringList names = Signatures::names(m_signatureDir); + for (const QString &name : names) { + auto *action = menu->addAction(name); + action->setCheckable(true); + action->setData(name); + connect(action, &QAction::triggered, this, [this, name]() { + m_signatureChosen = true; + applySignature(name); + markDirty(); + }); + } + + // A resumed draft is the message ITSELF and already carries whatever + // signature it was saved with, exactly as seedBody() takes its body + // verbatim. Seeding again would append a second one. + if (m_context.kind == ComposeContext::Kind::Draft) { + none->setChecked(true); + return; + } + + const QString seeded = seededSignatureName(); + if (seeded.isEmpty()) { + none->setChecked(true); + return; + } + if (!names.contains(seeded)) { + // Reported by Config as a problem; the composer still opens, with no + // signature, and the switch still works. + none->setChecked(true); + return; + } + + applySignature(seeded); + + // The seeded signature is not an edit the user made, so it must not + // survive as an undo step: one Ctrl+Z on a fresh composer would otherwise + // wipe content they never typed. Same reason seedBody() clears after the + // quote. + m_body->document()->clearUndoRedoStacks(); +} + void ComposeWindow::refreshAttachmentList() { m_attachmentList->clear(); @@ -897,6 +1032,7 @@ void ComposeWindow::setInputsEnabled(bool enabled) m_from->setEnabled(enabled); m_body->setReadOnly(!enabled); m_sendHtml->setEnabled(enabled); + m_signatureSwitch->setEnabled(enabled); m_attachmentList->setEnabled(enabled); m_formatToolbar->setEnabled(enabled); diff --git a/src/composewindow.h b/src/composewindow.h index c44fcda..918d4e3 100644 --- a/src/composewindow.h +++ b/src/composewindow.h @@ -96,6 +96,21 @@ public: /// and quitting therefore loses that text. bool lastSaveFailed() const { return m_saveFailed; } + /// Where the signature files live. Defaults to + /// /qtmaildir/signatures; a test points it at its own directory. + /// + /// A setter rather than a config key: nothing yet suggests the user wants + /// a second location, and the tests need to not read the real one. + void setSignatureDir(const QString &dir); + + /// Seeds the signature from config and fills the switch. + /// + /// Public and called by the constructor rather than private, so a test can + /// drive it after pointing setSignatureDir() somewhere safe. A resumed + /// draft seeds nothing: its body already carries the signature it was + /// written with. + void seedSignature(); + /// Writes the current buffer to the drafts folder now. Returns false and /// leaves the banner up on failure. /// @@ -176,6 +191,16 @@ private: /// a silently wrong send is not among the outcomes. void extractForwardedAttachments(); void seedBody(); + + /// Applies \p name to the buffer, replacing whatever is there. + void applySignature(const QString &name); + + /// The text of every signature on disk, for replace()'s guard. + QStringList knownSignatures() const; + + /// The signature name this account seeds, falling through to [compose]. + QString seededSignatureName() const; + void refreshAttachmentList(); void setInputsEnabled(bool enabled); void showSendFailure(const QString &stderrText); @@ -227,6 +252,14 @@ private: QComboBox *m_from = nullptr; QPlainTextEdit *m_body = nullptr; QToolButton *m_sendHtml = nullptr; + QToolButton *m_signatureSwitch = nullptr; + QString m_signatureDir; + QString m_signatureName; ///< The selected signature, empty for None. + + /// True once the user has used the switch. From then on a From: change + /// stops re-seeding, so a deliberate choice is never overwritten. Matches + /// how send_html seeds from context and is then left alone. + bool m_signatureChosen = false; QLabel *m_banner = nullptr; QListWidget *m_attachmentList = nullptr; QWidget *m_sendLogPane = nullptr; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 70f6537..5938aeb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ add_qtmaildir_test(maildirname) add_qtmaildir_test(draftstore) add_qtmaildir_test(messagesender) add_qtmaildir_test(composecontext) +add_qtmaildir_test(composewindow) add_qtmaildir_test(formattoolbar) add_qtmaildir_test(senddialog) add_qtmaildir_test(translations) diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp new file mode 100644 index 0000000..04d3115 --- /dev/null +++ b/tests/test_composewindow.cpp @@ -0,0 +1,220 @@ +/* + * 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. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "composecontext.h" +#include "composewindow.h" +#include "config.h" +#include "signatures.h" + +class TestComposeWindow : public QObject +{ + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void aNewMessageSeedsTheComposeSignature(); + void anAccountSignatureOverridesTheComposeOne(); + void aResumedDraftSeedsNoSignature(); + void anUnknownSignatureNameSeedsNothing(); + void theSwitchListsEveryFileAndNone(); + +private: + /// A config pointing at a signatures directory holding \p files, with one + /// account that can send. + Config makeConfig(const QList> &files, + const QString &composeSignature, + const QString &accountSignature = {}); + + /// QVERIFY cannot appear inside makeConfig(), which returns a value: the + /// macro expands to a bare `return;` on failure, which is invalid in a + /// non-void function. A void helper keeps the check and sidesteps that. + void writeFile(const QString &path, const QString &content); + + QTemporaryDir *m_dir = nullptr; + QString m_signatureDir; +}; + +void TestComposeWindow::init() +{ + m_dir = new QTemporaryDir; + QVERIFY(m_dir->isValid()); + m_signatureDir = m_dir->path() + QStringLiteral("/signatures"); + QVERIFY(QDir().mkpath(m_signatureDir)); +} + +void TestComposeWindow::cleanup() +{ + delete m_dir; + m_dir = nullptr; +} + +void TestComposeWindow::writeFile(const QString &path, const QString &content) +{ + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << content; +} + +Config TestComposeWindow::makeConfig( + const QList> &files, + const QString &composeSignature, const QString &accountSignature) +{ + for (const auto &entry : files) + writeFile(m_signatureDir + QStringLiteral("/") + entry.first, entry.second); + + QString conf; + { + QTextStream out(&conf); + out << "[compose]\n" + << "signature = " << composeSignature << "\n" + << "\n" + << "[account.work]\n" + << "name = Someone\n" + << "address = someone@example.org\n" + << "maildir = work\n" + << "send_command = /bin/cat\n"; + if (!accountSignature.isEmpty()) + out << "signature = " << accountSignature << "\n"; + } + const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf"); + writeFile(path, conf); + + Config config; + config.load(path); + return config; +} + +void TestComposeWindow::aNewMessageSeedsTheComposeSignature() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") } }, + QStringLiteral("work")); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild(QStringLiteral("body")); + QVERIFY(body); + QVERIFY(body->toPlainText().endsWith(QStringLiteral("-- \nJane Doe"))); +} + +void TestComposeWindow::anAccountSignatureOverridesTheComposeOne() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Long one") }, + { QStringLiteral("brief.md"), QStringLiteral("Brief") } }, + QStringLiteral("work"), QStringLiteral("brief")); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild(QStringLiteral("body")); + QVERIFY(body); + QVERIFY(body->toPlainText().endsWith(QStringLiteral("-- \nBrief"))); +} + +void TestComposeWindow::aResumedDraftSeedsNoSignature() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") } }, + QStringLiteral("work")); + + // The saved body already carries whatever signature it was written with. + // Seeding again would put a SECOND one on a message written once. + ComposeContext context; + context.kind = ComposeContext::Kind::Draft; + context.accountKey = QStringLiteral("work"); + context.body = QStringLiteral("Half a thought.\n\n-- \nJane Doe"); + context.draftPath = m_dir->path() + QStringLiteral("/draft"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild(QStringLiteral("body")); + QVERIFY(body); + QCOMPARE(body->toPlainText().count(QStringLiteral("-- \nJane Doe")), 1); +} + +void TestComposeWindow::anUnknownSignatureNameSeedsNothing() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") } }, + QStringLiteral("absent")); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild(QStringLiteral("body")); + QVERIFY(body); + // No signature, and the composer still opened rather than refusing. + QVERIFY(!body->toPlainText().contains(QStringLiteral("-- "))); +} + +void TestComposeWindow::theSwitchListsEveryFileAndNone() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Jane Doe") }, + { QStringLiteral("brief.md"), QStringLiteral("Brief") } }, + QString()); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *button = + window.findChild(QStringLiteral("signatureSwitch")); + QVERIFY(button); + QVERIFY(button->menu()); + // "None" plus one per file. + QCOMPARE(button->menu()->actions().size(), 3); +} + +QTEST_MAIN(TestComposeWindow) +#include "test_composewindow.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 1b0e756..a9c0f88 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -98,10 +98,22 @@ Sends the message as plain text with an HTML version alongside it. The plain text is what you typed. Invia il messaggio come testo semplice con una versione HTML a fianco. Il testo semplice è quello che hai scritto. + + Signature + Firma + + + Chooses the signature added to this message. + Sceglie la firma da aggiungere a questo messaggio. + Send Invia + + None + Nessuna + Large attachment Allegato di grandi dimensioni @@ -197,6 +209,10 @@ Il messaggio È stato inviato. Non inviarlo di nuovo. [compose] quote_position '%1' is not recognised; expected above or below. Using below. [compose] quote_position '%1' non è riconosciuto; atteso above o below. Uso below. + + [compose] signature_position '%1' is not recognised; expected end or above_quote. Using end. + [compose] signature_position '%1' non è riconosciuto; atteso end o above_quote. Uso end. + [compose] autosave_interval_ms '%1' is not a number; using %2. [compose] autosave_interval_ms '%1' non è un numero; verrà usato %2. -- cgit v1.2.3