Skip to content

feat(i18n): add interface localization with 10 languages - #88

Open
houko wants to merge 2 commits into
otty-shell:mainfrom
houko:feat/i18n-zh-cn
Open

feat(i18n): add interface localization with 10 languages#88
houko wants to merge 2 commits into
otty-shell:mainfrom
houko:feat/i18n-zh-cn

Conversation

@houko

@houko houko commented Aug 16, 2026

Copy link
Copy Markdown

Type of change

  • New Feature
  • Experimental Feature
  • Improvement
  • Performance Improvement
  • Backward Incompatible Change
  • Build/Testing/Packaging Improvement
  • Documentation (changelog entry is not required)
  • Critical Bug Fix (crash, data loss, RBAC) or LOGICAL_ERROR
  • Bug Fix (user-visible misbehavior in an official stable release)
  • CI Fix or Improvement (changelog entry is not required)
  • Not for changelog (changelog entry is not required)

User readable description

OTTY can now render its interface in ten languages. A new Settings › General › Language control offers System plus English, 简体中文, 繁體中文, 日本語, 한국어, Français, Deutsch, Español, Português (Brasil), and Русский. System follows the OS locale, so a non-English desktop gets a translated UI on first launch.

Before this PR every user-facing string was a hardcoded English literal spread across the view, type, and error modules, so the UI could not be translated at all.

Language names in the selector are endonyms, so a user can find their language without already being able to read the current interface language.


How translations are stored

Catalogs are JSON files under otty/src/i18n/locales/, embedded with include_str! so the binary stays self-contained:

otty/src/i18n/
├── key.rs              # Key enum — one variant per string
├── mod.rs              # loader, active locale, t(), template filling
└── locales/
    ├── en.json      ├── ko.json      ├── es.json
    ├── zh-CN.json   ├── fr.json      ├── pt-BR.json
    ├── zh-TW.json   ├── de.json      └── ru.json
    └── ja.json

Key stays a Rust enum so call sites remain type-checked, and serde deserializes each catalog straight into HashMap<Key, String>. That catches mistakes from both directions: an unknown key in a JSON file fails deserialization, a missing key is caught by catalogs_cover_every_key, and an extra one by catalogs_have_no_extra_keys.

Strings that interpolate values are templates with named placeholders ("Failed to launch \"{title}\"") filled by fill_in, so word order stays translatable instead of being hardcoded to English sentence structure.

Adding a language is a JSON file plus one Locale variant — no view code, no settings code. Locale::ALL is the single source of list order; catalog caches into an array indexed by Locale::index; and LanguageSetting is System | Fixed(Locale) with its selector derived from Locale::ALL rather than one variant per language.

No new dependency: serde and serde_json were already dependencies of the crate.

Locale detection

Chinese resolves by script rather than by primary subtag, so zh-TW, zh-HK, zh-MO, and an explicit Hant select Traditional while everything else Chinese selects Simplified. POSIX strings are normalized before matching, so de_AT@euro and ja_JP.UTF-8 resolve correctly. Detection reads LC_ALL, LC_MESSAGES, LANG in that order.

A stored general.language value, by contrast, must match a shipped tag exactly — an unrecognized or partial tag keeps following the system instead of silently picking a language.

Design decisions worth reviewing

The active locale is process-global. Threading a locale parameter through every widget signature would have touched nearly every view function, and the locale only changes when the user saves settings. If you would rather see it passed through ThemeProps-style props, I'm happy to rework it.

Settings tree paths had to be rebuilt on language change. This is the subtle one. TreePath is built by concatenating TreeNode::title() values (otty-ui/tree/src/model.rs:59), and settings section titles are now localized — so switching language leaves selected_path pointing at the previous language's title and silently breaks the navigation selection. SettingsState::replace_with_settings now rebuilds the tree and the selected path, and the locale is applied before that happens, both at startup (load_initial_settings_state) and on save (settings reducer).

Settings tests no longer hardcode English titles. They read paths from the tree instead, so they don't depend on which locale is active.

Deliberately not translated

  • Theme preset names (One Dark, Solarized Dark, Dracula) — proper nouns.
  • Sample placeholder values (example.com, ~/.ssh/id_ed25519, --flag) — format examples, not prose.
  • Log messages and infrastructure error types — these are for operators, not end users.

Backward compatibility

general.language is a new field. Settings files written by older builds simply lack it and fall back to System, covered by given_json_without_general_when_parsed_then_language_defaults_to_system.

{
  "general": { "language": "zh-CN" },
  "terminal": { "shell": "/bin/zsh", "editor": "nano" },
  "theme": { "palette": ["#..."] }
}

Verification

Built and ran the app under LC_ALL=zh_CN.UTF-8 and LC_ALL=ja_JP.UTF-8 against an isolated HOME. CJK glyphs render correctly through iced/cosmic-text system font fallback, so no embedded CJK font is needed — that was the main risk in this change.

$ cargo test --workspace --all-features
... 524 passed, 0 failed

$ cargo deny check
advisories ok, bans ok, licenses ok, sources ok

Coverage for the otty crate went up, 54.97% → 55.78% lines, measured with the same command on a clean main and on this branch.

27 tests were added. Three of them exist specifically because the translations are hand-written:

  • every locale keeps the {title}, {command}, {error}, {index} placeholders in its templates;
  • no catalog entry or palette label is blank;
  • every Locale::index matches its position in ALL — the invariant the catalog array indexing relies on.

The completeness tests iterate Locale::ALL, so a new language is covered the moment it is listed.

A note on translation review

The nine non-English catalogs are my own work, not machine-translated boilerplate, and I've used consistent terminology within each language (e.g. Traditional Chinese uses 分頁/資料夾/連接埠 rather than the mainland equivalents). That said, I'm a native speaker of only some of these. If you'd prefer to merge with fewer languages and let native speakers contribute the rest, I'm happy to trim the set — each language is a single self-contained JSON file, so dropping one is a file deletion plus one enum variant.

Pre-existing issues noticed while verifying

These reproduce identically on a clean main and are not touched by this PR. Reporting them in case they're useful:

  1. otty-escape/src/osc.rs:256 trips clippy::useless_borrows_in_formatting, a lint added in Rust 1.97, while rust-toolchain.toml pins 1.96. Only surfaces when RUSTUP_TOOLCHAIN overrides the pinned version.
  2. On macOS, otty/src/view.rs and otty/src/events/mod.rs report unused imports/variables because those bindings are only used in #[cfg(not(target_os = "macos"))] branches. This makes the lint pre-commit hook fail for any commit on a macOS machine.
  3. otty-ui-term's double_click_clears_selection fails under cargo llvm-cov instrumentation — the double-click time window is exceeded when instrumentation slows the build.
  4. given_default_state_when_set_shell_then_marks_dirty and given_save_completed_when_reduced_then_marks_state_saved fail when $SHELL is /bin/zsh: they use /bin/zsh as a value expected to differ from the default, but default_shell() reads $SHELL. They pass under SHELL=/bin/bash.

Happy to address any of these in a separate PR if you'd like.

Introduces a localization layer for the application UI and ships an English and a Simplified Chinese catalog. Previously every user-facing string was a hardcoded English literal spread across view, type, and error modules, so the interface could not be translated at all.

Translations live in JSON files under `otty/src/i18n/locales/` and are embedded with `include_str!`, keeping the binary self-contained. `Key` stays a Rust enum so call sites remain type-checked, and serde deserializes each catalog into `HashMap<Key, String>`; an unknown key in a JSON file fails deserialization, and tests assert that both catalogs cover every `Key` and contain no extras. No new dependency is required — `serde` and `serde_json` were already used by the crate.

The active locale is process-global rather than threaded through every widget signature, which would have touched every view function for no benefit. `Settings > General > Language` offers System / English / 简体中文, persisted as `general.language`; `System` resolves through the POSIX variables `LC_ALL`, `LC_MESSAGES`, `LANG`. Settings written by older builds simply lack the field and fall back to `System`.

Section titles double as `TreePath` segments in the settings navigation tree, so a language change invalidates every stored path. `SettingsState::replace_with_settings` now rebuilds the tree and the selected path, and the locale is applied before that happens — both at startup in `load_initial_settings_state` and on save in the reducer. The settings tests read their paths from the tree instead of hardcoding English titles, so they no longer depend on which locale is active.

Theme preset names (One Dark, Solarized Dark, Dracula) are left untranslated as proper nouns, as are sample placeholder values such as `example.com` and `~/.ssh/id_ed25519`. Log messages and infrastructure error types stay in English; only strings that reach the UI are localized.
Adds Traditional Chinese, Japanese, Korean, French, German, Spanish, Brazilian Portuguese, and Russian, bringing the shipped set to ten languages.

Adding a language previously meant touching three places: a `Locale` variant, a hand-written `OnceLock` in `catalog`, and a matching `LanguageSetting` variant. That does not scale past a couple of languages, so both types are now data-driven. `Locale::ALL` is the single list, `catalog` caches into an array indexed by `Locale::index`, and `LanguageSetting` collapsed from one variant per language into `System | Fixed(Locale)` with its selector derived from `Locale::ALL`. Adding a language is now a JSON file plus one enum variant, with no view or settings changes.

Locale detection handles the cases that matter for these languages. Chinese resolves by script rather than by primary subtag, so `zh-TW`, `zh-HK`, `zh-MO`, and an explicit `Hant` select Traditional while everything else Chinese selects Simplified. POSIX strings are normalized before matching, so `de_AT@euro` and `ja_JP.UTF-8` resolve correctly. A stored `general.language` value, in contrast, must match a shipped tag exactly, so an unrecognized or partial tag keeps following the system instead of silently picking a language.

Three tests were added that matter specifically for hand-written translations: every locale keeps the `{title}`, `{command}`, `{error}`, and `{index}` placeholders in its templates; no catalog entry or palette label is blank; and every `Locale::index` matches its position in `ALL`, which is the invariant the catalog array indexing relies on. The existing completeness tests now iterate `Locale::ALL` rather than a hardcoded pair, so a new language is covered the moment it is listed.

Language names in the selector are endonyms (日本語, Русский, Português (Brasil)) so a user can find their language without already being able to read the current interface language.
@houko houko changed the title feat(i18n): add interface localization with Simplified Chinese feat(i18n): add interface localization with 10 languages Aug 16, 2026
@kemokempo

Copy link
Copy Markdown
Contributor

Hi thx for your work please make the rebase with main

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.

2 participants