aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/specs/2026-08-13-rule-builder-design.md17
-rw-r--r--src/tagrulesdialog.cpp29
-rw-r--r--src/tagrulesdialog.h6
-rw-r--r--tests/test_tagrules.cpp44
4 files changed, 88 insertions, 8 deletions
diff --git a/docs/superpowers/specs/2026-08-13-rule-builder-design.md b/docs/superpowers/specs/2026-08-13-rule-builder-design.md
index a571506..4d6c001 100644
--- a/docs/superpowers/specs/2026-08-13-rule-builder-design.md
+++ b/docs/superpowers/specs/2026-08-13-rule-builder-design.md
@@ -275,7 +275,7 @@ The builder replaces the query line edit. Everything else in the form stays.
```
Id [ vendor-receipts ] Stage [ 50 ] [x] Applied on every sync
-Match (o) all ( ) any [ ] Edit as text
+Match (o) all ( ) any
[From v] [contains v] [vendor.example.org ] [+] [-]
[From v] [contains v] [vendor.example.net ] [+] [-]
But not
@@ -287,9 +287,22 @@ Add tags [ vendor, receipts ]
Remove tags [ ]
Note [ ... ]
-Query (from:vendor.example.org or ...) and not subject:receipt [Count matches]
+Query (from:vendor.example.org or ...) and not subject:receipt
+ [ ] Edit as text
```
+**The "Edit as text" toggle belongs to the QUERY row, not to the match row.**
+An earlier draft of this sketch put it beside the all/any radios, which is
+where it reads best and is also wrong: switching to text mode hides the
+builder, and a checkbox living inside the builder disappears with it, leaving
+no way back except closing the dialog. That shipped and a hand test found it
+within minutes. The query row is visible in both modes, so a toggle there is
+always reachable.
+
+The test for this must assert **reachability**, not the checked state. A
+hidden checkbox reports its state perfectly well, so a state assertion passes
+against the broken layout.
+
**The query line stays visible in builder mode, read-only.** It is what ships to
the hook, and watching it update as rows change is what makes the builder
trustworthy rather than a black box. In text mode the same widget becomes
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
index c81450c..75a9cbe 100644
--- a/src/tagrulesdialog.cpp
+++ b/src/tagrulesdialog.cpp
@@ -131,14 +131,9 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
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;
@@ -153,7 +148,21 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
builderLayout->addWidget(m_addExclusion, 0, Qt::AlignLeft);
form->addRow(tr("Match"), m_builder);
- form->addRow(tr("Query"), m_query);
+
+ // 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
@@ -475,6 +484,14 @@ 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,
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
index c91dc0c..9559918 100644
--- a/src/tagrulesdialog.h
+++ b/src/tagrulesdialog.h
@@ -82,6 +82,12 @@ public:
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;
+
signals:
/// Asks the owner to run countQueries() through the worker.
void countsRequested();
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
index 8207b3c..cf7dadb 100644
--- a/tests/test_tagrules.cpp
+++ b/tests/test_tagrules.cpp
@@ -48,6 +48,7 @@ private slots:
void aTextModeRuleStaysTextWhenAnotherRuleIsVisited();
void leavingTextModeIsRefusedWhenTheQueryCannotBeShownAsRows();
void aFolderRowUsesTheDropdownAndKeepsItsSuffix();
+ void theTextModeToggleSurvivesBeingSwitchedOn();
private:
QString writeRules(const QString &json);
@@ -590,5 +591,48 @@ void TestTagRules::aFolderRowUsesTheDropdownAndKeepsItsSuffix()
QStringLiteral("path:\"account-two/**\""));
}
+void TestTagRules::theTextModeToggleSurvivesBeingSwitchedOn()
+{
+ // The toggle governs the builder, so it must not live INSIDE the builder:
+ // switching to text mode hides that widget, and a checkbox parented there
+ // disappears along with the rows, leaving no way back except closing the
+ // dialog. That shipped in the first draft and a user found it by hand.
+ //
+ // Asserting on the checked state alone passes against the bug, because a
+ // hidden widget still reports its state perfectly well. The question is
+ // reachability.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ QVERIFY(dialog.textModeToggleIsReachableForTest());
+
+ dialog.setTextModeForTest(true);
+ QVERIFY2(dialog.textModeToggleIsReachableForTest(),
+ "the toggle must survive switching to text, or there is no "
+ "way back to the rows");
+
+ // And the round trip works, which is the behaviour the user wanted.
+ dialog.setTextModeForTest(false);
+ QVERIFY(!dialog.textModeForTest());
+ QVERIFY(dialog.textModeToggleIsReachableForTest());
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ QCOMPARE(dialog.queryLineForTest(),
+ QStringLiteral("from:vendor.example.org"));
+}
+
QTEST_MAIN(TestTagRules)
#include "test_tagrules.moc"