aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/composewindow.cpp136
-rw-r--r--src/composewindow.h33
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_composewindow.cpp220
-rw-r--r--translations/qtmaildir_it_IT.ts16
5 files changed, 406 insertions, 0 deletions
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 <QAction>
#include <QCheckBox>
@@ -40,9 +41,11 @@
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
+#include <QMenu>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
+#include <QStandardPaths>
#include <QTextCursor>
#include <QTimer>
#include <QToolBar>
@@ -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
+ /// <config>/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. <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 <QDir>
+#include <QFile>
+#include <QMenu>
+#include <QPlainTextEdit>
+#include <QTemporaryDir>
+#include <QTextStream>
+#include <QToolButton>
+
+#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<QPair<QString, QString>> &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<QPair<QString, QString>> &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<QPlainTextEdit *>(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<QPlainTextEdit *>(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<QPlainTextEdit *>(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<QPlainTextEdit *>(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<QToolButton *>(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
@@ -99,10 +99,22 @@
<translation>Invia il messaggio come testo semplice con una versione HTML a fianco. Il testo semplice è quello che hai scritto.</translation>
</message>
<message>
+ <source>Signature</source>
+ <translation>Firma</translation>
+ </message>
+ <message>
+ <source>Chooses the signature added to this message.</source>
+ <translation>Sceglie la firma da aggiungere a questo messaggio.</translation>
+ </message>
+ <message>
<source>Send</source>
<translation>Invia</translation>
</message>
<message>
+ <source>None</source>
+ <translation>Nessuna</translation>
+ </message>
+ <message>
<source>Large attachment</source>
<translation>Allegato di grandi dimensioni</translation>
</message>
@@ -198,6 +210,10 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
<translation>[compose] quote_position &apos;%1&apos; non è riconosciuto; atteso above o below. Uso below.</translation>
</message>
<message>
+ <source>[compose] signature_position &apos;%1&apos; is not recognised; expected end or above_quote. Using end.</source>
+ <translation>[compose] signature_position &apos;%1&apos; non è riconosciuto; atteso end o above_quote. Uso end.</translation>
+ </message>
+ <message>
<source>[compose] autosave_interval_ms &apos;%1&apos; is not a number; using %2.</source>
<translation>[compose] autosave_interval_ms &apos;%1&apos; non è un numero; verrà usato %2.</translation>
</message>