diff --git a/CMakeLists.txt b/CMakeLists.txt index b9cd2268..41d607d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,3 +37,9 @@ add_test(NAME folio_tests COMMAND folio_tests) set_tests_properties(folio_tests PROPERTIES ENVIRONMENT QT_QPA_PLATFORM=offscreen) # The bundled plugins, which the tests run against stubs. target_compile_definitions(folio_tests PRIVATE FOLIO_PLUGINS="${CMAKE_SOURCE_DIR}/plugins") + +add_executable(locale_tests tests/locale_tests.cpp src/translator.cpp src/translator.h) +target_include_directories(locale_tests PRIVATE src) +target_link_libraries(locale_tests PRIVATE Qt6::Quick Qt6::Test) +add_test(NAME locale_tests COMMAND locale_tests) +set_tests_properties(locale_tests PROPERTIES ENVIRONMENT QT_QPA_PLATFORM=offscreen) diff --git a/docs/i18n.md b/docs/i18n.md index 211ebf43..af217fbb 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -2,6 +2,12 @@ Files follows the language picked under Omarchy's **Setup > Language**: English plus Omarchy's 49 translations. Each language has a catalog, `src/i18n/.json`, a flat JSON object from English source text to its translation, compiled into the binary through `src/resources.qrc`. A counted string, `qsTr("%Ln items", "", count)`, maps to an object of plural forms keyed by CLDR category (`{"one": "%Ln item", "other": "%Ln items"}`), and each language gives exactly the categories its counts reach, by the rules in `Translator::pluralCategory`. A string a catalog doesn't have yet shows in English (a regional catalog such as `pt-BR` falls back to `pt` first). +## Date and time formats + +Dates and times honor a nonempty `LC_ALL`, then `LC_TIME`, from the app's environment. With neither set, they keep following the UI language, including live language switches. An explicit override stays in effect across those switches; numbers, sorting and window direction still follow the UI language as before. + +Locale names use Qt's locale data (for example `pt_PT.UTF-8` or `sr_Latn_RS`); explicit scripts are preserved, and no OS locale installation is needed. Explicit `C` and `POSIX`, including codeset suffixes such as `C.UTF-8`, use Qt's C date/time formats. An unrecognized locale that Qt resolves to C instead falls back to the UI language. A nonempty but invalid `LC_ALL` still takes precedence over `LC_TIME`, so it falls back rather than trying the lower-priority variable. Environment changes require restarting Files. + ## Adding or changing a string Pull requests only ever touch English: wrap the text in `qsTr()` (QML) or `tr()` (C++), and add it to `src/i18n/en.json` as itself, or with its `one` and `other` forms when it's counted. The `englishCatalogMatchesTheSources` test keeps `en.json` exactly in step with the sources. Leave the other catalogs alone. Since the English is the key, rewording a string makes a new key; its translations become pending, and the old key is dropped by the next sync. The catalog tests check the translations that are there (placeholders, plural forms, language codes, every catalog listed in `resources.qrc`), but never require them to be complete, so English is never blocked. diff --git a/src/FileRow.qml b/src/FileRow.qml index a8dab284..8b909013 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -121,6 +121,7 @@ Item { elide: Text.ElideMiddle } Text { + objectName: "sizeLabel" visible: row.showSize Layout.preferredWidth: 96 horizontalAlignment: Text.AlignRight @@ -129,14 +130,12 @@ Item { font.pixelSize: 12 } Text { + objectName: "dateLabel" visible: row.showDate Layout.preferredWidth: 96 Layout.leftMargin: 12 horizontalAlignment: Text.AlignLeft - text: { - translator.language // Re-read when the language (and so the default locale) changes. - return Format.when(row.modified) - } + text: Format.when(row.modified, translator.dateLocale) color: theme.secondaryForeground font.pixelSize: 12 elide: Text.ElideRight diff --git a/src/FileTile.qml b/src/FileTile.qml index 4c85c7a5..a4ee860f 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -87,6 +87,7 @@ Item { // "28.0 KB · 11/9/26" before; just the item count for a folder, the // subtitle for a plugin's item. Text { + objectName: "metadataLabel" anchors.top: label.bottom anchors.topMargin: 3 anchors.horizontalCenter: parent.horizontalCenter @@ -98,7 +99,7 @@ Item { const size = tile.isDir ? Format.count(tile.itemCount) : Format.size(tile.size) // A folder's own date says little (it changes when anything in // it is added or removed), so only files show one. - const date = tile.isDir ? "" : Format.when(tile.modified) + const date = tile.isDir ? "" : Format.when(tile.modified, translator.dateLocale) return size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date } color: theme.secondaryForeground diff --git a/src/Format.js b/src/Format.js index a359f60e..c9163473 100644 --- a/src/Format.js +++ b/src/Format.js @@ -3,8 +3,8 @@ // Sizes, dates, counts and thumbnail URLs as the views show them, in one // copy that every tile and row shares. A library can't see context -// properties, so a binding that shows a date reads translator.language -// itself to follow a language switch; whatever goes through qsTr here is +// properties, so callers pass translator.dateLocale for dates to follow +// a language switch; whatever goes through qsTr here is // refreshed with the rest of the strings. // Each unit is whole with its number, so a language can place and space it @@ -24,10 +24,10 @@ function free(bytes) { // When it changed: the time for today, the date before that. A plugin's // items have no date. -function when(date) { +function when(date, localeName) { if (!date || isNaN(date.getTime())) return "" - const locale = Qt.locale() + const locale = Qt.locale(localeName) const now = new Date() const today = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() return today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat) diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea0..b87a765c 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -149,13 +149,13 @@ Rectangle { } Text { visible: !pane.pluginItem; text: pane.trashed || pane.noting ? qsTr("Deleted") : qsTr("Modified"); color: theme.secondaryForeground; font.pixelSize: 12 } Text { + objectName: "timestampLabel" visible: !pane.pluginItem Layout.fillWidth: true horizontalAlignment: Text.AlignRight text: { - translator.language // Re-read when the language (and so the default locale) changes. const when = pane.trashed ? pane.info.deleted : pane.noting ? null : pane.item.modified - return pane.hasItem && when ? when.toLocaleString(Qt.locale(), Locale.ShortFormat) : "" + return pane.hasItem && when ? when.toLocaleString(Qt.locale(translator.dateLocale), Locale.ShortFormat) : "" } color: theme.foreground font.pixelSize: 12 diff --git a/src/translator.cpp b/src/translator.cpp index 237c76f3..6469bdbd 100644 --- a/src/translator.cpp +++ b/src/translator.cpp @@ -50,6 +50,22 @@ QLocale formatLocale(const QString &language) { return QLocale(language); } +// Only an explicit date/time override breaks the UI-language default. +// Qt accepts POSIX locale names with codesets/modifiers. Unknown names +// resolve to C; ignore those, but honor an explicitly requested C/POSIX. +QLocale dateLocale(const QLocale &fallback) { + QString name = qEnvironmentVariable("LC_ALL"); + if (name.isEmpty()) + name = qEnvironmentVariable("LC_TIME"); + if (name.isEmpty()) + return fallback; + const QString base = name.section('.', 0, 0).section('@', 0, 0); + if (base == "C" || base == "POSIX") + return QLocale::c(); + const QLocale locale(name); + return locale.language() == QLocale::C ? fallback : locale; +} + } // namespace // CLDR cardinal plural categories for whole counts, for Omarchy's languages @@ -109,6 +125,11 @@ QString Translator::language() const { return m_language; } +QString Translator::dateLocale() const { + QReadLocker locker(&m_lock); + return m_dateLocale; +} + bool Translator::isEmpty() const { QReadLocker locker(&m_lock); return m_strings.isEmpty(); @@ -164,16 +185,20 @@ void Translator::reload() { return; const auto strings = catalog(m_catalogDirectory, language); + QLocale locale = formatLocale(language); + const QLocale dates = ::dateLocale(locale); + // name() drops nondefault scripts (sr_Latn_RS becomes sr_RS). Keep the + // script when QML reparses it, and preserve the explicit C locale. + const QString dateName = dates.language() == QLocale::C ? QString("C") : dates.bcp47Name(); { QWriteLocker locker(&m_lock); m_language = language; + m_dateLocale = dateName; m_strings = strings; } - // Dates, numbers, and Qt's own day and month names follow the language - // too, and right-to-left languages mirror the window. Numbers aren't - // grouped, as in Omarchy, so a year or a picture's width never grows a - // separator. - QLocale locale = formatLocale(language); + // Numbers and sorting still follow the UI language, as does mirroring. + // Dates use their separate locale without changing those defaults. + // Numbers aren't grouped, as in Omarchy. locale.setNumberOptions(QLocale::OmitGroupSeparator); QLocale::setDefault(locale); QGuiApplication::setLayoutDirection(locale.textDirection()); diff --git a/src/translator.h b/src/translator.h index 52165d1c..513e085e 100644 --- a/src/translator.h +++ b/src/translator.h @@ -18,6 +18,7 @@ class Translator : public QTranslator { Q_OBJECT Q_PROPERTY(QString language READ language NOTIFY changed) + Q_PROPERTY(QString dateLocale READ dateLocale NOTIFY changed) public: explicit Translator(const QString &languageFile = {}, const QString &catalogDirectory = ":/i18n", @@ -25,6 +26,7 @@ class Translator : public QTranslator { ~Translator() override; QString language() const; + QString dateLocale() const; bool isEmpty() const override; QString translate(const char *context, const char *sourceText, const char *disambiguation = nullptr, int n = -1) const override; @@ -51,6 +53,7 @@ class Translator : public QTranslator { // swapped under a lock rather than freed beneath them. mutable QReadWriteLock m_lock; QString m_language; + QString m_dateLocale; // Each translation with the code of the catalog it came from, whose // plural rule its forms follow: a string still in English counts the // English way. diff --git a/tests/locale_tests.cpp b/tests/locale_tests.cpp new file mode 100644 index 00000000..351c73ae --- /dev/null +++ b/tests/locale_tests.cpp @@ -0,0 +1,189 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "translator.h" + +// Never read files or start Folio's thumbnail workers in these view tests. +class BlankThumbnail : public QQuickImageProvider { +public: + BlankThumbnail() : QQuickImageProvider(QQuickImageProvider::Image) {} + QImage requestImage(const QString &, QSize *, const QSize &) override { + QImage image(1, 1, QImage::Format_ARGB32); + image.fill(Qt::transparent); + return image; + } +}; + +class LocaleTests : public QObject { + Q_OBJECT + + static void write(const QString &path, const QByteArray &value) { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write(value), value.size()); + } + + static void verifyUiLocale(const Translator &translator, const QLocale &ui, const QString &open) { + QCOMPARE(QLocale().bcp47Name(), ui.bcp47Name()); + QCOMPARE(QLocale().toString(12345), ui.toString(12345)); + QCOMPARE(QLocale().toString(1.5, 'f', 1), ui.toString(1.5, 'f', 1)); + QCOMPARE(QCollator().locale().bcp47Name(), ui.bcp47Name()); + QCOMPARE(QGuiApplication::layoutDirection(), ui.textDirection()); + QCOMPARE(translator.translate("", "Open"), open); + } + +private slots: + void regionalDates_data() { + QTest::addColumn("all"); + QTest::addColumn("time"); + QTest::addColumn("regional"); + QTest::newRow("unset") << QByteArray() << QByteArray() << QString(); + QTest::newRow("empty") << QByteArray("") << QByteArray("") << QString(); + QTest::newRow("portuguese") << QByteArray("") << QByteArray("pt_PT.UTF-8") << QString("pt_PT"); + QTest::newRow("all-precedes-time") << QByteArray("de_DE.UTF-8") << QByteArray("pt_PT.UTF-8") << QString("de_DE"); + QTest::newRow("invalid-time") << QByteArray() << QByteArray("not_a_locale") << QString(); + QTest::newRow("invalid-all") << QByteArray("not_a_locale") << QByteArray("pt_PT.UTF-8") << QString(); + QTest::newRow("C") << QByteArray() << QByteArray("C") << QString("C"); + QTest::newRow("POSIX") << QByteArray() << QByteArray("POSIX") << QString("C"); + QTest::newRow("C-utf8") << QByteArray("C.UTF-8") << QByteArray("pt_PT.UTF-8") << QString("C"); + QTest::newRow("POSIX-utf8") << QByteArray() << QByteArray("POSIX.UTF-8") << QString("C"); + QTest::newRow("serbian-latin") << QByteArray() << QByteArray("sr_Latn_RS") << QString("sr_Latn_RS"); + QTest::newRow("azerbaijani-cyrillic") << QByteArray() << QByteArray("az_Cyrl_AZ") << QString("az_Cyrl_AZ"); + QTest::newRow("registry-script") << QByteArray() << QByteArray() << QString(); + } + + void regionalDates() { + QTest::failOnWarning(QRegularExpression(".*")); + QFETCH(QByteArray, all); + QFETCH(QByteArray, time); + QFETCH(QString, regional); + const QByteArray oldAll = qgetenv("LC_ALL"), oldTime = qgetenv("LC_TIME"), oldOmarchy = qgetenv("OMARCHY_PATH"); + const QLocale oldLocale; + const auto oldDirection = QGuiApplication::layoutDirection(); + const auto restore = qScopeGuard([&] { + for (const auto &entry : {qMakePair("LC_ALL", oldAll), qMakePair("LC_TIME", oldTime), qMakePair("OMARCHY_PATH", oldOmarchy)}) { + if (entry.second.isNull()) qunsetenv(entry.first); + else qputenv(entry.first, entry.second); + } + QLocale::setDefault(oldLocale); + QGuiApplication::setLayoutDirection(oldDirection); + }); + if (all.isNull()) qunsetenv("LC_ALL"); else qputenv("LC_ALL", all); + if (time.isNull()) qunsetenv("LC_TIME"); else qputenv("LC_TIME", time); + QTemporaryDir dir; + QVERIFY(dir.isValid()); + qputenv("OMARCHY_PATH", QFile::encodeName(dir.path())); + QVERIFY(QDir().mkpath(dir.filePath("default/i18n"))); + // The real Omarchy registry deliberately uses Western digits for Arabic. + write(dir.filePath("default/i18n/locales.json"), R"({"en":{"formatLocale":"en_US"},"pt":{"formatLocale":"pt_PT"},"ar":{"formatLocale":"ar_AE"},"fa":{"formatLocale":"fa_IR"},"sr":{"formatLocale":"sr_Latn_RS"}})"); + write(dir.filePath("en.json"), R"({"Open":"Open"})"); + write(dir.filePath("pt.json"), R"({"Open":"Abrir"})"); + write(dir.filePath("ar.json"), R"({"Open":"افتح"})"); + write(dir.filePath("fa.json"), R"({"Open":"باز کردن"})"); + write(dir.filePath("sr.json"), R"({"Open":"Otvori"})"); + write(dir.filePath("language"), "en\n"); + Translator translator(dir.filePath("language"), dir.path()); + QQmlEngine engine; + engine.addImageProvider("thumb", new BlankThumbnail); + connect(&translator, &Translator::changed, &engine, &QQmlEngine::retranslate); + engine.rootContext()->setContextProperty("translator", &translator); + QVariantMap theme; + for (const auto &name : {"background", "darkBackground", "foreground", "brightForeground", "secondaryForeground", "accent", "accentForeground", "red", "blue", "magenta", "orange", "yellow", "cyan"}) + theme.insert(name, QColor("#888888")); + theme.insert("radius", 0); + engine.rootContext()->setContextProperty("theme", theme); + engine.rootContext()->setContextProperty("places", QVariantMap{{"devices", QVariantList{}}, {"home", dir.path()}}); + QQmlComponent component(&engine); + component.setData(R"( +import QtQuick +Item { + id: fixture + property date stamp: new Date(2001, 10, 9, 14, 7) + property bool trash: false + // Exercise Qt's QML locale parser with month/day names, not only digits. + function longDate(localeName) { return stamp.toLocaleDateString(Qt.locale(localeName), Locale.LongFormat) } + property var backend: QtObject { + property var details: fixture.trash ? ({origin: "/old/sample", deleted: fixture.stamp}) : ({}) + function describe() {} + function typeName() { return "Text" } + function applications() { return [] } + } + FileRow { + objectName: "row"; width: 600; detailed: true + index: 0; name: "sample"; path: ""; isDir: false; kind: "text"; mime: "text/plain" + extension: "txt"; size: 1500; modified: parent.stamp; itemCount: 0; icon: ""; selected: false + } + FileTile { + objectName: "tile"; width: 200; height: 200 + index: 0; name: "sample"; path: ""; isDir: false; kind: "text"; mime: "text/plain" + extension: "txt"; size: 1500; modified: parent.stamp; itemCount: 0; icon: ""; selected: false + } + PreviewPane { + objectName: "details"; width: 400; height: 700 + folderPane: QtObject { + property var current: ({path: "", name: "sample", kind: "text", mime: "text/plain", modified: fixture.trash ? new Date(1999, 0, 1) : fixture.stamp}) + property bool inTrash: fixture.trash + property var paneBackend: ({path: ""}) + property var paneFileOps: QtObject { + function appsFor() { return [] } + function defaultAppName() { return "" } + } + } + } +} +)", QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js") + ".test.qml")); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + // Stable semantic selectors avoid child order, styling and unrelated text. + auto *rowDate = root->findChild("dateLabel"); + auto *rowSize = root->findChild("sizeLabel"); + auto *gridMetadata = root->findChild("metadataLabel"); + auto *detailsTimestamp = root->findChild("timestampLabel"); + QVERIFY(rowDate && rowSize && gridMetadata && detailsTimestamp); + const QHash locales{{"en", "en_US"}, {"pt", "pt_PT"}, {"fa", "fa_IR"}, {"ar", "ar_AE"}, {"sr", "sr_Latn_RS"}}; + const QHash open{{"en", "Open"}, {"pt", "Abrir"}, {"fa", "باز کردن"}, {"ar", "افتح"}, {"sr", "Otvori"}}; + const QDateTime oldDate(QDate(2001, 11, 9), QTime(14, 7)); + for (const QString &language : {QString("en"), QString("pt"), QString("fa"), QString("ar"), QString("sr"), QString("en")}) { + write(dir.filePath("language"), language.toUtf8() + '\n'); + QTRY_COMPARE(translator.language(), language); + QLocale ui(locales.value(language)); + ui.setNumberOptions(QLocale::OmitGroupSeparator); + const QLocale dateLocale = regional.isEmpty() ? ui : QLocale(regional); + verifyUiLocale(translator, ui, open.value(language)); + for (const auto &stamp : {oldDate, QDateTime(QDate::currentDate(), QTime(14, 7))}) { + root->setProperty("stamp", stamp); + const QString when = stamp.date() == QDate::currentDate() ? dateLocale.toString(stamp.time(), QLocale::ShortFormat) : dateLocale.toString(stamp.date(), QLocale::ShortFormat); + const QString size = ui.toString(1.5, 'f', 1) + " KB"; + const QString timestamp = dateLocale.toString(stamp, QLocale::ShortFormat); + QTRY_COMPARE(rowDate->property("text").toString(), when); + QTRY_COMPARE(gridMetadata->property("text").toString(), size + " · " + when); + QTRY_COMPARE(detailsTimestamp->property("text").toString(), timestamp); + QCOMPARE(rowSize->property("text").toString(), size); + QVariant longDate; + QVERIFY(QMetaObject::invokeMethod(root.get(), "longDate", Q_RETURN_ARG(QVariant, longDate), + Q_ARG(QVariant, translator.property("dateLocale")))); + QCOMPARE(longDate.toString(), dateLocale.toString(stamp.date(), QLocale::LongFormat)); + for (bool trash : {false, true}) { + root->setProperty("trash", trash); + QTRY_COMPARE(detailsTimestamp->property("text").toString(), timestamp); + } + root->setProperty("trash", false); + } + } + } +}; + +QTEST_MAIN(LocaleTests) +#include "locale_tests.moc"