aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md6
-rw-r--r--src/composewindow.cpp15
-rw-r--r--tests/test_mainwindow.cpp55
3 files changed, 69 insertions, 7 deletions
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 e008641..5b8d2ce 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
@@ -205,7 +205,7 @@ taking that too literally.
| 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable |
| 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text |
| 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 about one run in six | defect | ? | open, 2026-08-21, found while running the suite during item 123 task 10. A pre-existing race in the test or in Delete's file move, NOT caused by 123: reproduced on a clean tree with the branch's work stashed out, 1 failure in 6 runs, and the failing run took 70s against a normal 25s. Unrelated to `SendDialog`. Size unknown until the race is located |
+| 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 |
| 139 | Forward is reachable only from the Message menu | discoverability | XS | open, 2026-08-23, from the notes. Verified: `forward` is added to `messageMenu` and to no toolbar. Compose and Reply are on the toolbar, so the third member of the set is the only one hidden |
@@ -218,6 +218,10 @@ 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 |
+| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | open, 2026-08-24, from the notes. Observed in All accounts: a receive-only message raised the ribbon, and selecting a sendable account's mail left it visible. Verified: `MessageView::clear()` resets every other transient bar by hand (blocked-content label, stale notice, attachment bar) and does NOT touch `m_receiveOnlyRibbon`, which only `setReceiveOnlyAccount()` hides. So any path that repaints the pane without reaching `MainWindow::updateComposeActions()` strands it. Belongs in `clear()`, beside the other three, rather than as a new call site |
+| 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | open, 2026-08-24, from the notes. The user's design: a yellow ground for warnings (the item 150 ribbon), a blue one for actions (remote content blocked), with the action's button right-aligned. Both are plain `QLabel`s today. Colour alone is not enough per CLAUDE.md's palette rule, and the pane is theme-aware, so the two grounds need light and dark values rather than one literal |
+| 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 |
Sizes are rough: XS under an hour, S a sitting, M a session.
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 0445c5d..59eaccc 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -442,19 +442,22 @@ void ComposeWindow::seedBody()
// tracking "my text" and "the quote" as separate pieces to make a toggle
// reversible is machinery for a case answered by closing the composer and
// reopening it.
+ // quote_position names where the QUOTE goes, so the reply goes on the
+ // other side of it, and the cursor follows the reply rather than the
+ // buffer. Start in both cases was wrong for Above: it put the cursor on
+ // the attribution line, so the user had to make room before typing.
if (m_config.compose().quotePosition
== ComposeSettings::QuotePosition::Above) {
- // The quote first, then a blank line for the reply to be typed into.
+ // The quote first, then blank lines for the reply, and the cursor in
+ // them. Two lines rather than one so the reply is separated from the
+ // attribution by a blank line once typing starts.
m_body->setPlainText(m_context.quotedBody + QStringLiteral("\n\n"));
+ m_body->moveCursor(QTextCursor::End);
} else {
m_body->setPlainText(QStringLiteral("\n\n") + m_context.quotedBody);
+ m_body->moveCursor(QTextCursor::Start);
}
- // The cursor at the very top in both cases: with the quote below, the
- // blank lines the reply goes into are at the top; with it above, the user
- // scrolls past what they are answering, which is what quoting above means.
- m_body->moveCursor(QTextCursor::Start);
-
// The seeded quote 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 the
// quote and read as the buffer losing its content.
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 98dae12..91fbd44 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -56,6 +56,7 @@
#include "messagesender.h"
#include <QCheckBox>
#include <QPlainTextEdit>
+#include <QTextBlock>
#include <QPointer>
#include <QListWidget>
#include "cardlayout.h"
@@ -484,6 +485,7 @@ private slots:
void theAttachmentWarningRespectsTheConfiguredThreshold();
void aDisabledAttachmentWarningWarnsAboutNothing();
void theQuotePositionDecidesWhereTheQuoteLands();
+ void theCursorStartsOnBlankSpaceNotOnTheQuote();
void theSeededQuoteIsNotAnUndoStep();
void aReplySeedsTheHtmlToggleFromTheOriginal();
void aNewMessageSeedsTheHtmlToggleFromConfig();
@@ -12374,6 +12376,59 @@ void TestMainWindow::theQuotePositionDecidesWhereTheQuoteLands()
}
}
+void TestMainWindow::theCursorStartsOnBlankSpaceNotOnTheQuote()
+{
+ // The user types their reply where the cursor lands, so that line must be
+ // blank under BOTH quote positions. Asserting on the buffer's shape is not
+ // enough: theQuotePositionDecidesWhereTheQuoteLands() already does that and
+ // passed throughout the defect, because the quote was in the right place
+ // and the cursor was on top of it.
+ const QString quote = QStringLiteral("> the original");
+
+ const struct {
+ const char *position;
+ const char *label;
+ } cases[] = {
+ { "above", "quote_position=above" },
+ { "below", "quote_position=below" },
+ };
+
+ for (const auto &testCase : cases) {
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=%1")
+ .arg(QLatin1String(testCase.position))));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ const QTextCursor cursor = body->textCursor();
+ QVERIFY2(cursor.block().text().isEmpty(),
+ qPrintable(QStringLiteral("%1: the cursor starts on \"%2\", "
+ "not on a blank line")
+ .arg(QLatin1String(testCase.label),
+ cursor.block().text())));
+
+ // Typing must not land inside the quote either. A blank line that is
+ // still BELOW the quote would satisfy the check above while leaving the
+ // reply underneath what it answers, which is what quote_position
+ // decides and must not be silently inverted.
+ QTextCursor probe = cursor;
+ probe.insertText(QStringLiteral("typed"));
+ const QString text = body->toPlainText();
+ const bool typedFirst = text.indexOf(QStringLiteral("typed"))
+ < text.indexOf(quote);
+ QCOMPARE(typedFirst,
+ QLatin1String(testCase.position) == QLatin1String("above")
+ ? false
+ : true);
+ }
+}
+
void TestMainWindow::theSeededQuoteIsNotAnUndoStep()
{
ComposeFixture fixture;