summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-15 12:56:54 +0200
committerDanilo M. <danix@danix.xyz>2026-08-15 12:56:54 +0200
commit854134ef967568f1441093774cf7497e8c5d6b11 (patch)
tree2eafe37300fbedcaa6cdf028dc8531ad42e026fb
parentc9392961aa3554a17ab88bfe3ad4e6b76b9a60bc (diff)
downloadqtmaildir-854134ef967568f1441093774cf7497e8c5d6b11.tar.gz
qtmaildir-854134ef967568f1441093774cf7497e8c5d6b11.zip
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 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md10
-rw-r--r--README.md33
-rw-r--r--src/mainwindow.cpp63
-rw-r--r--src/mainwindow.h14
-rw-r--r--tests/test_mainwindow.cpp129
5 files changed, 245 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07f4a47..9c49873 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,16 @@ point at which they are stable.
locale runs in English as before. All 355 strings are translated.
- `ctest -R translations` guards the translation against the two ways it rots:
a new string added without one, and a string `lupdate` cannot see at all.
+- **`language` under `[general]`**, choosing the interface language regardless
+ of what the environment asks for. A short code or a full locale name both
+ work (`it`, `it_IT`), `system` is the default and follows `$LANG`, and
+ `en_US` forces English on a non-English desktop. A value that is not a locale
+ name is reported rather than silently ignored, since `itallian` and `en_US`
+ both load no translation and would otherwise look the same.
+- **The built-in filter matching the current view is drawn as a pressed
+ button**, so the row shows where you are. It tracks the query rather than the
+ last click, so editing the query clears the highlight and typing a filter's
+ query lights it; changing the account recomputes it.
### Fixed
diff --git a/README.md b/README.md
index 85fc469..0127649 100644
--- a/README.md
+++ b/README.md
@@ -69,15 +69,29 @@ cmake -S . -B build -DQTMAILDIR_BUILD_TESTS=OFF
### Translations
-qtmaildir ships an Italian translation. The language is taken from the
-environment, so there is no configuration key of its own:
+qtmaildir ships an Italian translation. By default the language comes from the
+environment:
```bash
LANG=it_IT.UTF-8 qtmaildir
```
-Any other locale runs the application in English, which is also what happens
-when the compiled translation is missing.
+The `language` key under `[general]` overrides that, in both directions: it
+selects Italian on an English desktop, and forces English on an Italian one.
+A short code or a full locale name both work.
+
+```ini
+[general]
+language = it ; or it_IT
+; language = en_US ; force English whatever $LANG says
+; language = system ; follow the environment (the default)
+```
+
+Any language with no translation runs the application in English, which is also
+what happens when the compiled translation is missing. A value that is not a
+locale name at all is reported as a configuration problem rather than silently
+falling back, since `language = itallian` and `language = en_US` would
+otherwise look identical from the outside.
`translations/qtmaildir_it_IT.ts` is tracked in git; the `.qm` it compiles to
is generated at build time and is not. Building needs Qt6's `LinguistTools`,
@@ -146,6 +160,11 @@ identity.
; starts in "work - Inbox". It only sets the STARTING scope: clicking a saved
; query that names no account still clears the selection, as it always does.
; startup_account = work
+; Optional. Interface language, overriding the one your environment asks for.
+; A locale name, short or full: "it" and "it_IT" both select Italian. The
+; default is "system", which follows $LANG. Set it to a language qtmaildir
+; does not ship, or to English, and the interface stays in English.
+; language = system
; Optional. Toolbar icon size in pixels, 16 to 64. Defaults to 24. The
; toolbar follows your desktop's toolbar button style, so if that is set to
; "icon only" this is the whole size of the control; 16 matches what most
@@ -300,6 +319,12 @@ 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.
+The built-in filter whose query is currently in the bar is drawn as a pressed
+button, so the row shows which view you are in. It follows the query rather
+than the last button you clicked: editing the query by hand clears the
+highlight, and typing a filter's own query lights it. Changing the account
+recomputes it, since a filter composes with the dropdown.
+
`startup_query` looks at your saved queries first and then at the built-in
filters, so it can name either; yours wins if both carry the same name. A name
matching neither falls back to the **Unread** filter.
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<QString, QToolButton *> m_filterButtons;
/// Whether deleting a saved query asks first. Always true outside tests.
bool m_confirmDelete = true;
QueryCompleter *m_queryCompleter = nullptr;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 38a8b08..c569f05 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -178,6 +178,9 @@ private slots:
void aFilterComposesWithTheSelectedAccount();
void aFilterAcrossAllAccountsIsUnscoped();
void aFilterDoesNotClearTheAccountSelection();
+ void theActiveFilterButtonIsChecked();
+ void aHandEditedQueryChecksNoFilterButton();
+ void theCheckedFilterFollowsTheAccount();
void aSavedQueryStillClearsTheAccountSelection();
void aFilterOffersNoEditOrDeleteActions();
void changingTheAccountRunsNothing();
@@ -6696,6 +6699,132 @@ void TestMainWindow::aFilterAcrossAllAccountsIsUnscoped()
QCOMPARE(queryEdit->text(), QStringLiteral("tag:unread"));
}
+void TestMainWindow::theActiveFilterButtonIsChecked()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *inbox =
+ window.findChild<QAbstractButton *>(QStringLiteral("inboxButton"));
+ auto *unread =
+ window.findChild<QAbstractButton *>(QStringLiteral("unreadButton"));
+ QVERIFY(inbox);
+ QVERIFY(unread);
+
+ // Checkable is what lets the style draw the active look at all. A test that
+ // only asserted isChecked() would pass against a button that can hold the
+ // state and never shows it.
+ QVERIFY2(inbox->isCheckable(), "the filter button cannot show a checked state");
+
+ // Unread is checked before anything is clicked, and that is correct rather
+ // than incidental: startup_query defaults to Unread, so the window opens
+ // showing it and the highlight describes the view from the first frame. The
+ // window having opened on a filter is asserted here so the Inbox
+ // assertions below are known to be a CHANGE of state rather than a button
+ // that happened to start unchecked.
+ QVERIFY2(unread->isChecked(),
+ "the default startup view is Unread, so its button should open "
+ "highlighted");
+ QVERIFY(!inbox->isChecked());
+
+ inbox->click();
+ QVERIFY2(inbox->isChecked(), "the filter that ran is not highlighted");
+ QVERIFY2(!unread->isChecked(), "a filter that did not run is highlighted");
+
+ // And the highlight MOVES rather than accumulating. Exactly one button can
+ // describe the current view.
+ unread->click();
+ QVERIFY(unread->isChecked());
+ QVERIFY2(!inbox->isChecked(), "the previous filter stayed highlighted");
+}
+
+void TestMainWindow::aHandEditedQueryChecksNoFilterButton()
+{
+ // The behaviour the user chose over "remember the last click": the
+ // highlight describes what is on screen, so editing the query away from a
+ // filter's own query clears it rather than leaving a button lit over a view
+ // it no longer describes.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *inbox =
+ window.findChild<QAbstractButton *>(QStringLiteral("inboxButton"));
+ QVERIFY(queryEdit);
+ QVERIFY(inbox);
+
+ inbox->click();
+ QVERIFY(inbox->isChecked());
+
+ queryEdit->setText(QStringLiteral("from:someone@example.org"));
+ QVERIFY2(!inbox->isChecked(),
+ "a hand-edited query left the Inbox button highlighted");
+
+ // And typing a filter's query by hand lights it, since the highlight is a
+ // property of the query rather than a record of which button was pressed.
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QVERIFY2(inbox->isChecked(),
+ "a query equal to the Inbox filter did not highlight it");
+
+ // An empty bar is not "every filter matches nothing", which a naive
+ // comparison against an unresolvable query would make it.
+ queryEdit->clear();
+ QVERIFY(!inbox->isChecked());
+}
+
+void TestMainWindow::theCheckedFilterFollowsTheAccount()
+{
+ // Changing the account re-resolves the filter to a different query string,
+ // and both are still "Inbox". The highlight is recomputed rather than
+ // dropped, or switching account would silently un-highlight the view the
+ // user is still looking at.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSentConfig(dir, {
+ {QStringLiteral("work"), QStringLiteral("Sent")},
+ {QStringLiteral("personal"), QStringLiteral("Sent")},
+ }));
+
+ MainWindow window(config);
+ auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *inbox =
+ window.findChild<QAbstractButton *>(QStringLiteral("inboxButton"));
+ QVERIFY(queryEdit);
+ QVERIFY(inbox);
+
+ window.selectAccountForTesting(QStringLiteral("work"));
+ inbox->click();
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("path:\"work/**\" and (tag:inbox)"));
+ QVERIFY(inbox->isChecked());
+
+ // The query bar still holds work's inbox query, which is NOT personal's, so
+ // the button correctly stops describing the view. The state after an
+ // account change is asserted rather than assumed: this is the case where a
+ // highlight keyed on the last click would go on lying.
+ window.selectAccountForTesting(QStringLiteral("personal"));
+ QVERIFY2(!inbox->isChecked(),
+ "the highlight survived an account change that left a query "
+ "belonging to the other account in the bar");
+
+ // Running it again under the new account lights it once more.
+ inbox->click();
+ QCOMPARE(queryEdit->text(),
+ QStringLiteral("path:\"personal/**\" and (tag:inbox)"));
+ QVERIFY(inbox->isChecked());
+}
+
void TestMainWindow::aFilterDoesNotClearTheAccountSelection()
{
// The defect item 90 filed: the button used to reset the dropdown to "All