aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-15 12:56:40 +0200
committerDanilo M. <danix@danix.xyz>2026-08-15 12:56:40 +0200
commitc9392961aa3554a17ab88bfe3ad4e6b76b9a60bc (patch)
tree6e7c4394a6baa774ed5ba2fd3b3fb343f9bf4b57
parent5a3f827a01d1902a0dadc5debb4200138d4af885 (diff)
downloadqtmaildir-c9392961aa3554a17ab88bfe3ad4e6b76b9a60bc.tar.gz
qtmaildir-c9392961aa3554a17ab88bfe3ad4e6b76b9a60bc.zip
feat(i18n): add a language key overriding the system locale
The interface language followed the environment and nothing else, so choosing it meant setting LANG for the whole application. [general] language overrides it in both directions: it selects Italian on an English desktop, and en_US forces English on an Italian one. A short code or a full locale name both work, since Qt resolves "it" to it_IT when the QLocale is built and QTranslator::load falls back from qtmaildir_it_IT to qtmaildir_it. "system" is the default written down, so the default can be expressed rather than only reached by deleting the key. Validated on the locale NAME rather than on whether a translation loads, because those are different questions and only one is an error. QLocale accepts any string and degrades an unrecognised one to C rather than failing, so `language = itallian` loads no translation and is otherwise indistinguishable from asking for English on purpose; meanwhile `language = en_US` legitimately loads nothing, English being the source language and shipping no .qm. Checking the name separates the typo from the deliberate choice, and the typo is reported. The translator is now installed after Config is loaded, since the config is what chooses it. The cost is that config warnings are generated before the translator exists and are therefore 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. Verified against the real loader across six configurations, under both LANG=en_US and LANG=it_IT: short and full codes select Italian, system and an absent key follow the environment, en_US forces English whatever the environment says, and a bad name reports a problem and falls back. Two mutations checked: dropping the name validation and treating "system" as a locale name each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--src/config.cpp24
-rw-r--r--src/config.h12
-rw-r--r--src/main.cpp28
-rw-r--r--tests/test_config.cpp81
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