diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-13 19:19:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-13 19:19:58 +0200 |
| commit | 0c5eea8f0d0ccc5b8eb6220814c9e212d6c1ccc2 (patch) | |
| tree | 4275734ed0c31f46609f9fd5b78412209dbeec1c | |
| parent | 5e30d1805656895387ba83865d9635caf2e51618 (diff) | |
| download | qtmaildir-0c5eea8f0d0ccc5b8eb6220814c9e212d6c1ccc2.tar.gz qtmaildir-0c5eea8f0d0ccc5b8eb6220814c9e212d6c1ccc2.zip | |
feat(queries): save a query from the UI, and split the buttons off the query row
Second half of item 23, on top of the storage change. A query can now be kept
without hand-editing a file, and the row of buttons no longer grows without
bound.
Ctrl+S opens a dialog on whatever is in the query bar, taking a name, an
optional account scope and whether the query is pinned. It preselects the
account already chosen in the dropdown, since that is the scope the user is
looking at, and it says so when a name is about to replace an existing query
rather than refusing the name: overwriting a saved query on purpose is a normal
edit, and the only thing worth preventing is doing it without noticing. Saving
over an entry keeps the stored entry's unknown fields rather than the dialog's
fresh value, so a field written by a later build survives being edited here.
The saved queries move to a row of their own beneath the query bar, pinned ones
as buttons and the rest behind a More queries menu that only exists when
something is in it. The ponytail note that stood in the query row predicted
exactly this: an unbounded list of buttons sharing the row squeezed the field.
Sent moves down with them and is still not a saved query, for the reason already
recorded there.
A saved query's account scope goes through the account DROPDOWN rather than
being baked into the query text. runQuery() already wraps the query in the
selected account's path, so pre-scoping here would apply it twice, and setting
the dropdown also shows the user which scope they are in. An unscoped query
clears the selection rather than inheriting whatever the last one left, which is
the same defect the rules preview had.
Seven tests, three mutations. Ignoring the pinned flag fails two of them,
pre-scoping the text instead of setting the dropdown fails two, and letting an
unscoped query inherit the previous account fails one. The menu-absence test
initially passed against no implementation at all, since it only asserted a
widget was missing; it now proves the row was populated first, which is the
guard that class of test needs.
Two existing invariants caught real omissions rather than needing adjustment:
every registered action must appear in KeyMap::knownActions(), which is what
gives it a configurable binding, and every action needs its own icon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | CHANGELOG.md | 37 | ||||
| -rw-r--r-- | README.md | 58 | ||||
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/keymap.cpp | 5 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 219 | ||||
| -rw-r--r-- | src/mainwindow.h | 21 | ||||
| -rw-r--r-- | src/savequerydialog.cpp | 128 | ||||
| -rw-r--r-- | src/savequerydialog.h | 63 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 275 |
9 files changed, 755 insertions, 52 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index e867f02..2d264e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,43 @@ point at which they are stable. ## [Unreleased] +### Added + +- A **Save query** action (`Ctrl+S`) keeps the query in the bar as a saved + query, naming it, optionally scoping it to one account, and choosing whether + it appears as a button or in a menu. Saved queries no longer have to be added + by hand-editing the config file (item 23). +- Saved queries live in `~/.config/qtmaildir/queries.json`, which carries their + **order**, a `pinned` flag and an optional account scope. The order in the + file is the order the buttons appear in, so rearranging them is a matter of + moving lines. + +### Changed + +- Saved queries have a **row of their own** beneath the query bar rather than + sharing it, and the unpinned ones sit behind a **More queries** menu, so the + query field is no longer squeezed by a long list of buttons. +- Saved-query buttons follow the file's order instead of appearing + alphabetically. + +### Upgrading + +Saved queries move out of the `[queries]` section of `qtmaildir.conf` and into +`~/.config/qtmaildir/queries.json`. **The first launch migrates them for you**: +the section is read, the JSON file is written from it, and every entry is +marked pinned so your buttons stay where they were. + +Your config file is left byte-for-byte alone. The old `[queries]` section stays +in it, ignored from then on, and can be deleted by hand whenever you like. It is +not removed automatically because rewriting the file would discard your comments +and reorder your keys. + +One behaviour changes with the move. Buttons used to appear in alphabetical +order and now follow the file. If `[general] startup_query` names a query that +does not exist, the fallback is likewise the first query in the file rather than +the alphabetically first one, so a config that relied on that fallback may open +on a different query than before. + ## [0.17.0] - 2026-08-13 A rule is built from rows now instead of typed into four free-text fields: @@ -106,7 +106,7 @@ identity. ; point: once you zoom with Ctrl+wheel or Ctrl+/Ctrl-, that is remembered ; separately and this value no longer applies. ; message_zoom = 1.0 -; Optional. Which [queries] entry to open at startup, by name. Defaults to +; Optional. Which saved query to open at startup, by name. Defaults to ; Unread. Falls back to the first saved query if no query by this name ; exists, and warns if you named one explicitly. ; startup_query = Unread @@ -191,11 +191,6 @@ shopping = #3366cc ; also colours shopping/amazon, shopping/nike, ... shopping/amazon = #ff9900 ; ... unless the exact tag overrides it work = #cc4444 -[queries] -Inbox = tag:inbox -Unread = tag:unread -Important = tag:flagged - [keys] Ctrl+E = archive Ctrl+D = delete @@ -203,14 +198,47 @@ j = next_thread k = prev_thread ``` -Saved-query buttons appear in alphabetical order rather than file order: -QSettings returns keys sorted, and preserving file order would mean -hand-rolling an INI parser. Which query opens at startup is therefore a -separate setting, `[general] startup_query`, rather than "the first one". +### Saved queries + +Saved queries live in `~/.config/qtmaildir/queries.json`, not in the config +file. They are written by the **Save query** action (`Ctrl+S`), which names the +query in the bar, optionally scopes it to one account, and chooses whether it +gets a button: + +```json +{ + "version": 1, + "queries": [ + { "name": "Inbox", "query": "tag:inbox", "pinned": true }, + { "name": "Unread", "query": "tag:unread", "pinned": true }, + { "name": "Billing", "query": "from:billing", "account": "work" } + ] +} +``` -The button text is the key you write here, so these names are yours to -choose. `Important = tag:flagged` and `Flagged = tag:flagged` run the same -query and differ only in what the button says. +The order in the file is the order the buttons appear in, so rearranging them +is a matter of moving lines. `pinned` decides between a button and the **More +queries** menu, which keeps the row usable once you have more than a handful. +`account` names an `[account.<key>]` section and scopes the query to it, the +same as choosing that account in the dropdown; leave it out for a query that +spans every account. + +The name is what the button says, so `Important` and `Flagged` can run the same +query and differ only in the label. + +**Upgrading from 0.17.0 or earlier.** Saved queries used to live in a +`[queries]` section of `qtmaildir.conf`. The first launch after upgrading reads +that section, writes `queries.json` from it, and marks every entry pinned so +your buttons stay where they were. Your config file is not modified: the old +`[queries]` section is left exactly as it is, ignored from then on, and you can +delete it by hand whenever you like. The reason it is not removed for you is +that rewriting the file would drop your comments and reorder your keys. + +One behaviour changes with the move. Buttons used to appear in alphabetical +order, because the INI backend returns keys sorted and preserving file order +would have meant hand-rolling a parser. They now follow the file. If +`startup_query` names a query that does not exist, the fallback is likewise the +first query in the file rather than the alphabetically first one. ### Sent mail @@ -253,7 +281,8 @@ behaves like any other query, threads and all. ## The query bar The bar at the top takes a notmuch query and shows the matching threads. -Saved queries from `[queries]` sit beside it as buttons. +Saved queries sit on their own row beneath it: the pinned ones as buttons, the +rest behind **More queries**. `Ctrl+S` keeps the current query as a new one. Completion helps with the syntax rather than replacing it. `Ctrl+Space` opens the popup, and ordinary typing keeps it up to date. Candidates carry a @@ -478,6 +507,7 @@ Defaults, all rebindable through `[keys]`: | `Ctrl+I` | `flag` | Mark important (adds `flagged`) | | `Ctrl+L` | `focus_query` | Focus and select the query bar | | `Ctrl+Space` | `complete_query` | Focus the query bar and offer completions | +| `Ctrl+S` | `save_query` | Keep the current query as a saved query | | `Ctrl+H` | `toggle_html` | Switch the thread between HTML and plain text | | `Ctrl+M` | `load_remote` | Load remote images for the current thread | | `Ctrl+T` | `edit_tags` | Add or remove any tag on the selected threads | diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6ae157b..0945f65 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(qtmaildir_lib STATIC notmuchworker.cpp tagchip.cpp tagcolors.cpp + savequerydialog.cpp tagdialog.cpp tagrules.cpp tagrulesdialog.cpp diff --git a/src/keymap.cpp b/src/keymap.cpp index 71c5355..52b015f 100644 --- a/src/keymap.cpp +++ b/src/keymap.cpp @@ -39,6 +39,7 @@ QStringList KeyMap::knownActions() QStringLiteral("flag"), QStringLiteral("focus_query"), QStringLiteral("complete_query"), + QStringLiteral("save_query"), QStringLiteral("select_all"), QStringLiteral("clear_pane"), QStringLiteral("clear_selection"), @@ -100,6 +101,10 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings() // shells and editors, and it is a named key rather than a symbol, so // no layout has to shift it. { QStringLiteral("Ctrl+Space"), QStringLiteral("complete_query") }, + // The conventional save key, and free here: nothing in this window + // saves a document, so Ctrl+S is unclaimed and means what a user + // expects it to. + { QStringLiteral("Ctrl+S"), QStringLiteral("save_query") }, // The conventional select-all key, and free here: the thread list is a // read-only view, so nothing else in the window wants it. { QStringLiteral("Ctrl+A"), QStringLiteral("select_all") }, diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e2df6de..3966e9f 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -56,6 +56,7 @@ #include "cardlayout.h" #include "tagchip.h" #include "tagdialog.h" +#include "savequerydialog.h" #include "tagrulesdialog.h" #include "threadlistmodel.h" #include "threadlistview.h" @@ -339,6 +340,21 @@ MainWindow::MainWindow(const Config &config, QWidget *parent) buildUi(); registerActions(); + + // After registerActions(), not inside buildUi(): the query bar exists by + // then but the action does not, so wiring this where the field is built + // silently connected nothing and left Save query enabled on an empty + // query. Hung on textChanged rather than textEdited, because the field is + // also set programmatically, by the saved-query buttons and by + // recoverStaleThread(), and the action must track those too. + if (QAction *save = m_actions.value(QStringLiteral("save_query"))) { + auto updateSaveState = [this, save]() { + save->setEnabled(!m_queryEdit->text().trimmed().isEmpty()); + }; + connect(m_queryEdit, &QLineEdit::textChanged, this, updateSaveState); + updateSaveState(); + } + buildMenus(); // After buildMenus(): QMainWindow::restoreState() matches toolbars by // object name, so they must already exist or their position is dropped. @@ -543,49 +559,17 @@ void MainWindow::buildUi() this, &MainWindow::onExternalSyncStateChanged); m_syncMonitor->start(); - // One row: the account dropdown, the query field, then the saved queries. - // The field is the only stretching item, so it is framed on both sides - // rather than running flush to the window edge, which is what the removed - // Sync button used to terminate. - // - // ponytail: no overflow handling. [queries] is unbounded and enough entries - // would squeeze the field, but three is the real-world case today. Item 23 - // already specifies buttons-plus-menu and is where that belongs. + // The query row proper: account, sort order, the field. The saved queries + // used to share it and now have a row of their own below, which is what + // stops an unbounded list squeezing the field (item 23; the ponytail note + // that stood here predicted exactly this). queryRow->addWidget(m_accountBox); queryRow->addWidget(m_sortOrder); queryRow->addWidget(m_queryEdit, 1); - for (const SavedQuery &saved : m_config.savedQueries()) { - auto *button = new QPushButton(saved.name, central); - connect(button, &QPushButton::clicked, this, [this, saved]() { - m_queryEdit->setText(saved.query); - runCurrentQuery(); - }); - queryRow->addWidget(button); - } - - // Sent sits with the saved queries and is not one: its query is COMPOSED - // from the accounts' `sent` keys at click time, so adding an account or - // correcting a folder name is a config edit and nothing else. A [queries] - // entry holding the same string would go stale silently, and could not - // narrow to the selected account the way this does through - // runCurrentQuery()'s existing scope wrap. - // - // Hidden entirely when no account configures a sent folder, rather than - // offering a button that always finds nothing. - if (!m_config.allSentQuery().isEmpty()) { - auto *sentButton = new QPushButton(tr("Sent"), central); - sentButton->setObjectName(QStringLiteral("sentButton")); - connect(sentButton, &QPushButton::clicked, this, [this]() { - m_queryEdit->setText(m_config.allSentQuery()); - // Flat for this query only. runCurrentQuery() clears it again for - // anything else, including the same query typed by hand, so the - // flag cannot outlive the button that set it. - runQuery(FlatResult::Yes); - }); - queryRow->addWidget(sentButton); - } layout->addLayout(queryRow); + buildSavedQueryRow(central, layout); + // Thread list and message pane. m_model = new ThreadListModel(this); m_model->setTagColors(&m_tagColors); @@ -836,6 +820,10 @@ void MainWindow::registerActions() tr("Edit the rules that tag mail as it arrives"), [this]() { showTagRulesDialog(); }); + addAction(QStringLiteral("save_query"), tr("&Save query..."), + tr("Keep the current query as a saved query"), [this]() { + saveCurrentQuery(); + }); addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"), tr("Switch the thread between HTML and plain text"), [this]() { m_messageView->toggleHtml(); @@ -982,6 +970,7 @@ void MainWindow::buildMenus() editMenu->addSeparator(); editMenu->addAction(m_actions.value(QStringLiteral("focus_query"))); editMenu->addAction(m_actions.value(QStringLiteral("complete_query"))); + editMenu->addAction(m_actions.value(QStringLiteral("save_query"))); editMenu->addSeparator(); editMenu->addAction(m_actions.value(QStringLiteral("select_all"))); @@ -1060,6 +1049,7 @@ void MainWindow::buildMenus() // the selection's tags. { QStringLiteral("tag_rules"), QStringLiteral("configure") }, { QStringLiteral("complete_query"), QStringLiteral("edit-find-replace") }, + { QStringLiteral("save_query"), QStringLiteral("document-save") }, { QStringLiteral("select_all"), QStringLiteral("edit-select-all") }, { QStringLiteral("clear_pane"), QStringLiteral("edit-clear") }, { QStringLiteral("clear_selection"), QStringLiteral("edit-clear-all") }, @@ -1644,6 +1634,159 @@ void MainWindow::showWarnings() problems.join(QLatin1Char('\n'))); } +void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) +{ + auto *row = new QWidget(parent); + row->setObjectName(QStringLiteral("savedQueryRow")); + auto *box = new QHBoxLayout(row); + box->setContentsMargins(0, 0, 0, 0); + + QList<SavedQuery> unpinned; + for (const SavedQuery &saved : m_config.savedQueries()) { + if (!saved.pinned) { + unpinned.append(saved); + continue; + } + auto *button = new QPushButton(saved.name, row); + connect(button, &QPushButton::clicked, this, + [this, saved]() { runSavedQuery(saved); }); + box->addWidget(button); + } + + // Sent sits with the saved queries and is not one: its query is COMPOSED + // from the accounts' `sent` keys at click time, so adding an account or + // correcting a folder name is a config edit and nothing else. A stored + // entry holding the same string would go stale silently, and could not + // narrow to the selected account the way this does through + // runCurrentQuery()'s existing scope wrap. + // + // Hidden entirely when no account configures a sent folder, rather than + // offering a button that always finds nothing. + if (!m_config.allSentQuery().isEmpty()) { + auto *sentButton = new QPushButton(tr("Sent"), row); + sentButton->setObjectName(QStringLiteral("sentButton")); + connect(sentButton, &QPushButton::clicked, this, [this]() { + m_queryEdit->setText(m_config.allSentQuery()); + // Flat for this query only. runCurrentQuery() clears it again for + // anything else, including the same query typed by hand, so the + // flag cannot outlive the button that set it. + runQuery(FlatResult::Yes); + }); + box->addWidget(sentButton); + } + + // The overflow menu, and only when something is in it: an empty menu + // button is a control that always does nothing. + if (!unpinned.isEmpty()) { + auto *menuButton = new QPushButton(tr("More queries"), row); + menuButton->setObjectName(QStringLiteral("savedQueryMenuButton")); + auto *menu = new QMenu(menuButton); + for (const SavedQuery &saved : unpinned) { + QAction *action = menu->addAction(saved.name); + connect(action, &QAction::triggered, this, + [this, saved]() { runSavedQuery(saved); }); + } + menuButton->setMenu(menu); + box->addWidget(menuButton); + } + + box->addStretch(1); + layout->addWidget(row); + + // Nothing saved and no sent folder leaves an empty strip of padding, so + // the row goes away rather than sitting there as a gap. + if (box->count() == 1) + row->hide(); +} + +void MainWindow::runSavedQuery(const SavedQuery &saved) +{ + // Through the dropdown, never by pre-scoping the text: runQuery() applies + // the selected account's path itself, so a scope baked in here would be + // applied twice. An unscoped query CLEARS the selection rather than + // inheriting whatever was there, which is the defect the rules preview hit. + const int index = saved.account.isEmpty() + ? m_accountBox->findData(QString()) + : m_accountBox->findData(saved.account); + if (index >= 0) + m_accountBox->setCurrentIndex(index); + + m_queryEdit->setText(saved.query); + runCurrentQuery(); +} + +void MainWindow::saveCurrentQuery() +{ + const QString query = m_queryEdit->text().trimmed(); + if (query.isEmpty()) + return; + + SaveQueryDialog dialog(m_config, query, + m_accountBox->currentData().toString(), this); + if (dialog.exec() != QDialog::Accepted) + return; + + QList<SavedQuery> queries = m_config.savedQueries(); + const SavedQuery saved = dialog.savedQuery(); + + // Replacing by name keeps the dialog's overwrite offer honest, and keeps + // the entry where it already sat rather than moving it to the end. + bool replaced = false; + for (SavedQuery &existing : queries) { + if (existing.name.compare(saved.name, Qt::CaseInsensitive) == 0) { + // The unknown fields belong to the STORED entry, not to the + // dialog's fresh value, so a field a later build wrote survives + // being edited here. + SavedQuery merged = saved; + merged.unknown = existing.unknown; + existing = merged; + replaced = true; + break; + } + } + if (!replaced) + queries.append(saved); + + m_config.setSavedQueries(queries); + if (!m_config.saveSavedQueries()) { + QMessageBox::warning(this, tr("Save query"), + tr("Could not write the saved queries file.")); + return; + } + + rebuildSavedQueryRow(); + statusBar()->showMessage(tr("Saved query '%1'.").arg(saved.name), + kStatusMessageMs); +} + +void MainWindow::rebuildSavedQueryRow() +{ + // The row is rebuilt wholesale rather than patched: a new query can be + // pinned, unpinned, or replace an existing one, and each moves a different + // widget. Deleting and rebuilding is a handful of buttons and cannot get + // the three cases wrong. + auto *old = findChild<QWidget *>(QStringLiteral("savedQueryRow")); + if (!old) + return; + + auto *layout = qobject_cast<QVBoxLayout *>(centralWidget()->layout()); + if (!layout) + return; + + const int index = layout->indexOf(old); + layout->removeWidget(old); + old->deleteLater(); + + buildSavedQueryRow(centralWidget(), layout); + + // buildSavedQueryRow appends; move it back to where the old row sat, or it + // lands under the thread list. + if (index >= 0) { + auto *item = layout->takeAt(layout->count() - 1); + layout->insertItem(index, item); + } +} + void MainWindow::runQuery(FlatResult flat) { // Set on EVERY run, not only when Yes. This is the line that stops flat diff --git a/src/mainwindow.h b/src/mainwindow.h index 18fbbb4..8b43fd0 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -49,6 +49,7 @@ class QPlainTextEdit; class QSplitter; class QProgressBar; class QTimer; +class QVBoxLayout; class ThreadListModel; class MessageView; @@ -231,6 +232,26 @@ private: /// is what widgets connect to. void runQuery(FlatResult flat); + /// Builds the row of saved-query buttons, the overflow menu and Sent. + /// + /// Its own row since item 23: an unbounded list of buttons sharing the + /// query row squeezed the field, which is the whole reason for the + /// pinned/unpinned split. + void buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout); + + /// Runs a saved query, taking its account scope through the dropdown. + /// + /// Not by pre-scoping the text: runQuery() already wraps the query in the + /// selected account's path, so a scope baked in here would be applied + /// twice. Setting the dropdown also shows the user what scope they are in. + void runSavedQuery(const SavedQuery &saved); + + /// Names the current query and stores it in queries.json. + void saveCurrentQuery(); + + /// Rebuilds the saved-query row in place after the stored list changed. + void rebuildSavedQueryRow(); + private slots: void runCurrentQuery() { runQuery(FlatResult::No); } diff --git a/src/savequerydialog.cpp b/src/savequerydialog.cpp new file mode 100644 index 0000000..1ea97c2 --- /dev/null +++ b/src/savequerydialog.cpp @@ -0,0 +1,128 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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. + */ + +#include "savequerydialog.h" + +#include <QCheckBox> +#include <QComboBox> +#include <QDialogButtonBox> +#include <QFormLayout> +#include <QLabel> +#include <QLineEdit> +#include <QPushButton> +#include <QVBoxLayout> + +bool SaveQueryDialog::namesAnExistingQuery(const Config &config, + const QString &name) +{ + for (const SavedQuery &saved : config.savedQueries()) { + if (saved.name.compare(name.trimmed(), Qt::CaseInsensitive) == 0) + return true; + } + return false; +} + +SaveQueryDialog::SaveQueryDialog(const Config &config, const QString &query, + const QString &accountKey, QWidget *parent) + : QDialog(parent) + , m_config(config) +{ + setWindowTitle(tr("Save query")); + + auto *layout = new QVBoxLayout(this); + auto *form = new QFormLayout; + + m_name = new QLineEdit(this); + m_name->setObjectName(QStringLiteral("saveQueryName")); + m_name->setPlaceholderText(tr("A name for this query")); + form->addRow(tr("Name"), m_name); + + m_query = new QLineEdit(query, this); + m_query->setObjectName(QStringLiteral("saveQueryQuery")); + form->addRow(tr("Query"), m_query); + + // The scope is stored as an account KEY, so the entries carry the key as + // data exactly as the main window's dropdown does. "All accounts" is the + // empty key, not a missing entry, so an unscoped query is a real choice + // rather than the absence of one. + m_account = new QComboBox(this); + m_account->setObjectName(QStringLiteral("saveQueryAccount")); + m_account->addItem(tr("All accounts"), QString()); + for (const Account &account : config.accounts()) + m_account->addItem(account.key, account.key); + const int index = m_account->findData(accountKey); + m_account->setCurrentIndex(index >= 0 ? index : 0); + form->addRow(tr("Account"), m_account); + + m_pinned = new QCheckBox(tr("Show as a button"), this); + m_pinned->setObjectName(QStringLiteral("saveQueryPinned")); + m_pinned->setChecked(true); + form->addRow(QString(), m_pinned); + + layout->addLayout(form); + + // Says what is about to happen rather than refusing the name. Overwriting + // a saved query on purpose is a normal edit, and the only thing worth + // preventing is doing it without noticing. + m_notice = new QLabel(this); + m_notice->setObjectName(QStringLiteral("saveQueryNotice")); + m_notice->setWordWrap(true); + layout->addWidget(m_notice); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Save | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(buttons); + + m_ok = buttons->button(QDialogButtonBox::Save); + m_ok->setObjectName(QStringLiteral("saveQueryOk")); + + connect(m_name, &QLineEdit::textChanged, + this, &SaveQueryDialog::updateOkState); + connect(m_query, &QLineEdit::textChanged, + this, &SaveQueryDialog::updateOkState); + updateOkState(); + + m_name->setFocus(); +} + +void SaveQueryDialog::updateOkState() +{ + const QString name = m_name->text().trimmed(); + const bool usable = !name.isEmpty() && !m_query->text().trimmed().isEmpty(); + m_ok->setEnabled(usable); + + if (!name.isEmpty() && namesAnExistingQuery(m_config, name)) { + m_notice->setText( + tr("A saved query named '%1' already exists and will be " + "replaced.").arg(name)); + } else { + m_notice->clear(); + } +} + +SavedQuery SaveQueryDialog::savedQuery() const +{ + SavedQuery saved; + saved.name = m_name->text().trimmed(); + saved.query = m_query->text().trimmed(); + saved.account = m_account->currentData().toString(); + saved.pinned = m_pinned->isChecked(); + return saved; +} diff --git a/src/savequerydialog.h b/src/savequerydialog.h new file mode 100644 index 0000000..be5a2af --- /dev/null +++ b/src/savequerydialog.h @@ -0,0 +1,63 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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 <QDialog> + +#include "config.h" + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPushButton; + +/// Names a query and keeps it in queries.json. +/// +/// Pure UI: it is handed the config and returns a SavedQuery. It writes +/// nothing, so it can be tested without touching a file, and the caller owns +/// the decision of what to do with the result. +class SaveQueryDialog : public QDialog +{ + Q_OBJECT +public: + /// `query` is the text to store, `accountKey` the scope to preselect, + /// which is the account the user is already looking at. + SaveQueryDialog(const Config &config, const QString &query, + const QString &accountKey, QWidget *parent = nullptr); + + /// The query as edited. Only meaningful after exec() returned Accepted. + SavedQuery savedQuery() const; + + /// Whether `name` already names a stored query. Case-insensitive, matching + /// how startup_query resolves, so "inbox" and "Inbox" cannot both exist and + /// leave the user unable to tell which one a button ran. + static bool namesAnExistingQuery(const Config &config, const QString &name); + +private: + void updateOkState(); + + const Config &m_config; + QLineEdit *m_name = nullptr; + QLineEdit *m_query = nullptr; + QComboBox *m_account = nullptr; + QCheckBox *m_pinned = nullptr; + QPushButton *m_ok = nullptr; + QLabel *m_notice = nullptr; +}; diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 4c1c5d9..2c70e0f 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -188,6 +188,14 @@ private slots: void flatModeDoesNotSurviveTheNextQuery(); void noTwoActionsShareAnIcon(); + void onlyPinnedQueriesBecomeButtons(); + void unpinnedQueriesReachTheMenu(); + void pinnedButtonsFollowTheDocumentOrder(); + void theSavedQueryMenuIsHiddenWhenEveryQueryIsPinned(); + void aScopedSavedQuerySelectsItsAccount(); + void anUnscopedSavedQueryClearsTheAccount(); + void theSaveQueryActionIsDisabledOnAnEmptyQuery(); + private: /// Owns the throwaway lock table init() points every test at. A pointer /// rather than a value because it is rebuilt per test, and QTemporaryDir @@ -5265,4 +5273,271 @@ int main(int argc, char *argv[]) return QTest::qExec(&test, argc, argv); } +// --------------------------------------------------------------------------- +// Saved queries in the query row (item 23) +// --------------------------------------------------------------------------- + +/// Writes a config plus a queries.json beside it, and loads both. +static void loadWithQueries(Config &config, QTemporaryDir &dir, + const QString &queriesJson, + const QString &iniExtra = {}) +{ + QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))); + const QString conf = + dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf")); + QFile ini(conf); + ini.open(QIODevice::WriteOnly | QIODevice::Text); + ini.write(iniExtra.toUtf8()); + ini.close(); + + QFile json(dir.filePath(QStringLiteral("qtmaildir/queries.json"))); + json.open(QIODevice::WriteOnly); + json.write(queriesJson.toUtf8()); + json.close(); + + config.load(conf); +} + +/// Buttons in the saved-query row, by label, in the order they are laid out. +static QStringList savedQueryButtonLabels(MainWindow &window) +{ + QStringList labels; + auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow")); + if (!row) + return labels; + const QList<QPushButton *> buttons = + row->findChildren<QPushButton *>(QString(), Qt::FindDirectChildrenOnly); + for (QPushButton *button : buttons) { + // The menu button is not a saved query and must not be counted as one. + if (button->objectName() != QStringLiteral("savedQueryMenuButton")) + labels.append(button->text()); + } + return labels; +} + +void TestMainWindow::onlyPinnedQueriesBecomeButtons() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Inbox", "query": "tag:inbox", "pinned": true }, + { "name": "Buried", "query": "tag:buried" } + ] + })")); + + MainWindow window(config); + const QStringList labels = savedQueryButtonLabels(window); + + QVERIFY2(labels.contains(QStringLiteral("Inbox")), + "a pinned query must have a button"); + QVERIFY2(!labels.contains(QStringLiteral("Buried")), + "an unpinned query must NOT have a button"); +} + +void TestMainWindow::unpinnedQueriesReachTheMenu() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Inbox", "query": "tag:inbox", "pinned": true }, + { "name": "Buried", "query": "tag:buried" } + ] + })")); + + MainWindow window(config); + auto *menuButton = + window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton")); + QVERIFY2(menuButton, "an unpinned query needs a menu to live in"); + QVERIFY(menuButton->menu()); + + QStringList entries; + const QList<QAction *> actions = menuButton->menu()->actions(); + for (QAction *action : actions) + entries.append(action->text()); + + QVERIFY2(entries.contains(QStringLiteral("Buried")), + "the unpinned query is missing from the menu"); + // A pinned query is already a button; listing it twice is the duplicate + // this asserts against. + QVERIFY2(!entries.contains(QStringLiteral("Inbox")), + "a pinned query must not also appear in the menu"); +} + +/// The property the whole storage change was made for. "Zebra" is written +/// first and must stay first; alphabetical order would put it last. +void TestMainWindow::pinnedButtonsFollowTheDocumentOrder() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Zebra", "query": "tag:zebra", "pinned": true }, + { "name": "Apple", "query": "tag:apple", "pinned": true } + ] + })")); + + MainWindow window(config); + const QStringList labels = savedQueryButtonLabels(window); + + QCOMPARE(labels.size(), 2); + QCOMPARE(labels.at(0), QStringLiteral("Zebra")); + QCOMPARE(labels.at(1), QStringLiteral("Apple")); +} + +void TestMainWindow::theSavedQueryMenuIsHiddenWhenEveryQueryIsPinned() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Inbox", "query": "tag:inbox", "pinned": true } + ] + })")); + + MainWindow window(config); + + // The guard the assertion below needs. Asserting only that the menu button + // is absent passed against NO implementation at all, before any of this + // was built, so it has to prove first that the row it is looking in was + // populated and that a button was found. + const QStringList labels = savedQueryButtonLabels(window); + QCOMPARE(labels, QStringList{ QStringLiteral("Inbox") }); + + auto *menuButton = + window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton")); + QVERIFY2(!menuButton, + "an empty menu button is a control that always does nothing"); +} + +/// The scope goes through the account dropdown rather than being baked into +/// the query text. runQuery() already scopes by that dropdown, so pre-scoping +/// the text would apply the path twice, and the selection would be invisible. +void TestMainWindow::aScopedSavedQuerySelectsItsAccount() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Billing", "query": "from:billing", + "account": "work", "pinned": true } + ] + })"), QStringLiteral( + "[account.work]\n" + "name=Test User\n" + "address=user@example.org\n" + "maildir=work-mail\n" + "\n" + "[account.personal]\n" + "name=Test User\n" + "address=me@example.net\n" + "maildir=personal\n" + )); + + MainWindow window(config); + auto *accountBox = + window.findChild<QComboBox *>(QStringLiteral("accountBox")); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(accountBox); + QVERIFY(queryEdit); + + // Start somewhere else, so a passing result cannot be the default. + accountBox->setCurrentIndex(accountBox->findData( + QStringLiteral("personal"))); + QCOMPARE(accountBox->currentData().toString(), QStringLiteral("personal")); + + auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow")); + QVERIFY(row); + auto *button = row->findChild<QPushButton *>(); + QVERIFY(button); + button->click(); + + QCOMPARE(accountBox->currentData().toString(), QStringLiteral("work")); + // The text is the bare query. The path scope is applied once, by + // runQuery(), from the dropdown this just set. + QCOMPARE(queryEdit->text(), QStringLiteral("from:billing")); + QVERIFY2(!queryEdit->text().contains(QStringLiteral("path:")), + "the scope must not be baked into the query text"); +} + +/// A query with no account must CLEAR the dropdown, not inherit whatever the +/// last one left there. Confirmed against the same defect in the rules +/// preview, where an already-selected account survived the click. +void TestMainWindow::anUnscopedSavedQueryClearsTheAccount() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Everywhere", "query": "tag:inbox", "pinned": true } + ] + })"), QStringLiteral( + "[account.work]\n" + "name=Test User\n" + "address=user@example.org\n" + "maildir=work-mail\n" + )); + + MainWindow window(config); + auto *accountBox = + window.findChild<QComboBox *>(QStringLiteral("accountBox")); + QVERIFY(accountBox); + + accountBox->setCurrentIndex(accountBox->findData(QStringLiteral("work"))); + QCOMPARE(accountBox->currentData().toString(), QStringLiteral("work")); + + auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow")); + QVERIFY(row); + auto *button = row->findChild<QPushButton *>(); + QVERIFY(button); + button->click(); + + QVERIFY2(accountBox->currentData().toString().isEmpty(), + "an unscoped saved query must clear the account selection"); +} + +void TestMainWindow::theSaveQueryActionIsDisabledOnAnEmptyQuery() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, "queries": [] + })")); + + MainWindow window(config); + auto *save = window.findChild<QAction *>(QStringLiteral("save_query")); + QVERIFY2(save, "there is no way to save a query"); + + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(queryEdit); + + queryEdit->clear(); + QVERIFY2(!save->isEnabled(), + "saving an empty query would store a query that matches nothing"); + + queryEdit->setText(QStringLiteral("tag:inbox")); + QVERIFY2(save->isEnabled(), "a real query must be savable"); + + // Whitespace is not a query. setText does not drive a completer, but it + // does emit textChanged, which is what the enabling is hung on. + queryEdit->setText(QStringLiteral(" ")); + QVERIFY(!save->isEnabled()); +} + #include "test_mainwindow.moc" |
