Internationalisation

switch UI language and watch plural rules update live.

▶ Run in browser

Tags: 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().locale live.

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