summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/mainwindow.cpp61
-rw-r--r--src/mainwindow.h26
-rw-r--r--src/notmuchworker.cpp37
-rw-r--r--src/notmuchworker.h17
-rw-r--r--src/rulequery.cpp414
-rw-r--r--src/rulequery.h65
-rw-r--r--src/tagrulesdialog.cpp689
-rw-r--r--src/tagrulesdialog.h164
9 files changed, 1461 insertions, 13 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 926f6a9..6ae157b 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -23,6 +23,7 @@ add_library(qtmaildir_lib STATIC
messageview.cpp
mainwindow.cpp
querycompleter.cpp
+ rulequery.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 331354f..e2df6de 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1313,8 +1313,20 @@ void MainWindow::showTagRulesDialog()
auto *dialog = new TagRulesDialog(this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
+
m_tagRulesDialog = dialog;
+ // The Folder row's dropdown, filled from the Maildir tree on disk rather
+ // than from config. Config names one subtree per account and nothing
+ // below it, so the dropdown offered five entries and no way to say Drafts
+ // or Sent, which is a folder a rule wants to target as often as a whole
+ // account. The answer comes back queued, after the dialog is already up;
+ // setFolders refills the rows that exist by then.
+ QMetaObject::invokeMethod(m_worker, "requestFolders", Qt::QueuedConnection);
+
+ connect(dialog, &TagRulesDialog::previewRequested,
+ this, &MainWindow::onRulePreviewRequested);
+
connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
QMetaObject::invokeMethod(
m_worker, "requestMessageCounts", Qt::QueuedConnection,
@@ -1414,6 +1426,15 @@ void MainWindow::wireWorker()
connect(m_worker, &NotmuchWorker::messageCountsReady,
this, &MainWindow::onRuleCountsReady);
+ // The rules dialog is the only consumer, and it may have been closed while
+ // the scan was in flight. No generation counter: the tree on disk does not
+ // change under a query, so a late answer is still the right one.
+ connect(m_worker, &NotmuchWorker::foldersReady, this,
+ [this](const QStringList &folders) {
+ if (m_tagRulesDialog)
+ m_tagRulesDialog->setFolders(folders);
+ });
+
// A confirmed write clears the pending revert: without this, a later
// unrelated error would roll back a change that actually succeeded.
connect(m_worker, &NotmuchWorker::tagsApplied,
@@ -1552,6 +1573,46 @@ void MainWindow::onCountsReady(const QVector<int> &counts, quint64 generation)
m_messageView->showPlaceholder(placeholderHelpers());
}
+QString MainWindow::queryTextForTesting() const
+{
+ return m_queryEdit->text();
+}
+
+QString MainWindow::selectedAccountForTesting() const
+{
+ return m_accountBox->currentData().toString();
+}
+
+void MainWindow::selectAccountForTesting(const QString &key)
+{
+ const int index = m_accountBox->findData(key);
+ if (index >= 0)
+ m_accountBox->setCurrentIndex(index);
+}
+
+void MainWindow::onRulePreviewRequested(const QString &query)
+{
+ // Unscoped, deliberately. runQuery() wraps the bar's text in the selected
+ // account's scope, and a rule query usually names its own path already
+ // (path:"work/**" is what every account rule looks like), so previewing
+ // one with an account selected would scope it twice and match nothing.
+ // That reads as "this rule collects no mail", which is the opposite of
+ // what the preview is for.
+ m_accountBox->setCurrentIndex(0);
+
+ // Through the query bar, like onPlaceholderQueryRequested: the bar then
+ // shows what is on screen and the user can edit the rule's query there
+ // before deciding to change the rule itself.
+ m_queryEdit->setText(query);
+ runCurrentQuery();
+
+ // The dialog is a separate window and may be covering this one or sitting
+ // beside it. Raising makes the result visible either way, and the dialog
+ // stays open so the two can be compared.
+ raise();
+ activateWindow();
+}
+
void MainWindow::onPlaceholderQueryRequested(const QString &query)
{
// Through the query bar rather than straight to the worker, so the bar
diff --git a/src/mainwindow.h b/src/mainwindow.h
index adf63b0..18fbbb4 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -185,6 +185,27 @@ public:
/// The generation the next counts reply must carry to be accepted.
quint64 countsGenerationForTesting() const { return m_countsGeneration; }
+ /// The query bar's text, and the account the selector is scoped to
+ /// (empty for "All accounts"). Both are what a rule preview writes: the
+ /// bar so the user can see and edit what ran, and the selector because
+ /// runQuery() wraps the text in the selected account's scope, which would
+ /// double-scope a rule query that already names its own path.
+ QString queryTextForTesting() const;
+ QString selectedAccountForTesting() const;
+
+ /// Scopes the view to one account, as choosing it in the selector does.
+ /// A test for the rule preview needs this: with no account selected the
+ /// box already sits at "All accounts", so asserting that a preview leaves
+ /// it there passes whether or not the preview clears it.
+ void selectAccountForTesting(const QString &key);
+
+ /// Runs a rule preview without the dialog, which the offscreen platform
+ /// cannot click a button in.
+ void previewRuleQueryForTesting(const QString &query)
+ {
+ onRulePreviewRequested(query);
+ }
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -293,6 +314,11 @@ private slots:
/// Runs a query the user clicked on the placeholder pane.
void onPlaceholderQueryRequested(const QString &query);
+ /// Runs one tagging rule's query in the thread list, so the user can see
+ /// which mail it collects. The rules dialog stays open; the point is to
+ /// compare the rule against its results.
+ void onRulePreviewRequested(const QString &query);
+
/// Opens the auto-tagging rules editor, or raises the one already open.
void showTagRulesDialog();
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 94fb79c..6aae397 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -20,6 +20,9 @@
#include <notmuch.h>
+#include <QDir>
+#include <QDirIterator>
+#include <QFileInfo>
#include <QSet>
#include <cstdlib>
@@ -678,3 +681,37 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries,
emit messageCountsReady(counts, generation);
}
+
+void NotmuchWorker::requestFolders()
+{
+ if (!openReadOnly())
+ return;
+
+ const QString root = QString::fromUtf8(notmuch_database_get_path(m_db));
+ if (root.isEmpty()) {
+ emit errorOccurred(
+ QStringLiteral("notmuch reports no database path."));
+ return;
+ }
+
+ // A Maildir folder is a directory holding cur/. Testing for that rather
+ // than listing every directory keeps the plumbing (cur, new, tmp) and an
+ // account's container directory out of the list; neither is somewhere mail
+ // is filed. Hidden directories are skipped, which is what excludes
+ // .notmuch itself.
+ QStringList folders;
+ QDirIterator it(root, QDir::Dirs | QDir::NoDotAndDotDot,
+ QDirIterator::Subdirectories);
+ const QDir rootDir(root);
+ while (it.hasNext()) {
+ const QString path = it.next();
+ if (!QFileInfo::exists(path + QStringLiteral("/cur")))
+ continue;
+ folders.append(rootDir.relativeFilePath(path));
+ }
+
+ // Sorted, so the dropdown keeps one order across openings. QDirIterator
+ // walks in filesystem order, which is neither stable nor alphabetical.
+ folders.sort();
+ emit foldersReady(folders);
+}
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index b3fbed3..9736ab8 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -151,6 +151,18 @@ public slots:
/// called when the dialog is opened and never on a timer.
void requestDatabaseStats(quint64 generation);
+ /// Every Maildir folder under the database root, as paths relative to it.
+ ///
+ /// From the DISK, not from the index: a folder mbsync created and nothing
+ /// has landed in yet is still a folder a tagging rule may target, and one
+ /// derived from indexed message paths would not offer it.
+ ///
+ /// Here rather than in MainWindow because the database root is
+ /// notmuch's `database.path` and this class owns the only handle that can
+ /// answer for it. Duplicating the path into config is exactly the second
+ /// source of truth the design refuses.
+ void requestFolders();
+
signals:
void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void queryFinished(int totalThreads, quint64 generation);
@@ -175,6 +187,11 @@ signals:
/// renders as unknown rather than as zero.
void databaseStatsReady(const DatabaseStats &stats, quint64 generation);
+ /// Maildir folders relative to the database root, sorted. No generation:
+ /// the tree on disk does not change under a query, and the one consumer
+ /// asks once when its dialog opens.
+ void foldersReady(const QStringList &folders);
+
void errorOccurred(const QString &message);
private:
diff --git a/src/rulequery.cpp b/src/rulequery.cpp
new file mode 100644
index 0000000..31996fa
--- /dev/null
+++ b/src/rulequery.cpp
@@ -0,0 +1,414 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "rulequery.h"
+
+#include <QPair>
+#include <QStringList>
+#include <QVector>
+
+namespace {
+
+/// The notmuch prefix each field compiles to. Wire format, never translated.
+QString prefixFor(RuleTerm::Field field)
+{
+ switch (field) {
+ case RuleTerm::From: return QStringLiteral("from");
+ case RuleTerm::To: return QStringLiteral("to");
+ case RuleTerm::Cc: return QStringLiteral("cc");
+ case RuleTerm::Subject: return QStringLiteral("subject");
+ case RuleTerm::Tag: return QStringLiteral("tag");
+ case RuleTerm::Folder: return QStringLiteral("path");
+ case RuleTerm::Attachment: return QStringLiteral("attachment");
+ case RuleTerm::Date: return QStringLiteral("date");
+ }
+ return QString();
+}
+
+bool isNegated(RuleTerm::Op op)
+{
+ return op == RuleTerm::ContainsNot || op == RuleTerm::IsNot
+ || op == RuleTerm::HasNot;
+}
+
+/// Quoted when the operator asks for an exact phrase, and ALWAYS when the
+/// value holds a space: unquoted, the space ends the term and the remainder
+/// becomes a bare word, which widens the rule instead of breaking it.
+bool needsQuotes(const RuleTerm &term)
+{
+ if (term.field == RuleTerm::Folder)
+ return true;
+ if (term.value.contains(QLatin1Char(' ')))
+ return true;
+ // Is/IsNot means an exact phrase, and only the free-text fields need
+ // quotes to express one. A tag or an attachment name is a single bare
+ // token to notmuch, which reads `tag:inbox` and `tag:"inbox"` identically
+ // (both count 5322 against the live index). Quoting them would therefore
+ // change the stored string without changing what it matches, and this
+ // type's whole contract is that an unedited rule compiles back byte for
+ // byte.
+ if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) {
+ return term.field == RuleTerm::From || term.field == RuleTerm::To
+ || term.field == RuleTerm::Cc || term.field == RuleTerm::Subject;
+ }
+ return false;
+}
+
+QString compileTerm(const RuleTerm &term)
+{
+ QString value = term.value;
+ if (term.field == RuleTerm::Folder)
+ value += QStringLiteral("/**");
+
+ QString body;
+ if (term.field == RuleTerm::Date) {
+ body = prefixFor(term.field) + QLatin1Char(':')
+ + (term.op == RuleTerm::Before
+ ? QStringLiteral("..") + value
+ : value + QStringLiteral(".."));
+ } else if (needsQuotes(term)) {
+ body = prefixFor(term.field) + QStringLiteral(":\"") + value
+ + QLatin1Char('"');
+ } else {
+ body = prefixFor(term.field) + QLatin1Char(':') + value;
+ }
+
+ return isNegated(term.op) ? QStringLiteral("not ") + body : body;
+}
+
+/// Splits on whitespace, keeping a double-quoted run as one token. Returns
+/// false when a quote is left open, which is a query this builder will not
+/// represent.
+bool tokenise(const QString &query, QStringList *out)
+{
+ QString current;
+ bool inQuotes = false;
+ bool has = false;
+
+ for (int i = 0; i < query.size(); ++i) {
+ const QChar c = query.at(i);
+ if (c == QLatin1Char('"')) {
+ inQuotes = !inQuotes;
+ current += c;
+ has = true;
+ } else if (!inQuotes && c.isSpace()) {
+ if (has) {
+ out->append(current);
+ current.clear();
+ has = false;
+ }
+ } else {
+ current += c;
+ has = true;
+ }
+ }
+
+ if (inQuotes)
+ return false;
+ if (has)
+ out->append(current);
+ return true;
+}
+
+bool fieldForPrefix(const QString &prefix, RuleTerm::Field *out)
+{
+ static const QVector<QPair<QString, RuleTerm::Field>> table = {
+ {QStringLiteral("from"), RuleTerm::From},
+ {QStringLiteral("to"), RuleTerm::To},
+ {QStringLiteral("cc"), RuleTerm::Cc},
+ {QStringLiteral("subject"), RuleTerm::Subject},
+ {QStringLiteral("tag"), RuleTerm::Tag},
+ {QStringLiteral("path"), RuleTerm::Folder},
+ {QStringLiteral("attachment"), RuleTerm::Attachment},
+ {QStringLiteral("date"), RuleTerm::Date},
+ };
+
+ for (const auto &entry : table) {
+ if (entry.first == prefix) {
+ *out = entry.second;
+ return true;
+ }
+ }
+ return false;
+}
+
+/// Parses ONE token into a term. Returns false for anything this builder does
+/// not represent, which is not the same as invalid: notmuch accepts far more
+/// than this.
+bool parseTerm(const QString &token, RuleTerm *out)
+{
+ const int colon = token.indexOf(QLatin1Char(':'));
+ if (colon <= 0)
+ return false;
+
+ RuleTerm::Field field;
+ if (!fieldForPrefix(token.left(colon), &field))
+ return false;
+
+ QString value = token.mid(colon + 1);
+ if (value.isEmpty())
+ return false;
+
+ bool quoted = false;
+ if (value.size() >= 2 && value.startsWith(QLatin1Char('"'))
+ && value.endsWith(QLatin1Char('"'))) {
+ value = value.mid(1, value.size() - 2);
+ quoted = true;
+ }
+ // A quote anywhere else means a shape this builder does not emit.
+ if (value.contains(QLatin1Char('"')))
+ return false;
+
+ out->field = field;
+
+ if (field == RuleTerm::Date) {
+ if (value.startsWith(QStringLiteral(".."))) {
+ out->op = RuleTerm::Before;
+ out->value = value.mid(2);
+ } else if (value.endsWith(QStringLiteral(".."))) {
+ out->op = RuleTerm::After;
+ out->value = value.chopped(2);
+ } else {
+ return false; // A two-sided range is not a row.
+ }
+ return !out->value.isEmpty();
+ }
+
+ if (field == RuleTerm::Folder) {
+ // Only the recursive form is representable; a bare path means
+ // something different to notmuch and must not be silently rewritten.
+ if (!value.endsWith(QStringLiteral("/**")))
+ return false;
+ value = value.chopped(3);
+ out->op = RuleTerm::Is;
+ out->value = value;
+ return !value.isEmpty();
+ }
+
+ // Tag and Attachment compile unquoted (see needsQuotes), so their
+ // operator must not be inferred from the quoting: reading a quoted tag
+ // back as a quoting operator would compile it unquoted and change the
+ // stored string.
+ if (field == RuleTerm::Attachment)
+ out->op = RuleTerm::Has;
+ else if (field == RuleTerm::Tag)
+ out->op = RuleTerm::Is;
+ else
+ out->op = quoted ? RuleTerm::Is : RuleTerm::Contains;
+
+ out->value = value;
+ return true;
+}
+
+/// Splits `(A or B) and not C and not D` into its group and its remainder.
+/// Returns false when the query does not start with a balanced group.
+bool splitLeadingGroup(const QString &query, QString *group, QString *rest)
+{
+ if (!query.startsWith(QLatin1Char('(')))
+ return false;
+
+ int depth = 0;
+ bool inQuotes = false;
+ for (int i = 0; i < query.size(); ++i) {
+ const QChar c = query.at(i);
+ if (c == QLatin1Char('"'))
+ inQuotes = !inQuotes;
+ if (inQuotes)
+ continue;
+ if (c == QLatin1Char('('))
+ ++depth;
+ else if (c == QLatin1Char(')')) {
+ --depth;
+ if (depth == 0) {
+ *group = query.mid(1, i - 1).trimmed();
+ *rest = query.mid(i + 1).trimmed();
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+} // namespace
+
+bool operator==(const RuleTerm &a, const RuleTerm &b)
+{
+ return a.field == b.field && a.op == b.op && a.value == b.value;
+}
+
+bool operator==(const RuleQuery &a, const RuleQuery &b)
+{
+ return a.parsed == b.parsed && a.join == b.join
+ && a.terms == b.terms && a.exclusions == b.exclusions;
+}
+
+QString RuleQuery::compile() const
+{
+ if (terms.isEmpty())
+ return QString();
+
+ QStringList parts;
+ for (const RuleTerm &term : terms)
+ parts.append(compileTerm(term));
+
+ const QString glue = join == Any ? QStringLiteral(" or ")
+ : QStringLiteral(" and ");
+ QString out = parts.join(glue);
+
+ // An `or` group followed by `and not` must be parenthesised or the `and`
+ // binds tighter than the `or`: `a or b and not c` is `a or (b and not c)`,
+ // which matches every `a` whatever the exclusion says.
+ if (join == Any && !exclusions.isEmpty() && terms.size() > 1)
+ out = QLatin1Char('(') + out + QLatin1Char(')');
+
+ for (const RuleTerm &term : exclusions) {
+ // The block IS the negation, so its rows are stored un-negated and
+ // the `and not` is applied here. A row stored negated would compile
+ // to `and not not subject:x`.
+ out += QStringLiteral(" and not ") + compileTerm(term);
+ }
+
+ return out;
+}
+
+RuleQuery RuleQuery::parse(const QString &query)
+{
+ RuleQuery out;
+
+ const QString trimmed = query.trimmed();
+ if (trimmed.isEmpty()) {
+ // An empty query is a rule with no rows yet, not a failure.
+ out.parsed = true;
+ return out;
+ }
+
+ QString group;
+ QString rest;
+ if (splitLeadingGroup(trimmed, &group, &rest)) {
+ // Only one nested shape is representable: an `or` group followed by
+ // `and not` exclusions. Anything else rejects whole.
+ if (group.contains(QLatin1Char('(')))
+ return RuleQuery();
+
+ const RuleQuery inner = parse(group);
+ if (!inner.parsed || inner.join != Any || !inner.exclusions.isEmpty())
+ return RuleQuery();
+
+ out.join = Any;
+ out.terms = inner.terms;
+
+ if (rest.isEmpty()) {
+ // A group with nothing after it compiles back WITHOUT parens,
+ // since compile() only adds them when exclusions follow. Round
+ // trip would break, so this is not representable.
+ return RuleQuery();
+ }
+
+ // The remainder must be nothing but `and not <term>` repetitions.
+ QStringList tail;
+ if (!tokenise(rest, &tail))
+ return RuleQuery();
+
+ int i = 0;
+ while (i < tail.size()) {
+ if (tail.at(i).compare(QStringLiteral("and"),
+ Qt::CaseInsensitive) != 0)
+ return RuleQuery();
+ ++i;
+ if (i >= tail.size()
+ || tail.at(i).compare(QStringLiteral("not"),
+ Qt::CaseInsensitive) != 0)
+ return RuleQuery();
+ ++i;
+ if (i >= tail.size())
+ return RuleQuery();
+
+ RuleTerm term;
+ if (!parseTerm(tail.at(i), &term))
+ return RuleQuery();
+ out.exclusions.append(term);
+ ++i;
+ }
+
+ out.parsed = true;
+ return out;
+ }
+
+ QStringList tokens;
+ if (!tokenise(trimmed, &tokens))
+ return RuleQuery();
+
+ // Walk the chain: term, operator, term, ... Anything else rejects whole.
+ bool sawOr = false;
+ bool sawAnd = false;
+ int i = 0;
+
+ while (i < tokens.size()) {
+ bool negated = false;
+ if (tokens.at(i).compare(QStringLiteral("not"),
+ Qt::CaseInsensitive) == 0) {
+ negated = true;
+ ++i;
+ if (i >= tokens.size())
+ return RuleQuery();
+ }
+
+ RuleTerm term;
+ if (!parseTerm(tokens.at(i), &term))
+ return RuleQuery();
+
+ if (negated) {
+ // `not date:` has no row form: "not before" is "after", which the
+ // unnegated operators already express.
+ if (term.field == RuleTerm::Date)
+ return RuleQuery();
+ // The block IS the negation, so the row is stored un-negated and
+ // compile() re-applies the `and not`.
+ out.exclusions.append(term);
+ } else {
+ out.terms.append(term);
+ }
+ ++i;
+
+ if (i >= tokens.size())
+ break;
+
+ const QString glue = tokens.at(i).toLower();
+ if (glue == QStringLiteral("and")) {
+ sawAnd = true;
+ } else if (glue == QStringLiteral("or")) {
+ sawOr = true;
+ } else {
+ return RuleQuery(); // Not a joining word: unrepresentable.
+ }
+ ++i;
+ if (i >= tokens.size())
+ return RuleQuery(); // Trailing operator.
+ }
+
+ // Mixed and/or without parentheses is ambiguous to a reader and binds in
+ // a way the rows cannot show. Reject rather than guess.
+ if (sawAnd && sawOr)
+ return RuleQuery();
+ if (out.terms.isEmpty())
+ return RuleQuery();
+
+ out.join = sawOr ? Any : All;
+ out.parsed = true;
+ return out;
+}
diff --git a/src/rulequery.h b/src/rulequery.h
new file mode 100644
index 0000000..9d487d2
--- /dev/null
+++ b/src/rulequery.h
@@ -0,0 +1,65 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QList>
+#include <QString>
+
+/// One row in the builder: a field, an operator, and a value.
+struct RuleTerm
+{
+ enum Field { From, To, Cc, Subject, Tag, Folder, Attachment, Date };
+
+ enum Op {
+ Contains, ContainsNot, ///< Unquoted term, optionally negated.
+ Is, IsNot, ///< Quoted phrase, optionally negated.
+ Has, HasNot, ///< attachment: only.
+ Before, After ///< date: only. "not before" is "after", so
+ ///< these carry no negated twin.
+ };
+
+ Field field = From;
+ Op op = Contains;
+ QString value;
+};
+
+bool operator==(const RuleTerm &a, const RuleTerm &b);
+
+/// A tagging rule's query, as rows.
+///
+/// The STRING is the stored format, shared with mailctl and executed by the
+/// notmuch post-new hook. This type is a view over it, never the store: a
+/// query it cannot represent must still open, save and run unchanged.
+struct RuleQuery
+{
+ enum Join { All, Any }; ///< and / or, over the positive terms only.
+
+ Join join = All;
+ QList<RuleTerm> terms; ///< Positive section.
+ QList<RuleTerm> exclusions; ///< The "but not" block, joined `and not`.
+
+ /// False when the query cannot be shown as rows. NOT an error and NOT a
+ /// claim that the query is invalid: the rule opens in text mode.
+ bool parsed = false;
+
+ static RuleQuery parse(const QString &query);
+ QString compile() const;
+};
+
+bool operator==(const RuleQuery &a, const RuleQuery &b);
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
index 40ee66a..4fca971 100644
--- a/src/tagrulesdialog.cpp
+++ b/src/tagrulesdialog.cpp
@@ -18,8 +18,12 @@
#include "tagrulesdialog.h"
+#include <QButtonGroup>
#include <QCheckBox>
+#include <QComboBox>
#include <QDialogButtonBox>
+#include <QDir>
+#include <QFileInfo>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
@@ -28,10 +32,16 @@
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
+#include <QRadioButton>
+#include <QScrollArea>
+#include <QSettings>
#include <QSpinBox>
+#include <QSplitter>
#include <QTreeWidget>
#include <QVBoxLayout>
+#include "mainwindow.h"
+
namespace {
/// Columns of the rule list.
@@ -50,12 +60,28 @@ QStringList splitTags(const QString &text)
return out;
}
+struct FieldEntry { RuleTerm::Field field; const char *label; };
+
+const FieldEntry kFields[] = {
+ {RuleTerm::From, QT_TR_NOOP("From")},
+ {RuleTerm::To, QT_TR_NOOP("To")},
+ {RuleTerm::Cc, QT_TR_NOOP("Cc")},
+ {RuleTerm::Subject, QT_TR_NOOP("Subject")},
+ {RuleTerm::Tag, QT_TR_NOOP("Tag")},
+ {RuleTerm::Folder, QT_TR_NOOP("Folder")},
+ {RuleTerm::Attachment, QT_TR_NOOP("Attachment")},
+ {RuleTerm::Date, QT_TR_NOOP("Date")},
+};
+
} // namespace
TagRulesDialog::TagRulesDialog(QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("Tagging rules"));
+ // The fallback for a first run. restoreUiState() overwrites it when a
+ // size was saved, and is called at the end of this constructor because
+ // the header state cannot be restored before the columns exist.
resize(760, 520);
m_rules.load();
@@ -82,7 +108,26 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
tr("Matches") });
m_list->setRootIsDecorated(false);
m_list->setUniformRowHeights(true);
- layout->addWidget(m_list, 1);
+
+ // The list and the editor go in a splitter, and the editor's own widget
+ // holds the form. A plain QVBoxLayout gave the list stretch 1 and still
+ // let a rule with eight condition rows squeeze it to about one visible
+ // row: a stretch factor only shares out space ABOVE each widget's
+ // minimum, and the form's grew with every row. Measured before the fix,
+ // the editor asked for 120px with one row and 414px with eight.
+ auto *editor = new QWidget(this);
+ auto *editorLayout = new QVBoxLayout(editor);
+ editorLayout->setContentsMargins(0, 0, 0, 0);
+
+ m_splitter = new QSplitter(Qt::Vertical, this);
+ m_splitter->addWidget(m_list);
+ m_splitter->addWidget(editor);
+ // Neither pane collapses to nothing by dragging the handle past the end,
+ // which would hide the thing the user was trying to make room for.
+ m_splitter->setChildrenCollapsible(false);
+ m_splitter->setStretchFactor(0, 1);
+ m_splitter->setStretchFactor(1, 0);
+ layout->addWidget(m_splitter, 1);
auto *form = new QFormLayout;
m_id = new QLineEdit(this);
@@ -104,9 +149,78 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
form->addRow(QString(), m_enabled);
form->addRow(tr("Add tags"), m_add);
form->addRow(tr("Remove tags"), m_remove);
- form->addRow(tr("Query"), m_query);
+ m_builder = new QWidget(this);
+ auto *builderLayout = new QVBoxLayout(m_builder);
+ builderLayout->setContentsMargins(0, 0, 0, 0);
+
+ auto *matchRow = new QHBoxLayout;
+ m_matchAll = new QRadioButton(tr("Match &all"), m_builder);
+ m_matchAny = new QRadioButton(tr("Match a&ny"), m_builder);
+ m_matchAll->setChecked(true);
+ auto *matchGroup = new QButtonGroup(this);
+ matchGroup->addButton(m_matchAll);
+ matchGroup->addButton(m_matchAny);
+ matchRow->addWidget(m_matchAll);
+ matchRow->addWidget(m_matchAny);
+ matchRow->addStretch();
+ builderLayout->addLayout(matchRow);
+
+ m_rowsLayout = new QVBoxLayout;
+ builderLayout->addLayout(m_rowsLayout);
+
+ m_exclusionsHeader = new QLabel(tr("But not"), m_builder);
+ builderLayout->addWidget(m_exclusionsHeader);
+ m_exclusionsLayout = new QVBoxLayout;
+ builderLayout->addLayout(m_exclusionsLayout);
+
+ m_addExclusion = new QPushButton(tr("Add e&xclusion"), m_builder);
+ builderLayout->addWidget(m_addExclusion, 0, Qt::AlignLeft);
+
+ // The rows scroll rather than growing without bound. The splitter alone
+ // fixes the squeeze, but only until the user drags the handle down; this
+ // caps what the editor can ever demand, so a rule with thirty senders
+ // stays as workable as one with two.
+ m_builderScroll = new QScrollArea(this);
+ m_builderScroll->setWidget(m_builder);
+ m_builderScroll->setWidgetResizable(true);
+ m_builderScroll->setFrameShape(QFrame::NoFrame);
+ m_builderScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ // Roughly four rows. Enough that the common rule needs no scrolling at
+ // all, small enough that the list keeps most of the window.
+ m_builderScroll->setMaximumHeight(190);
+ form->addRow(tr("Match"), m_builderScroll);
+
+ // The toggle sits with the QUERY line, not inside m_builder, because
+ // switching to text mode HIDES m_builder. A checkbox parented there
+ // vanishes with the rows it governs, leaving no way back except closing
+ // the dialog, which is exactly what shipped in the first draft of this
+ // builder. The query row is visible in both modes, so the toggle is
+ // always reachable.
+ auto *queryRow = new QHBoxLayout;
+ m_textMode = new QCheckBox(tr("Edit as &text"), this);
+ m_textMode->setToolTip(
+ tr("Edit the notmuch query directly. A rule too complex to show as "
+ "rows opens this way."));
+ queryRow->addWidget(m_query, 1);
+ queryRow->addWidget(m_textMode);
+ form->addRow(tr("Query"), queryRow);
+
+ // The query line shows what the rows compile to. Read-only in builder
+ // mode: it is what actually ships to the hook, and watching it change is
+ // what makes the rows trustworthy.
+ m_query->setReadOnly(true);
+
+ connect(m_matchAll, &QRadioButton::toggled,
+ this, &TagRulesDialog::syncQueryLine);
+ connect(m_textMode, &QCheckBox::toggled,
+ this, &TagRulesDialog::setTextMode);
+ connect(m_addExclusion, &QPushButton::clicked, this, [this] {
+ addRow(true);
+ syncQueryLine();
+ });
+
form->addRow(tr("Note"), m_note);
- layout->addLayout(form);
+ editorLayout->addLayout(form);
auto *buttons = new QHBoxLayout;
auto *addButton = new QPushButton(tr("&New"), this);
@@ -120,6 +234,11 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
buttons->addWidget(copyButton);
buttons->addWidget(deleteButton);
buttons->addStretch();
+ m_previewButton = new QPushButton(tr("&Preview in list"), this);
+ m_previewButton->setToolTip(
+ tr("Run this rule's query in the main window, to see which mail it "
+ "collects. This does not tag anything."));
+ buttons->addWidget(m_previewButton);
buttons->addWidget(refreshButton);
layout->addLayout(buttons);
@@ -137,6 +256,8 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
this, &TagRulesDialog::onCopyRule);
connect(deleteButton, &QPushButton::clicked,
this, &TagRulesDialog::onDeleteRule);
+ connect(m_previewButton, &QPushButton::clicked,
+ this, &TagRulesDialog::previewForTest);
connect(refreshButton, &QPushButton::clicked,
this, &TagRulesDialog::countsRequested);
connect(box, &QDialogButtonBox::accepted,
@@ -166,8 +287,134 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
connect(m_note, &QPlainTextEdit::textChanged,
this, &TagRulesDialog::applyEditsToCurrentRule);
+ addRow(false);
+ updateExclusionsVisibility();
+
reloadList();
showWarnings();
+
+ // Last, and after reloadList(): a header state cannot be applied before
+ // the columns it describes exist, and reloadList is what fills them.
+ restoreUiState();
+}
+
+/// Reads the window size and the rule list's header layout back.
+///
+/// The same file MainWindow uses, under keys of its own. Machine-written
+/// state, never the hand-edited config: a column width is not something
+/// anyone edits by hand, and mixing the two puts a blob in a file the user
+/// reads.
+void TagRulesDialog::restoreUiState()
+{
+ QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
+
+ const QByteArray geometry =
+ state.value(QStringLiteral("tagrules/geometry")).toByteArray();
+ if (!geometry.isEmpty())
+ restoreGeometry(geometry);
+
+ const QByteArray splitter =
+ state.value(QStringLiteral("tagrules/splitter")).toByteArray();
+ if (!splitter.isEmpty())
+ m_splitter->restoreState(splitter);
+
+ const QByteArray header =
+ state.value(QStringLiteral("tagrules/header")).toByteArray();
+ if (!header.isEmpty()) {
+ m_list->header()->restoreState(header);
+ // Counts as the one auto-size each column gets, so the restore is not
+ // immediately overwritten. reloadList() runs before this in the
+ // constructor and has already sized the first two; the count column
+ // has not been filled yet, and would otherwise resize over the
+ // restored width as soon as the first counts arrived.
+ m_columnsSized = true;
+ m_countColumnSized = true;
+ }
+}
+
+void TagRulesDialog::saveUiState()
+{
+ QDir().mkpath(QFileInfo(MainWindow::uiStatePath()).absolutePath());
+ QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
+ state.setValue(QStringLiteral("tagrules/geometry"), saveGeometry());
+ state.setValue(QStringLiteral("tagrules/header"),
+ m_list->header()->saveState());
+ state.setValue(QStringLiteral("tagrules/splitter"),
+ m_splitter->saveState());
+}
+
+/// Saves on the way out, whichever way that is.
+///
+/// done() rather than closeEvent, and this distinction shipped broken: Cancel
+/// calls reject() and Save calls accept(), and NEITHER sends a QCloseEvent.
+/// Only the window manager's X button does. Saving from closeEvent therefore
+/// kept the size for the one route the buttons never take, which is how a
+/// resize followed by Cancel came back forgotten. Both buttons funnel through
+/// done(), and QWidget::close() reaches it too.
+///
+/// On every route, not only on accept: the window's shape is not part of the
+/// edit being confirmed, so Cancel should discard the rule changes and keep
+/// the size.
+void TagRulesDialog::done(int result)
+{
+ saveUiState();
+ QDialog::done(result);
+}
+
+int TagRulesDialog::heightDemandedBelowListForTest() const
+{
+ // The builder's PREFERRED height, which is what grows with each condition
+ // row and what the list ends up paying for. minimumSizeHint is the wrong
+ // measure and reads 580 either way: a QFormLayout's minimum does not
+ // track its rows, so a test on it passes against the bug.
+ // The EDITOR PANE's minimum, which is what the splitter refuses to
+ // shrink below and therefore what the rule list actually pays. Not the
+ // builder's size hint: that grows with every row by design and is capped
+ // by the scroll area rather than reduced. Not minimumSizeHint on the
+ // dialog either, which does not track form rows at all and reads the
+ // same whether the bug is present or not.
+ return m_splitter->widget(1)->minimumSizeHint().height();
+}
+
+void TagRulesDialog::previewForTest()
+{
+ // Flush any half-typed edit first, so previewing shows what the rule
+ // says NOW rather than what it said when the row was selected.
+ applyEditsToCurrentRule();
+
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+
+ const QString query = m_working.at(index).query;
+ if (query.isEmpty())
+ return;
+
+ emit previewRequested(query);
+}
+
+int TagRulesDialog::conditionAreaHeightForTest() const
+{
+ // The CAP itself, not a qMin against the scroll area's own size hint: a
+ // QScrollArea reports a small hint whether or not it is capped, and an
+ // uncapped maximumHeight is QWIDGETSIZE_MAX, so qMin picked the hint and
+ // the assertion passed with the cap removed.
+ return m_builderScroll->maximumHeight();
+}
+
+int TagRulesDialog::columnWidthForTest(int column) const
+{
+ return m_list->columnWidth(column);
+}
+
+void TagRulesDialog::setColumnWidthForTest(int column, int width)
+{
+ m_list->setColumnWidth(column, width);
+}
+
+void TagRulesDialog::reloadListForTest()
+{
+ reloadList();
}
void TagRulesDialog::showWarnings()
@@ -210,8 +457,15 @@ void TagRulesDialog::reloadList()
auto *item = new QTreeWidgetItem(m_list);
fillItem(item, rule);
}
- m_list->resizeColumnToContents(ColumnEnabled);
- m_list->resizeColumnToContents(ColumnStage);
+ // Auto-sized on the FIRST fill only. After that the widths belong to the
+ // user, whether they came from a restored header or from a drag in this
+ // session, and resizing on every repopulate threw both away on the next
+ // add or delete.
+ if (!m_columnsSized) {
+ m_list->resizeColumnToContents(ColumnEnabled);
+ m_list->resizeColumnToContents(ColumnStage);
+ m_columnsSized = true;
+ }
m_reloading = false;
if (!m_working.isEmpty())
@@ -230,12 +484,23 @@ void TagRulesDialog::onSelectionChanged()
const int index = currentIndex();
if (index < 0 || index >= m_working.size())
return;
- const TagRule &rule = m_working.at(index);
-
- // The note's textChanged fires from setPlainText below, which would then
- // write the rule just loaded back over the rule now current. Harmless when
- // they are the same rule, destructive when the selection is what changed.
- const QSignalBlocker blockNote(m_note);
+ // By value: every setter below can reach applyEditsToCurrentRule(), which
+ // writes into m_working, and a reference into that list would then be read
+ // back half overwritten.
+ const TagRule rule = m_working.at(index);
+
+ // Populating the form emits change signals whose handlers write the form
+ // back onto the working copy, so the rule just loaded would be written over
+ // whichever rule is now current. Harmless when they are the same rule,
+ // destructive when the selection is what changed. m_enabled::toggled and
+ // the note's textChanged both do this, and the widgets are populated in an
+ // order where m_enabled fires while m_query still holds the PREVIOUS rule's
+ // text, which emptied the query of the first rule opened. The reloading
+ // flag is the existing guard for exactly this, so it covers the whole load,
+ // including rebuildRows: populating combo boxes and line edits fires
+ // currentIndexChanged, which runs syncQueryLine.
+ const bool wasReloading = m_reloading;
+ m_reloading = true;
m_id->setText(rule.id);
m_stage->setValue(rule.stage);
@@ -244,6 +509,26 @@ void TagRulesDialog::onSelectionChanged()
m_remove->setText(rule.remove.join(QStringLiteral(", ")));
m_query->setText(rule.query);
m_note->setPlainText(rule.note);
+
+ // Parse once, on load, and keep it: the save path compares against this to
+ // decide whether the stored string may be left alone, so that opening a
+ // rule and closing it cannot rewrite the file mailctl also reads.
+ m_loadedQuery = RuleQuery::parse(rule.query);
+
+ // A rule the builder cannot show opens as text, and one it can show
+ // returns to the builder. Blocked, because letting setChecked run
+ // setTextMode() here would recompile and overwrite m_query mid-load.
+ {
+ const QSignalBlocker blockTextMode(m_textMode);
+ m_textMode->setChecked(!m_loadedQuery.parsed);
+ }
+ m_builderScroll->setVisible(m_loadedQuery.parsed);
+ m_query->setReadOnly(m_loadedQuery.parsed);
+
+ if (m_loadedQuery.parsed)
+ rebuildRows(m_loadedQuery);
+
+ m_reloading = wasReloading;
}
void TagRulesDialog::applyEditsToCurrentRule()
@@ -261,7 +546,16 @@ void TagRulesDialog::applyEditsToCurrentRule()
rule.enabled = m_enabled->isChecked();
rule.add = splitTags(m_add->text());
rule.remove = splitTags(m_remove->text());
- rule.query = m_query->text().trimmed();
+ if (m_textMode->isChecked()) {
+ rule.query = m_query->text().trimmed();
+ } else {
+ const RuleQuery current = currentQueryFromRows();
+ // Unchanged rows mean the stored string is left exactly as it was
+ // read. Recompiling an untouched rule would churn a file the
+ // companion tool also reads, showing a diff the user never made.
+ if (!(current == m_loadedQuery))
+ rule.query = current.compile();
+ }
rule.note = m_note->toPlainText();
fillItem(m_list->topLevelItem(index), rule);
@@ -323,7 +617,12 @@ void TagRulesDialog::setCounts(const QVector<int> &counts)
counts.at(i) < 0 ? tr("invalid")
: QString::number(counts.at(i)));
}
- m_list->resizeColumnToContents(ColumnCount);
+ // Once, like the columns in reloadList. The counts arrive after the first
+ // fill, so this column gets its own flag rather than sharing that one.
+ if (!m_countColumnSized) {
+ m_list->resizeColumnToContents(ColumnCount);
+ m_countColumnSized = true;
+ }
}
void TagRulesDialog::onSave()
@@ -339,3 +638,367 @@ void TagRulesDialog::onSave()
}
accept();
}
+
+QString TagRulesDialog::queryLineForTest() const
+{
+ return m_query->text();
+}
+
+bool TagRulesDialog::textModeForTest() const
+{
+ return m_textMode->isChecked();
+}
+
+void TagRulesDialog::setRowValueForTest(int index, const QString &value)
+{
+ if (index < 0 || index >= m_rows.size())
+ return;
+ setRowValue(&m_rows[index], value);
+ syncQueryLine();
+}
+
+void TagRulesDialog::setQueryTextForTest(const QString &text)
+{
+ m_query->setText(text);
+}
+
+void TagRulesDialog::setTextModeForTest(bool on)
+{
+ m_textMode->setChecked(on);
+}
+
+bool TagRulesDialog::textModeToggleIsReachableForTest() const
+{
+ // isVisibleTo rather than isVisible: nothing is isVisible() on a dialog
+ // that was never shown, so that would report unreachable in both the
+ // working and the broken case.
+ return m_textMode->isVisibleTo(this);
+}
+
+QString TagRulesDialog::warningTextForTest() const
+{
+ // isVisible() is false for every child of a dialog that was never shown,
+ // so it would report no warning whatever the label held. isVisibleTo()
+ // answers the question actually being asked: would this be on screen if
+ // the dialog were.
+ return m_warningLabel->isVisibleTo(this) ? m_warningLabel->text()
+ : QString();
+}
+
+void TagRulesDialog::selectRuleForTest(int index)
+{
+ if (index >= 0 && index < m_list->topLevelItemCount())
+ m_list->setCurrentItem(m_list->topLevelItem(index));
+}
+
+QString TagRulesDialog::rowValue(const Row &row) const
+{
+ const bool isFolder =
+ RuleTerm::Field(row.field->currentData().toInt()) == RuleTerm::Folder;
+ return (isFolder ? row.folder->currentText() : row.value->text()).trimmed();
+}
+
+void TagRulesDialog::setRowValue(Row *row, const QString &value)
+{
+ if (RuleTerm::Field(row->field->currentData().toInt()) == RuleTerm::Folder)
+ row->folder->setCurrentText(value);
+ else
+ row->value->setText(value);
+}
+
+void TagRulesDialog::setFolders(const QStringList &folders)
+{
+ m_folders = folders;
+
+ // Rows already exist by the time this is called: the constructor loads the
+ // first rule and builds its rows before the caller can hand the list over.
+ // Repopulating them here rather than only in addRow() is what stops the row
+ // on screen from opening with an empty dropdown. The current text is
+ // preserved across the refill, since the combo is editable and may hold a
+ // folder the config does not list.
+ const auto refill = [&folders](const QList<Row> &rows) {
+ for (const Row &row : rows) {
+ const QString had = row.folder->currentText();
+ QSignalBlocker block(row.folder);
+ row.folder->clear();
+ row.folder->addItems(folders);
+ row.folder->setCurrentText(had);
+ }
+ };
+ refill(m_rows);
+ refill(m_exclusionRows);
+}
+
+TagRulesDialog::Row *TagRulesDialog::addRow(bool exclusion)
+{
+ Row row;
+ row.container = new QWidget(m_builder);
+ auto *layout = new QHBoxLayout(row.container);
+ layout->setContentsMargins(0, 0, 0, 0);
+
+ row.field = new QComboBox(row.container);
+ for (const FieldEntry &entry : kFields)
+ row.field->addItem(tr(entry.label), int(entry.field));
+
+ row.op = new QComboBox(row.container);
+ row.value = new QLineEdit(row.container);
+
+ row.folder = new QComboBox(row.container);
+ // Editable so a folder present in the file but absent from the config
+ // still displays and still saves, rather than being silently blanked.
+ row.folder->setEditable(true);
+ row.folder->addItems(m_folders);
+ row.folder->setVisible(false);
+
+ auto *plus = new QPushButton(QStringLiteral("+"), row.container);
+ auto *minus = new QPushButton(QStringLiteral("-"), row.container);
+ plus->setFixedWidth(30);
+ minus->setFixedWidth(30);
+
+ layout->addWidget(row.field);
+ layout->addWidget(row.op);
+ layout->addWidget(row.value, 1);
+ layout->addWidget(row.folder, 1);
+ layout->addWidget(plus);
+ layout->addWidget(minus);
+
+ QList<Row> &rows = exclusion ? m_exclusionRows : m_rows;
+ QVBoxLayout *target = exclusion ? m_exclusionsLayout : m_rowsLayout;
+ rows.append(row);
+ target->addWidget(row.container);
+
+ // The three widgets by pointer, never the Row by value: the row lives in a
+ // QList that reallocates as rows are added, so a copy taken here would be
+ // compared against, or written through, after that list has moved.
+ QComboBox *field = row.field;
+ QLineEdit *value = row.value;
+ QComboBox *folder = row.folder;
+ connect(row.field, &QComboBox::currentIndexChanged, this,
+ [this, exclusion, field, value, folder](int) {
+ populateOperators(exclusion);
+ const bool isFolder =
+ RuleTerm::Field(field->currentData().toInt())
+ == RuleTerm::Folder;
+ value->setVisible(!isFolder);
+ folder->setVisible(isFolder);
+ syncQueryLine();
+ });
+ connect(row.op, &QComboBox::currentIndexChanged,
+ this, [this](int) { syncQueryLine(); });
+ connect(row.value, &QLineEdit::textEdited,
+ this, [this](const QString &) { syncQueryLine(); });
+ connect(row.folder, &QComboBox::currentTextChanged,
+ this, [this](const QString &) { syncQueryLine(); });
+ connect(plus, &QPushButton::clicked, this, [this, exclusion] {
+ addRow(exclusion);
+ syncQueryLine();
+ });
+
+ QWidget *container = row.container;
+ connect(minus, &QPushButton::clicked, this, [this, exclusion, container] {
+ const QList<Row> &list = exclusion ? m_exclusionRows : m_rows;
+ for (int i = 0; i < list.size(); ++i) {
+ if (list.at(i).container == container) {
+ removeRow(exclusion, i);
+ break;
+ }
+ }
+ syncQueryLine();
+ });
+
+ populateOperators(exclusion);
+ updateExclusionsVisibility();
+ return &rows.last();
+}
+
+void TagRulesDialog::removeRow(bool exclusion, int index)
+{
+ QList<Row> &rows = exclusion ? m_exclusionRows : m_rows;
+ if (index < 0 || index >= rows.size())
+ return;
+
+ // The positive section keeps at least one row: a rule with no rows has an
+ // empty query, which is reachable by clearing the value rather than by
+ // deleting the last row out from under the user.
+ if (!exclusion && rows.size() == 1) {
+ setRowValue(&rows[0], QString());
+ return;
+ }
+
+ delete rows.at(index).container;
+ rows.removeAt(index);
+ updateExclusionsVisibility();
+}
+
+void TagRulesDialog::updateExclusionsVisibility()
+{
+ // Most rules have no exclusions, so an empty block on every rule is noise.
+ const bool any = !m_exclusionRows.isEmpty();
+ m_exclusionsHeader->setVisible(any);
+}
+
+void TagRulesDialog::populateOperators(bool exclusion)
+{
+ const QList<Row> &rows = exclusion ? m_exclusionRows : m_rows;
+ for (const Row &row : rows) {
+ const auto field = RuleTerm::Field(row.field->currentData().toInt());
+ const QString had = row.op->currentText();
+ QSignalBlocker block(row.op);
+ row.op->clear();
+
+ switch (field) {
+ case RuleTerm::Tag:
+ case RuleTerm::Folder:
+ row.op->addItem(tr("is"), int(RuleTerm::Is));
+ row.op->addItem(tr("is not"), int(RuleTerm::IsNot));
+ break;
+ case RuleTerm::Attachment:
+ row.op->addItem(tr("has"), int(RuleTerm::Has));
+ row.op->addItem(tr("has not"), int(RuleTerm::HasNot));
+ break;
+ case RuleTerm::Date:
+ row.op->addItem(tr("before"), int(RuleTerm::Before));
+ row.op->addItem(tr("after"), int(RuleTerm::After));
+ break;
+ default:
+ row.op->addItem(tr("contains"), int(RuleTerm::Contains));
+ row.op->addItem(tr("contains not"), int(RuleTerm::ContainsNot));
+ row.op->addItem(tr("is"), int(RuleTerm::Is));
+ row.op->addItem(tr("is not"), int(RuleTerm::IsNot));
+ break;
+ }
+
+ const int restored = row.op->findText(had);
+ if (restored >= 0)
+ row.op->setCurrentIndex(restored);
+ }
+}
+
+RuleQuery TagRulesDialog::currentQueryFromRows() const
+{
+ RuleQuery query;
+ query.parsed = true;
+ query.join = m_matchAny->isChecked() ? RuleQuery::Any : RuleQuery::All;
+
+ for (const Row &row : m_rows) {
+ const QString value = rowValue(row);
+ if (value.isEmpty())
+ continue;
+ query.terms.append({RuleTerm::Field(row.field->currentData().toInt()),
+ RuleTerm::Op(row.op->currentData().toInt()),
+ value});
+ }
+ for (const Row &row : m_exclusionRows) {
+ const QString value = rowValue(row);
+ if (value.isEmpty())
+ continue;
+ query.exclusions.append(
+ {RuleTerm::Field(row.field->currentData().toInt()),
+ RuleTerm::Op(row.op->currentData().toInt()),
+ value});
+ }
+ return query;
+}
+
+void TagRulesDialog::setTextMode(bool on)
+{
+ if (on) {
+ // Show what the rows currently mean, then hand the string over.
+ if (m_loadedQuery.parsed)
+ m_query->setText(currentQueryFromRows().compile());
+ m_builderScroll->setVisible(false);
+ m_query->setReadOnly(false);
+ return;
+ }
+
+ // Going back needs the typed query to be representable. If it is not, the
+ // checkbox cannot clear: there are no rows that mean this query.
+ //
+ // Said in the warning label rather than a modal. A modal here would block
+ // any test that reaches this branch, which is how a refusal path ends up
+ // shipping unverified, and it interrupts someone who is mid-edit to tell
+ // them something the label can hold while they keep typing.
+ const RuleQuery parsed = RuleQuery::parse(m_query->text().trimmed());
+ if (!parsed.parsed) {
+ const QSignalBlocker block(m_textMode);
+ m_textMode->setChecked(true);
+ m_warningLabel->setText(
+ tr("This query is more than the builder can show, so it stays as "
+ "text. It is still saved and applied normally."));
+ m_warningLabel->setVisible(true);
+ return;
+ }
+
+ const bool wasReloading = m_reloading;
+ m_reloading = true;
+ rebuildRows(parsed);
+ m_reloading = wasReloading;
+
+ m_loadedQuery = parsed;
+ m_builderScroll->setVisible(true);
+ m_query->setReadOnly(true);
+
+ // The refusal above writes into the same label the load warnings use, so
+ // a successful return to the rows must clear it or a stale complaint
+ // outlives the query that caused it. showWarnings() restores whatever the
+ // file itself had to say.
+ showWarnings();
+}
+
+void TagRulesDialog::rebuildRows(const RuleQuery &query)
+{
+ while (!m_rows.isEmpty())
+ delete m_rows.takeLast().container;
+ while (!m_exclusionRows.isEmpty())
+ delete m_exclusionRows.takeLast().container;
+
+ m_matchAll->setChecked(query.join == RuleQuery::All);
+ m_matchAny->setChecked(query.join == RuleQuery::Any);
+
+ for (const RuleTerm &term : query.terms)
+ applyTermToRow(addRow(false), term);
+ for (const RuleTerm &term : query.exclusions)
+ applyTermToRow(addRow(true), term);
+
+ if (m_rows.isEmpty())
+ addRow(false); // Always one row to type into.
+
+ updateExclusionsVisibility();
+}
+
+void TagRulesDialog::applyTermToRow(Row *row, const RuleTerm &term)
+{
+ const QSignalBlocker blockField(row->field);
+ const QSignalBlocker blockOp(row->op);
+ const QSignalBlocker blockValue(row->value);
+ const QSignalBlocker blockFolder(row->folder);
+
+ const int fieldIndex = row->field->findData(int(term.field));
+ if (fieldIndex >= 0)
+ row->field->setCurrentIndex(fieldIndex);
+
+ // The field's own handler is blocked here, so the swap it would have done
+ // has to happen explicitly or a Folder row opens showing the line edit.
+ const bool isFolder = term.field == RuleTerm::Folder;
+ row->value->setVisible(!isFolder);
+ row->folder->setVisible(isFolder);
+
+ // The operator list depends on the field just set, so it must be rebuilt
+ // before the operator can be found in it.
+ populateOperators(false);
+ populateOperators(true);
+
+ const int opIndex = row->op->findData(int(term.op));
+ if (opIndex >= 0)
+ row->op->setCurrentIndex(opIndex);
+
+ setRowValue(row, term.value);
+}
+
+void TagRulesDialog::syncQueryLine()
+{
+ if (m_reloading)
+ return;
+ m_query->setText(currentQueryFromRows().compile());
+ applyEditsToCurrentRule();
+}
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
index 0350bd5..e8979fe 100644
--- a/src/tagrulesdialog.h
+++ b/src/tagrulesdialog.h
@@ -19,17 +19,24 @@
#pragma once
#include <QDialog>
+#include <QStringList>
+#include "rulequery.h"
#include "tagrules.h"
class QCheckBox;
+class QComboBox;
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
+class QRadioButton;
+class QScrollArea;
+class QSplitter;
class QSpinBox;
class QTreeWidget;
class QTreeWidgetItem;
+class QVBoxLayout;
/// Views and edits the shared auto-tagging rules.
///
@@ -51,10 +58,94 @@ public:
/// handle and notmuch permits one per process.
QStringList countQueries() const;
+ /// Maildir folder paths, for the Folder row's dropdown, relative to the
+ /// database root. Supplied by the caller rather than read here: they come
+ /// from a scan of the tree under notmuch's database.path, which only
+ /// NotmuchWorker can answer for, and this dialog reaches neither it nor
+ /// Config.
+ ///
+ /// Arrives AFTER the dialog is on screen, since the scan crosses to the
+ /// worker on a queued call, so this refills the rows that already exist.
+ void setFolders(const QStringList &folders);
+
+ /// Test seams. The builder's state is otherwise reachable only through
+ /// synthetic clicks on widgets whose geometry the offscreen platform does
+ /// not guarantee.
+ int rowCountForTest() const { return m_rows.size(); }
+ QString queryLineForTest() const;
+
+ /// Selects the rule at `index` as a click on the list would.
+ void selectRuleForTest(int index);
+
+ bool textModeForTest() const;
+ void setRowValueForTest(int index, const QString &value);
+ /// Runs the Save path without showing the dialog.
+ void saveForTest() { onSave(); }
+
+ /// Types into the query field and toggles the mode, so the refusal path
+ /// can be reached without a synthetic click. The refusal reports through
+ /// the warning label rather than a modal precisely so this is testable.
+ void setQueryTextForTest(const QString &text);
+ void setTextModeForTest(bool on);
+ QString warningTextForTest() const;
+
+ /// Whether the text-mode toggle would be on screen. A toggle that hides
+ /// itself when switched on is a one-way trip, and asserting only on the
+ /// checked STATE passes against that, since the state is still readable
+ /// when the widget is not.
+ bool textModeToggleIsReachableForTest() const;
+
+ /// Width of one rule-list column. The geometry restore is asserted on the
+ /// saved and reread VALUE rather than on the resulting frame: item 46
+ /// records that the offscreen platform does not honour a window resize,
+ /// so a test comparing frames there passes or fails for reasons that have
+ /// nothing to do with the code.
+ int columnWidthForTest(int column) const;
+ void setColumnWidthForTest(int column, int width);
+
+ /// Repopulates the rule list, as adding or deleting a rule does. Exposed
+ /// because a restored column width has to survive one of these, not only
+ /// a close and reopen: `resizeColumnToContents` on every reload discarded
+ /// the width the user had dragged.
+ void reloadListForTest();
+
+ /// Presses Preview, which is otherwise reachable only through a click on
+ /// a button whose geometry the offscreen platform does not guarantee.
+ void previewForTest();
+
+ /// The height the condition-row editor asks for. This is what squeezed
+ /// the rule list: a stretch factor only shares out space ABOVE each
+ /// widget's minimum, so every row added here came out of the list.
+ /// Measured at 120px for one row and 414px for eight before the scroll
+ /// area capped it.
+ ///
+ /// Asserted on instead of the list's rendered height because the
+ /// offscreen platform does not honour a window size, so the rendered
+ /// height there is not evidence of anything (see CLAUDE.md). Note the
+ /// size HINT, not minimumSizeHint: a QFormLayout's minimum does not track
+ /// its rows and reads the same either way, which passed against the bug.
+ int heightDemandedBelowListForTest() const;
+
+ /// How tall the condition-row area may become. The scroll area caps it;
+ /// without the cap the rows grow without bound and a long rule fills the
+ /// window again, scrolling instead of squeezing. Asserted separately
+ /// because removing the cap leaves heightDemandedBelowListForTest
+ /// unchanged, so that measure alone does not cover it.
+ int conditionAreaHeightForTest() const;
+
signals:
/// Asks the owner to run countQueries() through the worker.
void countsRequested();
+ /// Asks the owner to run one rule's query in the main window, so the user
+ /// can see WHICH mail a rule collects rather than how much.
+ ///
+ /// The query goes out exactly as stored: no `tag:new`, and no wrapping
+ /// parentheses. The post-new hook adds both when it applies a rule, and a
+ /// preview that copied them would match nothing outside a sync window,
+ /// since `tag:new` is set only on mail that has just arrived.
+ void previewRequested(const QString &query);
+
public slots:
/// Corpus counts, positionally paired with countQueries().
void setCounts(const QVector<int> &counts);
@@ -67,8 +158,16 @@ private slots:
void applyEditsToCurrentRule();
void onSave();
+public slots:
+ /// Saves the window's size and column widths on the way out. Overridden
+ /// here rather than closeEvent because Save and Cancel do not send a
+ /// close event at all, only the window manager's X button does.
+ void done(int result) override;
+
private:
void reloadList();
+ void restoreUiState();
+ void saveUiState();
void showWarnings();
int currentIndex() const;
@@ -76,6 +175,44 @@ private:
/// applyEditsToCurrentRule() so the two cannot render a rule differently.
void fillItem(QTreeWidgetItem *item, const TagRule &rule) const;
+ /// One builder row's widgets, so a row can be removed as a unit.
+ struct Row
+ {
+ QWidget *container = nullptr;
+ QComboBox *field = nullptr;
+ QComboBox *op = nullptr;
+ QLineEdit *value = nullptr;
+ /// Shown in place of `value` for a Folder row. Never visible together.
+ QComboBox *folder = nullptr;
+ };
+
+ /// Reads a row's value from whichever of its two widgets the field selects,
+ /// so the read and write paths cannot disagree about where it lives.
+ QString rowValue(const Row &row) const;
+ void setRowValue(Row *row, const QString &value);
+
+ Row *addRow(bool exclusion);
+ void removeRow(bool exclusion, int index);
+ void populateOperators(bool exclusion);
+ void updateExclusionsVisibility();
+ void syncQueryLine();
+
+ void setTextMode(bool on);
+
+ void rebuildRows(const RuleQuery &query);
+ void applyTermToRow(Row *row, const RuleTerm &term);
+ RuleQuery currentQueryFromRows() const;
+
+ /// The query as parsed when the current rule was loaded. Task 10's save
+ /// path compares against this to decide whether the stored string may be
+ /// left alone.
+ RuleQuery m_loadedQuery;
+
+ QStringList m_folders;
+
+ QList<Row> m_rows;
+ QList<Row> m_exclusionRows;
+
TagRules m_rules;
QList<TagRule> m_working; ///< Edited copy; written only on Save.
@@ -85,6 +222,15 @@ private:
/// row's text, which applyEditsToCurrentRule() has no chance to flush.
bool m_reloading = false;
+ /// Each column is auto-sized to its contents ONCE, on its first fill.
+ /// After that its width belongs to the user, whether it came from a
+ /// restored header or from a drag, and resizeColumnToContents on every
+ /// repopulate threw both away on the next add or delete. Two flags rather
+ /// than one because the count column is filled later than the rest, by a
+ /// reply from the worker.
+ bool m_columnsSized = false;
+ bool m_countColumnSized = false;
+
QTreeWidget *m_list = nullptr;
QLineEdit *m_id = nullptr;
QLineEdit *m_add = nullptr;
@@ -95,4 +241,22 @@ private:
QCheckBox *m_enabled = nullptr;
QLabel *m_warningLabel = nullptr;
QPushButton *m_saveButton = nullptr;
+
+ QRadioButton *m_matchAll = nullptr;
+ QRadioButton *m_matchAny = nullptr;
+ QCheckBox *m_textMode = nullptr;
+ QWidget *m_builder = nullptr;
+ /// Scrolls the condition rows, so a rule with many of them cannot grow
+ /// the editor without bound. Shown and hidden in place of m_builder for
+ /// text mode: hiding the inner widget would leave an empty scroll area.
+ QScrollArea *m_builderScroll = nullptr;
+ /// Divides the rule list from the editor. The list had stretch 1 and was
+ /// still squeezed, because a stretch factor only shares out space above
+ /// each widget's minimum and the form's grew with every condition row.
+ QSplitter *m_splitter = nullptr;
+ QPushButton *m_previewButton = nullptr;
+ QVBoxLayout *m_rowsLayout = nullptr;
+ QVBoxLayout *m_exclusionsLayout = nullptr;
+ QLabel *m_exclusionsHeader = nullptr;
+ QPushButton *m_addExclusion = nullptr;
};