aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-12 11:55:42 +0200
committerDanilo M. <danix@danix.xyz>2026-08-12 11:55:42 +0200
commit8b67ed8d6d727aa99c091c6eb46160ecb6300f72 (patch)
treea17578371f2c87c1ffb0730555ae3c3bb03d8f84 /src
parent0afd62360ffa7f28bd24b7a37ddbe53456f94aa9 (diff)
downloadqtmaildir-8b67ed8d6d727aa99c091c6eb46160ecb6300f72.tar.gz
qtmaildir-8b67ed8d6d727aa99c091c6eb46160ecb6300f72.zip
feat(rules): open the tagging rules from the Message menu
Counts are generation-stamped and dropped when stale or when the dialog has closed: counting every rule against a cold index takes seconds, so an in-flight reply outliving its dialog is ordinary rather than rare. The stamp is its own counter, not m_generation as drafted. That one is the QUERY generation, compared against directly by every thread, tree and message load, so bumping it to count rules would discard whatever the user was opening at the time and blank the message pane for an unrelated reason. Registering an action obliges two more entries, both enforced by tests: the name in KeyMap::knownActions(), and a default binding, since every action carries one. Ctrl+Shift+T, shifted against Ctrl+T for edit_tags the way Ctrl+Shift+U is shifted against Ctrl+U. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp6
-rw-r--r--src/mainwindow.cpp53
-rw-r--r--src/mainwindow.h31
3 files changed, 90 insertions, 0 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index cdd26a1..71c5355 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -35,6 +35,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("toggle_unread"),
QStringLiteral("mark_all_read"),
QStringLiteral("edit_tags"),
+ QStringLiteral("tag_rules"),
QStringLiteral("flag"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
@@ -89,6 +90,11 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Ctrl+Shift+U"), QStringLiteral("mark_all_read") },
{ QStringLiteral("Ctrl+I"), QStringLiteral("flag") },
{ QStringLiteral("Ctrl+T"), QStringLiteral("edit_tags") },
+ // Shifted against Ctrl+T for the same reason Ctrl+Shift+U is shifted
+ // against Ctrl+U: this is the standing version of tagging, applied to
+ // every message that arrives rather than to the selection, so it takes
+ // the harder chord.
+ { QStringLiteral("Ctrl+Shift+T"), QStringLiteral("tag_rules") },
{ QStringLiteral("Ctrl+L"), QStringLiteral("focus_query") },
// Ctrl+Space is the completion idiom users already carry over from
// shells and editors, and it is a named key rather than a symbol, so
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index f731511..331354f 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -56,6 +56,7 @@
#include "cardlayout.h"
#include "tagchip.h"
#include "tagdialog.h"
+#include "tagrulesdialog.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
#include "version.h"
@@ -831,6 +832,10 @@ void MainWindow::registerActions()
tr("Add or remove any tag on the selected threads"), [this]() {
editTagsOnSelection();
});
+ addAction(QStringLiteral("tag_rules"), tr("Tagging &rules..."),
+ tr("Edit the rules that tag mail as it arrives"), [this]() {
+ showTagRulesDialog();
+ });
addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"),
tr("Switch the thread between HTML and plain text"), [this]() {
m_messageView->toggleHtml();
@@ -989,6 +994,10 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("mark_all_read")));
messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
+ // Separated from the entries above: those act on the selection, this edits
+ // a rule store shared with mailctl and changes nothing that is on screen.
+ messageMenu->addSeparator();
+ messageMenu->addAction(m_actions.value(QStringLiteral("tag_rules")));
auto *viewMenu = menuBar()->addMenu(tr("&View"));
viewMenu->addAction(m_actions.value(QStringLiteral("prev_thread")));
@@ -1046,6 +1055,10 @@ void MainWindow::buildMenus()
{ QStringLiteral("toggle_unread"), QStringLiteral("mail-mark-unread") },
{ QStringLiteral("mark_all_read"), QStringLiteral("mail-mark-read") },
{ QStringLiteral("edit_tags"), QStringLiteral("tag") },
+ // NOT "tag", which edit_tags uses: with the toolbar icon-only the icon
+ // is the whole control, and editing the standing rules is not editing
+ // the selection's tags.
+ { QStringLiteral("tag_rules"), QStringLiteral("configure") },
{ QStringLiteral("complete_query"), QStringLiteral("edit-find-replace") },
{ QStringLiteral("select_all"), QStringLiteral("edit-select-all") },
{ QStringLiteral("clear_pane"), QStringLiteral("edit-clear") },
@@ -1287,6 +1300,44 @@ void MainWindow::onDatabaseStatsReady(const DatabaseStats &stats,
number(stats.tags)));
}
+void MainWindow::showTagRulesDialog()
+{
+ // One dialog. A second would edit a stale copy and the last Save would
+ // silently win, which is the lost-edit case the atomic write cannot help
+ // with because both writers are this process.
+ if (m_tagRulesDialog) {
+ m_tagRulesDialog->raise();
+ m_tagRulesDialog->activateWindow();
+ return;
+ }
+
+ auto *dialog = new TagRulesDialog(this);
+ dialog->setAttribute(Qt::WA_DeleteOnClose);
+ m_tagRulesDialog = dialog;
+
+ connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
+ QMetaObject::invokeMethod(
+ m_worker, "requestMessageCounts", Qt::QueuedConnection,
+ Q_ARG(QStringList, dialog->countQueries()),
+ Q_ARG(quint64, ++m_ruleCountGeneration));
+ });
+
+ dialog->show();
+}
+
+void MainWindow::onRuleCountsReady(const QVector<int> &counts,
+ quint64 generation)
+{
+ // Stale reply, or the dialog closed while the count was in flight. Both
+ // are ordinary rather than rare: counting every rule against a cold index
+ // takes seconds, which is long enough for the user to close the dialog or
+ // press the button again.
+ if (generation != m_ruleCountGeneration || !m_tagRulesDialog)
+ return;
+
+ m_tagRulesDialog->setCounts(counts);
+}
+
void MainWindow::showAbout()
{
QDialog dialog(this);
@@ -1360,6 +1411,8 @@ void MainWindow::wireWorker()
this, &MainWindow::onCountsReady);
connect(m_worker, &NotmuchWorker::databaseStatsReady,
this, &MainWindow::onDatabaseStatsReady);
+ connect(m_worker, &NotmuchWorker::messageCountsReady,
+ this, &MainWindow::onRuleCountsReady);
// A confirmed write clears the pending revert: without this, a later
// unrelated error would roll back a change that actually succeeded.
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 19635dd..adf63b0 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -55,6 +55,7 @@ class MessageView;
class MailSync;
class NotmuchWorker;
class QueryCompleter;
+class TagRulesDialog;
class MainWindow : public QMainWindow
{
@@ -292,6 +293,14 @@ private slots:
/// Runs a query the user clicked on the placeholder pane.
void onPlaceholderQueryRequested(const QString &query);
+ /// Opens the auto-tagging rules editor, or raises the one already open.
+ void showTagRulesDialog();
+
+ /// Message counts for the rules dialog's queries, in the order it asked
+ /// for them. Does nothing if the dialog has since closed, or if a newer
+ /// request has superseded this one.
+ void onRuleCountsReady(const QVector<int> &counts, quint64 generation);
+
private:
void buildUi();
@@ -632,6 +641,28 @@ private:
/// and reopened, so an old answer cannot fill in a newer dialog.
quint64 m_statsGeneration = 0;
+ /// The open rules dialog, or null. Held so a counts reply can reach it,
+ /// and cleared when it closes: a reply that arrives after the dialog is
+ /// gone must find nothing rather than a dangling pointer. A QPointer for
+ /// the same reason m_overviewCounts is one, and the window is open wider
+ /// here, since counting every rule takes seconds.
+ QPointer<TagRulesDialog> m_tagRulesDialog;
+
+ /// Generation of the rules dialog's counts request.
+ ///
+ /// Its OWN counter, deliberately not m_generation. That one is the QUERY
+ /// generation, and onThreadsReady, onQueryFinished, onThreadLoaded,
+ /// onThreadTreeLoaded and onMessageLoaded all compare against it directly:
+ /// bumping it here would discard whatever thread load was in flight when
+ /// the user pressed Count matches, blanking the message pane for a reason
+ /// that has nothing to do with the query. Not m_countsGeneration either,
+ /// though that one is closer: it belongs to the placeholder pane's THREAD
+ /// counts, and sharing it would let each cancel the other's reply. The two
+ /// arrive on different signals (countsReady against messageCountsReady),
+ /// so they can never be confused for one another and need not share a
+ /// counter.
+ quint64 m_ruleCountGeneration = 0;
+
/// The generation of a REFRESH query, run after a sync to bring the list
/// up to date without disturbing it.
///