Skip to main content

proof_engine/editor/
localization.rs

1// localization.rs — Internationalization and localization system for proof-engine editor
2// Supports: runtime language switching, plural forms, parametric substitution,
3// RTL layout hints, font fallback chains, and locale-sensitive formatting.
4
5use std::collections::HashMap;
6use std::fmt;
7
8// ─── Locale identifier ───────────────────────────────────────────────────────
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct Locale {
12    pub language: String,   // ISO 639-1: "en", "fr", "de", "zh", "ja", ...
13    pub region: Option<String>, // ISO 3166-1 alpha-2: "US", "GB", "CN", ...
14    pub script: Option<String>, // ISO 15924: "Latn", "Cyrl", "Hans", ...
15}
16
17impl Locale {
18    pub fn new(language: &str) -> Self {
19        Self {
20            language: language.to_string(),
21            region: None,
22            script: None,
23        }
24    }
25
26    pub fn with_region(language: &str, region: &str) -> Self {
27        Self {
28            language: language.to_string(),
29            region: Some(region.to_string()),
30            script: None,
31        }
32    }
33
34    pub fn with_script(language: &str, script: &str) -> Self {
35        Self {
36            language: language.to_string(),
37            region: None,
38            script: Some(script.to_string()),
39        }
40    }
41
42    pub fn tag(&self) -> String {
43        let mut s = self.language.clone();
44        if let Some(sc) = &self.script {
45            s.push('-');
46            s.push_str(sc);
47        }
48        if let Some(r) = &self.region {
49            s.push('-');
50            s.push_str(r);
51        }
52        s
53    }
54
55    pub fn is_rtl(&self) -> bool {
56        matches!(self.language.as_str(), "ar" | "he" | "fa" | "ur" | "ps" | "yi" | "dv")
57    }
58
59    pub fn decimal_separator(&self) -> char {
60        match self.language.as_str() {
61            "de" | "fr" | "es" | "it" | "pt" | "nl" | "pl" | "ru" | "tr" => ',',
62            _ => '.',
63        }
64    }
65
66    pub fn thousands_separator(&self) -> char {
67        match self.language.as_str() {
68            "de" | "nl" => '.',
69            "fr" | "ru" => '\u{00A0}',
70            _ => ',',
71        }
72    }
73
74    pub fn date_format(&self) -> &'static str {
75        match self.language.as_str() {
76            "en" if self.region.as_deref() == Some("US") => "MM/DD/YYYY",
77            "en" => "DD/MM/YYYY",
78            "de" | "nl" | "pl" | "ru" => "DD.MM.YYYY",
79            "ja" | "zh" | "ko" => "YYYY/MM/DD",
80            "fr" | "es" | "it" | "pt" => "DD/MM/YYYY",
81            _ => "YYYY-MM-DD",
82        }
83    }
84
85    pub fn number_format(&self, value: f64, decimals: usize) -> String {
86        let dec = self.decimal_separator();
87        let thou = self.thousands_separator();
88        let multiplier = 10f64.powi(decimals as i32);
89        let rounded = (value * multiplier).round() / multiplier;
90        let int_part = rounded.abs().trunc() as u64;
91        let frac_part = ((rounded.abs() - rounded.abs().trunc()) * multiplier).round() as u64;
92        let mut int_str = int_part.to_string();
93        // Insert thousands separators
94        let mut result = String::new();
95        let int_chars: Vec<char> = int_str.chars().collect();
96        for (i, &ch) in int_chars.iter().enumerate() {
97            if i > 0 && (int_chars.len() - i) % 3 == 0 {
98                result.push(thou);
99            }
100            result.push(ch);
101        }
102        let _ = int_str;
103        if value < 0.0 { result.insert(0, '-'); }
104        if decimals > 0 {
105            result.push(dec);
106            result.push_str(&format!("{:0>width$}", frac_part, width = decimals));
107        }
108        result
109    }
110}
111
112impl fmt::Display for Locale {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "{}", self.tag())
115    }
116}
117
118impl Default for Locale {
119    fn default() -> Self { Self::new("en") }
120}
121
122// ─── Plural forms ─────────────────────────────────────────────────────────────
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum PluralForm {
126    Zero,
127    One,
128    Two,
129    Few,
130    Many,
131    Other,
132}
133
134pub trait PluralRule {
135    fn form(&self, n: u64) -> PluralForm;
136}
137
138pub struct EnglishPlural;
139impl PluralRule for EnglishPlural {
140    fn form(&self, n: u64) -> PluralForm {
141        if n == 1 { PluralForm::One } else { PluralForm::Other }
142    }
143}
144
145pub struct RussianPlural;
146impl PluralRule for RussianPlural {
147    fn form(&self, n: u64) -> PluralForm {
148        let rem100 = n % 100;
149        let rem10  = n % 10;
150        if rem100 >= 11 && rem100 <= 14 { return PluralForm::Many; }
151        match rem10 {
152            1 => PluralForm::One,
153            2 | 3 | 4 => PluralForm::Few,
154            _ => PluralForm::Many,
155        }
156    }
157}
158
159pub struct ArabicPlural;
160impl PluralRule for ArabicPlural {
161    fn form(&self, n: u64) -> PluralForm {
162        if n == 0 { return PluralForm::Zero; }
163        if n == 1 { return PluralForm::One; }
164        if n == 2 { return PluralForm::Two; }
165        let rem100 = n % 100;
166        if rem100 >= 3 && rem100 <= 10 { return PluralForm::Few; }
167        if rem100 >= 11 && rem100 <= 99 { return PluralForm::Many; }
168        PluralForm::Other
169    }
170}
171
172pub struct JapanesePlural;
173impl PluralRule for JapanesePlural {
174    fn form(&self, _n: u64) -> PluralForm { PluralForm::Other }
175}
176
177// ─── Translation value ────────────────────────────────────────────────────────
178
179#[derive(Debug, Clone)]
180pub enum TranslationValue {
181    Simple(String),
182    Plural {
183        zero: Option<String>,
184        one: Option<String>,
185        two: Option<String>,
186        few: Option<String>,
187        many: Option<String>,
188        other: String,
189    },
190    Ordinal { forms: HashMap<u64, String>, other: String },
191    SelectGender { masculine: String, feminine: String, neuter: Option<String> },
192}
193
194impl TranslationValue {
195    pub fn simple(s: &str) -> Self {
196        Self::Simple(s.to_string())
197    }
198
199    pub fn plural_en(one: &str, other: &str) -> Self {
200        Self::Plural {
201            zero: None,
202            one: Some(one.to_string()),
203            two: None,
204            few: None,
205            many: None,
206            other: other.to_string(),
207        }
208    }
209
210    pub fn get(&self, n: Option<u64>) -> &str {
211        match self {
212            Self::Simple(s) => s,
213            Self::Plural { zero, one, two, few, many, other } => {
214                match n {
215                    Some(0) => zero.as_deref().unwrap_or(other),
216                    Some(1) => one.as_deref().unwrap_or(other),
217                    Some(2) => two.as_deref().unwrap_or(other),
218                    _       => match n.map(|v| v % 10) {
219                        Some(3..=4) => few.as_deref().unwrap_or(other),
220                        _           => many.as_deref().unwrap_or(other),
221                    }
222                }
223            }
224            Self::Ordinal { forms, other } => {
225                n.and_then(|v| forms.get(&v)).map(|s| s.as_str()).unwrap_or(other)
226            }
227            Self::SelectGender { masculine, .. } => masculine,
228        }
229    }
230}
231
232// ─── Translation bundle ───────────────────────────────────────────────────────
233
234#[derive(Debug, Clone)]
235pub struct TranslationBundle {
236    pub locale: Locale,
237    pub namespace: String,
238    pub entries: HashMap<String, TranslationValue>,
239    pub fallback: Option<String>,
240    pub author: String,
241    pub version: String,
242}
243
244impl TranslationBundle {
245    pub fn new(locale: Locale, namespace: &str) -> Self {
246        Self {
247            locale,
248            namespace: namespace.to_string(),
249            entries: HashMap::new(),
250            fallback: None,
251            author: String::new(),
252            version: "1.0".into(),
253        }
254    }
255
256    pub fn insert(&mut self, key: &str, value: TranslationValue) {
257        self.entries.insert(key.to_string(), value);
258    }
259
260    pub fn insert_simple(&mut self, key: &str, text: &str) {
261        self.entries.insert(key.to_string(), TranslationValue::simple(text));
262    }
263
264    pub fn get(&self, key: &str) -> Option<&TranslationValue> {
265        self.entries.get(key)
266    }
267
268    pub fn entry_count(&self) -> usize {
269        self.entries.len()
270    }
271}
272
273// ─── String interpolation ─────────────────────────────────────────────────────
274
275/// Named parameters for string interpolation: `{name}` `{count}` `{file}`
276#[derive(Debug, Clone)]
277pub struct TranslationArgs {
278    args: HashMap<String, String>,
279}
280
281impl TranslationArgs {
282    pub fn new() -> Self {
283        Self { args: HashMap::new() }
284    }
285
286    pub fn set(mut self, key: &str, value: impl fmt::Display) -> Self {
287        self.args.insert(key.to_string(), value.to_string());
288        self
289    }
290
291    pub fn set_mut(&mut self, key: &str, value: impl fmt::Display) {
292        self.args.insert(key.to_string(), value.to_string());
293    }
294
295    /// Interpolate a template string.
296    /// `{key}` → value, `{key, plural, one {x} other {y}}` → plural form.
297    pub fn interpolate(&self, template: &str) -> String {
298        let mut result = String::with_capacity(template.len() + 32);
299        let mut chars = template.chars().peekable();
300        while let Some(ch) = chars.next() {
301            if ch == '{' {
302                let mut key = String::new();
303                for inner in chars.by_ref() {
304                    if inner == '}' { break; }
305                    key.push(inner);
306                }
307                let key = key.trim();
308                if let Some(val) = self.args.get(key) {
309                    result.push_str(val);
310                } else {
311                    result.push('{');
312                    result.push_str(key);
313                    result.push('}');
314                }
315            } else {
316                result.push(ch);
317            }
318        }
319        result
320    }
321}
322
323impl Default for TranslationArgs {
324    fn default() -> Self { Self::new() }
325}
326
327// ─── Localization manager ─────────────────────────────────────────────────────
328
329#[derive(Debug, Clone)]
330pub struct LocalizationManager {
331    pub current_locale: Locale,
332    pub fallback_locale: Locale,
333    bundles: HashMap<(String, String), TranslationBundle>,
334    pub missing_key_policy: MissingKeyPolicy,
335    pub debug_mode: bool,
336    missing_keys: Vec<String>,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum MissingKeyPolicy {
341    /// Return the key itself as the string
342    ReturnKey,
343    /// Return an empty string
344    Empty,
345    /// Panic in debug builds, return key in release
346    Panic,
347    /// Return a decorated key: `[MISSING: key]`
348    Decorated,
349}
350
351impl LocalizationManager {
352    pub fn new(locale: Locale) -> Self {
353        Self {
354            current_locale: locale.clone(),
355            fallback_locale: Locale::new("en"),
356            bundles: HashMap::new(),
357            missing_key_policy: MissingKeyPolicy::Decorated,
358            debug_mode: false,
359            missing_keys: Vec::new(),
360        }
361    }
362
363    pub fn load_bundle(&mut self, bundle: TranslationBundle) {
364        let key = (bundle.locale.tag(), bundle.namespace.clone());
365        self.bundles.insert(key, bundle);
366    }
367
368    pub fn set_locale(&mut self, locale: Locale) {
369        self.current_locale = locale;
370    }
371
372    pub fn translate(&mut self, namespace: &str, key: &str) -> String {
373        self.translate_n(namespace, key, None, None)
374    }
375
376    pub fn translate_args(&mut self, namespace: &str, key: &str, args: &TranslationArgs) -> String {
377        let base = self.translate_n(namespace, key, None, None);
378        args.interpolate(&base)
379    }
380
381    pub fn translate_n(&mut self, namespace: &str, key: &str, n: Option<u64>, args: Option<&TranslationArgs>) -> String {
382        // Try current locale
383        let val = self.lookup(namespace, key, &self.current_locale.clone(), n);
384        if let Some(s) = val {
385            return if let Some(a) = args { a.interpolate(&s) } else { s };
386        }
387        // Try fallback locale
388        let val = self.lookup(namespace, key, &self.fallback_locale.clone(), n);
389        if let Some(s) = val {
390            return if let Some(a) = args { a.interpolate(&s) } else { s };
391        }
392        // Missing
393        self.missing_keys.push(format!("{}/{}", namespace, key));
394        self.missing_string(key)
395    }
396
397    fn lookup(&self, namespace: &str, key: &str, locale: &Locale, n: Option<u64>) -> Option<String> {
398        let bkey = (locale.tag(), namespace.to_string());
399        let bundle = self.bundles.get(&bkey)?;
400        let value = bundle.get(key)?;
401        Some(value.get(n).to_string())
402    }
403
404    fn missing_string(&self, key: &str) -> String {
405        match self.missing_key_policy {
406            MissingKeyPolicy::ReturnKey  => key.to_string(),
407            MissingKeyPolicy::Empty      => String::new(),
408            MissingKeyPolicy::Decorated  => format!("[{}]", key),
409            MissingKeyPolicy::Panic      => {
410                #[cfg(debug_assertions)]
411                panic!("Missing translation key: {}", key);
412                #[cfg(not(debug_assertions))]
413                key.to_string()
414            }
415        }
416    }
417
418    pub fn missing_keys(&self) -> &[String] { &self.missing_keys }
419    pub fn clear_missing_keys(&mut self) { self.missing_keys.clear(); }
420
421    pub fn available_locales(&self) -> Vec<Locale> {
422        self.bundles.keys()
423            .map(|(tag, _)| tag)
424            .collect::<std::collections::HashSet<_>>()
425            .into_iter()
426            .map(|tag| {
427                let parts: Vec<&str> = tag.splitn(3, '-').collect();
428                match parts.len() {
429                    1 => Locale::new(parts[0]),
430                    2 => Locale::with_region(parts[0], parts[1]),
431                    3 => {
432                        let mut l = Locale::with_script(parts[0], parts[1]);
433                        l.region = Some(parts[2].to_string());
434                        l
435                    }
436                    _ => Locale::new(parts[0]),
437                }
438            })
439            .collect()
440    }
441
442    pub fn coverage_report(&self, namespace: &str) -> LocaleCoverageReport {
443        let en_key = (Locale::new("en").tag(), namespace.to_string());
444        let total = self.bundles.get(&en_key)
445            .map(|b| b.entry_count())
446            .unwrap_or(0);
447
448        let mut reports = Vec::new();
449        for ((locale_tag, ns), bundle) in &self.bundles {
450            if ns != namespace || *locale_tag == "en" { continue; }
451            let count = bundle.entry_count();
452            let pct = if total > 0 { count * 100 / total } else { 0 };
453            reports.push(LocaleReport {
454                locale: locale_tag.clone(),
455                translated: count,
456                total,
457                percent: pct,
458            });
459        }
460        reports.sort_by(|a, b| b.percent.cmp(&a.percent));
461        LocaleCoverageReport { namespace: namespace.to_string(), reports }
462    }
463}
464
465#[derive(Debug, Clone)]
466pub struct LocaleReport {
467    pub locale: String,
468    pub translated: usize,
469    pub total: usize,
470    pub percent: usize,
471}
472
473impl fmt::Display for LocaleReport {
474    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475        write!(f, "{}: {}/{} ({}%)", self.locale, self.translated, self.total, self.percent)
476    }
477}
478
479#[derive(Debug, Clone)]
480pub struct LocaleCoverageReport {
481    pub namespace: String,
482    pub reports: Vec<LocaleReport>,
483}
484
485impl fmt::Display for LocaleCoverageReport {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        writeln!(f, "Coverage report for namespace '{}':", self.namespace)?;
488        for r in &self.reports { writeln!(f, "  {}", r)?; }
489        Ok(())
490    }
491}
492
493// ─── Built-in bundles ─────────────────────────────────────────────────────────
494
495pub fn build_english_bundle() -> TranslationBundle {
496    let mut b = TranslationBundle::new(Locale::new("en"), "editor");
497    b.insert_simple("menu.file", "File");
498    b.insert_simple("menu.edit", "Edit");
499    b.insert_simple("menu.view", "View");
500    b.insert_simple("menu.window", "Window");
501    b.insert_simple("menu.help", "Help");
502    b.insert_simple("menu.file.new", "New Scene");
503    b.insert_simple("menu.file.open", "Open Scene...");
504    b.insert_simple("menu.file.save", "Save Scene");
505    b.insert_simple("menu.file.save_as", "Save Scene As...");
506    b.insert_simple("menu.file.export", "Export...");
507    b.insert_simple("menu.file.quit", "Quit");
508    b.insert_simple("menu.edit.undo", "Undo");
509    b.insert_simple("menu.edit.redo", "Redo");
510    b.insert_simple("menu.edit.cut", "Cut");
511    b.insert_simple("menu.edit.copy", "Copy");
512    b.insert_simple("menu.edit.paste", "Paste");
513    b.insert_simple("menu.edit.delete", "Delete");
514    b.insert_simple("menu.edit.select_all", "Select All");
515    b.insert_simple("menu.edit.deselect", "Deselect");
516    b.insert_simple("menu.edit.preferences", "Preferences...");
517    b.insert_simple("panel.hierarchy", "Hierarchy");
518    b.insert_simple("panel.inspector", "Inspector");
519    b.insert_simple("panel.scene", "Scene");
520    b.insert_simple("panel.assets", "Assets");
521    b.insert_simple("panel.console", "Console");
522    b.insert_simple("panel.timeline", "Timeline");
523    b.insert_simple("panel.kit_params", "Kit Parameters");
524    b.insert_simple("panel.shader_graph", "Shader Graph");
525    b.insert_simple("panel.perf", "Performance");
526    b.insert_simple("action.play", "Play");
527    b.insert_simple("action.pause", "Pause");
528    b.insert_simple("action.stop", "Stop");
529    b.insert_simple("action.step", "Step Frame");
530    b.insert_simple("gizmo.translate", "Translate");
531    b.insert_simple("gizmo.rotate", "Rotate");
532    b.insert_simple("gizmo.scale", "Scale");
533    b.insert_simple("gizmo.universal", "Universal");
534    b.insert_simple("gizmo.space.local", "Local");
535    b.insert_simple("gizmo.space.world", "World");
536    b.insert_simple("view.wireframe", "Wireframe");
537    b.insert_simple("view.lit", "Lit");
538    b.insert_simple("view.unlit", "Unlit");
539    b.insert_simple("view.normals", "Normals");
540    b.insert_simple("toolbar.new_entity", "New Entity");
541    b.insert_simple("toolbar.delete_selected", "Delete Selected");
542    b.insert_simple("toolbar.frame_selected", "Frame Selected");
543    b.insert_simple("toolbar.frame_all", "Frame All");
544    b.insert_simple("toolbar.snap_to_grid", "Snap to Grid");
545    b.insert_simple("toolbar.show_grid", "Show Grid");
546    b.insert_simple("toolbar.show_axes", "Show Axes");
547    b.insert_simple("toolbar.local_coords", "Local Coordinates");
548    b.insert("entity.count", TranslationValue::plural_en("{count} entity", "{count} entities"));
549    b.insert("keyframe.count", TranslationValue::plural_en("{count} keyframe", "{count} keyframes"));
550    b.insert("bone.count", TranslationValue::plural_en("{count} bone", "{count} bones"));
551    b.insert("particle.count", TranslationValue::plural_en("{count} particle", "{count} particles"));
552    b.insert("error.count", TranslationValue::plural_en("{count} error", "{count} errors"));
553    b.insert("warning.count", TranslationValue::plural_en("{count} warning", "{count} warnings"));
554    b.insert_simple("status.saved", "Scene saved");
555    b.insert_simple("status.save_failed", "Failed to save scene: {error}");
556    b.insert_simple("status.loaded", "Scene loaded: {name}");
557    b.insert_simple("status.compile_ok", "Shader compiled successfully");
558    b.insert_simple("status.compile_failed", "Shader compilation failed: {count} errors");
559    b.insert_simple("status.unsaved_changes", "Unsaved changes");
560    b.insert_simple("dialog.confirm_delete", "Delete {count} selected object(s)?");
561    b.insert_simple("dialog.unsaved_changes", "Scene has unsaved changes. Save before closing?");
562    b.insert_simple("dialog.save", "Save");
563    b.insert_simple("dialog.discard", "Don't Save");
564    b.insert_simple("dialog.cancel", "Cancel");
565    b.insert_simple("dialog.ok", "OK");
566    b.insert_simple("dialog.yes", "Yes");
567    b.insert_simple("dialog.no", "No");
568    b.insert_simple("inspector.transform", "Transform");
569    b.insert_simple("inspector.position", "Position");
570    b.insert_simple("inspector.rotation", "Rotation");
571    b.insert_simple("inspector.scale", "Scale");
572    b.insert_simple("inspector.visible", "Visible");
573    b.insert_simple("inspector.locked", "Locked");
574    b.insert_simple("inspector.static", "Static");
575    b.insert_simple("inspector.cast_shadows", "Cast Shadows");
576    b.insert_simple("inspector.receive_shadows", "Receive Shadows");
577    b.insert_simple("inspector.tag", "Tag");
578    b.insert_simple("inspector.layer", "Layer");
579    b.insert_simple("node.category.math", "Math");
580    b.insert_simple("node.category.vector", "Vector");
581    b.insert_simple("node.category.texture", "Texture");
582    b.insert_simple("node.category.color", "Color");
583    b.insert_simple("node.category.pbr", "PBR");
584    b.insert_simple("node.category.noise", "Noise");
585    b.insert_simple("node.category.input", "Input");
586    b.insert_simple("node.category.output", "Output");
587    b.insert_simple("node.category.sdf", "SDF");
588    b.insert_simple("shortcut.undo", "Ctrl+Z");
589    b.insert_simple("shortcut.redo", "Ctrl+Y");
590    b.insert_simple("shortcut.save", "Ctrl+S");
591    b.insert_simple("shortcut.open", "Ctrl+O");
592    b.insert_simple("shortcut.new", "Ctrl+N");
593    b.insert_simple("shortcut.delete", "Del");
594    b.insert_simple("shortcut.copy", "Ctrl+C");
595    b.insert_simple("shortcut.paste", "Ctrl+V");
596    b.insert_simple("shortcut.select_all", "Ctrl+A");
597    b.insert_simple("shortcut.frame_selected", "F");
598    b.insert_simple("shortcut.frame_all", "Shift+F");
599    b.insert_simple("shortcut.play", "Space");
600    b.insert_simple("shortcut.gizmo.translate", "G");
601    b.insert_simple("shortcut.gizmo.rotate", "R");
602    b.insert_simple("shortcut.gizmo.scale", "S");
603    b.insert_simple("tooltip.add_node", "Click to add this node to the graph");
604    b.insert_simple("tooltip.connect_ports", "Drag to connect ports");
605    b.insert_simple("tooltip.disconnect", "Right-click to disconnect");
606    b.insert_simple("tooltip.reset_value", "Double-click to reset to default");
607    b.insert_simple("tooltip.preview", "Toggle node preview");
608    b.insert_simple("tooltip.collapse", "Collapse node");
609    b.insert_simple("asset.kind.scene", "Scene");
610    b.insert_simple("asset.kind.mesh", "Mesh");
611    b.insert_simple("asset.kind.material", "Material");
612    b.insert_simple("asset.kind.texture", "Texture");
613    b.insert_simple("asset.kind.audio", "Audio");
614    b.insert_simple("asset.kind.script", "Script");
615    b.insert_simple("asset.kind.animation", "Animation");
616    b.insert_simple("asset.kind.shader", "Shader");
617    b.insert_simple("asset.kind.font", "Font");
618    b.insert_simple("asset.kind.config", "Config");
619    b.insert_simple("perf.fps", "FPS");
620    b.insert_simple("perf.frame_time", "Frame Time");
621    b.insert_simple("perf.gpu_time", "GPU Time");
622    b.insert_simple("perf.cpu_time", "CPU Time");
623    b.insert_simple("perf.draw_calls", "Draw Calls");
624    b.insert_simple("perf.triangles", "Triangles");
625    b.insert_simple("perf.memory_vram", "VRAM");
626    b.insert_simple("perf.memory_ram", "RAM");
627    b.insert_simple("perf.bottleneck", "Bottleneck");
628    b
629}
630
631pub fn build_french_bundle() -> TranslationBundle {
632    let mut b = TranslationBundle::new(Locale::new("fr"), "editor");
633    b.insert_simple("menu.file", "Fichier");
634    b.insert_simple("menu.edit", "Édition");
635    b.insert_simple("menu.view", "Affichage");
636    b.insert_simple("menu.window", "Fenêtre");
637    b.insert_simple("menu.help", "Aide");
638    b.insert_simple("menu.file.new", "Nouveau projet");
639    b.insert_simple("menu.file.open", "Ouvrir le projet...");
640    b.insert_simple("menu.file.save", "Enregistrer");
641    b.insert_simple("menu.file.save_as", "Enregistrer sous...");
642    b.insert_simple("menu.file.quit", "Quitter");
643    b.insert_simple("menu.edit.undo", "Annuler");
644    b.insert_simple("menu.edit.redo", "Rétablir");
645    b.insert_simple("menu.edit.cut", "Couper");
646    b.insert_simple("menu.edit.copy", "Copier");
647    b.insert_simple("menu.edit.paste", "Coller");
648    b.insert_simple("menu.edit.delete", "Supprimer");
649    b.insert_simple("menu.edit.select_all", "Tout sélectionner");
650    b.insert_simple("panel.hierarchy", "Hiérarchie");
651    b.insert_simple("panel.inspector", "Inspecteur");
652    b.insert_simple("panel.scene", "Scène");
653    b.insert_simple("panel.assets", "Ressources");
654    b.insert_simple("panel.console", "Console");
655    b.insert_simple("panel.timeline", "Chronologie");
656    b.insert_simple("action.play", "Jouer");
657    b.insert_simple("action.pause", "Pause");
658    b.insert_simple("action.stop", "Arrêter");
659    b.insert_simple("gizmo.translate", "Déplacer");
660    b.insert_simple("gizmo.rotate", "Pivoter");
661    b.insert_simple("gizmo.scale", "Redimensionner");
662    b.insert_simple("gizmo.space.local", "Local");
663    b.insert_simple("gizmo.space.world", "Monde");
664    b.insert_simple("dialog.save", "Enregistrer");
665    b.insert_simple("dialog.discard", "Ne pas enregistrer");
666    b.insert_simple("dialog.cancel", "Annuler");
667    b.insert_simple("dialog.ok", "OK");
668    b.insert_simple("inspector.transform", "Transformation");
669    b.insert_simple("inspector.position", "Position");
670    b.insert_simple("inspector.rotation", "Rotation");
671    b.insert_simple("inspector.scale", "Échelle");
672    b.insert_simple("inspector.visible", "Visible");
673    b.insert_simple("status.saved", "Scène enregistrée");
674    b.insert_simple("status.loaded", "Scène chargée : {name}");
675    b.insert_simple("shortcut.undo", "Ctrl+Z");
676    b.insert_simple("shortcut.redo", "Ctrl+Y");
677    b.insert_simple("shortcut.save", "Ctrl+S");
678    b.insert_simple("shortcut.frame_selected", "F");
679    b.insert_simple("shortcut.play", "Espace");
680    b
681}
682
683pub fn build_german_bundle() -> TranslationBundle {
684    let mut b = TranslationBundle::new(Locale::new("de"), "editor");
685    b.insert_simple("menu.file", "Datei");
686    b.insert_simple("menu.edit", "Bearbeiten");
687    b.insert_simple("menu.view", "Ansicht");
688    b.insert_simple("menu.window", "Fenster");
689    b.insert_simple("menu.help", "Hilfe");
690    b.insert_simple("menu.file.new", "Neue Szene");
691    b.insert_simple("menu.file.open", "Szene öffnen...");
692    b.insert_simple("menu.file.save", "Speichern");
693    b.insert_simple("menu.file.save_as", "Speichern als...");
694    b.insert_simple("menu.file.quit", "Beenden");
695    b.insert_simple("menu.edit.undo", "Rückgängig");
696    b.insert_simple("menu.edit.redo", "Wiederholen");
697    b.insert_simple("menu.edit.cut", "Ausschneiden");
698    b.insert_simple("menu.edit.copy", "Kopieren");
699    b.insert_simple("menu.edit.paste", "Einfügen");
700    b.insert_simple("menu.edit.delete", "Löschen");
701    b.insert_simple("menu.edit.select_all", "Alles auswählen");
702    b.insert_simple("panel.hierarchy", "Hierarchie");
703    b.insert_simple("panel.inspector", "Inspektor");
704    b.insert_simple("panel.scene", "Szene");
705    b.insert_simple("panel.assets", "Ressourcen");
706    b.insert_simple("panel.console", "Konsole");
707    b.insert_simple("panel.timeline", "Zeitachse");
708    b.insert_simple("action.play", "Abspielen");
709    b.insert_simple("action.pause", "Pause");
710    b.insert_simple("action.stop", "Stopp");
711    b.insert_simple("gizmo.translate", "Verschieben");
712    b.insert_simple("gizmo.rotate", "Drehen");
713    b.insert_simple("gizmo.scale", "Skalieren");
714    b.insert_simple("gizmo.space.local", "Lokal");
715    b.insert_simple("gizmo.space.world", "Welt");
716    b.insert_simple("dialog.save", "Speichern");
717    b.insert_simple("dialog.discard", "Nicht speichern");
718    b.insert_simple("dialog.cancel", "Abbrechen");
719    b.insert_simple("dialog.ok", "OK");
720    b.insert_simple("inspector.transform", "Transformation");
721    b.insert_simple("inspector.position", "Position");
722    b.insert_simple("inspector.rotation", "Rotation");
723    b.insert_simple("inspector.scale", "Skalierung");
724    b.insert_simple("status.saved", "Szene gespeichert");
725    b.insert_simple("status.loaded", "Szene geladen: {name}");
726    b
727}
728
729pub fn build_japanese_bundle() -> TranslationBundle {
730    let mut b = TranslationBundle::new(Locale::new("ja"), "editor");
731    b.insert_simple("menu.file", "ファイル");
732    b.insert_simple("menu.edit", "編集");
733    b.insert_simple("menu.view", "表示");
734    b.insert_simple("menu.window", "ウィンドウ");
735    b.insert_simple("menu.help", "ヘルプ");
736    b.insert_simple("menu.file.new", "新規シーン");
737    b.insert_simple("menu.file.open", "シーンを開く...");
738    b.insert_simple("menu.file.save", "保存");
739    b.insert_simple("menu.file.save_as", "名前を付けて保存...");
740    b.insert_simple("menu.file.quit", "終了");
741    b.insert_simple("menu.edit.undo", "元に戻す");
742    b.insert_simple("menu.edit.redo", "やり直す");
743    b.insert_simple("menu.edit.copy", "コピー");
744    b.insert_simple("menu.edit.paste", "貼り付け");
745    b.insert_simple("menu.edit.delete", "削除");
746    b.insert_simple("panel.hierarchy", "ヒエラルキー");
747    b.insert_simple("panel.inspector", "インスペクター");
748    b.insert_simple("panel.scene", "シーン");
749    b.insert_simple("panel.assets", "アセット");
750    b.insert_simple("panel.console", "コンソール");
751    b.insert_simple("panel.timeline", "タイムライン");
752    b.insert_simple("action.play", "再生");
753    b.insert_simple("action.pause", "一時停止");
754    b.insert_simple("action.stop", "停止");
755    b.insert_simple("gizmo.translate", "移動");
756    b.insert_simple("gizmo.rotate", "回転");
757    b.insert_simple("gizmo.scale", "スケール");
758    b.insert_simple("dialog.save", "保存");
759    b.insert_simple("dialog.discard", "保存しない");
760    b.insert_simple("dialog.cancel", "キャンセル");
761    b.insert_simple("dialog.ok", "OK");
762    b.insert_simple("inspector.transform", "トランスフォーム");
763    b.insert_simple("inspector.position", "位置");
764    b.insert_simple("inspector.rotation", "回転");
765    b.insert_simple("inspector.scale", "スケール");
766    b
767}
768
769pub fn build_spanish_bundle() -> TranslationBundle {
770    let mut b = TranslationBundle::new(Locale::new("es"), "editor");
771    b.insert_simple("menu.file", "Archivo");
772    b.insert_simple("menu.edit", "Editar");
773    b.insert_simple("menu.view", "Vista");
774    b.insert_simple("menu.window", "Ventana");
775    b.insert_simple("menu.help", "Ayuda");
776    b.insert_simple("menu.file.new", "Nueva escena");
777    b.insert_simple("menu.file.open", "Abrir escena...");
778    b.insert_simple("menu.file.save", "Guardar");
779    b.insert_simple("menu.file.save_as", "Guardar como...");
780    b.insert_simple("menu.file.quit", "Salir");
781    b.insert_simple("menu.edit.undo", "Deshacer");
782    b.insert_simple("menu.edit.redo", "Rehacer");
783    b.insert_simple("menu.edit.copy", "Copiar");
784    b.insert_simple("menu.edit.paste", "Pegar");
785    b.insert_simple("menu.edit.delete", "Eliminar");
786    b.insert_simple("panel.hierarchy", "Jerarquía");
787    b.insert_simple("panel.inspector", "Inspector");
788    b.insert_simple("panel.scene", "Escena");
789    b.insert_simple("panel.assets", "Recursos");
790    b.insert_simple("panel.console", "Consola");
791    b.insert_simple("panel.timeline", "Línea de tiempo");
792    b.insert_simple("action.play", "Reproducir");
793    b.insert_simple("action.pause", "Pausa");
794    b.insert_simple("action.stop", "Detener");
795    b.insert_simple("gizmo.translate", "Mover");
796    b.insert_simple("gizmo.rotate", "Rotar");
797    b.insert_simple("gizmo.scale", "Escalar");
798    b.insert_simple("dialog.save", "Guardar");
799    b.insert_simple("dialog.discard", "No guardar");
800    b.insert_simple("dialog.cancel", "Cancelar");
801    b.insert_simple("dialog.ok", "Aceptar");
802    b.insert_simple("inspector.transform", "Transformación");
803    b.insert_simple("inspector.position", "Posición");
804    b.insert_simple("inspector.rotation", "Rotación");
805    b.insert_simple("inspector.scale", "Escala");
806    b
807}
808
809pub fn build_chinese_simplified_bundle() -> TranslationBundle {
810    let mut b = TranslationBundle::new(
811        Locale::with_script("zh", "Hans"),
812        "editor",
813    );
814    b.insert_simple("menu.file", "文件");
815    b.insert_simple("menu.edit", "编辑");
816    b.insert_simple("menu.view", "视图");
817    b.insert_simple("menu.window", "窗口");
818    b.insert_simple("menu.help", "帮助");
819    b.insert_simple("menu.file.new", "新建场景");
820    b.insert_simple("menu.file.open", "打开场景...");
821    b.insert_simple("menu.file.save", "保存");
822    b.insert_simple("menu.file.save_as", "另存为...");
823    b.insert_simple("menu.file.quit", "退出");
824    b.insert_simple("menu.edit.undo", "撤销");
825    b.insert_simple("menu.edit.redo", "重做");
826    b.insert_simple("menu.edit.copy", "复制");
827    b.insert_simple("menu.edit.paste", "粘贴");
828    b.insert_simple("menu.edit.delete", "删除");
829    b.insert_simple("panel.hierarchy", "层级");
830    b.insert_simple("panel.inspector", "检查器");
831    b.insert_simple("panel.scene", "场景");
832    b.insert_simple("panel.assets", "资源");
833    b.insert_simple("panel.console", "控制台");
834    b.insert_simple("panel.timeline", "时间轴");
835    b.insert_simple("action.play", "播放");
836    b.insert_simple("action.pause", "暂停");
837    b.insert_simple("action.stop", "停止");
838    b.insert_simple("gizmo.translate", "移动");
839    b.insert_simple("gizmo.rotate", "旋转");
840    b.insert_simple("gizmo.scale", "缩放");
841    b.insert_simple("dialog.save", "保存");
842    b.insert_simple("dialog.discard", "不保存");
843    b.insert_simple("dialog.cancel", "取消");
844    b.insert_simple("dialog.ok", "确定");
845    b.insert_simple("inspector.transform", "变换");
846    b.insert_simple("inspector.position", "位置");
847    b.insert_simple("inspector.rotation", "旋转");
848    b.insert_simple("inspector.scale", "缩放");
849    b
850}
851
852/// Create a default LocalizationManager with all built-in bundles loaded
853pub fn default_localization() -> LocalizationManager {
854    let mut mgr = LocalizationManager::new(Locale::new("en"));
855    mgr.load_bundle(build_english_bundle());
856    mgr.load_bundle(build_french_bundle());
857    mgr.load_bundle(build_german_bundle());
858    mgr.load_bundle(build_japanese_bundle());
859    mgr.load_bundle(build_spanish_bundle());
860    mgr.load_bundle(build_chinese_simplified_bundle());
861    mgr
862}
863
864// ─── Helper macro ─────────────────────────────────────────────────────────────
865
866/// Convenience: t!(mgr, "panel.inspector") → translated string
867#[macro_export]
868macro_rules! t {
869    ($mgr:expr, $key:literal) => {{
870        $mgr.translate("editor", $key)
871    }};
872    ($mgr:expr, $ns:literal, $key:literal) => {{
873        $mgr.translate($ns, $key)
874    }};
875    ($mgr:expr, $key:literal, $($arg_k:literal = $arg_v:expr),* $(,)?) => {{
876        let args = $crate::editor::localization::TranslationArgs::new()
877            $(.set($arg_k, $arg_v))*;
878        $mgr.translate_args("editor", $key, &args)
879    }};
880}
881
882// ─── Tests ───────────────────────────────────────────────────────────────────
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    #[test]
889    fn english_translate() {
890        let mut mgr = default_localization();
891        assert_eq!(mgr.translate("editor", "menu.file"), "File");
892        assert_eq!(mgr.translate("editor", "action.play"), "Play");
893    }
894
895    #[test]
896    fn french_translate() {
897        let mut mgr = default_localization();
898        mgr.set_locale(Locale::new("fr"));
899        assert_eq!(mgr.translate("editor", "menu.file"), "Fichier");
900        assert_eq!(mgr.translate("editor", "action.play"), "Jouer");
901    }
902
903    #[test]
904    fn fallback_to_english() {
905        let mut mgr = default_localization();
906        mgr.set_locale(Locale::new("fr"));
907        // "shortcut.frame_all" is only in English
908        let s = mgr.translate("editor", "shortcut.frame_all");
909        assert_eq!(s, "Shift+F");
910    }
911
912    #[test]
913    fn missing_key_decorated() {
914        let mut mgr = default_localization();
915        let s = mgr.translate("editor", "nonexistent.key");
916        assert_eq!(s, "[nonexistent.key]");
917    }
918
919    #[test]
920    fn interpolation() {
921        let mut mgr = default_localization();
922        let args = TranslationArgs::new().set("name", "test_scene");
923        let s = mgr.translate_args("editor", "status.loaded", &args);
924        assert!(s.contains("test_scene"));
925    }
926
927    #[test]
928    fn locale_rtl() {
929        let arabic = Locale::new("ar");
930        assert!(arabic.is_rtl());
931        let english = Locale::new("en");
932        assert!(!english.is_rtl());
933    }
934
935    #[test]
936    fn number_format() {
937        let de = Locale::new("de");
938        let formatted = de.number_format(1234.56, 2);
939        assert!(formatted.contains(','), "DE should use comma decimal: {}", formatted);
940    }
941
942    #[test]
943    fn coverage_report() {
944        let mgr = default_localization();
945        let report = mgr.coverage_report("editor");
946        assert!(!report.reports.is_empty());
947    }
948}