From 854134ef967568f1441093774cf7497e8c5d6b11 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 15 Aug 2026 12:56:54 +0200 Subject: feat(ui): highlight the built-in filter matching the current view The four filter buttons gave no sign of which one you were looking at, so the row said what you could do and never where you were. The active filter is drawn as a checked QToolButton, which lets the style paint its own pressed look: a hand-picked highlight colour would have to be picked once per theme and would still be wrong under a third. The check state is derived from the query TEXT rather than from the last button clicked, which is the whole design decision. A record of what was pressed goes on lying the moment the query is edited into something else, where a highlight that follows the query clears itself and lights again when a filter's query is typed by hand. It is resolved against the account box, so changing account recomputes it rather than dropping it: the same filter under two accounts is two different query strings and both are still "Inbox". Buttons are held in a hash keyed by generator, cleared at the top of the row build because the row is rebuilt wholesale on every saved-query edit and stale entries would dangle. The connections are owned by the row widget, so a rebuild takes them with it rather than leaving a second copy firing at deleted buttons. Unread opens already highlighted, which is correct rather than incidental: startup_query defaults to it, so the window opens on that view. The test asserts it, so the assertions that follow are known to be a change of state rather than a button that happened to start unchecked. Mutation checked against the design that was rejected: deriving the state from the click instead of the query fails all three tests, each naming the behaviour it protects. Co-Authored-By: Claude Opus 5 --- src/mainwindow.cpp | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/mainwindow.h | 14 ++++++++++++ 2 files changed, 77 insertions(+) (limited to 'src') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3d7add2..7ece2f3 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1753,6 +1753,12 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) auto *box = new QHBoxLayout(row); box->setContentsMargins(0, 0, 0, 0); + // Cleared first: the row is rebuilt wholesale on every saved-query edit, so + // the buttons this hash points at are deleted and re-created. Keeping the + // old entries would leave dangling pointers that findChild() cannot save us + // from, since nothing looks them up by name. + m_filterButtons.clear(); + // 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 @@ -1804,8 +1810,22 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) // of control than it is. button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + // Checkable so the style draws its own "this is the current view" look, + // which is why no colour is chosen here: a hand-picked highlight would + // have to be picked twice, once per theme, and would still be wrong + // under a third. + // + // Not auto-exclusive and never toggled by the click itself. The check + // state is derived from the query bar in updateFilterButtons(), so a + // button that runs a filter and then has its query edited away does not + // stay lit. Letting the click set it would make the highlight a record + // of what was pressed rather than of what is shown. + button->setCheckable(true); + button->setFocusPolicy(Qt::NoFocus); + connect(button, &QToolButton::clicked, this, [this, filter]() { runFilter(filter); }); + m_filterButtons.insert(filter.generated, button); box->addWidget(button); } @@ -1886,6 +1906,22 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout) // query with no pinned ones still needs the row for its menu. if (contentCount == 0 && unpinned.isEmpty()) row->hide(); + + // Connected HERE rather than beside the query bar's other handlers, which + // run in registerActions() before this row exists. Both connections are + // owned by `row`, so a rebuild disconnects them with the widgets they + // update and cannot leave a second copy behind firing at deleted buttons. + // + // textChanged rather than editingFinished: the highlight has to clear while + // the user types, not once they leave the field. + connect(m_queryEdit, &QLineEdit::textChanged, row, + [this]() { updateFilterButtons(); }); + // The account is the other half of a filter's resolved query, so switching + // account re-resolves it and the highlight has to be recomputed against the + // new scope rather than assumed to survive. + connect(m_accountBox, &QComboBox::currentIndexChanged, row, + [this]() { updateFilterButtons(); }); + updateFilterButtons(); } void MainWindow::addSavedQueryActions(QWidget *target, const SavedQuery &saved) @@ -2048,6 +2084,33 @@ void MainWindow::runFilter(const SavedQuery &filter) AccountScope::AlreadyScoped); } +void MainWindow::updateFilterButtons() +{ + const QString current = m_queryEdit->text().trimmed(); + const QString accountKey = m_accountBox->currentData().toString(); + + for (auto it = m_filterButtons.constBegin(); + it != m_filterButtons.constEnd(); ++it) { + const SavedQuery filter = Config::builtinFilter(it.key()); + const QString resolved = m_config.resolvedQuery(filter, accountKey); + + // An unresolvable filter must never match, or every filter would light + // up on an empty query bar. matchNothingQuery() is a real query string + // and would compare equal to itself. + const bool matches = !current.isEmpty() + && resolved != Config::matchNothingQuery() + && resolved == current; + + // Blocked, because setChecked() on a checkable QToolButton emits + // toggled() and this runs from the query bar's own textChanged: a + // handler that ran runFilter() would re-enter the query path on every + // keystroke. Nothing connects toggled() today, so this is a guard + // against the obvious next edit rather than a fix for a live bug. + const QSignalBlocker blocker(it.value()); + it.value()->setChecked(matches); + } +} + void MainWindow::saveCurrentQuery() { const QString query = m_queryEdit->text().trimmed(); diff --git a/src/mainwindow.h b/src/mainwindow.h index 03f21bb..e2d1861 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -327,6 +327,16 @@ private: /// path: being hierarchical. See Config::resolvedQuery(query, accountKey). void runFilter(const SavedQuery &filter); + /// Checks the filter button whose query is what the bar currently holds, + /// and unchecks the rest. + /// + /// Derived from the query TEXT rather than from the last button pressed, so + /// editing the query by hand clears the highlight and typing a filter's + /// query lights it. Resolved against the account box, which is why changing + /// the account keeps the highlight: the same filter resolves to a different + /// query and both are still "Inbox". + void updateFilterButtons(); + /// Names the current query and stores it in queries.json. void saveCurrentQuery(); @@ -736,6 +746,10 @@ private: QLineEdit *m_queryEdit = nullptr; /// Save query, beside the field. Driven by the save_query action. QToolButton *m_saveQueryButton = nullptr; + /// The built-in filter buttons, by generator, so the one matching the + /// current view can be shown as checked. Kept because the buttons are built + /// in a loop and are otherwise unreachable without findChild() on a name. + QHash m_filterButtons; /// Whether deleting a saved query asks first. Always true outside tests. bool m_confirmDelete = true; QueryCompleter *m_queryCompleter = nullptr; -- cgit v1.2.3