Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions docs/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>.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.
Expand Down
7 changes: 3 additions & 4 deletions src/FileRow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Item {
elide: Text.ElideMiddle
}
Text {
objectName: "sizeLabel"
visible: row.showSize
Layout.preferredWidth: 96
horizontalAlignment: Text.AlignRight
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/FileTile.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/Format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/PreviewPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 30 additions & 5 deletions src/translator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions src/translator.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
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",
QObject *parent = nullptr);
~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;
Expand All @@ -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.
Expand Down
Loading