aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-29 12:45:39 +0200
committerDanilo M. <danix@danix.xyz>2026-08-29 12:45:39 +0200
commitca8b140de023d47ff7b34261051749ed9b254d4d (patch)
tree93e082d5991a7e5125fc5317a7d88536edd8b177 /src
parent063be87405277aef3122c448b064241fd15f2a92 (diff)
downloadqtmaildir-ca8b140de023d47ff7b34261051749ed9b254d4d.tar.gz
qtmaildir-ca8b140de023d47ff7b34261051749ed9b254d4d.zip
feat: give the trash its own actions on the message bar
The pane's bar offered Reply and Forward on a message the user had thrown away, which are the two things a trashed message is least likely to want, while Restore and the purges lived only in menus. The bar now has a third branch, asked before the draft one: a deleted draft must come out of the trash before it can be edited. It is keyed on the SELECTION being in a trash folder, the same predicate the menu entries use, rather than on the trash VIEW, which disagree on mail reached from a search. It carries Restore, Delete permanently and Empty trash, and only Restore is tinted: the two purges are one act at two scopes and need no colour to tell them from each other, only from the one action that gives mail back. Delete moves here from the main toolbar in the same change (item 186). It acts on the displayed message, like Reply and Forward, so it belongs on the pane's bar by the rule items 139 to 141 settled for those two. It stays in the Message menu and the context menu. Delete permanently is new. It is Empty trash scoped to the selection, the same purgeMessages() call with the ids resolved from the selection rather than from a query, so it inherits both of that action's safeguards: it confirms, naming the count, and it carries no default shortcut. One combined thread:/id: query resolves a mixed selection, so a conversation and a reply selected together still ask once. The bar is refilled when the conversation digest arrives as well as on selection, since a conversation's trash-ness is not known until every path has been reported. Closes items 185 and 186. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp4
-rw-r--r--src/mainwindow.cpp147
-rw-r--r--src/mainwindow.h18
-rw-r--r--src/messageview.cpp36
-rw-r--r--src/messageview.h9
5 files changed, 194 insertions, 20 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 17f8241..b7cf8f5 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -37,6 +37,10 @@ QStringList KeyMap::knownActions()
// that destroys mail with no undo, and a chord is how it would be run
// by accident. Menu only, which item 132 made a legitimate choice.
QStringLiteral("empty_trash"),
+ // Item 185. Empty trash's sibling, scoped to the selection, and it
+ // carries no default binding for exactly the same reason: the act is
+ // identical and so is the hazard.
+ QStringLiteral("purge"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
QStringLiteral("mark_all_read"),
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 4859b1f..44c7250 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1757,6 +1757,14 @@ void MainWindow::registerActions()
tr("Permanently delete every message in the trash"), [this]() {
emptyTrash();
});
+ // The same act as empty_trash, scoped to the selection: the user's own
+ // words, "they are the same action, scoped differently". So it inherits
+ // both safeguards rather than being reasoned about afresh, the
+ // confirmation and the absent default shortcut.
+ addAction(QStringLiteral("purge"), tr("Delete per&manently..."),
+ tr("Permanently delete the selected messages"), [this]() {
+ purgeSelected();
+ });
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
@@ -2046,6 +2054,11 @@ void MainWindow::buildMenus()
// disabled entry with its shortcut beside it says both that it exists and
// where it applies.
messageMenu->addAction(m_actions.value(QStringLiteral("restore")));
+ // Beside Restore, the other thing a trashed message affords. Hidden
+ // outside the trash rather than greyed, unlike Restore above: this one
+ // destroys, so offering it where it does not apply is worse than teaching
+ // that it exists.
+ messageMenu->addAction(m_actions.value(QStringLiteral("purge")));
messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
@@ -2122,6 +2135,11 @@ void MainWindow::buildMenus()
// thing it deliberately does not do.
{ QStringLiteral("cleanup_stranded"), QStringLiteral("system-search") },
{ QStringLiteral("empty_trash"), QStringLiteral("edit-delete-shred") },
+ // Item 185. Distinct from empty_trash's, because both reach the trash
+ // bar and there the icon IS the control: two buttons that destroy
+ // different amounts of mail must not look identical. `user-trash` is
+ // the theme's own wastebasket, which reads as "this one, gone".
+ { QStringLiteral("purge"), QStringLiteral("user-trash") },
{ QStringLiteral("undo"), QStringLiteral("edit-undo") },
{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("flag"), QStringLiteral("mail-mark-important") },
@@ -2248,7 +2266,10 @@ void MainWindow::buildMenus()
toolBar->addAction(syncAction);
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("archive")));
- toolBar->addAction(m_actions.value(QStringLiteral("delete")));
+ // Delete is NOT here (item 186). It acts on the displayed message, like
+ // Reply and Forward, so it lives on the pane's own bar by the same rule
+ // items 139 to 141 settled for those two. It stays in the Message menu
+ // and the context menu, so nothing became unreachable.
toolBar->addAction(m_actions.value(QStringLiteral("mark_all_read")));
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("undo")));
@@ -2308,17 +2329,45 @@ void MainWindow::populateMessageBar()
// changed the answer. The guard that IS load-bearing sits one level up in
// updateComposeActions(), where accountForCurrentMessage() would otherwise
// answer about a row this query is discarding.
+ // A THIRD branch since item 185, and the order matters: a draft that has
+ // been deleted is in the trash, where Edit draft is no more use than
+ // Reply. Trash is asked first for that reason.
+ //
+ // Keyed on the SELECTION being in a trash folder, the same predicate
+ // refreshTrashActions() uses, rather than on the trash VIEW: the two
+ // disagree on mail reached from a search, where a message can sit in the
+ // trash while the query was never the trash filter. Reading the view
+ // there would offer Reply on a trashed message, which is the whole
+ // complaint.
QList<QAction *> messageActions;
- if (currentMessageIsADraft()) {
+ QList<QAction *> tinted;
+ if (everySelectedRowIsInATrashFolder()
+ && !m_threadView->selectionModel()->selectedRows().isEmpty()) {
+ // Restore first, then the two purges, which are one act at two
+ // scopes: this message, and the whole trash. Delete is absent because
+ // refreshTrashActions() hides it on mail already in the trash, where
+ // it reported success and did nothing (item 168).
+ messageActions = { m_actions.value(QStringLiteral("restore")),
+ m_actions.value(QStringLiteral("purge")),
+ m_actions.value(QStringLiteral("empty_trash")) };
+ // Restore alone. The two purges are the same act at two scopes and
+ // need no colour to tell them from each other, only from this one.
+ tinted = { m_actions.value(QStringLiteral("restore")) };
+ } else if (currentMessageIsADraft()) {
messageActions = { m_actions.value(QStringLiteral("edit_draft")) };
} else {
+ // Delete joins the pair here (item 186), from the main toolbar. It is
+ // hidden on a reply row and outside its scope by
+ // refreshTrashActions(), which the bar inherits by showing the
+ // window's own QActions rather than copies.
messageActions = { m_actions.value(QStringLiteral("reply")),
- m_actions.value(QStringLiteral("forward")) };
+ m_actions.value(QStringLiteral("forward")),
+ m_actions.value(QStringLiteral("delete")) };
}
m_messageView->setBarActions(
messageActions, { m_actions.value(QStringLiteral("toggle_html")) },
- iconSize);
+ iconSize, tinted);
}
void MainWindow::showShortcutReference()
@@ -3743,6 +3792,15 @@ void MainWindow::refreshTrashActions()
restore->setVisible((!haveSelection || inTrash)
&& !m_replySelectionHidesDelete);
}
+
+ // Item 185. Follows Restore exactly: both are what a message ALREADY in
+ // the trash affords, and neither means anything outside it. Unlike the
+ // pair above, it is not hidden on a reply row: Delete is a
+ // conversation-level act because removing one reply from a live
+ // conversation is not offered, but a reply that is already in the trash
+ // is just mail, and destroying only that one is a coherent thing to ask.
+ if (auto *purge = m_actions.value(QStringLiteral("purge")))
+ purge->setVisible(haveSelection && inTrash);
}
void MainWindow::refreshUnreadAction()
@@ -4263,6 +4321,12 @@ void MainWindow::onThreadDigestLoaded(const ThreadDigest &digest,
m_conversationPathsThreadId = digest.threadId;
m_conversationPaths = digest.messagePaths;
refreshTrashActions();
+ // And the bar, for the same reason and on the same line of reasoning
+ // (item 185). A conversation's trash-ness is not known until the digest
+ // reports every path, so the bar filled at selection time answered from
+ // the summary's single path; refilling here is what makes it agree with
+ // the menu entries refreshTrashActions() just corrected.
+ populateMessageBar();
m_messageView->showDashboard(digest);
}
@@ -6282,7 +6346,17 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
}
if (requestTag == QStringLiteral("empty_trash")) {
- confirmAndPurge(messageIds);
+ const QString accountKey = m_accountBox->currentData().toString();
+ confirmAndPurge(messageIds, accountKey.isEmpty()
+ ? tr("every account")
+ : m_accountBox->currentText());
+ return;
+ }
+
+ if (requestTag == QStringLiteral("purge_selection")) {
+ // No scope named: the prompt says "the selection", which is what the
+ // user pointed at, rather than an account they did not.
+ confirmAndPurge(messageIds, QString());
return;
}
@@ -6597,25 +6671,66 @@ void MainWindow::emptyTrash()
Q_ARG(QString, QStringLiteral("empty_trash")));
}
-void MainWindow::confirmAndPurge(const QStringList &messageIds)
+void MainWindow::purgeSelected()
{
- if (messageIds.isEmpty()) {
- showTransientStatus(tr("The trash is already empty"));
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty()) {
+ showTransientStatus(tr("Nothing selected to delete"));
return;
}
- const QString accountKey = m_accountBox->currentData().toString();
- const QString where = accountKey.isEmpty()
- ? tr("every account")
- : m_accountBox->currentText();
+ if (!m_worker) {
+ showTransientStatus(tr("Not connected to the mail index"));
+ return;
+ }
+
+ const ActionScope scope = m_model->scopeForSelection(rows);
+
+ // One combined query rather than a resolve per scope, since two round
+ // trips would reach confirmAndPurge() twice and ask the user twice for
+ // one gesture. `thread:` and `id:` compose in one notmuch query, which is
+ // what lets a mixed selection stay a single confirmation.
+ QStringList terms;
+ terms.reserve(scope.threadIds.size() + scope.messageIds.size());
+ for (const QString &id : scope.threadIds)
+ terms.append(QStringLiteral("thread:%1").arg(id));
+ for (const QString &id : scope.messageIds)
+ terms.append(QStringLiteral("id:%1").arg(id));
+
+ if (terms.isEmpty())
+ return;
+
+ // Resolved by the WORKER, like every other count that precedes a
+ // destructive act: the dialog must name what will actually be destroyed,
+ // and the model holds whatever the view last painted.
+ QMetaObject::invokeMethod(
+ m_worker, "resolveQueryMessages", Qt::QueuedConnection,
+ Q_ARG(QString, terms.join(QStringLiteral(" or "))),
+ Q_ARG(QString, QStringLiteral("purge_selection")));
+}
+
+void MainWindow::confirmAndPurge(const QStringList &messageIds,
+ const QString &where)
+{
+ if (messageIds.isEmpty()) {
+ showTransientStatus(where.isEmpty()
+ ? tr("Nothing selected to delete")
+ : tr("The trash is already empty"));
+ return;
+ }
QMessageBox box(this);
box.setObjectName(QStringLiteral("emptyTrashConfirmation"));
box.setIcon(QMessageBox::Warning);
- box.setWindowTitle(tr("Empty trash"));
- box.setText(tr("Permanently delete %n message(s) from the trash of %1?",
- "", messageIds.size())
- .arg(where));
+ box.setWindowTitle(where.isEmpty() ? tr("Delete permanently")
+ : tr("Empty trash"));
+ box.setText(where.isEmpty()
+ ? tr("Permanently delete %n selected message(s)?", "",
+ messageIds.size())
+ : tr("Permanently delete %n message(s) from the trash "
+ "of %1?", "", messageIds.size())
+ .arg(where));
// Said plainly, because it is the only place in this application where it
// is true.
box.setInformativeText(tr("This cannot be undone."));
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 9c93f4f..3829ae2 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -1272,9 +1272,23 @@ private:
/// which holds whatever the current view happens to show.
void emptyTrash();
+ /// Empty trash's sibling, scoped to the SELECTION rather than to the
+ /// account's whole trash. The act is identical, `purgeMessages()` in both
+ /// cases, and so are its safeguards: it confirms, and it carries no
+ /// default shortcut. Only where the ids come from differs, which is why
+ /// there are two entry points rather than a parameter.
+ ///
+ /// Asynchronous for the same reason emptyTrash() is: the count in the
+ /// dialog must be what will be destroyed, so a conversation row's
+ /// messages are resolved against the database rather than counted from
+ /// the model. The answer arrives at onThreadMessagesResolved() tagged
+ /// `purge_selection`.
+ void purgeSelected();
+
/// The confirmation, and the only one in this application. Destroys
- /// nothing if the user declines.
- void confirmAndPurge(const QStringList &messageIds);
+ /// nothing if the user declines. \p where names the scope in the prompt,
+ /// since the two callers destroy very different amounts of mail.
+ void confirmAndPurge(const QStringList &messageIds, const QString &where);
/// Moves each resolved message home, using the tags and paths the WORKER
/// reported rather than anything the model holds.
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 7106ab5..77d79dd 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -673,7 +673,8 @@ void MessageView::applyNoticeBarStyles()
void MessageView::setBarActions(const QList<QAction *> &messageActions,
const QList<QAction *> &viewControls,
- int iconSize)
+ int iconSize,
+ const QList<QAction *> &tinted)
{
m_messageBar->clear();
if (iconSize > 0)
@@ -684,6 +685,39 @@ void MessageView::setBarActions(const QList<QAction *> &messageActions,
m_messageBar->addAction(action);
}
+ // Applied after the actions are added, because a QToolBar creates the
+ // button for an action when it takes it: widgetForAction() returns
+ // nothing before that, so tinting in the loop above would silently do
+ // nothing at all.
+ //
+ // Per-button rather than a bar-wide sheet keyed on the object name, since
+ // the bar refills on every selection change and a sheet naming actions
+ // would have to be rewritten each time anyway. The ground follows the
+ // palette by the same rule applyNoticeBarStyles() uses: QPalette::Base
+ // decides which way round the theme is, so the tint cannot come out
+ // near-white on near-white.
+ if (!tinted.isEmpty()) {
+ const bool dark = palette().color(QPalette::Base).lightnessF() < 0.5;
+ // Green, not the blue the notice bars use: those are informational,
+ // and this marks the one button on a destructive bar that gives mail
+ // back. Each set carries its own ground and border rather than being
+ // the other dimmed, for the reason recorded beside the notice bars.
+ const QString ground = dark ? QStringLiteral("#12301c")
+ : QStringLiteral("#e2f4e6");
+ const QString border = dark ? QStringLiteral("#2b5c39")
+ : QStringLiteral("#a9d5b5");
+ const QString sheet = QStringLiteral(
+ "QToolButton { background: %1; border: 1px solid %2; "
+ "border-radius: 4px; padding: 2px; }").arg(ground, border);
+
+ for (QAction *action : tinted) {
+ if (!action)
+ continue;
+ if (QWidget *button = m_messageBar->widgetForAction(action))
+ button->setStyleSheet(sheet);
+ }
+ }
+
// The stretch is what separates the two scopes, so the view controls end
// up at the right edge. A QToolBar has no addStretch(), so it takes an
// expanding spacer widget.
diff --git a/src/messageview.h b/src/messageview.h
index 10b9430..54dd6e7 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -278,9 +278,16 @@ public:
/// separated from \p messageActions by a stretch: acting on the message
/// and changing how it is displayed are different scopes, which is the
/// confusion the bar exists to remove one level up.
+ /// \p tinted names the actions whose buttons take a coloured ground. The
+ /// trash bar is icons-only like the rest, so the tint is what separates
+ /// the one action that gives mail back from the two that destroy it. Only
+ /// the positive one is tinted, at the user's decision: colouring all
+ /// three would make the bar a warning strip and the tint would stop
+ /// meaning anything.
void setBarActions(const QList<QAction *> &messageActions,
const QList<QAction *> &viewControls,
- int iconSize = 0);
+ int iconSize = 0,
+ const QList<QAction *> &tinted = {});
/// Tells the pane whether the query bar currently holds anything.
///