From 51f04ffc3c17da59b6072c4362029c45b780de82 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 15 Aug 2026 11:00:58 +0200 Subject: feat(filters): put the four built-in filters on the query row Item 93, the UI half. Unread, Inbox, Flagged and Sent are buttons the application ships, sitting first on the row, ahead of the user's pinned saved queries. runFilter() is runSavedQuery()'s opposite in the one way that matters: it READS the account box and never writes it. That is item 90's defect. A filter narrows what the user is already looking at, so the dropdown is its input rather than something it resets on the way past. A saved query keeps setting the account from what it stored, because it is a destination and states its own scope. runQuery() gains an AccountScope parameter. A filter's text arrives already resolved in the selected account's scope, and scoping it again would put path:"work/Sent/**" inside path:"work/**". Two migration changes, both of which unpin rather than delete: - Sent is no longer migrated from the INI into queries.json. The built-in filter covers it, and migrating one too would put two Sent buttons on the row, one editable and one not. - A stored entry naming a known generator is unpinned on load, which is what every install upgraded through 0.19.0 carries. It keeps its name and its generator and moves to the menu. Deleting it would be data loss on a file whose readers are supposed to preserve what they do not own. The test suite needed the same distinction the design makes. savedQueryButtonLabels() now skips the filters, and savedQueryButton(window, label) replaces five positional row->findChild() lookups that were silently returning Unread. One rendering probe had to be fixed rather than adapted. replyRowsKeepTheirTextUnderTheThreadLine resized the window to 300px, and four more buttons pushed the reply row below the viewport: the pixel loop then ran zero times and reported "0 pixels, the row was painted over", which is a different defect from the one it exists to catch. It gets 600px and a guard asserting the row is really inside the viewport, so the next person to shrink it gets told the truth. Verified by putting 300 back: the guard names the row at 83..165 in an 82px viewport. --- src/mainwindow.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 12 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index c6df95c..6bd91d9 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1717,12 +1717,31 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) auto *box = new QHBoxLayout(row); box->setContentsMargins(0, 0, 0, 0); - // Sent is an ordinary row here, not a hardcoded button beside the others. - // It is still GENERATED, so its query is composed from the accounts' `sent` - // keys at click time and correcting a folder name stays a config edit and - // nothing else; what changed is that the entry can now be reordered, - // renamed, unpinned or removed like every other, instead of being the one - // control on the row the user did not own. + // The built-in filters come first, in their own fixed order, and they are + // not saved queries: they are shipped, they are not in queries.json, and + // the user cannot edit or delete them (item 93). They are what the row is + // FOR; the pinned saved queries below them are the transitional half that + // item 94 removes. + for (const SavedQuery &filter : Config::builtinFilters()) { + // Sent with no account configuring a sent folder finds nothing by + // construction. Hidden rather than present and empty, which is what the + // hardcoded Sent button did and is worth keeping: a control that always + // returns nothing reads as broken rather than as absent. + if (m_config.resolvedQuery(filter, QString()) + == Config::matchNothingQuery()) + continue; + + auto *button = new QPushButton(filter.name, row); + // A stable object name per filter, so a test finds the button without + // depending on the label, which is translated. + button->setObjectName(filter.generated + QStringLiteral("Button")); + connect(button, &QPushButton::clicked, this, + [this, filter]() { runFilter(filter); }); + box->addWidget(button); + } + + // The user's own saved queries. A pinned one is still a button, beside the + // filters, until item 94 makes the menu their only home. QList unpinned; for (const SavedQuery &saved : m_config.savedQueries()) { // A generator whose accounts configure nothing produces a button that @@ -1736,10 +1755,9 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) continue; } auto *button = new QPushButton(saved.name, row); - // The generated entries keep a stable object name so a test can find - // the sent button without depending on what the user renamed it to. - if (saved.generated == QStringLiteral("sent")) - button->setObjectName(QStringLiteral("sentButton")); + // No object name here any more. "sentButton" now belongs to the BUILT-IN + // Sent filter, and a migrated Sent entry claiming it too would give two + // buttons one name, so findChild() would return whichever came first. connect(button, &QPushButton::clicked, this, [this, saved]() { runSavedQuery(saved); }); addSavedQueryActions(button, saved); @@ -1928,6 +1946,22 @@ void MainWindow::runSavedQuery(const SavedQuery &saved) runQuery(saved.flat ? FlatResult::Yes : FlatResult::No); } +void MainWindow::runFilter(const SavedQuery &filter) +{ + // The account box is READ and never written. That is the whole difference + // from runSavedQuery(), and it is item 90's defect: a filter narrows what + // the user is already looking at, so the dropdown is its input rather than + // something it resets on the way past. + const QString accountKey = m_accountBox->currentData().toString(); + + // Resolved here, in the account's scope, and put in the bar so what ran is + // visible and editable. runQuery() is told not to scope it again. + m_queryEdit->setText(m_config.resolvedQuery(filter, accountKey)); + + runQuery(filter.flat ? FlatResult::Yes : FlatResult::No, + AccountScope::AlreadyScoped); +} + void MainWindow::saveCurrentQuery() { const QString query = m_queryEdit->text().trimmed(); @@ -2007,7 +2041,7 @@ void MainWindow::rebuildSavedQueryRow() } } -void MainWindow::runQuery(FlatResult flat) +void MainWindow::runQuery(FlatResult flat, AccountScope scope) { // Set on EVERY run, not only when Yes. This is the line that stops flat // mode leaking: any query that is not the Sent button restores the tree, @@ -2017,8 +2051,12 @@ void MainWindow::runQuery(FlatResult flat) QString query = m_queryEdit->text().trimmed(); + // A built-in filter arrives already resolved in the selected account's + // scope, because a generator has to be asked for the account's own query + // rather than have its all-accounts query wrapped. Scoping again here would + // put path:"work/Sent/**" inside path:"work/**". const QString accountKey = m_accountBox->currentData().toString(); - if (!accountKey.isEmpty()) + if (scope == AccountScope::Apply && !accountKey.isEmpty()) query = m_config.account(accountKey).scopedQuery(query); if (query.isEmpty()) -- cgit v1.2.3 From b1a7339120385f3fd8f1f5251ec20e3f0ff94b22 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 15 Aug 2026 11:09:29 +0200 Subject: fix(queries): make a query in the overflow menu runnable An unpinned query could not be run. Its menu action carried both a triggered connection and a submenu of edit actions, and Qt does not emit triggered for an action that owns a menu: clicking the entry only opened the submenu, so the connection had never fired. It shipped unnoticed because the menu was the rarely-used half while the user's queries were pinned buttons. Item 93 moved every query into the menu, which is how it surfaced, and item 94 makes the menu their only home, so this is now the path that has to work. Running is an item inside the submenu, first and above a separator, with the edit actions below it. The entry keeps its submenu because an unpinned query must still be editable and deletable. The test asserts the Run item exists and is first, then that triggering it reaches the query, then that Edit and Delete survived beside it. Restoring the old wiring fails it on the first of those, naming the Qt behaviour rather than just reporting a wrong query string. --- src/mainwindow.cpp | 25 ++++++++++++++--- tests/test_mainwindow.cpp | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6bd91d9..bf1a33b 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1779,12 +1779,29 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) 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); }); + // A menu entry has no context menu of its own, so its own submenu - // carries the same three actions; an unpinned query would - // otherwise be the one thing that cannot be edited or deleted. + // carries the same actions; an unpinned query would otherwise be + // the one thing that cannot be edited or deleted. auto *entryMenu = new QMenu(menu); + + // Running the query is an item INSIDE that submenu, and must be: + // Qt does not emit triggered for an action that owns a menu, so a + // connection on `action` itself never fires and clicking the entry + // only opens the submenu. That shipped, and went unnoticed while + // the menu was the rarely-used half and the user's queries were + // pinned buttons. Item 93 moved every query into the menu, and item + // 94 makes it their only home. + auto *run = new QAction(tr("Run"), entryMenu); + run->setObjectName(QStringLiteral("runQuery")); + connect(run, &QAction::triggered, this, + [this, saved]() { runSavedQuery(saved); }); + entryMenu->addAction(run); + + auto *runSeparator = new QAction(entryMenu); + runSeparator->setSeparator(true); + entryMenu->addAction(runSeparator); + addSavedQueryActions(entryMenu, saved); action->setMenu(entryMenu); } diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 4cc9954..6a049e3 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -169,6 +169,7 @@ private slots: void narrowingAnEmptyQueryBarIsAPlainSearch(); void aMalformedAccountIsReportedWithoutBlockingTheConstructor(); void aWorkerBackedWindowReturnsRealThreads(); + void aQueryInTheMenuCanActuallyBeRun(); void theFourBuiltinFiltersAreOnTheRowInOrder(); void aFilterComposesWithTheSelectedAccount(); void aFilterAcrossAllAccountsIsUnscoped(); @@ -6349,6 +6350,74 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads() QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); } +void TestMainWindow::aQueryInTheMenuCanActuallyBeRun() +{ + // An unpinned query was UNRUNNABLE. Its action carried both a triggered + // connection and a submenu of edit actions, and Qt does not emit triggered + // for an action that owns a menu: clicking it opens the submenu and nothing + // else. The connection had never fired. + // + // It shipped unnoticed because the menu was the rarely-used half while the + // user's queries were pinned buttons. Item 93 moved every one of them into + // the menu, which is how it surfaced, and item 94 makes the menu their only + // home, so this is the path that has to work. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + Config config; + loadWithQueries(config, dir, QStringLiteral(R"({ + "version": 1, + "queries": [ + { "name": "Menued", "query": "tag:menued", "pinned": false } + ] + })")); + + MainWindow window(config); + auto *queryEdit = window.findChild(QStringLiteral("queryEdit")); + QVERIFY(queryEdit); + + auto *menuButton = + window.findChild(QStringLiteral("savedQueryMenuButton")); + QVERIFY2(menuButton, "no overflow menu for an unpinned query"); + QVERIFY(menuButton->menu()); + + QAction *entry = nullptr; + for (QAction *action : menuButton->menu()->actions()) { + if (action->text() == QStringLiteral("Menued")) + entry = action; + } + QVERIFY2(entry, "the unpinned query is not in the menu"); + + // The entry keeps its submenu, because an unpinned query must still be + // editable and deletable. What it cannot be is the ONLY thing there: Qt + // does not emit triggered for an action that owns a menu, so running the + // query needs an item of its own. + QVERIFY2(entry->menu(), "the per-query actions are gone"); + + QAction *run = nullptr; + for (QAction *action : entry->menu()->actions()) { + if (action->objectName() == QStringLiteral("runQuery")) + run = action; + } + QVERIFY2(run, "no way to run the query: its submenu offers only edit " + "actions, and Qt never emits triggered for the parent"); + + // First, before the edit actions. Running is what the entry is for; editing + // is what one does to it occasionally. + QCOMPARE(entry->menu()->actions().constFirst(), run); + + run->trigger(); + QCOMPARE(queryEdit->text(), QStringLiteral("tag:menued")); + + // The edit actions survived beside it. + QStringList names; + for (QAction *action : entry->menu()->actions()) + names.append(action->objectName()); + QVERIFY2(names.contains(QStringLiteral("editQuery")), + "the entry lost Edit"); + QVERIFY2(names.contains(QStringLiteral("deleteQuery")), + "the entry lost Delete"); +} + void TestMainWindow::theFourBuiltinFiltersAreOnTheRowInOrder() { // Shipped, not pinned. Nothing in this config names a query, so a row with -- cgit v1.2.3