feat(i18n): add interface localization with 10 languages - #88
Open
houko wants to merge 2 commits into
Open
Conversation
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.
Contributor
|
Hi thx for your work please make the rebase with main |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type of change
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 Русский.
Systemfollows 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 withinclude_str!so the binary stays self-contained:Keystays a Rust enum so call sites remain type-checked, and serde deserializes each catalog straight intoHashMap<Key, String>. That catches mistakes from both directions: an unknown key in a JSON file fails deserialization, a missing key is caught bycatalogs_cover_every_key, and an extra one bycatalogs_have_no_extra_keys.Strings that interpolate values are templates with named placeholders (
"Failed to launch \"{title}\"") filled byfill_in, so word order stays translatable instead of being hardcoded to English sentence structure.Adding a language is a JSON file plus one
Localevariant — no view code, no settings code.Locale::ALLis the single source of list order;catalogcaches into an array indexed byLocale::index; andLanguageSettingisSystem | Fixed(Locale)with its selector derived fromLocale::ALLrather than one variant per language.No new dependency:
serdeandserde_jsonwere 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 explicitHantselect Traditional while everything else Chinese selects Simplified. POSIX strings are normalized before matching, sode_AT@euroandja_JP.UTF-8resolve correctly. Detection readsLC_ALL,LC_MESSAGES,LANGin that order.A stored
general.languagevalue, 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.
TreePathis built by concatenatingTreeNode::title()values (otty-ui/tree/src/model.rs:59), and settings section titles are now localized — so switching language leavesselected_pathpointing at the previous language's title and silently breaks the navigation selection.SettingsState::replace_with_settingsnow 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
example.com,~/.ssh/id_ed25519,--flag) — format examples, not prose.Backward compatibility
general.languageis a new field. Settings files written by older builds simply lack it and fall back toSystem, covered bygiven_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-8andLC_ALL=ja_JP.UTF-8against an isolatedHOME. 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.Coverage for the
ottycrate went up, 54.97% → 55.78% lines, measured with the same command on a cleanmainand on this branch.27 tests were added. Three of them exist specifically because the translations are hand-written:
{title},{command},{error},{index}placeholders in its templates;Locale::indexmatches its position inALL— 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
mainand are not touched by this PR. Reporting them in case they're useful:otty-escape/src/osc.rs:256tripsclippy::useless_borrows_in_formatting, a lint added in Rust 1.97, whilerust-toolchain.tomlpins 1.96. Only surfaces whenRUSTUP_TOOLCHAINoverrides the pinned version.otty/src/view.rsandotty/src/events/mod.rsreport unused imports/variables because those bindings are only used in#[cfg(not(target_os = "macos"))]branches. This makes thelintpre-commit hook fail for any commit on a macOS machine.otty-ui-term'sdouble_click_clears_selectionfails undercargo llvm-covinstrumentation — the double-click time window is exceeded when instrumentation slows the build.given_default_state_when_set_shell_then_marks_dirtyandgiven_save_completed_when_reduced_then_marks_state_savedfail when$SHELLis/bin/zsh: they use/bin/zshas a value expected to differ from the default, butdefault_shell()reads$SHELL. They pass underSHELL=/bin/bash.Happy to address any of these in a separate PR if you'd like.