diff options
| -rw-r--r-- | CHANGELOG.md | 10 | ||||
| -rw-r--r-- | README.md | 33 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 63 | ||||
| -rw-r--r-- | src/mainwindow.h | 14 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 129 |
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 @@ -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 |
