From cf5604dc65ff2064885963cf4e2ed69fe865c212 Mon Sep 17 00:00:00 2001 From: Maksim Date: Sat, 10 Oct 2026 12:41:54 -0400 Subject: [PATCH] Add UI localisation (i18n) with a Russian catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every user-visible string in the interface is now looked up from a per-language catalog at render time instead of being compiled in as English. English stays the source of truth and the lookup key; a missing row falls back to English rather than failing. - crates/ui-egui/src/i18n: `t`/`tl!` for literals, `tr_ctx` (one English word needing different translations), `tr_id` (command-id keyed), `trn`/`tn` (plurals), `fmt` ({name} placeholders filled after the lookup) and `message`/`msg` (backend status and error text, translated where it is shown). Split into lang.rs (the registry and locale tags), detect.rs (the system language), translate.rs (lookups) and catalog.rs (the TSV parser); mod.rs only declares them. - ru.tsv: the whole interface in Russian, with East Slavic plural forms, month abbreviations, a day-month-year date template and each app's tagline. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English. - The language follows the system by default: ARTCRAFT_LAUNCHER_LOCALE, then the OS's preferred UI languages through the sys-locale crate (desktop only; LANGUAGE/LC_*/LANG on Linux and the BSDs, the preferred languages on macOS, the user's UI languages on Windows), and `?lang=` then `navigator.languages` on the web. Settings > Language overrides it. - core stays pure and wasm-safe: `time::ago_kind` and `month_short_en` let the UI render relative times and dates in the current language. Ported onto main from the original PR (#28): every string main added since (feed notices and alerts, the "using ArtCraft API and GitHub" footer, window buttons, waiting and retry states, the install-cancelled toasts, filter tooltips, package formats, the reveal label) goes through the catalog, with Russian rows. Also changed from the original: - `@msg` templates now match real messages: `Couldn't open {name}: {e}` translates "Couldn't open PhotoCraft: …", the most specific template wins. Rows for messages the launcher never sends (clipboard, recovery data) are gone; rows for its install/update/remove toasts are added. - The locale probes no longer spawn /usr/bin/defaults or reg.exe; the small, dependency-free sys-locale crate asks the OS instead. - Folded in from #34's framework fixes: catalog tests split rows with trim_end (trim dropped the leading TAB, so plain rows were silently skipped), the English plural fallback goes by n == 1, @plural rows take 1 to 3 forms, and Settings::language_label comes from the registry. - Tests are registry-driven: placeholders, plural form counts and, for every catalog marked complete, coverage of every tl!/t literal, every tn plural key, each tagline and the package formats. A Russian UI test renders the grid and table. Catalog warnings go to the log, not stderr. - ru.tsv: "Launcher" and "Craft App Suite" stay in English; "лаунчер" is spelled one way; "Verifying…" (Проверка целостности…) and "Discard download" (Удалить скачанное) no longer read like "Checking…" and "Cancel download"; the table's Installed/Latest headers are short enough to fit; the reveal rows match the backend's "Show in …" labels. - The toolbar drops its "Sort" caption when longer labels leave no room for it, rather than drawing over the filters. - Unused settings helpers and LANGUAGE_CODES were removed, and core's doc comments now describe what the UI actually translates. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 + Cargo.lock | 14 +- apps/artcraft-launcher-web/src/web.rs | 56 ++++- apps/artcraft-launcher/src/main.rs | 2 + crates/core/src/catalog.rs | 5 +- crates/core/src/platform.rs | 3 + crates/core/src/time.rs | 121 +++++++++- crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/src/app.rs | 36 +-- crates/ui-egui/src/dialogs.rs | 95 ++++---- crates/ui-egui/src/i18n/catalog.rs | 201 +++++++++++++++++ crates/ui-egui/src/i18n/detect.rs | 70 ++++++ crates/ui-egui/src/i18n/lang.rs | 140 ++++++++++++ crates/ui-egui/src/i18n/mod.rs | 34 +++ crates/ui-egui/src/i18n/ru.tsv | 314 ++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 294 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/translate.rs | 97 ++++++++ crates/ui-egui/src/lib.rs | 13 ++ crates/ui-egui/src/model.rs | 34 +-- crates/ui-egui/src/notes.rs | 6 +- crates/ui-egui/src/settings.rs | 22 +- crates/ui-egui/src/theme.rs | 9 +- crates/ui-egui/src/views.rs | 311 +++++++++++++++---------- crates/ui-egui/src/widgets.rs | 10 +- crates/ui-egui/tests/russian.rs | 129 +++++++++++ crates/ui-egui/tests/ui.rs | 3 + 26 files changed, 1810 insertions(+), 220 deletions(-) create mode 100644 crates/ui-egui/src/i18n/catalog.rs create mode 100644 crates/ui-egui/src/i18n/detect.rs create mode 100644 crates/ui-egui/src/i18n/lang.rs create mode 100644 crates/ui-egui/src/i18n/mod.rs create mode 100644 crates/ui-egui/src/i18n/ru.tsv create mode 100644 crates/ui-egui/src/i18n/tests.rs create mode 100644 crates/ui-egui/src/i18n/translate.rs create mode 100644 crates/ui-egui/tests/russian.rs diff --git a/AGENTS.md b/AGENTS.md index bbe73319..e0aa7aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,13 @@ hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % widt and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour. +### Languages + +Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` for literals, `fmt` +for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string +means adding its row to every catalog marked `complete_menus` (`ru.tsv`), which the i18n tests +check. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English. + ### Other rules 1. **Assets need attribution.** Every bundled asset gets a row in `ATTRIBUTION.md` (path, title, diff --git a/Cargo.lock b/Cargo.lock index d11f3483..43dc42ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,7 @@ dependencies = [ "image", "log", "serde", + "sys-locale", ] [[package]] @@ -849,7 +850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3944,7 +3945,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4370,6 +4371,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" diff --git a/apps/artcraft-launcher-web/src/web.rs b/apps/artcraft-launcher-web/src/web.rs index 9f9feb88..cd26acd7 100644 --- a/apps/artcraft-launcher-web/src/web.rs +++ b/apps/artcraft-launcher-web/src/web.rs @@ -6,14 +6,68 @@ use std::rc::Rc; use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source}; use artcraft_launcher_core::release::{self, Release}; use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version}; -use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; +use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, i18n}; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; const SETTINGS_KEY: &str = "settings"; +/// Tell the UI the browser's languages (`?lang=ru` in the address first, as `ARTCRAFT_LAUNCHER_LOCALE` +/// does on the desktop) before the first frame, so the Automatic interface language follows the +/// browser; and show the loading text in that language. +fn detect_language() { + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let document = match window.document() { + Some(d) => d, + None => return, + }; + let mut tags = Vec::new(); + + // ?lang=xx in the URL takes priority. + if let Ok(href) = window.location().href() + && let Some(idx) = href.find('?') + { + for pair in href[idx + 1..].split('&') { + if let Some((k, v)) = pair.split_once('=') + && k == "lang" + && !v.is_empty() + { + tags.push(v.to_string()); + } + } + } + + // Then the browser's preferred languages. + let navigator = window.navigator(); + let langs: js_sys::Array = navigator.languages(); + for i in 0..langs.length() { + if let Some(v) = langs.get(i).as_string() { + tags.push(v); + } + } + if let Some(lang) = navigator.language() { + tags.push(lang); + } + i18n::set_system_locales(&tags); + + // Screen readers and spell-check follow the page language: match + // to what was detected (`en` when the browser asks for nothing supported). + if let Some(root) = document.document_element() { + let _ = root.set_attribute("lang", i18n::system_lang().code()); + } + + // Update loading text in the detected language. + if let Some(el) = document.get_element_by_id("launcher_loading") { + el.set_text_content(Some(i18n::tr(i18n::system_lang(), "Loading ArtCraft Launcher…"))); + } +} pub fn start() { eframe::WebLogger::init(log::LevelFilter::Warn).ok(); + // Detect language before starting the UI. + detect_language(); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; let Some(canvas) = document.get_element_by_id("launcher_canvas").and_then(|c| c.dyn_into::().ok()) else { diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs index ae247cdd..6d2f4dbc 100644 --- a/apps/artcraft-launcher/src/main.rs +++ b/apps/artcraft-launcher/src/main.rs @@ -30,6 +30,8 @@ type Wake = Arc>>; fn main() -> ExitCode { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + // Detect system language in the background so the first frame can use it. + artcraft_launcher_ui_egui::i18n::detect_system_lang_in_background(); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { println!("artcraft-launcher {}", env!("CARGO_PKG_VERSION")); diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs index 2e97f8e6..5c7e2812 100644 --- a/crates/core/src/catalog.rs +++ b/crates/core/src/catalog.rs @@ -1,6 +1,9 @@ //! The Craft App Suite: the twelve apps the launcher knows about. //! //! The list is data so a later version can fetch it from an API; [`SUITE`] is the built-in copy. +//! +//! Text here is English, the source the UI translates from: each `tagline` has a catalog row +//! keyed by the app id (`tr_ctx(app.id, app.tagline)` in `ui-egui`). use serde::{Deserialize, Serialize}; @@ -29,7 +32,7 @@ pub struct CraftApp { pub id: &'static str, /// Display name (`PhotoCraft`). pub name: &'static str, - /// What it's for, in a few words. + /// What it's for, in a few words (English; the UI translates it by app id). pub tagline: &'static str, /// The familiar app it reimplements, for orientation. pub like: &'static str, diff --git a/crates/core/src/platform.rs b/crates/core/src/platform.rs index 85b6a44e..8201eec1 100644 --- a/crates/core/src/platform.rs +++ b/crates/core/src/platform.rs @@ -2,6 +2,9 @@ //! //! Every Craft App release names its files `---[-portable].`, for //! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`. +//! +//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are +//! shown as they are. use serde::{Deserialize, Serialize}; diff --git a/crates/core/src/time.rs b/crates/core/src/time.rs index 7a648c93..22b580f4 100644 --- a/crates/core/src/time.rs +++ b/crates/core/src/time.rs @@ -1,4 +1,37 @@ //! Just enough calendar to read GitHub's timestamps and show them, without a date crate. +//! +//! # Internationalisation +//! [`format_ago`] and [`format_date`] are English-only fallbacks. Translatable UI must use +//! [`ago_kind`] plus [`month_short_en`] (or [`civil_from_unix`] directly) and render through +//! the UI crate's `tl!` / `trn` / `fmt` helpers, so this crate stays pure and wasm-safe with +//! no dependency on `ui-egui`: +//! - [`TimeAgo::JustNow`] → `tl!("just now")` +//! - [`TimeAgo::Minutes(n)`] → `trn(n, "{n} min ago", "{n} min ago")` +//! - [`TimeAgo::Hours(n)`] → `trn(n, "{n} h ago", "{n} h ago")` +//! - [`TimeAgo::Yesterday`] → `tl!("yesterday")` +//! - [`TimeAgo::Days(n)`] → `trn(n, "{n} day ago", "{n} days ago")` +//! - [`TimeAgo::Date(secs)`] → `fmt(tl!("{month} {day}, {year}"), …)` with the month from +//! [`month_short_en`] translated via `tr_ctx("month", …)` and `{day}` / `{year}` filled in. + +/// What a relative timestamp means, without any language attached. +/// +/// The UI translates each variant (`tl!` / `trn` / `fmt`); see the module docs. Counts are +/// non-negative. [`TimeAgo::Date`] carries the original Unix time for the date fallback. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TimeAgo { + /// Less than a minute old (or in the future). + JustNow, + /// Minutes old (1–59). + Minutes(u64), + /// Hours old (1–23). + Hours(u64), + /// Between one and two days old. + Yesterday, + /// Days old (2–29). + Days(u64), + /// Thirty days or older: show the calendar date; carries the original Unix time. + Date(i64), +} /// Seconds since the Unix epoch for an RFC 3339 UTC timestamp such as `2026-10-08T14:52:50Z`. pub fn parse_rfc3339(text: &str) -> Option { @@ -61,23 +94,51 @@ pub fn civil_from_unix(secs: i64) -> (i64, u32, u32) { const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +/// English three-letter month abbreviation for month `m` (1–12); `"?"` when out of range. +/// Source key for `tr_ctx("month", …)` at the call site; never shown untranslated in +/// non-English UI. +pub fn month_short_en(m: u32) -> &'static str { + if (1..=12).contains(&m) { MONTHS.get((m - 1) as usize).copied().unwrap_or("?") } else { "?" } +} + /// `Oct 8, 2026` pub fn format_date(secs: i64) -> String { let (y, m, d) = civil_from_unix(secs); - let month = MONTHS.get(m.saturating_sub(1) as usize).copied().unwrap_or("?"); + let month = month_short_en(m); format!("{month} {d}, {y}") } -/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date. -pub fn format_ago(secs: i64, now: i64) -> String { +/// Classify a timestamp for translation-neutral rendering (no allocation, no English). +/// Thresholds match [`format_ago`]: <60s → [`TimeAgo::JustNow`], <1h → minutes, +/// <1d → hours, <2d → yesterday, <30d → days, older → [`TimeAgo::Date`]. +pub fn ago_kind(secs: i64, now: i64) -> TimeAgo { let dt = now.saturating_sub(secs); - match dt { - i64::MIN..60 => "just now".into(), - 60..3600 => format!("{} min ago", dt / 60), - 3600..86_400 => format!("{} h ago", dt / 3600), - 86_400..172_800 => "yesterday".into(), - 172_800..2_592_000 => format!("{} days ago", dt / 86_400), - _ => format_date(secs), + if dt < 60 { + TimeAgo::JustNow + } else if dt < 3600 { + TimeAgo::Minutes(u64::try_from(dt / 60).unwrap_or(0)) + } else if dt < 86_400 { + TimeAgo::Hours(u64::try_from(dt / 3600).unwrap_or(0)) + } else if dt < 172_800 { + TimeAgo::Yesterday + } else if dt < 2_592_000 { + TimeAgo::Days(u64::try_from(dt / 86_400).unwrap_or(0)) + } else { + TimeAgo::Date(secs) + } +} + +/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date. +/// +/// English-only fallback; translatable UI should match on [`ago_kind`] instead. +pub fn format_ago(secs: i64, now: i64) -> String { + match ago_kind(secs, now) { + TimeAgo::JustNow => "just now".into(), + TimeAgo::Minutes(n) => format!("{n} min ago"), + TimeAgo::Hours(n) => format!("{n} h ago"), + TimeAgo::Yesterday => "yesterday".into(), + TimeAgo::Days(n) => format!("{n} days ago"), + TimeAgo::Date(s) => format_date(s), } } @@ -108,4 +169,44 @@ mod tests { assert_eq!(format_ago(now - 5 * 86_400, now), "5 days ago"); assert_eq!(format_ago(now - 90 * 86_400, now), "Jul 10, 2026"); } + + #[test] + fn ago_kind_matches_the_english_fallback() { + let now = 1_791_471_170; + let cases: [(i64, TimeAgo, &str); 7] = [ + (5, TimeAgo::JustNow, "just now"), + (600, TimeAgo::Minutes(10), "10 min ago"), + (7200, TimeAgo::Hours(2), "2 h ago"), + (90_000, TimeAgo::Yesterday, "yesterday"), + (5 * 86_400, TimeAgo::Days(5), "5 days ago"), + (90 * 86_400, TimeAgo::Date(now - 90 * 86_400), "Jul 10, 2026"), + (-30, TimeAgo::JustNow, "just now"), + ]; + for (delta, kind, english) in cases { + assert_eq!(ago_kind(now - delta, now), kind, "{english}"); + assert_eq!(format_ago(now - delta, now), english); + } + // Boundaries. + assert_eq!(ago_kind(now - 59, now), TimeAgo::JustNow); + assert_eq!(ago_kind(now - 60, now), TimeAgo::Minutes(1)); + assert_eq!(ago_kind(now - 3599, now), TimeAgo::Minutes(59)); + assert_eq!(ago_kind(now - 3600, now), TimeAgo::Hours(1)); + assert_eq!(ago_kind(now - 86_399, now), TimeAgo::Hours(23)); + assert_eq!(ago_kind(now - 86_400, now), TimeAgo::Yesterday); + assert_eq!(ago_kind(now - 172_799, now), TimeAgo::Yesterday); + assert_eq!(ago_kind(now - 172_800, now), TimeAgo::Days(2)); + assert_eq!(ago_kind(now - 2_591_999, now), TimeAgo::Days(29)); + assert!(matches!(ago_kind(now - 2_592_000, now), TimeAgo::Date(_))); + } + + #[test] + fn month_keys_cover_the_calendar() { + let names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + for (i, name) in names.iter().enumerate() { + let m = u32::try_from(i + 1).unwrap_or(1); + assert_eq!(month_short_en(m), *name); + } + assert_eq!(month_short_en(0), "?"); + assert_eq!(month_short_en(13), "?"); + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index af89e617..9fee23ee 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,10 @@ image = { version = "0.25", default-features = false, features = ["png"] } serde = { workspace = true } log = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# The OS's preferred UI languages, without spawning `defaults` or `reg.exe`. +sys-locale = "0.3" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 20ea5de9..d5003e85 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -9,6 +9,7 @@ use artcraft_launcher_core::release::Release; use artcraft_launcher_core::status::AppStatus; use artcraft_launcher_core::{Platform, SUITE}; +use crate::i18n::set_current; use crate::model::AppEntry; use crate::settings::{Filter, Settings, SortKey, ViewMode}; use crate::theme::{self, Tokens}; @@ -183,6 +184,7 @@ impl LauncherUi { /// Non-drawing work: runs every frame and also while the window is hidden in the tray. pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) { + set_current(crate::i18n::Lang::from_pref(&self.settings.language)); let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases }; if self.sent_options.as_ref() != Some(&options) { self.backend.send(Command::Configure(options.clone())); @@ -287,17 +289,17 @@ impl LauncherUi { Ok(msg) => self.toast(ctx, msg, false), // The paused card says it all. Err(e) if e == PAUSED => {} - Err(e) if e == CANCELLED && launcher => self.toast(ctx, "Update cancelled", false), + Err(e) if e == CANCELLED && launcher => self.toast(ctx, tl!("Update cancelled"), false), // An install's own cancellations end as INSTALL_CANCELLED, so past the // download this is a removal called off while it waited for the installer. Err(e) if e == CANCELLED && matches!(last, Some(JobPhase::Waiting | JobPhase::Removing)) => { - self.toast(ctx, "Uninstall cancelled", false); + self.toast(ctx, tl!("Uninstall cancelled"), false); } - Err(e) if e == CANCELLED => self.toast(ctx, "Download cancelled", false), + Err(e) if e == CANCELLED => self.toast(ctx, tl!("Download cancelled"), false), Err(e) if e == INSTALL_CANCELLED && kept => { - self.toast(ctx, "Install cancelled. The download is kept for the next try.", false); + self.toast(ctx, tl!("Install cancelled. The download is kept for the next try."), false); } - Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, "Install cancelled", false), + Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, tl!("Install cancelled"), false), Err(e) => self.toast(ctx, e, true), } true @@ -337,9 +339,13 @@ impl LauncherUi { } if self.settings.notify { let (title, body) = match fresh.as_slice() { - [(_, name, v)] => (format!("{name} {v} is available"), "Open ArtCraft Launcher to update.".to_string()), + [(_, name, v)] => { + let body: String = tl!("Open ArtCraft Launcher to update.").to_string(); + (crate::i18n::fmt(tl!("{name} {v} is available"), &[("name", name), ("v", v)]), body) + } many => { - (format!("{} updates are available", many.len()), many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::>().join(", ")) + let body: String = many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::>().join(", "); + (crate::i18n::fmt(tl!("{count} updates are available"), &[("count", &many.len().to_string())]), body) } }; host.notify(&title, &body); @@ -352,6 +358,8 @@ impl LauncherUi { /// Draws the launcher. pub fn ui(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) { let ctx = ui.ctx().clone(); + // Set the current language for this frame based on settings. + set_current(crate::i18n::Lang::from_pref(&self.settings.language)); if !self.fonts_ready { self.fonts_ready = true; ctx.request_repaint(); @@ -361,24 +369,24 @@ impl LauncherUi { theme::apply(&ctx, self.settings.theme); self.applied_theme = Some(self.settings.theme); } - let t = Tokens::get(&ctx); + let tokens = Tokens::get(&ctx); if self.entries.iter().any(|e| e.job.is_some()) || self.checking { ctx.request_repaint_after(Duration::from_millis(50)); } // The bars' separator lines are the hairlines under the header and over the footer. egui::Panel::top("header") - .frame(egui::Frame::new().fill(t.bg)) + .frame(egui::Frame::new().fill(tokens.bg)) .exact_size(crate::views::HEADER_H) .resizable(false) .show(ui, |ui| self.header(ui, host.title_bar())); egui::Panel::bottom("footer") - .frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin::symmetric(20, 0))) + .frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin::symmetric(20, 0))) .exact_size(crate::views::FOOTER_H) .resizable(false) .show(ui, |ui| self.footer(ui)); egui::CentralPanel::default() - .frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 })) + .frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 })) .show(ui, |ui| { self.toolbar(ui); ui.add_space(18.0); @@ -392,7 +400,7 @@ impl LauncherUi { if ids.is_empty() { ui.add_space(40.0); ui.vertical_centered(|ui| { - crate::widgets::hud(ui, "No apps match", t.muted); + crate::widgets::hud(ui, tl!("No apps match"), tokens.muted); }); } else { match self.settings.view { @@ -489,7 +497,7 @@ impl LauncherUi { } } match (api, github) { - (true, true) => Some(format!("{} and {}", Origin::ArtCraftApi.label(), Origin::GitHub.label())), + (true, true) => Some(crate::i18n::fmt(tl!("{a} and {b}"), &[("a", Origin::ArtCraftApi.label()), ("b", Origin::GitHub.label())])), (true, false) => Some(Origin::ArtCraftApi.label().to_string()), (false, true) => Some(Origin::GitHub.label().to_string()), (false, false) => None, @@ -534,7 +542,7 @@ impl LauncherUi { ui.horizontal(|ui| { let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover()); ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, accent); - ui.add(egui::Label::new(egui::RichText::new(&toast.text).color(t.ink)).wrap()); + ui.add(egui::Label::new(egui::RichText::new(crate::i18n::msg(&toast.text)).color(t.ink)).wrap()); }); }); if r.response.interact(egui::Sense::click()).clicked() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e47c1383..2756d1ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,11 +1,12 @@ //! Modal dialogs: release notes, uninstall confirmation, settings. +use crate::i18n::fmt; use artcraft_launcher_core::protocol::Command; -use artcraft_launcher_core::{Os, PackageKind, time}; +use artcraft_launcher_core::{Os, PackageKind}; use egui::{RichText, vec2}; use crate::app::{Dialog, Host, LauncherUi}; -use crate::settings::CheckEvery; +use crate::settings::{CheckEvery, Settings}; use crate::theme::{self, ThemeChoice, Tokens}; use crate::widgets::{self, Cell, Kind}; @@ -45,9 +46,9 @@ impl LauncherUi { }); ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ok = widgets::button(ui, "OK", Kind::Primary, 90.0).clicked(); + ok = widgets::button(ui, tl!("OK"), Kind::Primary, 90.0).clicked(); if let Some(link) = &alert.link - && widgets::button(ui, "Open link", Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked() + && widgets::button(ui, tl!("Open link"), Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone())); } @@ -81,11 +82,11 @@ impl LauncherUi { let Some(entry) = self.entry(id).cloned() else { return true }; let Some(release) = entry.release().cloned() else { return true }; ui.set_width(560.0f32.min(ui.ctx().content_rect().width() - 80.0)); - let date = release.published_at.map(|d| format!("Released {}", time::format_date(d))).unwrap_or_default(); + let date = release.published_at.map(|d| fmt(tl!("Released {date}"), &[("date", &crate::views::date_text(d))])).unwrap_or_default(); Self::title(ui, crate::icons::app(id), &format!("{} {}", entry.app.name, release.version_label()), &date); egui::ScrollArea::vertical().max_height(ui.ctx().content_rect().height() * 0.6).show(ui, |ui| { if release.notes.is_empty() { - ui.label("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."); + ui.label(tl!("These notes weren't fetched (GitHub's request limit was reached). They're on the release page.")); } else { crate::notes::show(ui, &release.notes); } @@ -93,9 +94,9 @@ impl LauncherUi { ui.add_space(12.0); let mut close = false; ui.horizontal(|ui| { - widgets::hud_link(ui, "Open on GitHub", &release.html_url); + widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - close = widgets::button(ui, "Close", Kind::Secondary, 90.0).clicked(); + close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked(); }); }); close @@ -105,28 +106,28 @@ impl LauncherUi { let Some(entry) = self.entry(id).cloned() else { return true }; let t = Tokens::get(ui.ctx()); ui.set_width(420.0); - Self::title(ui, crate::icons::app(id), &format!("Uninstall {}?", entry.app.name), ""); + Self::title(ui, crate::icons::app(id), &fmt(tl!("Uninstall {name}?"), &[("name", entry.app.name)]), ""); let how = match entry.installed.as_ref().map(|i| i.source) { - Some(artcraft_launcher_core::InstallSource::MacApp) => "The app will be moved to the Trash. Your documents and settings stay.", + Some(artcraft_launcher_core::InstallSource::MacApp) => tl!("The app will be moved to the Trash. Your documents and settings stay."), Some(artcraft_launcher_core::InstallSource::Msi) => { - "Windows will remove it like any installed program. Your documents and settings stay." + tl!("Windows will remove it like any installed program. Your documents and settings stay.") } Some( artcraft_launcher_core::InstallSource::Deb | artcraft_launcher_core::InstallSource::Rpm | artcraft_launcher_core::InstallSource::Pacman, - ) => "Your system's package manager will remove it, and may ask for your password. Your documents and settings stay.", - _ => "The app will be removed. Your documents and settings stay.", + ) => tl!("Your system's package manager will remove it, and may ask for your password. Your documents and settings stay."), + _ => tl!("The app will be removed. Your documents and settings stay."), }; ui.label(RichText::new(how).color(t.muted)); ui.add_space(20.0); let mut close = false; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::button(ui, "Uninstall", Kind::Danger, 110.0).clicked() { + if widgets::button(ui, tl!("Uninstall"), Kind::Danger, 110.0).clicked() { self.send(Command::Uninstall { app: id.to_string() }); close = true; } - if widgets::button(ui, "Cancel", Kind::Secondary, 90.0).clicked() { + if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() { close = true; } }); @@ -137,7 +138,7 @@ impl LauncherUi { let t = Tokens::get(ui.ctx()); // Wide enough for the community buttons and every social icon on one row. ui.set_width(520.0); - Self::title(ui, Some(crate::icons::artcraft_mark()), "Settings", &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION"))); + Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION"))); let section = |ui: &mut egui::Ui, s: &str| { ui.add_space(14.0); widgets::hud(ui, s, t.faint); @@ -150,9 +151,23 @@ impl LauncherUi { }); }; - section(ui, "Updates"); + let mut lang_code = self.settings.language.clone(); + row(ui, tl!("Language"), &mut |ui| { + egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code)).width(180.0).show_ui(ui, |ui| { + ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto")); + for lang in crate::i18n::Lang::all() { + ui.selectable_value(&mut lang_code, lang.code().to_string(), lang.name()); + } + }); + }); + if lang_code != self.settings.language { + self.settings.language = lang_code; + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.settings.language)); + } + + section(ui, tl!("Updates")); let mut every = self.settings.check_every; - row(ui, "Check for updates", &mut |ui| { + row(ui, tl!("Check for updates"), &mut |ui| { egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| { for e in CheckEvery::ALL { ui.selectable_value(&mut every, e, e.label()); @@ -160,55 +175,55 @@ impl LauncherUi { }); }); self.settings.check_every = every; - ui.checkbox(&mut self.settings.notify, "Notify me when an installed app has an update"); - ui.checkbox(&mut self.settings.prereleases, "Include pre-releases (betas and release candidates)"); + ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update")); + ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)")); if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Linux) { - section(ui, "Installing on Linux"); + section(ui, tl!("Installing on Linux")); let mut choice = self.settings.preferred_kinds.first().copied(); - row(ui, "Install new apps as", &mut |ui| { - let label = choice.map_or("System package (recommended)", PackageKind::label); + row(ui, tl!("Install new apps as"), &mut |ui| { + let label = choice.map_or(tl!("System package (recommended)"), |k| crate::i18n::t(k.label())); egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| { - ui.selectable_value(&mut choice, None, "System package (recommended)"); + ui.selectable_value(&mut choice, None, tl!("System package (recommended)")); for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] { - ui.selectable_value(&mut choice, Some(k), k.label()); + ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label())); } }); }); self.settings.preferred_kinds = choice.into_iter().collect(); - ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.muted)); + ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted)); } if self.caps.manage_apps { - section(ui, "Running in the background"); + section(ui, tl!("Running in the background")); if host.has_tray() { - ui.checkbox(&mut self.settings.close_to_tray, "Keep running in the tray when the window is closed"); + ui.checkbox(&mut self.settings.close_to_tray, tl!("Keep running in the tray when the window is closed")); } if host.has_desktops() { - ui.checkbox(&mut self.settings.show_on_current_desktop, "Show the window on the current desktop") - .on_hover_text("When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on."); + ui.checkbox(&mut self.settings.show_on_current_desktop, tl!("Show the window on the current desktop")) + .on_hover_text(tl!("When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on.")); } if let Some(mut on) = host.login_item() - && ui.checkbox(&mut on, "Start ArtCraft Launcher when I log in").changed() + && ui.checkbox(&mut on, tl!("Start ArtCraft Launcher when I log in")).changed() && let Err(e) = host.set_login_item(on) { let ctx = ui.ctx().clone(); - self.toast(&ctx, format!("Couldn't change the login item: {e}"), true); + self.toast(&ctx, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true); } } - section(ui, "Community"); - ui.label(RichText::new("Questions, ideas and news from the ArtCraft team").color(t.muted)); + section(ui, tl!("Community")); + ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted)); ui.add_space(6.0); ui.horizontal(|ui| { use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL}; - if widgets::button(ui, "Join the Discord", Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() { + if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(DISCORD_URL)); } - if widgets::button(ui, "Website", Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() { + if widgets::button(ui, tl!("Website"), Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(WEBSITE_URL)); } - if widgets::button(ui, "GitHub", Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() { + if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -217,7 +232,7 @@ impl LauncherUi { }); }); - section(ui, "Appearance"); + section(ui, tl!("Appearance")); let mut theme_choice = self.settings.theme; ui.horizontal(|ui| { widgets::cells(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, Cell { label: c.label(), tip: c.label(), count: None }))); @@ -227,11 +242,11 @@ impl LauncherUi { ui.add_space(24.0); let mut close = false; ui.horizontal(|ui| { - if self.caps.manage_apps && widgets::button(ui, "Quit ArtCraft Launcher", Kind::Secondary, 0.0).clicked() { + if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() { host.quit(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - close = widgets::button(ui, "Done", Kind::Primary, 90.0).clicked(); + close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked(); }); }); close diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs new file mode 100644 index 00000000..2088fb81 --- /dev/null +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -0,0 +1,201 @@ +//! TSV catalog parsing for UI localisation. +//! +//! Format (one entry per line): +//! contextsourcetranslation +//! - Empty context = plain UI string (`tl!`, `t`). +//! - `@id` context = keyed by command id (`tr_id`). +//! - `@plural` context = plural forms: the English `one|other` key, and the language's forms +//! separated by `|` (one form for a language whose nouns don't inflect for number, up to +//! three: `one|few|many` for Russian). +//! - `@msg` context = a status/error message, or a template whose `{placeholders}` match any text +//! (`message`, `msg`). +//! - Any other context = disambiguation for `tr_ctx`. +//! +//! Escapes in source/translation: `\t` `\n` `\\` (parsed once at load time). +//! Placeholders `{name}` must appear in both source and translation. +//! A trailing `…` (U+2026) in source must be preserved in translation. + +use std::collections::HashMap; + +/// A parsed translation catalog for one language. +#[derive(Debug, Default)] +pub struct Catalog { + /// Plain strings: English -> translation. + plain: HashMap, + /// Contextual strings: context -> (English -> translation). + contextual: HashMap>, + /// Command-id keyed strings: id -> translation. + by_id: HashMap, + /// Plural forms: English "one|other" -> the language's forms. + plural: HashMap>, + /// Messages: English message or template -> translation. + messages: HashMap, + /// The `@msg` rows with placeholders, matched against messages that have no exact row. + templates: Vec