diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-13 17:07:16 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-13 17:07:16 +0200 |
| commit | 4d84062e48ec35598b7c2ecdf8446939be6064c4 (patch) | |
| tree | ab280755094321666d29d50694c48722809d953c /src | |
| parent | df6461fa9136f2110c8a24567a3e4a32432a9476 (diff) | |
| parent | b8de7ce746718c7193e40b0f70cd3e2178f4ed8a (diff) | |
| download | qtmaildir-4d84062e48ec35598b7c2ecdf8446939be6064c4.tar.gz qtmaildir-4d84062e48ec35598b7c2ecdf8446939be6064c4.zip | |
Merge branch 'rule-builder': a row builder for the tagging rules
Item 76 replaces the four free-text fields with a row builder: field and
operator dropdowns per condition, +/- to add and remove them, a match
all/any choice, and a separate "but not" block. The stored format does
not change, so mailctl needs no edit. The query string stays
authoritative and remains visible, and a rule the builder cannot
represent opens in a text mode every rule carries.
Along the way, items 75, 77 and 80, and a data-loss defect released in
0.16.0 (item 79): opening the dialog and pressing Save destroyed the
first rule with nothing edited. That one damaged a real rule in the
user's own file, which was repaired by hand.
Four defects in this work were found by hand rather than by the suite,
and each is recorded where it was missed: a lost note, a one-way text
mode toggle, a geometry save on a path neither button takes, and a rule
list squeezed to one row by a long rule. Two Qt traps and one about the
user's compositor went into CLAUDE.md.
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 61 | ||||
| -rw-r--r-- | src/mainwindow.h | 26 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 37 | ||||
| -rw-r--r-- | src/notmuchworker.h | 17 | ||||
| -rw-r--r-- | src/rulequery.cpp | 414 | ||||
| -rw-r--r-- | src/rulequery.h | 65 | ||||
| -rw-r--r-- | src/tagrulesdialog.cpp | 689 | ||||
| -rw-r--r-- | src/tagrulesdialog.h | 164 |
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; }; |
