Files

165 lines
11 KiB
Markdown
Raw Permalink Normal View History

2026-09-03 09:16:56 +03:00
# Localization
`localization.h` / `localization.cpp`
Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
`lrelease` и Linguist не нужны, а все переводы видны в одном файле.
Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
## Доступ из QML
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
в любом файле модуля без импорта.
```qml
Text { text: Loc.t.settings.title }
Text { text: Loc.t.java.progress.downloading.arg(label) }
DarkCombo { model: Loc.t.profile.authTypes }
```
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
плоских ключей каталога разбиением по точке.
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
потому что при смене языка дерево заменяется целиком.
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
языка.
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
```qml
onLaunched: (profileName, buildName) =>
window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")
```
## Доступ из C++
Свободные функции, а не методы: их вызывают и из namespace-обёрток
([LauncherPaths](launcherpaths.md), [JavaLocator](javalocator.md),
[ZlibReference](zlibreference.md)), где никакого `QObject` нет.
```cpp
#include "localization.h"
emit launchError(Loc::text("launch.error.noProfile"));
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
const QStringList kinds = Loc::list("profile.authTypes");
```
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
### Потоки
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
## Каталог
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
```json
{
"_meta": {
"languages": ["ru", "en"],
"displayNames": { "ru": "Русский", "en": "English" }
},
"strings": {
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
"java.progress.downloading": { "ru": "Загрузка Java «%1»…", "en": "Downloading Java \"%1\"…" },
"profile.authTypes": {
"ru": ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"],
"en": ["Offline (no password)", "Ely.by", "Microsoft (licensed)"]
}
}
}
```
Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок
файла, а третий язык добавляется колонкой без правок загрузчика.
**Имя ключа — `<домен>.<вид>.<имя>`**, сегменты в lowerCamelCase.
- **Домен** — область, а не имя файла: `app`, `common`, `settings`, `profile`, `build`,
`seasonal`, `version`, `java`, `loader`, `auth` (с `auth.ely.*`, `auth.msa.*`), `launch`,
`game`, `switch`, `storage`, `zlib`.
- **Вид** — `title`, `label`, `button`, `placeholder`, `hint`, `header`; для сообщений `error`,
`progress`, `status`, `warning`.
- **Имя** описывает условие, а не формулировку, чтобы перевод не переименовывал ключ:
`gameRunning`, `downloadFailed`, `checksumMismatch`.
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
строки, которой оно требуется.
## Как добавить строку
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. Прогнать `python3 tools/check_translations.py`.
## Как добавить язык
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
2. Добавить колонку с этим кодом в каждую запись `strings`.
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
одинаково во всех колонках).
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
русский для русской системной локали и английский во всех остальных случаях.
## Выбор языка и его хранение
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
диалога «Настройки запуска», применяется по кнопке «Сохранить».
Круг замкнут в одну сторону, циклической зависимости нет:
```
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
```
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
не запустился.
**Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
[BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
диагностические и короткоживущие.
## Проверка
`tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
- наборы `%N` совпадают по языкам, длины списков равны;
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
- ключи, на которые никто не ссылается, — предупреждением.