Coverage for paintmaskanimator/i18n.py: 82%

60 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Translation lookup and translator installation. 

2 

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``. 

7 

8Usage:: 

9 

10 from .i18n import tr 

11 

12 QMessageBox.critical(self, tr("PSD書き出し"), tr("PSDを書き出せませんでした。")) 

13 

14Interpolation must happen **after** translation, and by name, because a 

15translator may need to reorder the placeholders:: 

16 

17 tr("{count}個のキーフレームを書き出しました。").format(count=exported) 

18 

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 

23 

24from pathlib import Path 

25 

26from PySide6.QtCore import QCoreApplication, QLibraryInfo, QLocale, QTranslator 

27 

28from . import config 

29 

30#: Basename of the compiled catalogues under ``translations/``. 

31CATALOGUE = "paintmaskanimator" 

32 

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 = "" 

40 

41#: Locale name -> label shown in the language menu. The source language first. 

42SUPPORTED_LANGUAGES = { 

43 "ja": "日本語", 

44 "en": "English", 

45} 

46 

47DEFAULT_LANGUAGE = "ja" 

48 

49#: Installed translators, kept referenced so Qt does not collect them. 

50_installed: list[QTranslator] = [] 

51 

52 

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) 

56 

57 

58def translations_dir() -> Path: 

59 return Path(__file__).resolve().parent / "translations" 

60 

61 

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 

71 

72 

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 

80 

81 

82def install(app: QCoreApplication, language: str | None = None) -> str: 

83 """Install the catalogue for ``language`` (auto-detected when ``None``). 

84 

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() 

91 

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 

97 

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) 

106 

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 

114 

115 

116#: Config key holding the user's explicit language choice ("system" to follow the OS). 

117CONFIG_KEY = "language" 

118SYSTEM = "system" 

119 

120 

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 

125 

126 

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) 

132 

133 

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)