aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 10:13:20 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 10:13:20 +0200
commit07c36ce617512bb9514e604b614e95a15c390b04 (patch)
treec933635e83f55cda94e4b1a9457a28cebefc3ed4
parent01ea9e9d7df04dc771430e4c378202b8ef37b8db (diff)
downloadqtmaildir-07c36ce617512bb9514e604b614e95a15c390b04.tar.gz
qtmaildir-07c36ce617512bb9514e604b614e95a15c390b04.zip
feat(compose): a menu bar on the composer
File, Edit and Format, to the scope the user chose. Save draft (Ctrl+S) is the only new action: saveDraftNow() was reachable from the autosave timer, the send path and closeEvent, so there was no way for the user to ask for a save. It routes through that same function, which is what emits draftSaved for item 158's indexing, reports through item 160's status bar and raises the failure banner; a second write path would have to repeat all three. The menus show the toolbar's own QAction objects rather than copies, as item 140 required for the message pane's bar. Two needed hand-building. The HTML toggle is a QToolButton and cannot go in a menu, so a checkable twin mirrors it in both directions, since a menu entry that only follows the button is half a control. The signature entry takes the switch's own QMenu pointer, because that menu is rebuilt whenever the signatures change and copied entries would go stale. Edit's entries drive QPlainTextEdit and follow its own undoAvailable and copyAvailable, so a greyed entry tells the truth about what pressing it would do. theMenuBarReachesEveryComposerAction() is item 132's reachability rule applied to the composer: it walks the real menu bar and collects the composer's actions with findChildren, so an action added to the toolbar and forgotten in the menus fails without the test being touched. It skips actions owning a submenu, since Qt emits no triggered for those. The composer's actions stay out of KeyMap, per item 148: they are parented to this window, so they are WindowShortcuts dispatched to the active composer and the main window's namespace is untouched. lrelease reports 496 finished, 0 unfinished. Closes item 161. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
-rw-r--r--CHANGELOG.md6
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md42
-rw-r--r--src/composewindow.cpp117
-rw-r--r--src/composewindow.h10
-rw-r--r--tests/test_composewindow.cpp165
-rw-r--r--translations/qtmaildir_it_IT.ts36
6 files changed, 371 insertions, 5 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61a93c7..3dcc0a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,12 @@ point at which they are stable.
own modified marker for the same state, so it is visible while the composer
sits behind another window. Autosaving already worked; it was silent on
success, and the only feedback was the banner that reports a FAILED save.
+- **A menu bar on the composer**, with File, Edit and Format. **Save draft
+ (`Ctrl+S`) is new**: autosave, sending and closing were the only things that
+ could write a draft, so there was no way to ask for one. Everything else was
+ already there and is now findable: Send and Close under File, the editor's
+ undo and clipboard actions under Edit, and the formatting buttons, Attach,
+ Send as HTML and the signature switch under Format.
## [0.27.0] - 2026-08-25
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 41a7a1b..2e941ec 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -7709,3 +7709,45 @@ because the user looked at it.
severity is the established vocabulary here.
- `saveDraftNow()` returns false on failure and the banner takes over, so the
status line must not claim a save the write did not make.
+
+
+
+## 161. The composer has no menu bar
+
+**Observed (user, 2026-08-25):** "the compose window should have a menu bar on
+top", with "save draft (ctrl+s)" and "duplicate the other actions like we do
+on the main window".
+
+**Cause (verified in code):** the composer has no `QMenuBar`, and **Save draft
+does not exist as an action at all.** `saveDraftNow()` is reachable only from
+the autosave timer, from the send path, and from `closeEvent`; there is no way
+for the user to ask for a save, and no `Ctrl+S` anywhere in the composer or in
+`KeyMap`.
+
+The actions the composer does have are ad-hoc `QAction`s parented to the
+window: `m_sendAction` and a Close action (`composewindow.cpp:583-608`), plus
+the formatting toolbar's own. **None is registered in `KeyMap`**, which is
+deliberate and documented for `Ctrl+W` in item 148: they are WindowShortcuts
+dispatched to the active composer only, so they never touch the main window's
+namespace. A consequence worth stating before this item is built:
+`everyActionIsReachableFromAMenu()` walks the MAIN window's menu bar, so it
+does not currently constrain these, and adding a composer menu bar does not
+automatically bring them under item 132's rules.
+
+**Approach.** A `QMenuBar` on the composer, with Save draft (`Ctrl+S`) as the
+new action and the existing ones gathered under it rather than duplicated.
+
+**Constraints.**
+
+- **"Duplicate the other actions" means the main window's MESSAGE actions, and
+ most of them are meaningless here.** A composer has no thread, no selection
+ and no tag surface. Ask which the user actually wants before building a menu
+ that offers Archive or Mark all read over a message being written.
+- Keep the composer's actions out of `KeyMap`, per item 148's reasoning. A
+ composer menu bar is not a reason to move them.
+- Save draft must go through `saveDraftNow()`, which already handles the
+ failure banner and emits `draftSaved` for the indexing item 158 added. A
+ second write path would reintroduce the ghost-file problem that fixed.
+- **Item 160 unblocked this** on 2026-08-25: the status bar exists, so a
+ manual save reports through `refreshDraftStatus()` like an autosave. Route
+ `Ctrl+S` through `saveDraftNow()` and the reporting is already done.
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 2b0b9d8..879a9d1 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -42,6 +42,7 @@
#include <QLineEdit>
#include <QListWidget>
#include <QMenu>
+#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
@@ -132,6 +133,9 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
buildUi();
buildFormatToolbar();
+ // AFTER buildFormatToolbar(): the menus show its actions, so they must
+ // exist before a menu can hold them.
+ buildMenuBar();
seedFields();
seedBody();
seedSignature();
@@ -606,11 +610,114 @@ void ComposeWindow::buildFormatToolbar()
// close() rather than anything of its own: closeEvent() already decides
// whether the draft is saved or discarded, and a second route out that
// skipped it would lose the message.
- auto *closeAction = new QAction(tr("Close"), this);
- closeAction->setObjectName(QStringLiteral("compose_close"));
- closeAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+W")));
- connect(closeAction, &QAction::triggered, this, &ComposeWindow::close);
- addAction(closeAction);
+ m_closeAction = new QAction(tr("Close"), this);
+ m_closeAction->setObjectName(QStringLiteral("compose_close"));
+ m_closeAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+W")));
+ connect(m_closeAction, &QAction::triggered, this, &ComposeWindow::close);
+ addAction(m_closeAction);
+
+ // Save draft, the one action item 161 adds rather than gathers. It goes
+ // through saveDraftNow() like every other write: that is what emits
+ // draftSaved for item 158's indexing, reports through item 160's status
+ // bar, and raises the failure banner. A second write path would have to
+ // repeat all three and would reintroduce the ghost file 158 removed.
+ m_saveAction = new QAction(tr("Save draft"), this);
+ m_saveAction->setObjectName(QStringLiteral("compose_save"));
+ m_saveAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+S")));
+ const QIcon saveIcon = QIcon::fromTheme(QStringLiteral("document-save"));
+ if (!saveIcon.isNull())
+ m_saveAction->setIcon(saveIcon);
+ connect(m_saveAction, &QAction::triggered, this,
+ [this]() { saveDraftNow(); });
+ addAction(m_saveAction);
+
+ // The menu twin of the HTML tool button. m_sendHtml is a QToolButton, so
+ // it cannot go in a menu; this mirrors it in BOTH directions, since a
+ // menu entry that only follows the button is half a control.
+ m_sendHtmlAction = new QAction(tr("Send as HTML"), this);
+ m_sendHtmlAction->setObjectName(QStringLiteral("compose_send_html"));
+ m_sendHtmlAction->setCheckable(true);
+ m_sendHtmlAction->setChecked(m_sendHtml->isChecked());
+ connect(m_sendHtml, &QToolButton::toggled, m_sendHtmlAction,
+ &QAction::setChecked);
+ connect(m_sendHtmlAction, &QAction::toggled, m_sendHtml,
+ &QToolButton::setChecked);
+}
+
+void ComposeWindow::buildMenuBar()
+{
+ // The menus SHOW the toolbar's own QActions rather than owning copies,
+ // exactly as item 140 required for the message pane's bar: a copy drifts,
+ // so an enablement change or a new shortcut would reach one surface and
+ // not the other.
+ QMenu *file = menuBar()->addMenu(tr("&File"));
+ file->addAction(m_saveAction);
+ file->addAction(m_sendAction);
+ file->addSeparator();
+ file->addAction(m_closeAction);
+
+ // The editor's own undo stack, which had no menu presence at all. These
+ // are QPlainTextEdit's actions, so they follow its state for free: undo
+ // greys out when there is nothing to undo, and the clipboard entries
+ // follow the selection.
+ QMenu *edit = menuBar()->addMenu(tr("&Edit"));
+ const auto addEdit = [this, edit](const QString &name, const QString &text,
+ const QKeySequence &shortcut,
+ void (QPlainTextEdit::*slot)()) {
+ QAction *action = edit->addAction(text);
+ action->setObjectName(name);
+ action->setShortcut(shortcut);
+ connect(action, &QAction::triggered, m_body, slot);
+ return action;
+ };
+ QAction *undo = addEdit(QStringLiteral("compose_undo"), tr("Undo"),
+ QKeySequence::Undo, &QPlainTextEdit::undo);
+ QAction *redo = addEdit(QStringLiteral("compose_redo"), tr("Redo"),
+ QKeySequence::Redo, &QPlainTextEdit::redo);
+ edit->addSeparator();
+ QAction *cut = addEdit(QStringLiteral("compose_cut"), tr("Cut"),
+ QKeySequence::Cut, &QPlainTextEdit::cut);
+ QAction *copy = addEdit(QStringLiteral("compose_copy"), tr("Copy"),
+ QKeySequence::Copy, &QPlainTextEdit::copy);
+ addEdit(QStringLiteral("compose_paste"), tr("Paste"), QKeySequence::Paste,
+ &QPlainTextEdit::paste);
+
+ // Enablement follows the editor, so a greyed entry tells the truth about
+ // what pressing it would do.
+ undo->setEnabled(false);
+ redo->setEnabled(false);
+ cut->setEnabled(false);
+ copy->setEnabled(false);
+ connect(m_body, &QPlainTextEdit::undoAvailable, undo, &QAction::setEnabled);
+ connect(m_body, &QPlainTextEdit::redoAvailable, redo, &QAction::setEnabled);
+ connect(m_body, &QPlainTextEdit::copyAvailable, cut, &QAction::setEnabled);
+ connect(m_body, &QPlainTextEdit::copyAvailable, copy, &QAction::setEnabled);
+
+ QMenu *format = menuBar()->addMenu(tr("F&ormat"));
+ const auto byName = [this](const char *name) {
+ return findChild<QAction *>(QString::fromLatin1(name));
+ };
+ for (const char *name : { "format_bold", "format_italic", "format_code",
+ "format_strike" }) {
+ if (QAction *action = byName(name))
+ format->addAction(action);
+ }
+ format->addSeparator();
+ for (const char *name : { "format_link", "format_quote" }) {
+ if (QAction *action = byName(name))
+ format->addAction(action);
+ }
+ format->addSeparator();
+ format->addAction(m_attachAction);
+ format->addAction(m_detachAction);
+ format->addSeparator();
+ format->addAction(m_sendHtmlAction);
+
+ // The switch's own menu, shown a second time. It is rebuilt whenever the
+ // signatures change, so taking the QMenu itself keeps both in step; a
+ // copy of its entries would go stale on the next rebuild.
+ QAction *signature = format->addMenu(m_signatureSwitch->menu());
+ signature->setText(tr("Signature"));
}
void ComposeWindow::seedFields()
diff --git a/src/composewindow.h b/src/composewindow.h
index dc7f3c0..2eaeeea 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -228,6 +228,10 @@ private:
void applyEdit(const MarkdownFormat::Edit &edit);
void markDirty();
+ /// Builds the menu bar (item 161). Called after buildFormatToolbar(),
+ /// since the menus SHOW its actions rather than owning copies.
+ void buildMenuBar();
+
/// Builds the status bar carrying the unsaved cue and the age line.
void buildDraftStatusBar();
@@ -310,6 +314,12 @@ private:
QPlainTextEdit *m_sendLog = nullptr;
QToolBar *m_formatToolbar = nullptr;
QAction *m_sendAction = nullptr;
+ QAction *m_saveAction = nullptr;
+ QAction *m_closeAction = nullptr;
+
+ /// The menu twin of the m_sendHtml tool button, which is a QToolButton
+ /// and cannot be put in a menu. Kept in step both ways.
+ QAction *m_sendHtmlAction = nullptr;
QAction *m_attachAction = nullptr;
QAction *m_detachAction = nullptr;
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
index 68ec4d4..779d48c 100644
--- a/tests/test_composewindow.cpp
+++ b/tests/test_composewindow.cpp
@@ -24,6 +24,10 @@
#include <QPlainTextEdit>
#include <QSignalSpy>
#include <QLabel>
+#include <QMenuBar>
+#include <QToolBar>
+#include <QSet>
+#include <functional>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QTimer>
@@ -56,6 +60,10 @@ private slots:
void aSavedDraftReportsItAndClearsTheCue();
void aSentMessageLeavesNoUnsavedCue();
void onlyTheSetterWritesTheDirtyFlag();
+ void theMenuBarReachesEveryComposerAction();
+ void saveDraftWritesAndReports();
+ void theMenusReuseTheToolbarActions();
+ void theHtmlMenuItemTracksTheToolbarButton();
void theAgeLineFollowsTheClock();
private:
@@ -643,5 +651,162 @@ void TestComposeWindow::theAgeLineFollowsTheClock()
"the age line must move as the clock does");
}
+/// Item 132's rule for the main window, applied to the composer: an action
+/// nobody can find in a menu is reachable only by a chord the user has to
+/// know. Walks the real menu bar rather than a list, so an action added to
+/// the toolbar and forgotten in the menus fails here.
+void TestComposeWindow::theMenuBarReachesEveryComposerAction()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+
+ QMenuBar *bar = window.menuBar();
+ QVERIFY2(bar, "the composer has no menu bar");
+
+ // Every action the menus reach, submenus included.
+ QSet<QString> reachable;
+ std::function<void(QMenu *)> walk = [&](QMenu *menu) {
+ const QList<QAction *> actions = menu->actions();
+ for (QAction *action : actions) {
+ if (QMenu *sub = action->menu()) {
+ // An action owning a submenu is not itself reachable: Qt
+ // emits no triggered for it, as CLAUDE.md records.
+ walk(sub);
+ continue;
+ }
+ if (action->isSeparator())
+ continue;
+ if (!action->objectName().isEmpty())
+ reachable.insert(action->objectName());
+ }
+ };
+ const QList<QAction *> top = bar->actions();
+ for (QAction *action : top) {
+ QVERIFY2(action->menu(), "a top-level menu bar entry with no menu");
+ walk(action->menu());
+ }
+
+ // Every named action the composer owns. findChildren, so an action added
+ // later is picked up without touching this list.
+ const QList<QAction *> owned = window.findChildren<QAction *>();
+ QStringList missing;
+ for (QAction *action : owned) {
+ const QString name = action->objectName();
+ if (name.isEmpty())
+ continue;
+ // The signature entries are built from the files on disk and named
+ // per signature; the switch itself is what a menu offers.
+ if (name.startsWith(QStringLiteral("signature_choice")))
+ continue;
+ if (!reachable.contains(name))
+ missing.append(name);
+ }
+
+ QVERIFY2(missing.isEmpty(),
+ qPrintable(QStringLiteral("not reachable from any menu: %1")
+ .arg(missing.join(QStringLiteral(", ")))));
+}
+
+/// The action item 161 adds. Save draft did not exist at all: saveDraftNow()
+/// was reachable only from the timer, the send path and closeEvent, so the
+/// user could not ask for a save.
+void TestComposeWindow::saveDraftWritesAndReports()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Saved by hand."));
+
+ auto *save = window.findChild<QAction *>(QStringLiteral("compose_save"));
+ QVERIFY2(save, "there is no Save draft action");
+ QCOMPARE(save->shortcut(), QKeySequence(QStringLiteral("Ctrl+S")));
+
+ QSignalSpy saved(&window, &ComposeWindow::draftSaved);
+ save->trigger();
+
+ QCOMPARE(saved.size(), 1);
+
+ // Routed through saveDraftNow(), so item 160's reporting comes free. A
+ // second write path would have to repeat it, and would be the ghost-file
+ // bug item 158 fixed.
+ auto *age = window.findChild<QLabel *>(QStringLiteral("draftAge"));
+ QVERIFY(age);
+ QVERIFY2(!age->text().isEmpty(), "a manual save must report like an autosave");
+ QVERIFY2(!window.isWindowModified(), "a manual save must clear the marker");
+}
+
+/// The same QAction objects, shown twice over, exactly as item 140 required
+/// for the message pane's bar. A copy would drift: an enablement change or a
+/// new shortcut would reach one surface and not the other.
+void TestComposeWindow::theMenusReuseTheToolbarActions()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ auto *toolbar = window.findChild<QToolBar *>(QStringLiteral("formatToolbar"));
+ QVERIFY(bold);
+ QVERIFY(toolbar);
+ QVERIFY2(toolbar->actions().contains(bold),
+ "Bold left the formatting toolbar");
+
+ QMenu *format = nullptr;
+ const QList<QAction *> top = window.menuBar()->actions();
+ for (QAction *action : top) {
+ if (action->menu() && action->menu()->actions().contains(bold))
+ format = action->menu();
+ }
+ QVERIFY2(format, "Bold is not in any menu");
+
+ // The pointer itself, not a namesake.
+ QVERIFY2(format->actions().contains(bold),
+ "the menu holds a copy of Bold rather than the action itself");
+}
+
+/// The HTML toggle is a QToolButton, not a QAction, so a menu entry for it
+/// has to be built and kept in step by hand. Both directions: a menu that
+/// only follows the button is half a control.
+void TestComposeWindow::theHtmlMenuItemTracksTheToolbarButton()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+
+ auto *button = window.findChild<QToolButton *>(QStringLiteral("sendHtml"));
+ auto *item = window.findChild<QAction *>(QStringLiteral("compose_send_html"));
+ QVERIFY(button);
+ QVERIFY2(item, "there is no menu entry for the HTML toggle");
+ QVERIFY(item->isCheckable());
+
+ const bool initial = button->isChecked();
+ QCOMPARE(item->isChecked(), initial);
+
+ button->setChecked(!initial);
+ QCOMPARE(item->isChecked(), !initial);
+
+ item->setChecked(initial);
+ QCOMPARE(button->isChecked(), initial);
+}
+
QTEST_MAIN(TestComposeWindow)
#include "test_composewindow.moc"
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 5773461..913d151 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -111,6 +111,42 @@
<translation>Invia</translation>
</message>
<message>
+ <source>Save draft</source>
+ <translation>Salva bozza</translation>
+ </message>
+ <message>
+ <source>&amp;File</source>
+ <translation>&amp;File</translation>
+ </message>
+ <message>
+ <source>&amp;Edit</source>
+ <translation>&amp;Modifica</translation>
+ </message>
+ <message>
+ <source>Undo</source>
+ <translation>Annulla</translation>
+ </message>
+ <message>
+ <source>Redo</source>
+ <translation>Ripeti</translation>
+ </message>
+ <message>
+ <source>Cut</source>
+ <translation>Taglia</translation>
+ </message>
+ <message>
+ <source>Copy</source>
+ <translation>Copia</translation>
+ </message>
+ <message>
+ <source>Paste</source>
+ <translation>Incolla</translation>
+ </message>
+ <message>
+ <source>F&amp;ormat</source>
+ <translation>F&amp;ormato</translation>
+ </message>
+ <message>
<source>None</source>
<translation>Nessuna</translation>
</message>