aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md5
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md34
-rw-r--r--src/tagrulesdialog.cpp76
-rw-r--r--src/tagrulesdialog.h30
-rw-r--r--tests/test_tagrules.cpp72
5 files changed, 211 insertions, 6 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbcc854..e46584a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,11 @@ point at which they are stable.
on disk, so a folder that exists but has no mail in it yet is still offered.
It stays editable, so a folder in the rules file that is no longer on disk
still opens and still saves.
+- The rule list and the rule editor are now divided by a draggable splitter,
+ and the condition rows scroll instead of growing without limit. A rule with
+ eight senders used to squeeze the list to about one visible row, since the
+ editor grew with every condition and the list gave up the space. Where you
+ leave the divider is remembered.
- The tagging rules window remembers the widths of the rule list's columns. A
column you widen also survives adding or deleting a rule, which previously
reset it. The window's own size is saved too, but a tiling window manager
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index e9f18d9..110938d 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -135,6 +135,7 @@ taking that too literally.
| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13 on `rule-builder`, unreleased. See `specs/2026-08-13-rule-builder-design.md` |
| 77 | No way to see what a rule would collect, in the thread list | workflow | S | open; the dialog counts matches, it cannot show them |
| 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it |
+| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13 on `rule-builder`, unreleased. Follows item 76 |
| 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -4975,6 +4976,39 @@ in a released build.
**Size: XS** for the fix. The reproduction and the field repair were the work.
+## 80. A rule with many conditions squeezes the rule list to one visible row
+
+**Observed.** A rule with eight From conditions left the rule list showing
+about one and a half rows, with the second rule half cut off under the first.
+Reported with a screenshot; the builder filled the window and the list it sits
+under kept almost nothing.
+
+**Cause.** `m_list` was added to the dialog's `QVBoxLayout` with stretch 1
+(`src/tagrulesdialog.cpp:109`) and the form below it with none, which looks
+like the list wins. It does not: a stretch factor only distributes space ABOVE
+each widget's minimum, and the form's minimum grows with every condition row,
+so each row came directly out of the list. Measured on the builder's size hint:
+120px with one row, 414px with eight.
+
+**Approach.** Done 2026-08-13. A `QSplitter` divides the list from the editor,
+so the balance is the user's and is saved to `uistate.conf` beside the column
+widths, and the condition rows sit in a `QScrollArea` capped at 190px so the
+editor cannot grow without bound whatever the splitter is set to. The scroll
+area rather than the builder is what text mode hides, since hiding the inner
+widget would leave an empty frame.
+
+**Constraints.** Three measures were tried before one distinguished the bug
+from the fix, and two passed against broken code: the dialog's
+`minimumSizeHint` does not track form rows and read 580 either way, and a
+`qMin` against the scroll area's own size hint read small whether or not the
+cap was set, because an uncapped `maximumHeight` is `QWIDGETSIZE_MAX`. The
+assertions that survive mutation are the editor pane's minimum inside the
+splitter, and the cap read directly. A row's size hint is also invalid until
+the event loop has run, so the test needs `processEvents` after selecting a
+rule or it measures one row's height twice.
+
+**Size: XS.**
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
index 54600c3..285043a 100644
--- a/src/tagrulesdialog.cpp
+++ b/src/tagrulesdialog.cpp
@@ -33,8 +33,10 @@
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
+#include <QScrollArea>
#include <QSettings>
#include <QSpinBox>
+#include <QSplitter>
#include <QTreeWidget>
#include <QVBoxLayout>
@@ -106,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);
@@ -155,7 +176,19 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
m_addExclusion = new QPushButton(tr("Add e&xclusion"), m_builder);
builderLayout->addWidget(m_addExclusion, 0, Qt::AlignLeft);
- form->addRow(tr("Match"), m_builder);
+ // 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
@@ -187,7 +220,7 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
});
form->addRow(tr("Note"), m_note);
- layout->addLayout(form);
+ editorLayout->addLayout(form);
auto *buttons = new QHBoxLayout;
auto *addButton = new QPushButton(tr("&New"), this);
@@ -273,6 +306,11 @@ void TagRulesDialog::restoreUiState()
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()) {
@@ -294,6 +332,8 @@ void TagRulesDialog::saveUiState()
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.
@@ -314,6 +354,30 @@ void TagRulesDialog::done(int result)
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();
+}
+
+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);
@@ -434,7 +498,7 @@ void TagRulesDialog::onSelectionChanged()
const QSignalBlocker blockTextMode(m_textMode);
m_textMode->setChecked(!m_loadedQuery.parsed);
}
- m_builder->setVisible(m_loadedQuery.parsed);
+ m_builderScroll->setVisible(m_loadedQuery.parsed);
m_query->setReadOnly(m_loadedQuery.parsed);
if (m_loadedQuery.parsed)
@@ -818,7 +882,7 @@ void TagRulesDialog::setTextMode(bool on)
// Show what the rows currently mean, then hand the string over.
if (m_loadedQuery.parsed)
m_query->setText(currentQueryFromRows().compile());
- m_builder->setVisible(false);
+ m_builderScroll->setVisible(false);
m_query->setReadOnly(false);
return;
}
@@ -847,7 +911,7 @@ void TagRulesDialog::setTextMode(bool on)
m_reloading = wasReloading;
m_loadedQuery = parsed;
- m_builder->setVisible(true);
+ m_builderScroll->setVisible(true);
m_query->setReadOnly(true);
// The refusal above writes into the same label the load warnings use, so
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
index bdcaff9..de35a55 100644
--- a/src/tagrulesdialog.h
+++ b/src/tagrulesdialog.h
@@ -31,6 +31,8 @@ class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QRadioButton;
+class QScrollArea;
+class QSplitter;
class QSpinBox;
class QTreeWidget;
class QTreeWidgetItem;
@@ -107,6 +109,26 @@ public:
/// the width the user had dragged.
void reloadListForTest();
+ /// 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();
@@ -211,6 +233,14 @@ private:
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;
QVBoxLayout *m_rowsLayout = nullptr;
QVBoxLayout *m_exclusionsLayout = nullptr;
QLabel *m_exclusionsHeader = nullptr;
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
index 8ca7671..268c41f 100644
--- a/tests/test_tagrules.cpp
+++ b/tests/test_tagrules.cpp
@@ -54,6 +54,7 @@ private slots:
void theWindowSizeAndColumnWidthsSurviveAReopen();
void theWindowSizeIsSavedOnEveryWayOutOfTheDialog();
void aReloadDoesNotDiscardARestoredColumnWidth();
+ void manyConditionRowsDoNotSqueezeTheRuleList();
private:
QString writeRules(const QString &json);
@@ -321,6 +322,11 @@ void TestTagRules::switchingRulesDoesNotLeakRowsBetweenThem()
// And back, to prove the first rule was not overwritten by loading the
// second.
dialog.selectRuleForTest(0);
+ // The row widgets are created during the select and their size hints are
+ // not valid until the layout has run, which needs the event loop: without
+ // this the builder reports the same height for one row and for eight, and
+ // the test passes against the bug.
+ QCoreApplication::processEvents();
QCOMPARE(dialog.rowCountForTest(), 1);
QCOMPARE(dialog.queryLineForTest(), QStringLiteral("from:one.example.org"));
}
@@ -795,5 +801,71 @@ void TestTagRules::aReloadDoesNotDiscardARestoredColumnWidth()
qputenv("XDG_STATE_HOME", previousState);
}
+void TestTagRules::manyConditionRowsDoNotSqueezeTheRuleList()
+{
+ // A rule with eight senders left the rule list showing about one and a
+ // half rows: the list had stretch 1, but a stretch factor only shares out
+ // space ABOVE each widget's minimum, and the form below it has no ceiling,
+ // so every condition row added to the minimum the list had to give up.
+ //
+ // Measured as the height the layout demands below the list. A rule with
+ // many rows must not demand materially more than a rule with one; what it
+ // needs beyond that belongs in the builder's own scroll area.
+ 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": "one", "query": "from:a.example.org",
+ "add": ["x"], "stage": 50, "enabled": true},
+ {"id": "many", "query":
+ "from:a.example.org or from:b.example.org or from:c.example.org or from:d.example.org or from:e.example.org or from:f.example.org or from:g.example.org or from:h.example.org",
+ "add": ["y"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ dialog.show();
+
+ dialog.selectRuleForTest(0);
+ // The row widgets are created during the select and their size hints are
+ // not valid until the layout has run, which needs the event loop: without
+ // this the builder reports the same height for one row and for eight, and
+ // the test passes against the bug.
+ QCoreApplication::processEvents();
+ QCOMPARE(dialog.rowCountForTest(), 1);
+ const int withOneRow = dialog.heightDemandedBelowListForTest();
+
+ dialog.selectRuleForTest(1);
+ QCoreApplication::processEvents();
+ // Guard: the fixture must actually produce the many-row case, or this
+ // test passes by measuring the same rule twice.
+ QCOMPARE(dialog.rowCountForTest(), 8);
+ const int withEightRows = dialog.heightDemandedBelowListForTest();
+
+ // Seven extra rows at roughly 30px each would be over 200px of growth.
+ // A small increase is fine (the scroll area still has a minimum), a
+ // proportional one is the bug.
+ QVERIFY2(withEightRows - withOneRow < 100,
+ qPrintable(QStringLiteral("one row demands %1, eight demand %2")
+ .arg(withOneRow).arg(withEightRows)));
+
+ // And the rows are CAPPED, not merely allowed to grow inside a scroll
+ // area that has no ceiling. Asserted separately because removing the cap
+ // leaves the assertion above green: the editor's minimum stays flat
+ // either way, so only the visible height of the row area distinguishes
+ // them. Without a cap a thirty-sender rule fills the window again, this
+ // time scrolling instead of squeezing.
+ QVERIFY2(dialog.conditionAreaHeightForTest() <= 200,
+ qPrintable(QStringLiteral("condition area is %1px tall")
+ .arg(dialog.conditionAreaHeightForTest())));
+}
+
QTEST_MAIN(TestTagRules)
#include "test_tagrules.moc"