aboutsummaryrefslogtreecommitdiffstats
path: root/src/tagrulesdialog.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 17:07:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 17:07:16 +0200
commit4d84062e48ec35598b7c2ecdf8446939be6064c4 (patch)
treeab280755094321666d29d50694c48722809d953c /src/tagrulesdialog.cpp
parentdf6461fa9136f2110c8a24567a3e4a32432a9476 (diff)
parentb8de7ce746718c7193e40b0f70cd3e2178f4ed8a (diff)
downloadqtmaildir-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/tagrulesdialog.cpp')
-rw-r--r--src/tagrulesdialog.cpp689
1 files changed, 676 insertions, 13 deletions
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();
+}