diff options
| -rw-r--r-- | src/config.cpp | 24 | ||||
| -rw-r--r-- | src/config.h | 12 | ||||
| -rw-r--r-- | src/main.cpp | 28 | ||||
| -rw-r--r-- | tests/test_config.cpp | 81 |
4 files changed, 136 insertions, 9 deletions
diff --git a/src/config.cpp b/src/config.cpp index 266ed39..600c558 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -196,6 +196,30 @@ void Config::load(const QString &path) m_startupAccount = settings.value(QStringLiteral("startup_account")).toString().trimmed(); + // Interface language. "system" is spelled out so the default can be written + // down rather than only expressed by deleting the key. + // + // Validated here rather than left to whether a translation loads, because + // those are different questions and only one of them is an error. QLocale + // accepts anything and degrades an unrecognised name to C, so `language = + // itallian` would load no translation and look exactly like asking for + // English. Meanwhile `language = en_US` legitimately loads nothing, since + // English is the source language and ships no .qm. Checking the NAME + // separates the typo from the deliberate choice. + const QString language = + settings.value(QStringLiteral("language")).toString().trimmed(); + if (!language.isEmpty() + && language.compare(QStringLiteral("system"), Qt::CaseInsensitive) != 0) { + if (QLocale(language).language() == QLocale::C) { + addProblem(tr("Language '%1' is not a locale name; using the " + "system language. Expected something like 'it' or " + "'it_IT'.") + .arg(language)); + } else { + m_language = language; + } + } + const QVariant zoom = settings.value(QStringLiteral("message_zoom")); if (zoom.isValid()) { bool ok = false; diff --git a/src/config.h b/src/config.h index d1f8e65..70f7181 100644 --- a/src/config.h +++ b/src/config.h @@ -292,6 +292,17 @@ public: /// the same fixed string on every card rather than failing visibly. QString dateFormat() const { return m_dateFormat; } + /// Interface language, or empty to follow the environment. + /// + /// A locale name, short ("it") or full ("it_IT"); Qt resolves the short + /// form to a country. `system` reads as empty, so a user can write the + /// default down rather than having to delete the key to get it back. + /// + /// Validated at load, because an unrecognised name does NOT fail: QLocale + /// degrades it to C, which then loads no translation and is indistinguishable + /// from asking for English on purpose. A typo would otherwise be silent. + QString language() const { return m_language; } + /// The saved query to open at startup, by name. Falls back to "Unread" /// when unset, and to the first saved query when no query by that name /// exists: [queries] is read through childKeys(), which sorts @@ -404,6 +415,7 @@ private: int m_toolbarIconSize = 24; QString m_notmuchConfig; QString m_dateFormat; + QString m_language; qreal m_messageZoom = 1.0; bool m_completionOnFocus = false; int m_markReadDelayMs = 2000; diff --git a/src/main.cpp b/src/main.cpp index 59f39be..2ed8057 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -89,14 +89,27 @@ int main(int argc, char *argv[]) app.setWindowIcon(QIcon(QStringLiteral(":/icons/qtmaildir.svg"))); app.setDesktopFileName(QStringLiteral("qtmaildir")); + Config config; + config.load(Config::defaultPath()); + // On main's stack deliberately: a QTranslator must outlive exec(), and one // scoped to a helper function unloads on return, silently reverting every - // string to English. Installed before Config is loaded, because config - // warnings are generated at load time and are themselves translated. + // string to English. + // + // Loaded AFTER Config, because [general] language overrides the + // environment. The cost is that config warnings are generated before the + // translator exists, so they are built in English; retranslating them would + // mean re-running load(), and a warning about the config file is the one + // string a user can still act on in either language. // - // QLocale() reads the system locale, so LANG=it_IT.UTF-8 selects the file - // with no config key of our own. A missing .qm returns false and the app - // runs in English, which is the correct failure rather than a fatal one. + // An empty language() means follow the environment, which is what QLocale() + // default-constructs to. A missing .qm returns false and the app runs in + // English: that is the correct outcome both for an unsupported language and + // for `language = en_US`, since English is the source and ships no .qm. + const QLocale locale = config.language().isEmpty() + ? QLocale() + : QLocale(config.language()); + QTranslator translator; QStringList translationDirs; // Beside the binary first, so a build tree works without installing. @@ -108,7 +121,7 @@ int main(int argc, char *argv[]) translationDirs << dir + QStringLiteral("/translations"); for (const QString &dir : std::as_const(translationDirs)) { - if (translator.load(QLocale(), QStringLiteral("qtmaildir"), + if (translator.load(locale, QStringLiteral("qtmaildir"), QStringLiteral("_"), dir)) { app.installTranslator(&translator); break; @@ -122,9 +135,6 @@ int main(int argc, char *argv[]) return 1; } - Config config; - config.load(Config::defaultPath()); - MainWindow window(config); window.show(); diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 00e8d89..0dfda86 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -101,6 +101,8 @@ private slots: void theStartupAccountTakesTheKeyNotTheSyncChannel(); void theStartupQueryCanNameABuiltinFilter(); void theStartupQuerySurvivesATranslatedFilterName(); + void theLanguageKeyOverridesTheEnvironment(); + void theLanguageKeyRejectsWhatIsNotALocale(); void theStartupQueryPrefersASavedQueryOverAFilterOfTheSameName(); void anUnmatchedStartupQueryFallsBackToAFilterNotAStrayQuery(); void theFlaggedFilterIsCalledImportant(); @@ -1262,6 +1264,85 @@ void TestConfig::theStartupQuerySurvivesATranslatedFilterName() qApp->removeTranslator(&translator); } +void TestConfig::theLanguageKeyOverridesTheEnvironment() +{ + // A directory PER CASE. writeIni() always writes qtmaildir.conf and + // QSettings caches by path, so five loads from one QTemporaryDir all see + // whichever file was written first: this test failed reporting "it_IT" + // where it had just written en_US. + QTemporaryDir dir, systemDir, shortDir, fullDir, englishDir; + + // Unset means follow the environment, which is what an empty value tells + // main.cpp to do by default-constructing a QLocale. + Config unset; + unset.load(writeIni(dir, QStringLiteral("[general]\n"))); + QVERIFY(unset.language().isEmpty()); + QVERIFY(unset.problems().isEmpty()); + + // "system" is the default written down. It must read as unset rather than + // being passed to QLocale, which would resolve it to C and force English. + Config system; + system.load(writeIni(systemDir, QStringLiteral( + "[general]\n" + "language = system\n"))); + QVERIFY2(system.language().isEmpty(), + "'system' must read as unset, not as a locale name"); + QVERIFY(system.problems().isEmpty()); + + // A short code is accepted as written. Qt resolves "it" to it_IT when the + // QLocale is built, and QTranslator::load falls back from qtmaildir_it_IT + // to qtmaildir_it, so the short form needs no expansion here. + Config shortCode; + shortCode.load(writeIni(shortDir, QStringLiteral( + "[general]\n" + "language = it\n"))); + QCOMPARE(shortCode.language(), QStringLiteral("it")); + QVERIFY(shortCode.problems().isEmpty()); + QCOMPARE(QLocale(shortCode.language()).name(), QStringLiteral("it_IT")); + + Config full; + full.load(writeIni(fullDir, QStringLiteral( + "[general]\n" + "language = it_IT\n"))); + QCOMPARE(full.language(), QStringLiteral("it_IT")); + QVERIFY(full.problems().isEmpty()); + + // Forcing English is legitimate and must NOT be reported as a problem, even + // though it loads no .qm: English is the source language and ships none. + // This is the case that separates "no translation" from "bad value". + Config english; + english.load(writeIni(englishDir, QStringLiteral( + "[general]\n" + "language = en_US\n"))); + QCOMPARE(english.language(), QStringLiteral("en_US")); + QVERIFY2(english.problems().isEmpty(), + "forcing English is a valid choice, not a configuration error"); +} + +void TestConfig::theLanguageKeyRejectsWhatIsNotALocale() +{ + // The trap this guards. QLocale accepts any string and degrades an + // unrecognised one to C rather than failing, so `language = itallian` + // would load no translation and be indistinguishable from asking for + // English on purpose: the user's typo would be silent forever. Verified + // against QLocale directly first, so the test rests on measured behaviour + // rather than on the assumption that a bad name is rejected somewhere. + QCOMPARE(QLocale(QStringLiteral("itallian")).language(), QLocale::C); + + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[general]\n" + "language = itallian\n"))); + + QCOMPARE(config.problems().size(), 1); + QVERIFY2(config.problems().first().contains(QStringLiteral("itallian")), + "the warning must name the value the user wrote"); + // Cleared, so the caller falls back to the environment rather than being + // handed a name that resolves to C and forces English. + QVERIFY(config.language().isEmpty()); +} + void TestConfig::theFlaggedFilterIsCalledImportant() { // Item 57 decided this and item 93 contradicted it. The `flag` ACTION has |
