aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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