aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-24 12:15:09 +0200
committerDanilo M. <danix@danix.xyz>2026-08-24 12:15:09 +0200
commit98ea0ee4691e60b430bd78eb615838da90bce244 (patch)
treed64d9d02e25f5b04044ad982beefbce3600a1503
parent02a795abe731da908928cd08a799ad79002ff421 (diff)
downloadqtmaildir-98ea0ee4691e60b430bd78eb615838da90bce244.tar.gz
qtmaildir-98ea0ee4691e60b430bd78eb615838da90bce244.zip
feat: add a Drafts filter, and close the composer with Ctrl+W
Items 138 and 148. The query row carried Unread, Inbox, Important, Sent and Trash, and no Drafts, though the composer has been autosaving into each account's drafts folder since compose shipped. Reaching them meant typing a query by hand. Smaller than its size suggested: Account::draftsQuery() and Config::allDraftsQuery() already existed for the placeholder pane's drafts count, and builtinFilters() derives the row from kQueryGenerators, so the work was the generator entry, two resolvedQuery branches, a label and an icon. It follows TRASH rather than Sent. Folder-matched like both, because `draft` is a Maildir flag notmuch surfaces as a tag while the folder is what the user means and what the composer actually writes into. But NOT flat: Sent is flat so a thread cannot fold the user's own message back into the conversation it answers, and a draft reply belongs with its conversation for the same reason a trashed message does. An account with no drafts folder shows no button, per item 103's rule. The existing row test surfaced that by failing until its fixture configured one, which is the rule working rather than a defect. Ctrl+W closes the composer, which bound nothing at all: the only way out was the title bar. The action is parented to the composer, so it is a WindowShortcut dispatched to the active one only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls close() rather than doing anything of its own, since closeEvent() already decides whether the draft is saved and a second route out that skipped it would lose the message. The Italian gains "Bozze"; lrelease reports 478 finished, 0 unfinished.
-rw-r--r--CHANGELOG.md7
-rw-r--r--README.md3
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md28
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md32
-rw-r--r--src/composewindow.cpp14
-rw-r--r--src/config.cpp22
-rw-r--r--src/mainwindow.cpp1
-rw-r--r--tests/test_config.cpp67
-rw-r--r--tests/test_mainwindow.cpp61
-rw-r--r--translations/qtmaildir_it_IT.ts4
10 files changed, 204 insertions, 35 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0a567c..19e52d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,13 @@ point at which they are stable.
reply follows what the message being answered used.
- Drafts autosave to the account's `drafts` folder as ordinary Maildir files,
so mbsync carries them to the server and another client can pick one up.
+- **A Drafts filter** in the query row, beside Sent and Trash. It matches each
+ account's `drafts` folder, so it finds what the composer actually writes
+ rather than trusting a flag. An account that configures no drafts folder
+ contributes nothing and shows no button.
+- `Ctrl+W` closes a composer, the way it closes a window elsewhere. The draft
+ is saved or discarded exactly as it is when the window is closed by any
+ other route.
- **The composer is laid out by scope.** Send is a large button beside the
headers rather than one more entry in a row of formatting buttons; the
formatting controls are icons on their own bar directly above the editor,
diff --git a/README.md b/README.md
index c80323f..ae51de5 100644
--- a/README.md
+++ b/README.md
@@ -231,7 +231,8 @@ maildir = work-mail ; relative to notmuch's mail root
trash = Trash ; Delete moves the file here. Not optional in
; practice: without it the application reports a
; config problem and Delete does not work.
-drafts = Drafts ; optional; where the composer autosaves
+drafts = Drafts ; optional; enables the Drafts button, and where
+ ; the composer autosaves
sent = Sent ; optional; enables the Sent button, and where a
; sent copy is filed
inbox = Inbox ; optional; where Restore puts a message whose
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 e5a046e..133a761 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
@@ -7320,3 +7320,31 @@ holding an address is a message going somewhere the sender cannot see, which is
worse than the clutter this removes. The seeding runs before `buildUi()`'s
`markDirty()` connections per the constructor's ordering comment, so whatever
decides the initial state has to read the seeded values rather than the widgets.
+
+## 138. No Drafts filter beside Sent and Trash
+
+**Observed.** The query row carries Unread, Inbox, Important, Sent and Trash.
+There is no Drafts button, though the composer has been writing drafts to each
+account's drafts folder since item 123.
+
+**Cause, verified 2026-08-23.** `kQueryGenerators` in `config.cpp:62-66` is a
+closed set of five, and `drafts` is not among them. Every account already
+carries a `drafts` key (`config.cpp:453`), read for the composer's autosave, so
+the data the filter needs is configured and unused by the query row.
+
+**Approach.** Follow `sent`, not `inbox`. A tag query would be wrong for the
+same reason it is wrong for Sent: `draft` is a Maildir flag notmuch surfaces as
+a tag, but the folder is what the user means, and a message a provider marks
+differently would disagree. `Config::allSentQuery()` composes the union over
+every account's folder and `Account::sentQuery()` the per-account half; both
+need a drafts twin. The generator string is wire format and must stay `drafts`
+in queries.json whatever the button is called in a given locale.
+
+**Constraints.** An account with no `drafts` key contributes NOTHING rather
+than an empty term, or the button shows the whole Maildir: this is
+`Config::matchNothingQuery()`'s reason for existing. The hook's carve-out
+(`assets/hooks/qtmaildirconf.py`) reads the same key, so the two now agree on
+what a drafts folder is; they are separate readers and neither should start
+importing the other.
+
+---
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 2227019..f53a013 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -207,7 +207,7 @@ taking that too literally.
| 135 | The formatting toolbar's buttons stack rather than toggle | v2 | S | open, 2026-08-21, asked for by the user during item 123 task 8 and reverted the same session. **A spec change, not a defect**: it conflicts with spec:236 ("deliberately no live toggle") and spec:187-190. Both sites need amending FIRST, and the amendment must resolve what replaces bold-then-italic, which is the gesture spec:187's preserved selection exists to serve and which a toggle makes unreachable. That question is the work; the state machine is understood and written up in the section |
| 136 | `undoMovesTheMessageBack` fails when run ALONE, passes in the full suite | defect | ? | open, 2026-08-21, re-measured 2026-08-24 and it is not what the row said. Filed as an intermittent race (1 in 6); it is in fact **deterministic on the selection**: 6 failures in 6 when named on the command line, and 0 failures in the full 258-test run, on a clean tree with the day's work stashed out. All three of its 15s `QTRY` timeouts expire, giving 45s against a 25s whole-suite run, so undo never moves the file rather than losing a race. A test that needs its predecessors is the likely shape (the `init()` lock-table fixture of item 61 is one candidate), which makes it a TEST defect until shown otherwise. Not caused by item 149 |
| 137 | A reply to a message that arrived at two accounts can come from the wrong one | defect | S | open, 2026-08-22, found while building item 123 task 12. `ComposeContextBuilder::accountForReply()` takes `messagePaths` PLURAL to disambiguate, and nothing upstream ever gives it more than one path, so the disambiguation is inert |
-| 138 | No Drafts filter beside Sent and Trash | workflow | S | open, 2026-08-23, from the notes. Verified: `kQueryGenerators` has no `drafts` entry, though every account already configures a `drafts` folder. Follows the `sent` generator exactly, which composes per-account folders rather than matching a tag |
+| 138 | No Drafts filter beside Sent and Trash | workflow | S | **done** 2026-08-24, unreleased. Smaller than sized: `Account::draftsQuery()` and `Config::allDraftsQuery()` already existed for the placeholder pane's count, so only the `kQueryGenerators` entry, the two `resolvedQuery` branches, the label and an icon were missing, and `builtinFilters()` derives the row from that set. Follows TRASH rather than Sent: folder-matched like both, but NOT flat, since a draft reply belongs with the conversation it answers. An account with no `drafts` key shows no button at all, per item 103's rule, which the existing row test surfaced by failing until its fixture configured one |
| 139 | Forward is reachable only from the Message menu | discoverability | XS | **done** 2026-08-24, unreleased, inside 140/141 as that entry said it would be. Forward is on the message pane's own bar with Compose and Reply |
| 140 | Compose, Reply and Forward belong over the message pane, not on the main toolbar | presentation | M | **done** 2026-08-24, unreleased, with 139 and 141, then REVISED the same day after the user looked at it. Reply and Forward move; **Compose stays on the main toolbar**, because the split that survives contact is what the action NEEDS rather than what it is about, and composing needs no message at all. The moved actions leave the toolbar rather than gaining a second home. Same `QAction` objects shown twice over, never copies, so enablement and the menu entries stay single-sourced |
| 141 | The message pane has no button bar of its own | presentation | M | **done** 2026-08-24, unreleased, with 139 and 140. The design question the entry flagged was settled with the user: message actions left, view controls right, separated by an expanding spacer, with `toggle_html` the first of the latter. It sits directly above the web view, BELOW the subject and details rows, which was the user's correction after seeing it at the top of the pane read as window chrome. Icons are 7/8 of `toolbar_icon_size` (28 against the user's 32), derived so the relation survives a change to that key. `MessageView::setBarActions()` is the seam, so the pane still knows nothing about `MainWindow`'s action map. Two traps: a toolbar has no `addStretch()`, and `noTwoActionsShareAnIcon` took an UNNAMED `findChild<QToolBar*>` which now has two candidates, so it is pinned to `main_toolbar` or it would assert against the wrong bar and pass while the rule went unchecked |
@@ -217,7 +217,7 @@ taking that too literally.
| 145 | Cc and Bcc are permanent rows on every composer | presentation | S | **done** 2026-08-24, unreleased, inside 142. A `QToolButton` disclosure beside To:. `revealCcBccIfUsed()` is the load-bearing half the entry called for: it only ever SHOWS, never hides, so nothing but the user's own click can make a field holding an address invisible. `ComposeContext` carries no `bcc` at all, so the seeded-Bcc case can only arrive from a reopened draft, which is what its test drives. The LABEL is hidden with each field: a `QFormLayout` holds the two as separate items, so hiding the line edit alone strands a `Cc:` over empty space |
| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **duplicate of 119**, recorded 2026-08-23 from the notes. Same request, and 119 already carries the blocker: one of the four things the count sums holds no message ids, so a list cannot be complete without changing how the count is kept |
| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes. The notes ask for exactly what 99 describes: "Mark as read" on an unread message and the reverse. 99 already records that the label is harder than it looks, since a multi-row selection has no single direction |
-| 148 | Ctrl+W does not close the composer | discoverability | XS | open, 2026-08-23, from the notes. Verified: nothing binds `Ctrl+W` anywhere, and the composer has no close action of its own. Belongs with item 21's table rather than bound in isolation |
+| 148 | Ctrl+W does not close the composer | discoverability | XS | **done** 2026-08-24, unreleased. A `QAction` parented to the composer, so it is a WindowShortcut dispatched to the active composer only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls `close()` rather than doing anything of its own: `closeEvent()` already decides whether the draft is saved, and a second route out that skipped it would lose the message. Not registered in `KeyMap`, so item 132's rules do not apply |
| 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased, in TWO passes. The first fixed the cursor within each branch (`End` under Above, `Start` under Below) and the user still saw the old layout, because the branches were already right and the DEFAULT was wrong: `above` shipped, and the layout asked for is what `below` produces. Default flipped, and the composer now focuses the body whenever To: is already filled, which a Reply and a Forward always are. Both halves were invisible to the existing `theQuotePositionDecidesWhereTheQuoteLands`, which asserts the quote's position and never the cursor's |
| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason |
| 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | **done** 2026-08-24, unreleased. Two severities as the user asked: yellow for a warning that only explains (the receive-only ribbon), blue for one offering an action (remote content blocked, stale thread), each with its own light and dark set read off `QPalette::Base` as `HtmlBuilder` does. The blocked row had to become a WIDGET first: it was a bare `QHBoxLayout`, which has nothing to paint a ground on, and its six `hide()` sites then had to move to the wrapper or a painted empty strip would show. Both action bars put the button right of a stretch |
@@ -1346,31 +1346,3 @@ production move rather than the test, this stops being a test-hygiene item and
becomes a mail-safety one.
---
-
-## 138. No Drafts filter beside Sent and Trash
-
-**Observed.** The query row carries Unread, Inbox, Important, Sent and Trash.
-There is no Drafts button, though the composer has been writing drafts to each
-account's drafts folder since item 123.
-
-**Cause, verified 2026-08-23.** `kQueryGenerators` in `config.cpp:62-66` is a
-closed set of five, and `drafts` is not among them. Every account already
-carries a `drafts` key (`config.cpp:453`), read for the composer's autosave, so
-the data the filter needs is configured and unused by the query row.
-
-**Approach.** Follow `sent`, not `inbox`. A tag query would be wrong for the
-same reason it is wrong for Sent: `draft` is a Maildir flag notmuch surfaces as
-a tag, but the folder is what the user means, and a message a provider marks
-differently would disagree. `Config::allSentQuery()` composes the union over
-every account's folder and `Account::sentQuery()` the per-account half; both
-need a drafts twin. The generator string is wire format and must stay `drafts`
-in queries.json whatever the button is called in a given locale.
-
-**Constraints.** An account with no `drafts` key contributes NOTHING rather
-than an empty term, or the button shows the whole Maildir: this is
-`Config::matchNothingQuery()`'s reason for existing. The hook's carve-out
-(`assets/hooks/qtmaildirconf.py`) reads the same key, so the two now agree on
-what a drafts folder is; they are separate readers and neither should start
-importing the other.
-
----
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 6193e1c..5a92fa2 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -551,6 +551,20 @@ void ComposeWindow::buildFormatToolbar()
// Remove attachment moves to its own button beside the list it acts on.
m_detachButton->setDefaultAction(m_detachAction);
+
+ // Ctrl+W closes the composer (item 148), the way it closes a window in
+ // every other application. Parented to this window like the formatting
+ // shortcuts, so it is a WindowShortcut dispatched to the active composer
+ // only and the main window's own namespace is untouched.
+ //
+ // 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);
}
void ComposeWindow::seedFields()
diff --git a/src/config.cpp b/src/config.cpp
index b148102..cb6168f 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -63,11 +63,12 @@ const QStringList kQueryGenerators = { QStringLiteral("unread"),
QStringLiteral("inbox"),
QStringLiteral("flagged"),
QStringLiteral("sent"),
+ QStringLiteral("drafts"),
QStringLiteral("trash") };
/// The tag a generator matches, for the three filters that are a plain tag
-/// query. Empty for "sent", which composes from each account's folder instead
-/// and is handled separately.
+/// query. Empty for "sent", "drafts" and "trash", which compose from each
+/// account's folder instead and are handled separately.
QString generatorTag(const QString &generator)
{
if (generator == QStringLiteral("unread"))
@@ -986,6 +987,12 @@ SavedQuery Config::builtinFilter(const QString &generator)
// thread would fold the user's sent message back into the conversation
// it belongs to, which is item 63's finding.
filter.flat = true;
+ } else if (generator == QStringLiteral("drafts")) {
+ // The LABEL is translated; the generator stays `drafts`, which is what
+ // queries.json stores and what a closed set is matched against.
+ filter.name = tr("Drafts");
+ // NOT flat, like Trash and unlike Sent: a draft reply belongs with the
+ // conversation it answers.
} else if (generator == QStringLiteral("trash")) {
filter.name = tr("Trash");
// NOT flat, unlike Sent. A deleted message still belongs to its
@@ -1015,6 +1022,10 @@ QString Config::resolvedQuery(const SavedQuery &query,
const QString all = allSentQuery();
return all.isEmpty() ? matchNothingQuery() : all;
}
+ if (query.generated == QStringLiteral("drafts")) {
+ const QString all = allDraftsQuery();
+ return all.isEmpty() ? matchNothingQuery() : all;
+ }
if (query.generated == QStringLiteral("trash")) {
const QString all = allTrashQuery();
return all.isEmpty() ? matchNothingQuery() : all;
@@ -1039,6 +1050,13 @@ QString Config::resolvedQuery(const SavedQuery &query,
return sent.isEmpty() ? matchNothingQuery() : sent;
}
+ if (query.generated == QStringLiteral("drafts")) {
+ // The account's OWN drafts query, for the reason spelled out above the
+ // sent case.
+ const QString drafts = scope.draftsQuery();
+ return drafts.isEmpty() ? matchNothingQuery() : drafts;
+ }
+
if (query.generated == QStringLiteral("trash")) {
// The account's OWN trash query, for the reason spelled out above the
// sent case: wrapping the all-accounts query in this account's path
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 5cb1ab8..da18869 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -2630,6 +2630,7 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
{ QStringLiteral("inbox"), QStringLiteral("mail-inbox") },
{ QStringLiteral("flagged"), QStringLiteral("starred") },
{ QStringLiteral("sent"), QStringLiteral("mail-folder-sent") },
+ { QStringLiteral("drafts"), QStringLiteral("document-edit") },
{ QStringLiteral("trash"), QStringLiteral("user-trash") },
};
button->setIcon(
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index c46b153..4d56ec1 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -119,6 +119,9 @@ private slots:
void anAccountCarriesItsTrashFolder();
void aBracketedTrashFolderIsQuoted();
void anAccountWithoutATrashFolderWarns();
+ void theDraftsFilterComposesPerAccount();
+ void theDraftsFilterMatchesNothingWithoutAFolder();
+ void theDraftsFilterIsThreadedNotFlat();
void theTrashFilterComposesPerAccount();
void theTrashFilterMatchesNothingWithoutAFolder();
void anAccountWithoutASendCommandIsReceiveOnly();
@@ -1021,6 +1024,67 @@ void TestConfig::anAccountWithoutATrashFolderWarns()
QVERIFY(joined.contains(QStringLiteral("trash")));
}
+void TestConfig::theDraftsFilterComposesPerAccount()
+{
+ // Item 138. Follows `sent` and `trash`, which match a FOLDER: `draft` is a
+ // Maildir flag notmuch surfaces as a tag, but the folder is what the user
+ // means by Drafts, and a provider that flags differently would disagree
+ // with the folder the composer actually writes into.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "drafts=Drafts\n"
+ "\n"
+ "[account.personal]\n"
+ "maildir=personal\n"
+ "drafts=[Provider]/Bozze\n")));
+
+ const SavedQuery drafts = Config::builtinFilter(QStringLiteral("drafts"));
+ QVERIFY2(drafts.isGenerated(), "drafts is not a generated filter");
+
+ const QString all = config.resolvedQuery(drafts, QString());
+ QVERIFY(all.contains(QStringLiteral("path:\"work/Drafts/**\"")));
+ QVERIFY(all.contains(
+ QStringLiteral("path:\"personal/[Provider]/Bozze/**\"")));
+
+ // The account's OWN query, asserted on the STRING: the all-accounts query
+ // wrapped in this account's path returns exactly the right rows because
+ // path: is hierarchical, so a row count passes against the wrong thing.
+ const QString scoped = config.resolvedQuery(drafts, QStringLiteral("work"));
+ QCOMPARE(scoped, QStringLiteral("path:\"work/Drafts/**\""));
+ QVERIFY(!scoped.contains(QStringLiteral("personal")));
+}
+
+void TestConfig::theDraftsFilterMatchesNothingWithoutAFolder()
+{
+ // An empty query means "match everything" to notmuch, so a button labelled
+ // Drafts would show the whole Maildir.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n")));
+
+ const SavedQuery drafts = Config::builtinFilter(QStringLiteral("drafts"));
+ QCOMPARE(config.resolvedQuery(drafts, QString()),
+ Config::matchNothingQuery());
+}
+
+void TestConfig::theDraftsFilterIsThreadedNotFlat()
+{
+ // Unlike Sent, and deliberately. Sent is flat because a thread would fold
+ // the user's own message back into the conversation it answers, which is
+ // item 63's finding. A draft reply belongs with its conversation for the
+ // same reason a trashed message does, so drafts follow trash here.
+ const SavedQuery drafts = Config::builtinFilter(QStringLiteral("drafts"));
+ QVERIFY2(!drafts.flat, "the drafts filter is flat, like Sent");
+
+ const SavedQuery sent = Config::builtinFilter(QStringLiteral("sent"));
+ QVERIFY2(sent.flat, "Sent stopped being flat, which item 63 requires");
+}
+
void TestConfig::theTrashFilterComposesPerAccount()
{
// Two accounts, one with a plain folder and one nested under a bracketed
@@ -1492,7 +1556,7 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator()
Config config;
const QList<SavedQuery> filters = config.builtinFilters();
- QCOMPARE(filters.size(), 5);
+ QCOMPARE(filters.size(), 6);
QStringList names;
for (const SavedQuery &filter : filters) {
@@ -1514,6 +1578,7 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator()
QStringLiteral("Inbox"),
QStringLiteral("Important"),
QStringLiteral("Sent"),
+ QStringLiteral("Drafts"),
QStringLiteral("Trash") }));
}
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4c33302..3dbb227 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -276,6 +276,7 @@ private slots:
void aStartupAccountAlsoScopesASavedStartupQuery();
void aGeneratedStartupQueryActuallyRuns();
void everyBuiltinFilterButtonCarriesAnIconAndItsText();
+ void theDraftsButtonIsAbsentWithoutADraftsFolder();
void aQueryInTheMenuCanActuallyBeRun();
void theFourBuiltinFiltersAreOnTheRowInOrder();
void aFilterComposesWithTheSelectedAccount();
@@ -476,6 +477,7 @@ private slots:
// MainWindow hands it, so a Config written to a temporary INI is the whole
// fixture.
void aComposerOpensClean();
+ void ctrlWClosesTheComposer();
void theComposerSplitsItsToolbarByScope();
void ccAndBccHideBehindADisclosure();
void ccAndBccAreRevealedWhenTheyCarryAValue();
@@ -9572,6 +9574,32 @@ void TestMainWindow::aGeneratedStartupQueryActuallyRuns()
QCOMPARE(queryEdit->text(), config.allSentQuery());
}
+void TestMainWindow::theDraftsButtonIsAbsentWithoutADraftsFolder()
+{
+ // Item 138 follows item 103's rule: a folder filter with no folder to
+ // match is left out of the row rather than shown resolving to
+ // matchNothingQuery(). A button that can only ever report nothing is worse
+ // than no button, since it reads as "you have no drafts".
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString path = writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ });
+
+ Config config;
+ config.load(path);
+ MainWindow window(config);
+
+ QVERIFY2(!window.findChild<QAbstractButton *>(
+ QStringLiteral("draftsButton")),
+ "a Drafts button appeared for an account with no drafts folder");
+
+ // The guard: Sent IS configured here, so a change that dropped every
+ // filter button would otherwise pass the assertion above.
+ QVERIFY2(window.findChild<QAbstractButton *>(QStringLiteral("sentButton")),
+ "the Sent button is missing, so this test proves nothing");
+}
+
void TestMainWindow::everyBuiltinFilterButtonCarriesAnIconAndItsText()
{
// The filters are part of the application now, so they carry icons like the
@@ -9589,11 +9617,12 @@ void TestMainWindow::everyBuiltinFilterButtonCarriesAnIconAndItsText()
});
// A trash key too, or the Trash filter finds nothing and is skipped from
// the row entirely (item 103), leaving no trashButton for this loop to
- // find.
+ // find. Drafts behaves the same way since item 138.
{
QSettings s(path, QSettings::IniFormat);
s.beginGroup(QStringLiteral("account.work"));
s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.setValue(QStringLiteral("drafts"), QStringLiteral("Drafts"));
s.endGroup();
}
Config config;
@@ -12420,6 +12449,36 @@ void TestMainWindow::removeAttachmentAppearsOnlyWithAttachments()
"Remove attachment is not offered with a file attached");
}
+void TestMainWindow::ctrlWClosesTheComposer()
+{
+ // Item 148. Ctrl+W closes a window in every application the user runs, and
+ // the composer bound nothing, so the only way out was the title bar.
+ //
+ // Scoped to the composer, not registered in KeyMap: Qt dispatches a
+ // WindowShortcut to the active window only, which is the same reason the
+ // formatting shortcuts are parented here rather than to the main window.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+ ComposeContext context = newContext();
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ window->show();
+ QVERIFY(QTest::qWaitForWindowExposed(window));
+
+ auto *close = window->findChild<QAction *>(QStringLiteral("compose_close"));
+ QVERIFY2(close, "the composer has no close action");
+ QVERIFY2(close->shortcut() == QKeySequence(QStringLiteral("Ctrl+W")),
+ qPrintable(QStringLiteral("the close action is bound to '%1', "
+ "not Ctrl+W")
+ .arg(close->shortcut().toString())));
+
+ close->trigger();
+
+ // WA_DeleteOnClose, so the window really goes rather than merely hiding.
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 5000);
+}
+
void TestMainWindow::aComposerOpensClean()
{
ComposeFixture fixture;
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 0b54957..9a8f7cf 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -274,6 +274,10 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
<translation>Inviati</translation>
</message>
<message>
+ <source>Drafts</source>
+ <translation>Bozze</translation>
+ </message>
+ <message>
<source>Trash</source>
<translation>Cestino</translation>
</message>