diff options
Diffstat (limited to 'src/tagrulesdialog.cpp')
| -rw-r--r-- | src/tagrulesdialog.cpp | 689 |
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(); +} |
