summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 11:20:40 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 11:20:40 +0200
commit88d19a61227150dc58f7e6915a4c401001545d7e (patch)
tree1a093f504f45c633cb6ea5a0c850245d6671f6b2 /src
parentd8bb09415630c219271a01478393d43c1a2f28f0 (diff)
downloadqtmaildir-88d19a61227150dc58f7e6915a4c401001545d7e.tar.gz
qtmaildir-88d19a61227150dc58f7e6915a4c401001545d7e.zip
feat(rules): add the builder row widgets
Diffstat (limited to 'src')
-rw-r--r--src/tagrulesdialog.cpp216
-rw-r--r--src/tagrulesdialog.h31
2 files changed, 247 insertions, 0 deletions
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
index 40ee66a..f7db936 100644
--- a/src/tagrulesdialog.cpp
+++ b/src/tagrulesdialog.cpp
@@ -18,7 +18,9 @@
#include "tagrulesdialog.h"
+#include <QButtonGroup>
#include <QCheckBox>
+#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
@@ -28,6 +30,7 @@
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
+#include <QRadioButton>
#include <QSpinBox>
#include <QTreeWidget>
#include <QVBoxLayout>
@@ -50,6 +53,19 @@ 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)
@@ -104,7 +120,53 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
form->addRow(QString(), m_enabled);
form->addRow(tr("Add tags"), m_add);
form->addRow(tr("Remove tags"), m_remove);
+ 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);
+ m_textMode = new QCheckBox(tr("Edit as &text"), m_builder);
+ m_textMode->setToolTip(
+ tr("Edit the notmuch query directly. A rule too complex to show as "
+ "rows opens this way."));
+ matchRow->addWidget(m_matchAll);
+ matchRow->addWidget(m_matchAny);
+ matchRow->addStretch();
+ matchRow->addWidget(m_textMode);
+ 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);
+
+ form->addRow(tr("Match"), m_builder);
form->addRow(tr("Query"), m_query);
+
+ // 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_addExclusion, &QPushButton::clicked, this, [this] {
+ addRow(true);
+ syncQueryLine();
+ });
+
form->addRow(tr("Note"), m_note);
layout->addLayout(form);
@@ -166,6 +228,9 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
connect(m_note, &QPlainTextEdit::textChanged,
this, &TagRulesDialog::applyEditsToCurrentRule);
+ addRow(false);
+ updateExclusionsVisibility();
+
reloadList();
showWarnings();
}
@@ -339,3 +404,154 @@ void TagRulesDialog::onSave()
}
accept();
}
+
+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);
+
+ 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(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);
+
+ connect(row.field, &QComboBox::currentIndexChanged, this,
+ [this, exclusion](int) {
+ populateOperators(exclusion);
+ syncQueryLine();
+ });
+ connect(row.op, &QComboBox::currentIndexChanged,
+ this, [this](int) { syncQueryLine(); });
+ connect(row.value, &QLineEdit::textEdited,
+ 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) {
+ rows[0].value->clear();
+ 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);
+ }
+}
+
+void TagRulesDialog::syncQueryLine()
+{
+ RuleQuery query;
+ query.parsed = true;
+ query.join = m_matchAny->isChecked() ? RuleQuery::Any : RuleQuery::All;
+
+ for (const Row &row : m_rows) {
+ const QString value = row.value->text().trimmed();
+ 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 = row.value->text().trimmed();
+ if (value.isEmpty())
+ continue;
+ query.exclusions.append(
+ {RuleTerm::Field(row.field->currentData().toInt()),
+ RuleTerm::Op(row.op->currentData().toInt()),
+ value});
+ }
+
+ m_query->setText(query.compile());
+}
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
index 0350bd5..b60b095 100644
--- a/src/tagrulesdialog.h
+++ b/src/tagrulesdialog.h
@@ -20,16 +20,20 @@
#include <QDialog>
+#include "rulequery.h"
#include "tagrules.h"
class QCheckBox;
+class QComboBox;
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
+class QRadioButton;
class QSpinBox;
class QTreeWidget;
class QTreeWidgetItem;
+class QVBoxLayout;
/// Views and edits the shared auto-tagging rules.
///
@@ -76,6 +80,24 @@ 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;
+ };
+
+ Row *addRow(bool exclusion);
+ void removeRow(bool exclusion, int index);
+ void populateOperators(bool exclusion);
+ void updateExclusionsVisibility();
+ void syncQueryLine();
+
+ QList<Row> m_rows;
+ QList<Row> m_exclusionRows;
+
TagRules m_rules;
QList<TagRule> m_working; ///< Edited copy; written only on Save.
@@ -95,4 +117,13 @@ 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;
+ QVBoxLayout *m_rowsLayout = nullptr;
+ QVBoxLayout *m_exclusionsLayout = nullptr;
+ QLabel *m_exclusionsHeader = nullptr;
+ QPushButton *m_addExclusion = nullptr;
};