Internationalisation¶
switch UI language and watch plural rules update live.
▶ Run in browserTags: ui i18n localisation text plural
A centred panel shows a greeting, a pluralised item count, and the active language name in four locales (English, French, Japanese, Arabic). Click the locale buttons or press 1-4 to switch and the greeting, plural phrasing and script all change, including Japanese CJK glyphs and a right-aligned Arabic line. Adjust the item count to watch each locale’s plural category (English one/other, Arabic zero/one/two/few/many/other) select the correct phrase.
What it demonstrates¶
Loading translation tables and switching
Translations.instance().localelive.tr(key, **kwargs)lookup with{name}interpolation and locale fallback.CLDR plural rules via
translate_plural, incl. Arabic’s six plural categories.Multi-script text (Latin, Japanese, Arabic) via the engine’s font fallback chain.
Anchored, resize-aware UI (centred Panel + VBox) with clickable locale buttons.
Controls: 1 / 2 / 3 / 4 - English / French / Japanese / Arabic (or click the buttons) Left / Right - Decrease / increase the item count (or the - / + buttons) ESC - Quit
Run: uv run python examples/features/ui/i18n.py Headless self-check: uv run python examples/features/ui/i18n.py –test
Source¶
1"""Internationalisation: switch UI language and watch plural rules update live.
2
3A centred panel shows a greeting, a pluralised item count, and the active
4language name in four locales (English, French, Japanese, Arabic). Click the
5locale buttons or press 1-4 to switch and the greeting, plural phrasing and
6script all change, including Japanese CJK glyphs and a right-aligned Arabic
7line. Adjust the item count to watch each locale's plural category (English
8one/other, Arabic zero/one/two/few/many/other) select the correct phrase.
9
10# /// simvx
11# tags = ["ui", "i18n", "localisation", "text", "plural"]
12# web = { root = "I18nDemo", width = 720, height = 480, responsive = true }
13# ///
14
15## What it demonstrates
16- Loading translation tables and switching `Translations.instance().locale` live.
17- `tr(key, **kwargs)` lookup with `{name}` interpolation and locale fallback.
18- CLDR plural rules via `translate_plural`, incl. Arabic's six plural categories.
19- Multi-script text (Latin, Japanese, Arabic) via the engine's font fallback chain.
20- Anchored, resize-aware UI (centred Panel + VBox) with clickable locale buttons.
21
22Controls:
23 1 / 2 / 3 / 4 - English / French / Japanese / Arabic (or click the buttons)
24 Left / Right - Decrease / increase the item count (or the - / + buttons)
25 ESC - Quit
26
27Run: uv run python examples/features/ui/i18n.py
28Headless self-check: uv run python examples/features/ui/i18n.py --test
29"""
30
31from simvx.core import (
32 AnchorPreset,
33 Colour,
34 Input,
35 InputMap,
36 Key,
37 Label,
38 Node,
39 Panel,
40 Vec2,
41)
42from simvx.core.i18n import Translations, tr
43from simvx.core.ui import Button, HBoxContainer, VBoxContainer
44from simvx.graphics import App
45
46# Translation data (inline: no external file needed)
47TRANSLATIONS = {
48 "en": {
49 "greeting": "Hello, {name}!",
50 "item_one": "You have {count} item.",
51 "item_other": "You have {count} items.",
52 "language": "Language: English",
53 "count_label": "Items: {count}",
54 "instructions": "1=EN 2=FR 3=JA 4=AR Left/Right = count ESC = Quit",
55 },
56 "fr": {
57 "greeting": "Bonjour, {name} !",
58 "item_one": "Vous avez {count} objet.",
59 "item_other": "Vous avez {count} objets.",
60 "language": "Langue : Français",
61 "count_label": "Objets : {count}",
62 "instructions": "1=EN 2=FR 3=JA 4=AR Gauche/Droite = nombre ESC = Quitter",
63 },
64 "ja": {
65 "greeting": "こんにちは、{name}さん!",
66 "item_other": "アイテムが{count}個あります。",
67 "language": "言語: 日本語",
68 "count_label": "個数: {count}",
69 "instructions": "1=EN 2=FR 3=JA 4=AR ←/→ = 数 ESC = 終了",
70 },
71 "ar": {
72 "greeting": "مرحبا، {name}!",
73 "item_zero": "ليس لديك عناصر.",
74 "item_one": "لديك عنصر واحد.",
75 "item_two": "لديك عنصران.",
76 "item_few": "لديك {count} عناصر.",
77 "item_many": "لديك {count} عنصرا.",
78 "item_other": "لديك {count} عنصر.",
79 "language": "اللغة: العربية",
80 "count_label": "العدد: {count}",
81 "instructions": "1=EN 2=FR 3=JA 4=AR ←/→ ESC",
82 },
83}
84
85# Ordered locale list drives both the key bindings and the on-screen buttons.
86LOCALES = [("en", "EN"), ("fr", "FR"), ("ja", "JA"), ("ar", "AR")]
87_LOCALE_KEYS = {"en": Key.KEY_1, "fr": Key.KEY_2, "ja": Key.KEY_3, "ar": Key.KEY_4}
88_RTL_LOCALES = {"ar"}
89
90PANEL_W, PANEL_H = 560.0, 340.0
91
92
93class I18nDemo(Node):
94 """Root scene: a centred panel that swaps its language and plural forms live."""
95
96 def on_ready(self):
97 ts = Translations.instance()
98 ts.load_dict(TRANSLATIONS)
99 ts.locale = "en"
100 self._item_count = 5
101
102 # Key bindings: 1-4 select a locale, arrows change the count, ESC quits.
103 for code, key in _LOCALE_KEYS.items():
104 InputMap.add_action(f"locale_{code}", [key])
105 InputMap.add_action("count_down", [Key.LEFT])
106 InputMap.add_action("count_up", [Key.RIGHT])
107 InputMap.add_action("quit", [Key.ESCAPE])
108
109 # Centred panel: symmetric margins around the screen centre keep it put
110 # at any window size, so the layout stays legible and resize-aware.
111 panel = Panel(name="Panel")
112 panel.set_anchor_preset(AnchorPreset.CENTER)
113 panel.margin_left = -PANEL_W / 2
114 panel.margin_right = PANEL_W / 2
115 panel.margin_top = -PANEL_H / 2
116 panel.margin_bottom = PANEL_H / 2
117 panel.bg_colour = Colour.hex("#1A1A2E")
118 self.add_child(panel)
119
120 # Vertical stack filling the panel with a small inset. FULL_RECT
121 # stretches both axes, so all four margins are positive insets.
122 column = VBoxContainer(name="Column")
123 column.separation = 14.0
124 column.set_anchor_preset(AnchorPreset.FULL_RECT)
125 column.margin_left = 26
126 column.margin_right = 26
127 column.margin_top = 22
128 column.margin_bottom = 22
129 panel.add_child(column)
130
131 self._title = self._add_label(column, font_size=20.0, colour=Colour.WHITE, align="center")
132 self._greeting = self._add_label(column, font_size=18.0, colour=Colour.hex("#FFD166"))
133 self._plural = self._add_label(column, font_size=16.0, colour=Colour.WHITE)
134 self._language = self._add_label(column, font_size=14.0, colour=Colour.LIGHT_GRAY)
135
136 # Item-count row: minus / plus buttons flanking the live count.
137 count_row = HBoxContainer(name="CountRow")
138 count_row.separation = 10.0
139 self._minus = minus = Button("-", on_press=lambda: self._change_count(-1))
140 minus.size = Vec2(40, 30)
141 count_row.add_child(minus)
142 self._count_label = Label("")
143 self._count_label.font_size = 14.0
144 self._count_label.size = Vec2(120, 30)
145 self._count_label.alignment = "center"
146 count_row.add_child(self._count_label)
147 self._plus = plus = Button("+", on_press=lambda: self._change_count(1))
148 plus.size = Vec2(40, 30)
149 count_row.add_child(plus)
150 column.add_child(count_row)
151
152 # Clickable locale buttons mirror the 1-4 keys for mouse/touch play.
153 button_row = HBoxContainer(name="Locales")
154 button_row.separation = 8.0
155 self._locale_buttons: dict[str, Button] = {}
156 for code, short in LOCALES:
157 btn = Button(short, on_press=lambda c=code: self._set_locale(c))
158 btn.size = Vec2(108, 34)
159 self._locale_buttons[code] = btn
160 button_row.add_child(btn)
161 column.add_child(button_row)
162
163 self._help = self._add_label(column, font_size=12.0, colour=Colour.GRAY, align="center")
164
165 self._refresh()
166
167 @staticmethod
168 def _add_label(parent, *, font_size: float, colour, align: str = "left") -> Label:
169 label = Label("")
170 label.font_size = font_size
171 label.text_colour = colour
172 label.alignment = align
173 parent.add_child(label)
174 return label
175
176 def on_update(self, dt: float):
177 if Input.is_action_just_pressed("quit"):
178 self.app.quit()
179 return
180 for code, _short in LOCALES:
181 if Input.is_action_just_pressed(f"locale_{code}"):
182 self._set_locale(code)
183 break
184 if Input.is_action_just_pressed("count_up"):
185 self._change_count(1)
186 if Input.is_action_just_pressed("count_down"):
187 self._change_count(-1)
188
189 def _set_locale(self, code: str):
190 Translations.instance().locale = code
191 self._refresh()
192
193 def _change_count(self, delta: int):
194 self._item_count = max(0, self._item_count + delta)
195 self._refresh()
196
197 def _refresh(self):
198 ts = Translations.instance()
199 rtl = ts.locale in _RTL_LOCALES
200 # Right-align the translated body lines for right-to-left locales so the
201 # presentation reads in the correct direction.
202 body_align = "right" if rtl else "left"
203
204 self._title.text = f"SimVX i18n Demo [{ts.locale.upper()}]"
205 self._greeting.text = tr("greeting", name="Player")
206 self._plural.text = ts.translate_plural("item", self._item_count)
207 self._language.text = tr("language")
208 self._help.text = tr("instructions")
209 for label in (self._greeting, self._plural, self._language):
210 label.alignment = body_align
211
212 # The count row is localised too: it goes through tr() like the other body lines.
213 self._count_label.text = tr("count_label", count=self._item_count)
214
215 # Highlight the active locale button so the current language is obvious.
216 for code, btn in self._locale_buttons.items():
217 btn.set_visual_state_override("pressed" if code == ts.locale else None)
218
219
220def _selftest() -> bool:
221 """Headless: switch locale by key and by button, and walk the plural categories.
222
223 Both routes into a locale are driven the way a player drives them -- a key
224 press through the action map, a click on the button's own rectangle -- and the
225 result is read back off the labels. The plural claim is the interesting one, so
226 the Arabic count is stepped through values that select each of its six
227 categories and the phrases are required to be six different strings.
228 """
229 from simvx.core.testing import InputSimulator
230 from simvx.core.ui.testing import UITestHarness
231
232 harness = UITestHarness(I18nDemo(name="I18nDemo"), screen_size=(720, 480))
233 scene = harness.tree.root
234 sim = InputSimulator(tree=harness.tree)
235 ok = True
236
237 def check(label: str, passed: bool, detail: str) -> None:
238 nonlocal ok
239 ok = ok and passed
240 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
241
242 def tap(key: Key) -> None:
243 """One frame with the key down: the scene reads it as just-pressed."""
244 sim.press_key(key)
245 harness.tick()
246 sim.release_key(key)
247
248 harness.tick()
249 check("it opens in English", Translations.instance().locale == "en", scene._language.text)
250
251 by_key = {}
252 for code, _short in LOCALES:
253 tap(_LOCALE_KEYS[code])
254 by_key[code] = (Translations.instance().locale, scene._language.text, scene._greeting.text)
255 check(
256 "each number key selects its locale and relabels the whole panel",
257 all(got == code for code, (got, _, _) in by_key.items())
258 and len({lang for _, lang, _ in by_key.values()}) == len(LOCALES),
259 " | ".join(lang for _, lang, _ in by_key.values()),
260 )
261 check(
262 "and the greeting is translated with the name interpolated into it",
263 all("Player" in greeting for _, _, greeting in by_key.values()),
264 " | ".join(greeting for _, _, greeting in by_key.values()),
265 )
266
267 # The buttons are the mouse route to the same switch, and the active one is
268 # held in its pressed look so the current language is visible.
269 harness.click(scene._locale_buttons["fr"])
270 harness.tick()
271 pressed = {code: btn.visual_state_override for code, btn in scene._locale_buttons.items()}
272 check(
273 "clicking a locale button switches too, and only that button stays pressed",
274 Translations.instance().locale == "fr"
275 and [code for code, state in pressed.items() if state is not None] == ["fr"],
276 f"{scene._language.text}; pressed = {[c for c, s in pressed.items() if s is not None]}",
277 )
278
279 # Right-to-left locales right-align the translated body lines.
280 harness.click(scene._locale_buttons["ar"])
281 harness.tick()
282 rtl = {label.alignment for label in (scene._greeting, scene._plural, scene._language)}
283 harness.click(scene._locale_buttons["en"])
284 harness.tick()
285 ltr = {label.alignment for label in (scene._greeting, scene._plural, scene._language)}
286 check(
287 "the Arabic body reads right to left and the English one left to right",
288 rtl == {"right"} and ltr == {"left"},
289 f"ar {rtl.pop()}, en {ltr.pop()}",
290 )
291
292 # English has two plural forms; the count is only ever moved by clicking the
293 # row's own buttons, so their wiring is part of every count below.
294 def set_count(target: int) -> None:
295 button = scene._plus if target > scene._item_count else scene._minus
296 for _ in range(abs(target - scene._item_count)):
297 harness.click(button)
298 harness.tick()
299
300 set_count(1)
301 one = scene._plural.text
302 harness.click(scene._plus)
303 harness.tick()
304 two = scene._plural.text
305 check(
306 "English picks the singular at one and the plural at two",
307 one.endswith("item.") and two.endswith("items.") and scene._item_count == 2,
308 f"{one} / {two}",
309 )
310
311 for _ in range(4):
312 harness.click(scene._minus)
313 harness.tick()
314 check(
315 "the minus button stops at zero rather than going negative",
316 scene._item_count == 0,
317 f"count = {scene._item_count} after four more clicks from two",
318 )
319
320 # Arabic's six CLDR categories, each selected by a count that falls in it.
321 harness.click(scene._locale_buttons["ar"])
322 harness.tick()
323 phrases = {}
324 for count, category in ((0, "zero"), (1, "one"), (2, "two"), (3, "few"), (11, "many"), (100, "other")):
325 set_count(count)
326 phrases[category] = scene._plural.text
327 check(
328 "Arabic selects a different phrase for each of its six plural categories",
329 len(set(phrases.values())) == 6
330 and all(
331 phrases[c] == TRANSLATIONS["ar"][f"item_{c}"].replace("{count}", str(n))
332 for c, n in (("zero", 0), ("one", 1), ("two", 2), ("few", 3), ("many", 11), ("other", 100))
333 ),
334 ", ".join(phrases),
335 )
336
337 harness.teardown()
338 print("SELFTEST:", "PASS" if ok else "FAIL")
339 return ok
340
341
342if __name__ == "__main__":
343 import sys
344
345 if "--test" in sys.argv:
346 sys.exit(0 if _selftest() else 1)
347 App(title="SimVX i18n Demo", width=720, height=480).run(I18nDemo())