diff --git a/Cargo.lock b/Cargo.lock index e55839e8..c49dc459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,15 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -420,6 +429,12 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + [[package]] name = "base64" version = "0.22.1" @@ -526,6 +541,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -1799,6 +1824,36 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "glow" version = "0.16.0" @@ -2438,6 +2493,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -2543,6 +2614,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3115,6 +3195,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3829,6 +3918,7 @@ dependencies = [ "rand 0.10.1", "reqwest 0.13.4", "rfd", + "rust-i18n", "serde", "serde_json", "serde_yaml", @@ -4142,7 +4232,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -4458,6 +4548,54 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rust-i18n" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55691a65892c33ee2de49c15ea5600c6f4a70e8eeb8e6c3cd96d2a231d230c40" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30de488acadcf767d97cd48518a8da8ea9777b1c9a5beca4eab78bbf77d07309" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn", +] + +[[package]] +name = "rust-i18n-support" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea0fef8a93c06326b66392c95a115120e609674cb2132d37d276a6b05b545b4" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5850,6 +5988,17 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 39eadad2..ed60eaa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,14 @@ authors = ["Aaron Allen "] description = "A one-stop-shop capsuleer manager for EVE Online." edition = "2024" homepage = "https://pod.aaronmallen.dev" -include = ["assets/*", "migrations/*", "src/*", "LICENSE", "README.md"] +include = [ + "assets/*", + "locales/*", + "migrations/*", + "src/*", + "LICENSE", + "README.md", +] license = "MIT" name = "pod" publish = false @@ -66,6 +73,7 @@ open = "5" rand = "0.10" reqwest = { features = ["form", "gzip", "json"], version = "0.13" } rfd = "0.17" +rust-i18n = "4" serde = "1" serde_json = "1" serde_yaml = "0.9" diff --git a/docs/dev/README.md b/docs/dev/README.md index b0383b35..7a98921e 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -4,10 +4,12 @@ - [Code Style] – How we write and structure our code - [Commits] – How we write and structure our commits +- [i18n] – How we localize UI strings - [Tasks] – How to use development scripts - [Testing] – How we test our code [Code Style]: code-style.md [Commits]: commits.md +[i18n]: i18n.md [Tasks]: tasks.md [Testing]: testing.md diff --git a/docs/dev/i18n.md b/docs/dev/i18n.md new file mode 100644 index 00000000..fa2ff582 --- /dev/null +++ b/docs/dev/i18n.md @@ -0,0 +1,96 @@ +# Internationalization (i18n) + +Pod localizes its own UI chrome with [`rust-i18n`](https://crates.io/crates/rust-i18n). Translation strings live in +TOML files under `locales/` at the repo root. The framework is wired in `src/main.rs` (the `i18n!` macro) and the +active locale is set once at startup from the configured language. + +## How it is wired + +- `src/main.rs` declares `#[macro_use] extern crate rust_i18n;` (exposing the `t!` macro crate-wide) and calls + `rust_i18n::i18n!("locales", fallback = "en");` at the crate root. +- `src/i18n.rs` exposes `i18n::set_locale(language: Language)`, which maps the `Language` enum to its rust-i18n locale + string via `Language::esi_code()` (the enum codes — `en`, `de`, `en-us`, `es`, `fr`, `ja`, `ko`, `ru`, `zh` — are + exactly the locale strings rust-i18n expects). +- `app::boot()` calls `i18n::set_locale(accessibility.language())` once at startup, alongside the existing + high-contrast and scale application. A language change applies at the next restart (see ADR-0041); nothing swaps the + locale live mid-session. + +## Locale file layout — one file per feature + +Each feature module owns its own locale fragment. **Do not funnel strings into a single shared `locales/en.toml`.** +rust-i18n globs `locales/**/*.{toml,yml,yaml}` and merges every file, and for `_version: 1` files (the default) it +reads the locale from **the segment after the last dot in the filename stem**. So: + +```text +locales/ + shell.en.toml # locale "en" (shell.en -> last segment "en") + wallet.en.toml # locale "en" + assets.en.toml # locale "en" + common.en.toml # locale "en" (shared-ui namespace) + wallet.de.toml # locale "de" + _meta..toml # one per locale, registers the nine ESI codes +``` + +Every fragment is merged into one flat key map per locale, so `locales/wallet.en.toml` and `locales/shell.en.toml` +both contribute to the `en` locale without colliding as long as their top-level namespaces differ. This is what lets +each feature's string-extraction task edit a different file: pick **your feature's** fragment and never touch another. + +The fragment filename is `..toml`. The English baseline you author is `.en.toml`. The eight +non-`en` locales are stubbed at the repo level (`_meta..toml`) so all nine codes register today; the +machine-translated `..toml` fragments land later. `en-us` begins as an exact copy of `en` and is +reconciled at the translation capstone. + +## Key-namespace convention + +Keys are dot-separated and namespaced by their origin: + +| Pattern | Use for | Example | +|-------------------------------|------------------------------------|------------------------------------| +| `..` | feature-module UI strings | `wallet.journal.title` | +| `config..` | user-facing labels on domain enums | `config.feature.wallet` | +| `common.` | shared, cross-feature strings | `common.cancel` | + +In a TOML fragment, group keys under their namespace as a table so the file stays a small, append-only surface: + +```toml +[shell.notifications] +mark_all_read = "Mark all read" +title = "Notifications" +``` + +`` is the module name (`wallet`, `assets`, `calendar`, `industry`, `mail`, `roster`, `skills`, `settings`, +`shell`, `splash`, ...). `` groups within a feature (`journal`, `transactions`, ...). Keep variants and keys +alphabetical, matching the project's ordering convention. + +## Interpolation convention + +Use rust-i18n named placeholders — `%{name}` in the locale value, and `name => value` (or `name = value`) when +calling `t!`. Never build user-facing strings with `format!`; interpolate through `t!` so word order can change per +language. + +```toml +# locales/shell.en.toml +[shell.notifications] +footer_unread = "%{count} unread" +``` + +```rust +// returns a Cow; call .into_owned() / .to_string() when an owned String is needed +let label = t!("shell.notifications.footer_unread", count => total); +``` + +## Missing-key behavior + +- A key missing in the active locale resolves to the `en` value (the `fallback = "en"` chain). +- A key absent from every locale resolves to the key string itself and never panics. + +Both are covered by unit tests in `src/i18n.rs`. + +## Extracting strings (the per-feature task) + +1. Add or extend `locales/.en.toml` with the keys you are introducing, grouped under their namespace table. +2. Replace `text("literal")` with `text(t!(".."))` and `format!(...)` user-facing strings with + `t!("...", name => value)`. +3. Leave the non-`en` fragments to the translation pass — author only the English baseline. + +See `src/app.rs` notification chrome (`shell.notifications.*`) for the reference conversion. diff --git a/locales/_meta.de.toml b/locales/_meta.de.toml new file mode 100644 index 00000000..5fc3b84e --- /dev/null +++ b/locales/_meta.de.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "de" diff --git a/locales/_meta.en-us.toml b/locales/_meta.en-us.toml new file mode 100644 index 00000000..781d2ea8 --- /dev/null +++ b/locales/_meta.en-us.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "en-us" diff --git a/locales/_meta.en.toml b/locales/_meta.en.toml new file mode 100644 index 00000000..0f9e4ffe --- /dev/null +++ b/locales/_meta.en.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "en" diff --git a/locales/_meta.es.toml b/locales/_meta.es.toml new file mode 100644 index 00000000..0540f89d --- /dev/null +++ b/locales/_meta.es.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "es" diff --git a/locales/_meta.fr.toml b/locales/_meta.fr.toml new file mode 100644 index 00000000..d88e90fe --- /dev/null +++ b/locales/_meta.fr.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "fr" diff --git a/locales/_meta.ja.toml b/locales/_meta.ja.toml new file mode 100644 index 00000000..b87f0adf --- /dev/null +++ b/locales/_meta.ja.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "ja" diff --git a/locales/_meta.ko.toml b/locales/_meta.ko.toml new file mode 100644 index 00000000..cedf2298 --- /dev/null +++ b/locales/_meta.ko.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "ko" diff --git a/locales/_meta.ru.toml b/locales/_meta.ru.toml new file mode 100644 index 00000000..4ff5ed0e --- /dev/null +++ b/locales/_meta.ru.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "ru" diff --git a/locales/_meta.zh.toml b/locales/_meta.zh.toml new file mode 100644 index 00000000..ef5bfbf5 --- /dev/null +++ b/locales/_meta.zh.toml @@ -0,0 +1,2 @@ +[_meta] +locale = "zh" diff --git a/locales/common.en.toml b/locales/common.en.toml new file mode 100644 index 00000000..4c9313c9 --- /dev/null +++ b/locales/common.en.toml @@ -0,0 +1,2 @@ +[common] +cancel = "Cancel" diff --git a/locales/shell.en.toml b/locales/shell.en.toml new file mode 100644 index 00000000..ad5a0ab0 --- /dev/null +++ b/locales/shell.en.toml @@ -0,0 +1,5 @@ +[shell.notifications] +footer_total = "%{count} total" +footer_unread = "%{count} unread" +mark_all_read = "Mark all read" +title = "Notifications" diff --git a/src/app.rs b/src/app.rs index 52c5d21b..186eaf79 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,7 +42,7 @@ use crate::{ splash, wallet, wallet::contract_detail, }, - mcp, + i18n, mcp, services::{crash, images, telemetry, updater}, store, sync::{self, FreshnessSummary, JobKey, JobKind}, @@ -1047,6 +1047,7 @@ fn boot() -> (App, Task) { let settings = config::load().unwrap_or_default(); let accessibility = *settings.accessibility(); color::set_high_contrast(*accessibility.high_contrast()); + i18n::set_locale(accessibility.language()); let image_root = settings.storage().resolved_cache_dir().join("images"); store::images::init_root(image_root); @@ -2395,7 +2396,7 @@ fn notifications_panel(app: &App, nav_location: config::NavLocation) -> Element< }; let header = Row::with_children(vec![ - text("Notifications") + text(t!("shell.notifications.title")) .font(typography::body::MEDIUM) .size(typography::size::LG) .style(typography::colored(color::text::PRIMARY)) @@ -2474,8 +2475,10 @@ fn notifications_panel(app: &App, nav_location: config::NavLocation) -> Element< /// rows, mirroring the mockup's `shown.length > 0` guard. fn notifications_footer_label(tab: NotificationTab, total: usize, history_count: usize) -> Option { match tab { - NotificationTab::New => (total > 0).then(|| format!("{total} unread")), - NotificationTab::History => (history_count > 0).then(|| format!("{total} total")), + NotificationTab::New => (total > 0).then(|| t!("shell.notifications.footer_unread", count => total).into_owned()), + NotificationTab::History => { + (history_count > 0).then(|| t!("shell.notifications.footer_total", count => total).into_owned()) + } } } @@ -2486,7 +2489,7 @@ fn mark_all_read_button<'a>(enabled: bool) -> button::Button<'a, Message> { color::text::tertiary() }; let button = button( - text("Mark all read") + text(t!("shell.notifications.mark_all_read")) .font(typography::body::MEDIUM) .size(typography::size::XS_PLUS) .style(move |_| text::Style { diff --git a/src/i18n.rs b/src/i18n.rs index f2aec935..093dc802 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -4,3 +4,44 @@ mod language; // nothing outside this module references it yet, so it reads as unused until those land. #[allow(unused_imports)] pub use language::Language; + +pub fn set_locale(language: Language) { + rust_i18n::set_locale(language.esi_code()); +} + +#[cfg(test)] +mod tests { + use super::*; + + mod set_locale { + use super::*; + + #[test] + fn it_resolves_a_key_in_the_active_locale() { + set_locale(Language::En); + + assert_eq!(t!("shell.notifications.title"), "Notifications"); + } + + #[test] + fn it_interpolates_a_named_placeholder() { + set_locale(Language::En); + + assert_eq!(t!("shell.notifications.footer_unread", count => 3), "3 unread"); + } + + #[test] + fn it_falls_back_to_en_for_a_missing_locale() { + set_locale(Language::EnUs); + + assert_eq!(t!("shell.notifications.title"), "Notifications"); + } + + #[test] + fn it_returns_the_key_for_an_absent_key_without_panicking() { + set_locale(Language::En); + + assert_eq!(t!("totally.absent.key"), "totally.absent.key"); + } + } +} diff --git a/src/main.rs b/src/main.rs index f1168bea..0abd1d6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,10 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +#[macro_use] +extern crate rust_i18n; + +rust_i18n::i18n!("locales", fallback = "en"); + mod app; mod clients; mod config;