diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-13 17:07:16 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-13 17:07:16 +0200 |
| commit | 4d84062e48ec35598b7c2ecdf8446939be6064c4 (patch) | |
| tree | ab280755094321666d29d50694c48722809d953c /src/tagrulesdialog.h | |
| parent | df6461fa9136f2110c8a24567a3e4a32432a9476 (diff) | |
| parent | b8de7ce746718c7193e40b0f70cd3e2178f4ed8a (diff) | |
| download | qtmaildir-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.h')
| -rw-r--r-- | src/tagrulesdialog.h | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h index 0350bd5..e8979fe 100644 --- a/src/tagrulesdialog.h +++ b/src/tagrulesdialog.h @@ -19,17 +19,24 @@ #pragma once #include <QDialog> +#include <QStringList> +#include "rulequery.h" #include "tagrules.h" class QCheckBox; +class QComboBox; class QLabel; class QLineEdit; class QPlainTextEdit; class QPushButton; +class QRadioButton; +class QScrollArea; +class QSplitter; class QSpinBox; class QTreeWidget; class QTreeWidgetItem; +class QVBoxLayout; /// Views and edits the shared auto-tagging rules. /// @@ -51,10 +58,94 @@ public: /// handle and notmuch permits one per process. QStringList countQueries() const; + /// Maildir folder paths, for the Folder row's dropdown, relative to the + /// database root. Supplied by the caller rather than read here: they come + /// from a scan of the tree under notmuch's database.path, which only + /// NotmuchWorker can answer for, and this dialog reaches neither it nor + /// Config. + /// + /// Arrives AFTER the dialog is on screen, since the scan crosses to the + /// worker on a queued call, so this refills the rows that already exist. + void setFolders(const QStringList &folders); + + /// Test seams. The builder's state is otherwise reachable only through + /// synthetic clicks on widgets whose geometry the offscreen platform does + /// not guarantee. + int rowCountForTest() const { return m_rows.size(); } + QString queryLineForTest() const; + + /// Selects the rule at `index` as a click on the list would. + void selectRuleForTest(int index); + + bool textModeForTest() const; + void setRowValueForTest(int index, const QString &value); + /// Runs the Save path without showing the dialog. + void saveForTest() { onSave(); } + + /// Types into the query field and toggles the mode, so the refusal path + /// can be reached without a synthetic click. The refusal reports through + /// the warning label rather than a modal precisely so this is testable. + void setQueryTextForTest(const QString &text); + 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; + + /// Width of one rule-list column. The geometry restore is asserted on the + /// saved and reread VALUE rather than on the resulting frame: item 46 + /// records that the offscreen platform does not honour a window resize, + /// so a test comparing frames there passes or fails for reasons that have + /// nothing to do with the code. + int columnWidthForTest(int column) const; + void setColumnWidthForTest(int column, int width); + + /// Repopulates the rule list, as adding or deleting a rule does. Exposed + /// because a restored column width has to survive one of these, not only + /// a close and reopen: `resizeColumnToContents` on every reload discarded + /// the width the user had dragged. + void reloadListForTest(); + + /// Presses Preview, which is otherwise reachable only through a click on + /// a button whose geometry the offscreen platform does not guarantee. + void previewForTest(); + + /// 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(); + /// Asks the owner to run one rule's query in the main window, so the user + /// can see WHICH mail a rule collects rather than how much. + /// + /// The query goes out exactly as stored: no `tag:new`, and no wrapping + /// parentheses. The post-new hook adds both when it applies a rule, and a + /// preview that copied them would match nothing outside a sync window, + /// since `tag:new` is set only on mail that has just arrived. + void previewRequested(const QString &query); + public slots: /// Corpus counts, positionally paired with countQueries(). void setCounts(const QVector<int> &counts); @@ -67,8 +158,16 @@ private slots: void applyEditsToCurrentRule(); void onSave(); +public slots: + /// Saves the window's size and column widths on the way out. Overridden + /// here rather than closeEvent because Save and Cancel do not send a + /// close event at all, only the window manager's X button does. + void done(int result) override; + private: void reloadList(); + void restoreUiState(); + void saveUiState(); void showWarnings(); int currentIndex() const; @@ -76,6 +175,44 @@ 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; + /// Shown in place of `value` for a Folder row. Never visible together. + QComboBox *folder = nullptr; + }; + + /// Reads a row's value from whichever of its two widgets the field selects, + /// so the read and write paths cannot disagree about where it lives. + QString rowValue(const Row &row) const; + void setRowValue(Row *row, const QString &value); + + Row *addRow(bool exclusion); + void removeRow(bool exclusion, int index); + void populateOperators(bool exclusion); + void updateExclusionsVisibility(); + void syncQueryLine(); + + void setTextMode(bool on); + + void rebuildRows(const RuleQuery &query); + void applyTermToRow(Row *row, const RuleTerm &term); + RuleQuery currentQueryFromRows() const; + + /// The query as parsed when the current rule was loaded. Task 10's save + /// path compares against this to decide whether the stored string may be + /// left alone. + RuleQuery m_loadedQuery; + + QStringList m_folders; + + QList<Row> m_rows; + QList<Row> m_exclusionRows; + TagRules m_rules; QList<TagRule> m_working; ///< Edited copy; written only on Save. @@ -85,6 +222,15 @@ private: /// row's text, which applyEditsToCurrentRule() has no chance to flush. bool m_reloading = false; + /// Each column is auto-sized to its contents ONCE, on its first fill. + /// After that its width belongs to the user, whether it came from a + /// restored header or from a drag, and resizeColumnToContents on every + /// repopulate threw both away on the next add or delete. Two flags rather + /// than one because the count column is filled later than the rest, by a + /// reply from the worker. + bool m_columnsSized = false; + bool m_countColumnSized = false; + QTreeWidget *m_list = nullptr; QLineEdit *m_id = nullptr; QLineEdit *m_add = nullptr; @@ -95,4 +241,22 @@ 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; + /// 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; + QPushButton *m_previewButton = nullptr; + QVBoxLayout *m_rowsLayout = nullptr; + QVBoxLayout *m_exclusionsLayout = nullptr; + QLabel *m_exclusionsHeader = nullptr; + QPushButton *m_addExclusion = nullptr; }; |
