diff options
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; }; |
