summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md40
-rw-r--r--src/mainwindow.cpp80
-rw-r--r--src/mainwindow.h22
-rw-r--r--tests/test_mainwindow.cpp110
4 files changed, 239 insertions, 13 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 7f79100..67668d4 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
@@ -60,7 +60,7 @@ taking that too literally.
| 13 | No visual feedback that an action stuck | feedback | S | **done** |
| 14 | Tag column unreadable, tags need another home | presentation | M | **done** |
| 15 | Attachments are parsed but unreachable from the UI | information | M | **done** |
-| 16 | Delete on an already-deleted thread should undelete | behavior | S | open |
+| 16 | Delete on an already-deleted thread should undelete | behavior | S | **done** |
| 17 | No completion for tags in the query bar | workflow | M | **done** |
| 18 | No visual cue that there are unsynced edits | feedback | S | **done** |
| 19 | No prompt to sync on exit when edits are pending | behavior | S | **done** |
@@ -77,7 +77,7 @@ taking that too literally.
| 30 | The blank right pane is wasted space | presentation | M | open |
| 31 | The quit prompt has no highlighted default button | discoverability | XS | **done** |
| 32 | Esc does not blank the right pane | workflow | XS | **done** |
-| 33 | Status bar messages never expire | feedback | S | open |
+| 33 | Status bar messages never expire | feedback | S | **done** |
| 34 | No overview of the Maildir itself | information | M | open |
| 35 | No refresh of the thread list after a sync | workflow | M | open |
| 36 | `test_mainwindow` cannot reach the worker | testing | S | open, on demand |
@@ -797,6 +797,18 @@ Deleting a thread that already carries `deleted` removes it instead.
- The same question applies to `spam` and `flag`. Do not change those in this
item; note whether the answer generalises once `delete` is built.
+### Outcome (done)
+
+Built as specced, including the all-or-nothing rule: undelete only when every
+selected thread already carries `deleted`, otherwise delete the whole selection.
+A test covers the mixed case and passed before the change, since the old
+always-delete behaviour satisfies it; it is there to stop a later "improvement"
+from toggling per row.
+
+**It generalises to `spam` and `flag`, and they were still left alone.** The
+same shape would work, but neither has been asked for, and `flag` in particular
+is already reachable both ways through the tag dialog.
+
## 17. No completion for tags in the query bar
**Observed:** typing a query means remembering the exact tag name, including
@@ -1583,6 +1595,30 @@ completes.
pane, which does persist, but the status text should outlast a two-second
timeout.
+### Outcome (done)
+
+`showTransientStatus()` sets the text and arms a 6 s single-shot timer that
+restores the last query's thread count. Messages were classified rather than
+blanket-timed, which is the whole substance of the item:
+
+- **Events expire:** "Sync complete", "Nothing to undo", "Sync already
+ running", the skip notice, the background-sync notice, and the per-action
+ "Archive: 3 threads".
+- **State persists:** "Searching...", "Syncing...", "Syncing before
+ quitting...", "Background sync running...", the selection count, and
+ **"Sync failed (exit N)"**, per the constraint that an error must not vanish
+ before it is read.
+
+**A test caught a real mistake while routing them.** Making the per-action
+message transient armed the timer during `selectAll()`, because `tagSelected()`
+runs on a selection that `onSelectionChanged()` had just described. The count is
+state and must outlive any transient still counting down, so writing it now
+cancels the timer.
+
+`QStatusBar::showMessage()` was considered and not used: the label is added with
+`addWidget()` alongside permanent widgets, so switching would mean reworking that
+arrangement for the same behaviour.
+
## 34. No overview of the Maildir itself
**Observed (user, 2026-08-04):** wants "info on the maildir": total messages,
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index b62d8d7..a7f9b40 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -337,6 +337,26 @@ void MainWindow::buildUi()
m_statusLabel->setObjectName(QStringLiteral("statusMessage"));
statusBar()->addWidget(m_statusLabel);
+ // Transient messages describe an EVENT and go stale: "Sync complete" reads
+ // as the present tense until something else overwrites it. State messages,
+ // the selection count above all, describe what is true right now and must
+ // not expire while it stays true, so only showTransientStatus() arms this.
+ //
+ // ponytail: one timer beside the label, not QStatusBar::showMessage().
+ // That would mean moving off addWidget() and reworking the permanent
+ // widgets beside it, for the same behaviour.
+ m_statusTimer = new QTimer(this);
+ m_statusTimer->setObjectName(QStringLiteral("statusTimer"));
+ m_statusTimer->setSingleShot(true);
+ m_statusTimer->setInterval(kStatusMessageMs);
+ connect(m_statusTimer, &QTimer::timeout, this, [this]() {
+ // Only take back a message this timer armed. Anything written since is
+ // newer and more relevant than the default.
+ if (m_statusLabel->text() == m_transientMessage)
+ m_statusLabel->setText(m_defaultStatus);
+ m_transientMessage.clear();
+ });
+
// Beside the sync status rather than as a widget competing with it: the two
// say related things and reading them apart would be worse than reading
// them together.
@@ -428,7 +448,7 @@ void MainWindow::buildUi()
}
connect(m_syncButton, &QPushButton::clicked, this, [this]() {
if (!m_sync->start()) {
- m_statusLabel->setText(tr("Sync already running"));
+ showTransientStatus(tr("Sync already running"));
return;
}
// Fresh run, fresh output: leaving the previous run's lines in place
@@ -594,8 +614,29 @@ void MainWindow::registerActions()
tagSelected({}, { QStringLiteral("inbox") }, tr("Archive"));
});
addAction(QStringLiteral("delete"), tr("&Delete"),
- tr("Add the deleted tag"), [this]() {
- tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
+ tr("Add or remove the deleted tag"), [this]() {
+ // A toggle, like toggle_unread: pressing Delete twice is the natural
+ // way to say "no, put it back", and adding a tag that is already there
+ // is a no-op the user cannot see.
+ //
+ // One direction for the WHOLE selection. Toggling each thread
+ // independently would leave one keystroke with the selection in two
+ // states, which is worse than either outcome, so undelete only when
+ // every selected thread is already deleted.
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ bool allDeleted = !rows.isEmpty();
+ for (const QModelIndex &index : rows) {
+ if (!m_model->threadAt(index.row()).isDeleted()) {
+ allDeleted = false;
+ break;
+ }
+ }
+
+ if (allDeleted)
+ tagSelected({}, { QStringLiteral("deleted") }, tr("Undelete"));
+ else
+ tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
});
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
@@ -675,7 +716,7 @@ void MainWindow::registerActions()
if (m_undoStack.canUndo())
m_undoStack.undo();
else
- m_statusLabel->setText(tr("Nothing to undo"));
+ showTransientStatus(tr("Nothing to undo"));
});
addAction(QStringLiteral("sync"), tr("&Sync"),
tr("Run the configured sync command"), [this]() {
@@ -1061,7 +1102,11 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
{
if (generation != m_generation)
return;
- m_statusLabel->setText(tr("%n thread(s)", "", total));
+ // The query's own result is what the bar says when nothing more pressing
+ // is happening, so a transient message falls back to it rather than to
+ // nothing.
+ m_defaultStatus = tr("%n thread(s)", "", total);
+ m_statusLabel->setText(m_defaultStatus);
}
void MainWindow::showThreadContextMenu(const QPoint &pos)
@@ -1112,6 +1157,12 @@ void MainWindow::onSelectionChanged()
m_selectionMessage = tr("%n thread(s) selected", "", selected);
m_statusLabel->setText(m_selectionMessage);
+ // State, not an event: it must persist while the selection does. Cancel any
+ // transient message still counting down, or that timer fires and replaces a
+ // count that is still true.
+ m_statusTimer->stop();
+ m_transientMessage.clear();
+
// Ctrl+click and selectAll() reach a multi-row selection without moving
// current, so onThreadSelected never runs and its guard never fires. The
// pane and the pending timer have to be dealt with here as well.
@@ -1251,7 +1302,7 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
m_pendingEdits = 0;
updatePendingIndicator();
- m_statusLabel->setText(tr("Sync complete"));
+ showTransientStatus(tr("Sync complete"));
if (m_syncingForExit) {
// The work is safely across, so finish the quit the user asked for.
@@ -1274,8 +1325,8 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
// Not a failure: another run holds the lock and is doing the work.
// The user's cron fires every ten minutes, so a click landing inside
// one is routine and must not raise an error or the log pane.
- m_statusLabel->setText(tr("A sync is already running (started "
- "elsewhere); this one was skipped"));
+ showTransientStatus(tr("A sync is already running (started "
+ "elsewhere); this one was skipped"));
if (m_syncingForExit) {
// The other run is syncing, but this application cannot see when
@@ -1374,12 +1425,19 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
// be read, so nothing was observed, and "sync finished" would be a claim
// this cannot support.
if (state == SyncMonitor::State::Idle) {
- m_statusLabel->setText(
+ showTransientStatus(
tr("Background sync completed. Press Enter in the query bar to "
"refresh."));
}
}
+void MainWindow::showTransientStatus(const QString &text)
+{
+ m_transientMessage = text;
+ m_statusLabel->setText(text);
+ m_statusTimer->start();
+}
+
void MainWindow::setSyncBusy(bool busy)
{
m_localSyncBusy = busy;
@@ -1501,7 +1559,7 @@ void MainWindow::editTagsOnSelection()
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty()) {
- m_statusLabel->setText(tr("Select a thread first"));
+ showTransientStatus(tr("Select a thread first"));
return;
}
@@ -1551,7 +1609,7 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove,
description));
- m_statusLabel->setText(
+ showTransientStatus(
tr("%1: %n thread(s)", "", threadIds.size()).arg(description));
}
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 61722aa..2edeb79 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -84,6 +84,11 @@ public:
/// `assets/mailsync.sh` is the reference implementation of this contract.
static constexpr int kSyncSkippedExitCode = 75;
+ /// How long a transient status message stays before the bar falls back to
+ /// the thread count. Long enough to read a sentence, short enough that a
+ /// stale "Sync complete" does not sit there describing the present.
+ static constexpr int kStatusMessageMs = 6000;
+
/// Path of the machine-written UI state file. Deliberately not
/// Config::defaultPath(): the config is hand-edited and must never gain a
/// base64 geometry blob, nor be rewritten on exit (QSettings does not
@@ -115,6 +120,14 @@ private slots:
void onWorkerError(const QString &message);
void onSyncFinished(bool success, int exitCode);
+ /// Shows a message that describes an event and takes it back after a few
+ /// seconds, restoring the last query's thread count.
+ ///
+ /// Use this for events ("Sync complete"), never for state: the selection
+ /// count must persist while the selection does. A private slot so tests can
+ /// drive it through the meta-object.
+ void showTransientStatus(const QString &text);
+
/// Reacts to a sync started outside this window, by cron or by hand.
///
/// A private slot rather than a plain method so tests can drive it through
@@ -254,6 +267,15 @@ private:
QPushButton *m_syncButton = nullptr;
QLabel *m_statusLabel = nullptr;
+ /// Expires a transient status message. See showTransientStatus().
+ QTimer *m_statusTimer = nullptr;
+
+ /// The message m_statusTimer armed for, so it takes back only its own.
+ QString m_transientMessage;
+
+ /// What the status bar falls back to: the last query's thread count.
+ QString m_defaultStatus;
+
/// Says how many tag changes have not been seen to reach the mail store.
/// Hidden entirely at zero rather than reading "0 unsynced", which is noise.
QLabel *m_pendingLabel = nullptr;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 8a1dde3..f791d8f 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -82,6 +82,10 @@ private slots:
void theSyncButtonIsDisabledWhileABackgroundSyncHoldsTheLock();
void anUnobservableLockTableLeavesTheSyncButtonUsable();
void escapeBlanksTheMessagePane();
+ void deleteTogglesOnAnAlreadyDeletedThread();
+ void deleteOnAMixedSelectionDeletesRatherThanSplittingIt();
+ void aTransientStatusMessageExpires();
+ void theSelectionCountIsStateAndDoesNotExpire();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -1070,6 +1074,112 @@ void TestMainWindow::escapeBlanksTheMessagePane()
QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
}
+void TestMainWindow::deleteTogglesOnAnAlreadyDeletedThread()
+{
+ // Hitting Delete twice is the natural way to say "no, put it back", and
+ // adding a tag that is already present is a no-op the user cannot see.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("deleted") }) });
+ view->selectRow(0);
+
+ action->trigger();
+
+ // The optimistic model update is synchronous, so the row reflects the
+ // change without a worker.
+ QVERIFY2(!model->threadAt(0).isDeleted(),
+ "delete on an already-deleted thread did not undelete it");
+}
+
+void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt()
+{
+ // The constraint that makes this more than a one-liner: toggling each
+ // thread independently would leave one keystroke with the selection in two
+ // states, which is worse than either outcome. Undelete only when every
+ // selected thread is already deleted.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(action);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("deleted") }),
+ makeThread(QStringLiteral("t2"), {}) });
+
+ view->selectAll();
+ QCOMPARE(view->selectionModel()->selectedRows().size(), 2);
+
+ action->trigger();
+
+ QVERIFY2(model->threadAt(0).isDeleted() && model->threadAt(1).isDeleted(),
+ "a mixed selection split instead of deleting the whole selection");
+}
+
+void TestMainWindow::aTransientStatusMessageExpires()
+{
+ // "Sync complete" describes an event, not a state, and reads as though it
+ // describes the present until something else overwrites it.
+ const Config config;
+ MainWindow window(config);
+
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("statusTimer"));
+ QVERIFY2(timer, "no status expiry timer");
+
+ QMetaObject::invokeMethod(&window, "showTransientStatus",
+ Q_ARG(QString, QStringLiteral("Sync complete")));
+ QCOMPARE(status->text(), QStringLiteral("Sync complete"));
+ QVERIFY(timer->isActive());
+
+ // Fire it rather than waiting out the real interval.
+ timer->setInterval(0);
+ QTRY_VERIFY_WITH_TIMEOUT(status->text() != QStringLiteral("Sync complete"),
+ 2000);
+}
+
+void TestMainWindow::theSelectionCountIsStateAndDoesNotExpire()
+{
+ // Not everything in the status bar is an event. The selection count
+ // describes what is true right now and must persist while it stays true;
+ // expiring it would undo the 0.8.0 discoverability work.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTableView *>();
+ QVERIFY(view);
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("statusTimer"));
+ QVERIFY(timer);
+
+ model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
+ makeThread(QStringLiteral("t2"), {}) });
+ view->selectAll();
+
+ QVERIFY2(status->text().contains(QStringLiteral("2")),
+ "the selection count was not reported");
+ QVERIFY2(!timer->isActive(),
+ "the selection count armed the expiry timer; it is state, "
+ "not an event");
+}
+
// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.