summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 19:54:40 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 19:54:40 +0200
commitbd1b907ba22464bb869bcc10cc9327ed74c44791 (patch)
treec593b55f9a5fd4912104c33cddf59f894c9308aa
parenta1f11aa861136fca3ec464e0a6268abeed1d8109 (diff)
downloadqtmaildir-bd1b907ba22464bb869bcc10cc9327ed74c44791.tar.gz
qtmaildir-bd1b907ba22464bb869bcc10cc9327ed74c44791.zip
feat(ui): make Delete a toggle, and expire transient status messages
Items 16 and 33. Delete now removes the `deleted` tag when every selected thread already carries it, so pressing it twice puts a thread back. One direction for the whole selection, never per row: toggling each independently would leave a single keystroke with the selection in two states, which is worse than either outcome. Status messages are classified rather than blanket-timed, which is the substance of item 33. Events expire after six seconds and fall back to the last query's thread count: "Sync complete", "Nothing to undo", the skip notice, the per-action "Archive: 3 threads". State does not expire: "Searching...", "Syncing...", the selection count, and "Sync failed (exit N)", because an error must not vanish before it is read. A test caught a mistake in that routing. Making the per-action message transient armed the timer during select-all, since tagSelected() runs on a selection onSelectionChanged() had just described, and the count would then be replaced while it was still true. Writing the count now cancels any transient still counting down. QStatusBar::showMessage() would give the same behaviour but the label is added with addWidget() beside permanent widgets, so adopting it means reworking that arrangement. One timer beside the label is the smaller change. Both fixes verified by reverting them and watching the tests fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-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.