/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #pragma once #include #include #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. /// /// Does NOT apply rules to existing mail. The counts are shown so a rule can be /// judged before it runs, but the only thing that tags mail is the notmuch /// post-new hook. Backfill is deliberately out of this version: a rule that is /// safe against arrivals is not automatically safe unscoped, and the /// confirmation story for a bulk write does not exist yet. class TagRulesDialog : public QDialog { Q_OBJECT public: explicit TagRulesDialog(QWidget *parent = nullptr); /// Opens with one new rule already in the working list, selected, and the /// Add tags field focused. The rule is a pending edit like any other: it /// reaches the file on Save and is discarded on Cancel. /// /// The seed is a whole TagRule rather than a query string because item 78 /// will seed from a sender and will want to set tags too. explicit TagRulesDialog(const TagRule &seed, QWidget *parent = nullptr); /// Appends `seed` to the working rules, selects it and focuses Add tags. /// Public because the dialog is single-instance and non-modal: a second /// Create tagging rule while it is open seeds the dialog already up /// rather than being dropped. void seedRule(const TagRule &seed); /// The queries whose message counts the dialog wants, in the order its /// rows appear. MainWindow hands these to the worker; the dialog never /// touches the database itself, because NotmuchWorker owns the only /// 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; /// The warning's appearance and place, asserted as widget properties. A /// render probe cannot carry this: see "Rendering probes lie" in CLAUDE.md. QString warningStyleForTest() const; Qt::TextFormat warningTextFormatForTest() const; bool warningIsBelowTheRuleListForTest() const; /// Clicks the warning's dismiss button, so the test drives the same signal /// the user's click does rather than calling setWarning() behind it. void dismissWarningForTest(); /// Types a name and commits it the way leaving the field does. The commit /// is the point: the sanitiser runs on editingFinished, so a test that /// only calls setText() asserts against a field nothing has processed. void setNameForTest(const QString &name); QString nameLineForTest() const; /// Adding and filling a rule the way the buttons do, so a test can drive /// the whole journey the user takes rather than only its last step. int ruleCountForTest() const; /// The selected rule's enabled flag, so the seeded default is asserted /// rather than assumed from TagRule's initialiser. bool currentRuleEnabledForTest() const; void addRuleForTest() { onAddRule(); } void setTagsForTest(const QString &tags); /// 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 &counts); private slots: void onSelectionChanged(); void onAddRule(); void onCopyRule(); void onDeleteRule(); 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(); /// The only way the warning label is written. An empty string hides it. /// One route in so the icon and the red styling cannot drift between the /// load path, the save refusal and the text-mode notice. void setWarning(const QString &text); int currentIndex() const; /// Writes one rule's summary onto its row. Shared by reloadList() and /// 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 m_rows; QList m_exclusionRows; TagRules m_rules; QList m_working; ///< Edited copy; written only on Save. /// True while reloadList() is repopulating the tree. The row it selects /// afterwards emits currentItemChanged, and onSelectionChanged() would /// then load a rule into the form while the form still holds the previous /// 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; QWidget *m_warningBanner = nullptr; QPushButton *m_warningClose = nullptr; QLineEdit *m_id = nullptr; QLineEdit *m_add = nullptr; QLineEdit *m_remove = nullptr; QLineEdit *m_query = nullptr; QPlainTextEdit *m_note = nullptr; QSpinBox *m_stage = nullptr; 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; };