Skip to content

feat: add UI translation system (i18n) with live language switching#735

Open
foXaCe wants to merge 3 commits into
LoveRetro:mainfrom
foXaCe:feature/i18n-translation-system
Open

feat: add UI translation system (i18n) with live language switching#735
foXaCe wants to merge 3 commits into
LoveRetro:mainfrom
foXaCe:feature/i18n-translation-system

Conversation

@foXaCe
Copy link
Copy Markdown

@foXaCe foXaCe commented May 21, 2026

Summary

Adds a runtime translation layer so all user-facing strings can be localized without restarting the app. English ships as the reference language; French is bundled as a complete second locale (425 keys).

The default behavior is unchanged for existing installs: with no .lang files present or language=en, every T(key) falls back to the literal — which for legacy strings is the original English text → zero regression.

Architecture

  • New workspace/all/common/i18n.{c,h} — minimal i18n core
    • Hash table (FNV-1a, 1024 buckets, 32 KB arena)
    • Plain key=value .lang files (no JSON dependency)
    • T(key) returns the translation, or the input verbatim when no entry matches (safe fallback)
    • I18N_init(code) called from GFX_init; I18N_reload(code) hot-swaps the table at runtime
  • New skeleton/SYSTEM/res/lang/{en,fr}.lang (425 paired keys)
  • NextUISettings.language[8] persisted in minuisettings.txt (defaults to \"en\"; backward-compatible read)

Coverage

All user-visible strings flow through T():

  • Launcher: nextui.c
  • In-game menu: minarch/{ma_menu,ma_frontend_opts,ma_saves,ra_integration}.c
  • Settings UI (settings/*.cpp/hpp): Appearance, Display, Audio, System, FN switch, In-Game, RetroAchievements (incl. sync overlay), About, Bluetooth, WiFi, color picker, keyboard prompt
  • Hardware hints in common/api.c
  • Tools: battery, clock, gametime, ledcontrol, minput, bootlogo

Hot-reload (Settings only)

`Settings → System → Language` switches locale live without exit:

  • `AbstractMenuItem::getName/getDesc` and `MenuItem::getLabel/getLabels` now call `T()` at each access (storing keys, not pre-translated strings)
  • `ScopedOverlay` and `MenuList::showOverlay` apply `T()` internally
  • `getRawName()` added so `selectByName` survives a language change

Other apps (launcher, minarch, tools) load the active language on startup — they are short-lived processes relaunched per action, so per-app hot-reload is unnecessary.

Build

`i18n.c` linked into every app that calls `T()`: nextui, minarch, settings, battery, clock, gametime, ledcontrol, minput, bootlogo (9 makefiles updated). Pure C module; safe to include from C++ via the existing `extern "C"` block in `settings/menu.hpp`.

Resource impact:

  • Disk: ~7 KB per locale (en.lang ≈ 20 KB, fr.lang ≈ 22 KB)
  • RAM: ~24 KB for the active table (8 KB buckets + 16 KB arena)
  • CPU: FNV-1a + open-addressed lookup ≈ 80 ns per `T()` call on Cortex-A53 @ 1.2 GHz; negligible vs. `TTF_RenderUTF8_Blended` already on the hot path

Compatibility

  • No-op for users without .lang files installed — behavior identical to before this PR
  • New `language=` line appended to `minuisettings.txt` (older consumers ignore it)
  • Adding a locale = drop `.lang` into `.system/res/lang/`; auto-discovered by Settings via `discoverLanguages()`
  • Latin-script locales fully covered by the existing `font1.ttf` (BPreplay). CJK / Cyrillic would need a complementary font pack — could naturally pair with #729 (custom-font picker) for a later phase

Test plan

  • Built natively (PLATFORM=desktop) and cross-compiled for tg5040 (Docker toolchain)
  • Smoke-tested the i18n module standalone (table load, lookup, fallback on missing key, hot-reload between en ↔ fr)
  • Settings runs without crash, full navigation Appearance/Display/System/FN switch/In-Game/RetroAchievements/About
  • Live language switch (Settings → System → Language → Français) updates every menu item, value, description and overlay without restart
  • Deployed on TrimUI Brick hardware; main launcher, in-game menu, and Tools paks all render in the selected language
  • Smart Pro (tg5050) — built but not validated on hardware (identical platform code path, should be a no-op)
  • Translators welcome: copy `en.lang` to `.lang` and translate values

Notes

  • Hardware button labels (A, B, X, Y, L1/R1, MENU, POWER…) are kept untranslated since they are physically silkscreened on the device
  • Language autonyms (English, Français, Deutsch…) are kept in their own language by convention
  • Keyboard virtual keys "shift", "space", "enter" are left as-is since they also serve as internal identifiers via `strcmp` and refactoring that would be disproportionate for this PR

Happy to split this into smaller PRs (core i18n, launcher, settings, tools…) if that's preferred for review.

foXaCe added 3 commits May 21, 2026 19:35
Adds a runtime translation layer so all user-facing strings can be
localized without restarting the app. English ships as the reference
language; French is bundled as a complete second locale (425 keys).

## Architecture

- New `workspace/all/common/i18n.{c,h}` — minimal i18n core:
  - Hash table (FNV-1a, 1024 buckets, 32 KB arena)
  - Parser for plain `key=value` `.lang` files (no JSON dep)
  - Lookup `T(key)` returns the translated string, or the input
    verbatim when no entry matches → safe fallback
  - `I18N_init(code)` called from `GFX_init`; `I18N_reload(code)`
    hot-swaps the table at runtime
- New `skeleton/SYSTEM/res/lang/{en,fr}.lang` (425 key pairs)
- `NextUISettings.language[8]` persisted in `minuisettings.txt`
  (defaults to "en"; backward-compatible read)

## Coverage

All user-visible strings are passed through `T()`:

- Launcher: `workspace/all/nextui/nextui.c`
- In-game menu: `workspace/all/minarch/{ma_menu,ma_frontend_opts,
  ma_saves,ra_integration}.c`
- Settings UI (`workspace/all/settings/*.cpp/hpp`): main menus,
  Appearance, Display, Audio, System, FN switch, In-Game,
  RetroAchievements (including sync overlay messages), About,
  Bluetooth, WiFi, color picker, keyboard prompt
- Hardware hints in `workspace/all/common/api.c`
- Tools: `battery`, `clock`, `gametime`, `ledcontrol`, `minput`,
  `bootlogo` (button hints + on-screen labels)

## Hot-reload (Settings only)

`Settings → System → Language` switches locale live without exit:

- `AbstractMenuItem::getName/getDesc` and `MenuItem::getLabel/getLabels`
  now call `T()` at each access (storing i18n keys, not pre-translated
  strings)
- `ScopedOverlay` and `MenuList::showOverlay` apply `T()` internally
- `getRawName()` added for `selectByName` so selection survives a
  language change
- Other apps (launcher, minarch, tools) load the active language on
  startup — since they're short-lived processes relaunched per action,
  no hot-reload is required there

## Build

- `i18n.c` linked into every app that calls `T()`: `nextui`, `minarch`,
  `settings`, `battery`, `clock`, `gametime`, `ledcontrol`, `minput`,
  `bootlogo` (9 makefiles updated)
- Pure C module; safe to include from C++ via `extern "C"` (already
  done in `settings/menu.hpp`)

## Compatibility

- Default behavior unchanged: with no `.lang` files installed or
  `language=en`, every `T(key)` falls back to the literal key — which
  for legacy strings is the original English text → zero regression
- New `language=` line appended to `minuisettings.txt` (older
  consumers can simply ignore it)
- Adding a new locale = drop `<code>.lang` into `.system/res/lang/`;
  it is auto-discovered by Settings via `discoverLanguages()`
Covers the ~30 options of the in-game Frontend menu (MENU → Options →
Frontend during gameplay) plus their option-value labels. Adds ~90 new
key pairs to en.lang and fr.lang (total: 514 keys).

## Changes

- workspace/all/minarch/ma_config.c
  - Replace literal English strings in .name / .desc with i18n keys
    (e.g. `"Screen Scaling"` → `"frontend.opt.screen_scaling"`)
  - Replace label arrays (onoff_labels, scaling_labels, resample_labels,
    rewind_enable_labels, rewind_compression_accel_labels,
    ambient_labels, effect_labels, overlay_labels) with key-based
    entries
  - getScreenScalingDesc() returns a key instead of a literal
- workspace/all/minarch/ma_menu.c
  - Wrap rendering sites in T() so the displayed text is translated at
    each frame (live-reloadable):
    - item->name (3 sites)
    - item->values[item->value] (1 site)
    - item->values[j] (size calc, 1 site)
    - list/item->desc footer (size + blit, 2 sites)
- skeleton/SYSTEM/res/lang/en.lang
- skeleton/SYSTEM/res/lang/fr.lang
  - +~90 paired keys covering option names, descriptions, and value
    labels for the Frontend menu
…ollections)

The main launcher built its root menu through Entry_new(path, ENTRY_DIR),
which derives the displayed name from the directory path
(getDisplayName()), bypassing translation. Switch those root entries to
Entry_newNamed with i18n keys so the labels render in the active
language.

## Changes

- workspace/all/nextui/nextui.c
  - getRoot(): Tools / Recently Played / Collections now built with
    Entry_newNamed + T() keys
  - getQuickEntries(): same fix for the Quickswitcher entries
- skeleton/SYSTEM/res/lang/en.lang
- skeleton/SYSTEM/res/lang/fr.lang
  - +3 paired keys: launcher.tools, launcher.recently_played,
    launcher.collections

Brings total to 517 paired keys.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant