diff options
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 2 | ||||
| -rw-r--r-- | src/composewindow.cpp | 8 | ||||
| -rw-r--r-- | src/config.cpp | 4 | ||||
| -rw-r--r-- | src/config.h | 5 | ||||
| -rw-r--r-- | tests/test_config.cpp | 12 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 43 |
8 files changed, 72 insertions, 12 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e19ff2..abd0ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ 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 reply opens with the cursor on a blank line above the quote, and with the + body focused, so typing can start immediately. `[compose] quote_position` + defaults to `below` (your reply first, the quote under it); `above` puts the + quote first and the cursor after it. Closing a composer with unsaved edits asks first, and so does quitting with one open. - Sending goes to a per-account `send_command` on stdin, so any sendmail @@ -262,8 +262,10 @@ drafts = Drafts ; about the sender's software rather than a guess about their taste. send_html = true -; Where the quoted original goes in a reply: above or below. -quote_position = above +; Where the quoted original goes relative to your reply: below (the default, +; you type at the top and the quote follows) or above (bottom-posting, the +; quote first and the cursor after it). +quote_position = below ; How long the send popup counts down before the command runs, in ; milliseconds. This is the window in which Undo can still stop it; 0 skips 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 e9d04d4..0b1195e 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 @@ -218,7 +218,7 @@ taking that too literally. | 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 | -| 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased. `quote_position` names where the QUOTE goes, so the cursor follows the reply and not the buffer: `End` under Above, `Start` under Below, where a shared `Start` had put it on the `On ... wrote:` line. The existing `theQuotePositionDecidesWhereTheQuoteLands` passed throughout, because the quote WAS in the right place | +| 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 | | 152 | Signatures are not managed at all | v2 | ? | open, 2026-08-24, from the notes, asked for as a brainstorm rather than a build. Nothing in `[compose]` or `[account.*]` mentions a signature, so this is unspecified: per-account text, where it sits relative to the quote, and whether the HTML part gets its own form are all open. Needs the user to say what they picture before it can be sized | diff --git a/src/composewindow.cpp b/src/composewindow.cpp index 59eaccc..1c9b492 100644 --- a/src/composewindow.cpp +++ b/src/composewindow.cpp @@ -143,6 +143,14 @@ ComposeWindow::ComposeWindow(const ComposeContext &context, // as the flag cleared, since markDirty() started it. m_dirty = false; m_autosaveTimer->stop(); + + // The body, whenever there is already a recipient: a Reply or a Forward + // has To: filled in from the context, so the first widget in the form + // would take focus and the user would have to click into the editor + // before typing. A New message keeps the default, since To: is empty and + // is genuinely the first thing to fill in. + if (!m_to->text().trimmed().isEmpty()) + m_body->setFocus(); } diff --git a/src/config.cpp b/src/config.cpp index c6bedd2..b148102 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -531,7 +531,7 @@ void Config::load(const QString &path) // other enum-ish key in this file (sync_on_exit, language, date_format) // rather than being the one silent exception. const QString quotePosition = - settings.value(QStringLiteral("quote_position"), QStringLiteral("above")) + settings.value(QStringLiteral("quote_position"), QStringLiteral("below")) .toString().trimmed(); if (quotePosition.compare(QStringLiteral("above"), Qt::CaseInsensitive) == 0) { m_compose.quotePosition = ComposeSettings::QuotePosition::Above; @@ -539,7 +539,7 @@ void Config::load(const QString &path) m_compose.quotePosition = ComposeSettings::QuotePosition::Below; } else { addProblem(tr("[compose] quote_position '%1' is not recognised; " - "expected above or below. Using above.") + "expected above or below. Using below.") .arg(quotePosition)); } diff --git a/src/config.h b/src/config.h index 51fc1ee..3ddd76a 100644 --- a/src/config.h +++ b/src/config.h @@ -212,7 +212,10 @@ struct ComposeSettings /// reply_no_quote does not). enum class QuotePosition { Above, Below }; - QuotePosition quotePosition = QuotePosition::Above; + /// Below by default: the reply is typed at the top and the quote sits + /// under it, which is what the user asked for and what every mail client + /// they compare against does. Above is bottom-posting and stays available. + QuotePosition quotePosition = QuotePosition::Below; /// Seeds the per-message toggle for New and Forward only. Reply and /// Reply-all seed from whether the original carried a text/html part, diff --git a/tests/test_config.cpp b/tests/test_config.cpp index a902425..c46b153 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -130,7 +130,7 @@ private slots: void garbageSendDelayIsRejectedNotZero(); void garbageAttachmentWarnBytesIsRejectedNotZero(); void zeroOrNegativeAutosaveIntervalIsClamped(); - void unrecognisedQuotePositionWarnsAndFallsBackToAbove(); + void unrecognisedQuotePositionWarnsAndFallsBackToBelow(); }; static QString writeIni(const QTemporaryDir &dir, const QString &body) @@ -2359,8 +2359,8 @@ void TestConfig::composeSettingsDefaultWhenTheSectionIsAbsent() config.load(writeIni(dir, QStringLiteral("[general]\n"))); const ComposeSettings compose = config.compose(); - QVERIFY2(compose.quotePosition == ComposeSettings::QuotePosition::Above, - "default quote position must be Above"); + QVERIFY2(compose.quotePosition == ComposeSettings::QuotePosition::Below, + "default quote position must be Below"); QVERIFY2(compose.sendHtml, "default send_html must be true"); QCOMPARE(compose.autosaveIntervalMs, 30000); QCOMPARE(compose.sendDelayMs, 5000); @@ -2492,7 +2492,7 @@ void TestConfig::zeroOrNegativeAutosaveIntervalIsClamped() .arg(negative.compose().autosaveIntervalMs))); } -void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToAbove() +void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToBelow() { // Matches the precedent set by sync_on_exit, language and date_format: // the only silent fallbacks in this file are for ABSENT keys, never for @@ -2503,8 +2503,8 @@ void TestConfig::unrecognisedQuotePositionWarnsAndFallsBackToAbove() "[compose]\n" "quote_position=abov\n"))); - QVERIFY2(config.compose().quotePosition == ComposeSettings::QuotePosition::Above, - "an unrecognised quote_position must still fall back to Above"); + QVERIFY2(config.compose().quotePosition == ComposeSettings::QuotePosition::Below, + "an unrecognised quote_position must still fall back to Below"); QVERIFY2(!config.problems().isEmpty(), "an unrecognised quote_position was accepted silently"); } diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 9e4543c..84a6fd1 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -487,6 +487,7 @@ private slots: void aDisabledAttachmentWarningWarnsAboutNothing(); void theQuotePositionDecidesWhereTheQuoteLands(); void theCursorStartsOnBlankSpaceNotOnTheQuote(); + void aReplyOpensWithTheBodyFocused(); void theSeededQuoteIsNotAnUndoStep(); void aReplySeedsTheHtmlToggleFromTheOriginal(); void aNewMessageSeedsTheHtmlToggleFromConfig(); @@ -12525,6 +12526,48 @@ void TestMainWindow::theCursorStartsOnBlankSpaceNotOnTheQuote() } } +void TestMainWindow::aReplyOpensWithTheBodyFocused() +{ + // A Reply arrives with To: already filled, so the first widget in the form + // taking focus means the user has to click into the editor before typing. + // A New message is the opposite case and keeps the default. + ComposeFixture fixture; + QVERIFY(fixture.build()); + + { + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.to = { QStringLiteral("someone@example.org") }; + context.quotedBody = QStringLiteral("> the original"); + + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + // focusWidget(), not hasFocus(): an unshown window is never active, so + // hasFocus() is false whatever the code does and the assertion would + // fail against a correct fix. This is the same class of trap CLAUDE.md + // records for the offscreen platform and window sizing. + QVERIFY2(window.focusWidget() == body, + "a reply did not open with the body focused"); + } + + { + // The guard: without it, focusing the body unconditionally would pass + // the assertion above while taking focus off an empty To: field, which + // is the one thing a new message needs first. + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::New; + context.to.clear(); + + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *to = window.findChild<QLineEdit *>(QStringLiteral("to")); + QVERIFY(body && to); + QVERIFY2(window.focusWidget() != body, + "a new message stole focus from the empty To: field"); + } +} + void TestMainWindow::theSeededQuoteIsNotAnUndoStep() { ComposeFixture fixture; |
