Coverage for paintmaskanimator/i18n.py: 82%
60 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Translation lookup and translator installation.
3The UI is authored in Japanese: the Japanese string *is* the message id, so an
4untranslated build behaves exactly as before this module existed. Wrapping a
5string in :func:`tr` is therefore always safe and never changes behaviour on its
6own -- it only makes the string visible to ``pyside6-lupdate``.
8Usage::
10 from .i18n import tr
12 QMessageBox.critical(self, tr("PSD書き出し"), tr("PSDを書き出せませんでした。"))
14Interpolation must happen **after** translation, and by name, because a
15translator may need to reorder the placeholders::
17 tr("{count}個のキーフレームを書き出しました。").format(count=exported)
19Never wrap a log message, a config key, a file-format token or a dict key: those
20are protocol, not prose, and translating them would corrupt saved data.
21"""
22from __future__ import annotations
24from pathlib import Path
26from PySide6.QtCore import QCoreApplication, QLibraryInfo, QLocale, QTranslator
28from . import config
30#: Basename of the compiled catalogues under ``translations/``.
31CATALOGUE = "paintmaskanimator"
33#: Translation context. Deliberately empty: ``pyside6-lupdate`` cannot see
34#: through the :func:`tr` wrapper to a class, so it files every string under the
35#: empty context. The runtime lookup must use the same one or *nothing* resolves
36#: -- silently, since an unresolved lookup returns the source string, which is
37#: already Japanese. ``test_i18n.py`` round-trips a real catalogue to catch a
38#: regression here.
39CONTEXT = ""
41#: Locale name -> label shown in the language menu. The source language first.
42SUPPORTED_LANGUAGES = {
43 "ja": "日本語",
44 "en": "English",
45}
47DEFAULT_LANGUAGE = "ja"
49#: Installed translators, kept referenced so Qt does not collect them.
50_installed: list[QTranslator] = []
53def tr(text: str, disambiguation: str | None = None, n: int = -1) -> str:
54 """Translate ``text`` in the application context (identity when untranslated)."""
55 return QCoreApplication.translate(CONTEXT, text, disambiguation, n)
58def translations_dir() -> Path:
59 return Path(__file__).resolve().parent / "translations"
62def available_languages() -> dict[str, str]:
63 """Languages with a compiled catalogue present, plus the source language."""
64 found = {DEFAULT_LANGUAGE: SUPPORTED_LANGUAGES[DEFAULT_LANGUAGE]}
65 for code, label in SUPPORTED_LANGUAGES.items():
66 if code == DEFAULT_LANGUAGE:
67 continue
68 if (translations_dir() / f"{CATALOGUE}_{code}.qm").exists(): 68 ↛ 65line 68 didn't jump to line 65 because the condition on line 68 was always true
69 found[code] = label
70 return found
73def system_language() -> str:
74 """The best supported match for the OS locale, else the source language."""
75 for name in QLocale.system().uiLanguages():
76 code = name.replace("-", "_").split("_")[0].lower()
77 if code in SUPPORTED_LANGUAGES:
78 return code
79 return DEFAULT_LANGUAGE
82def install(app: QCoreApplication, language: str | None = None) -> str:
83 """Install the catalogue for ``language`` (auto-detected when ``None``).
85 Returns the language actually installed. The source language installs no
86 catalogue at all, so it can never be stale relative to the code.
87 """
88 for translator in _installed:
89 app.removeTranslator(translator)
90 _installed.clear()
92 code = language or system_language()
93 if code not in SUPPORTED_LANGUAGES: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 code = DEFAULT_LANGUAGE
95 if code == DEFAULT_LANGUAGE:
96 return code
98 catalogue = translations_dir() / f"{CATALOGUE}_{code}.qm"
99 translator = QTranslator()
100 if not translator.load(str(catalogue)):
101 # A missing or unreadable catalogue must not break start-up; the app
102 # simply stays in the source language.
103 return DEFAULT_LANGUAGE
104 app.installTranslator(translator)
105 _installed.append(translator)
107 # Qt's own strings (standard dialog buttons, file dialog) ship separately.
108 qt_translator = QTranslator()
109 qt_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
110 if qt_translator.load(f"qtbase_{code}", qt_path): 110 ↛ 113line 110 didn't jump to line 113 because the condition on line 110 was always true
111 app.installTranslator(qt_translator)
112 _installed.append(qt_translator)
113 return code
116#: Config key holding the user's explicit language choice ("system" to follow the OS).
117CONFIG_KEY = "language"
118SYSTEM = "system"
121def preferred_language() -> str:
122 """The saved language choice, or ``SYSTEM`` when the user has not picked one."""
123 value = config.get_value(CONFIG_KEY, SYSTEM)
124 return value if value in SUPPORTED_LANGUAGES or value == SYSTEM else SYSTEM
127def set_preferred_language(language: str) -> None:
128 """Persist the language choice. Takes effect on the next start-up."""
129 if language != SYSTEM and language not in SUPPORTED_LANGUAGES:
130 raise ValueError(f"unsupported language: {language!r}")
131 config.set_value(CONFIG_KEY, language)
134def install_preferred(app: QCoreApplication) -> str:
135 """Install the catalogue for the saved choice (or the OS locale)."""
136 choice = preferred_language()
137 return install(app, None if choice == SYSTEM else choice)