diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..7d9eb4bb7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1083,6 +1083,21 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he }); } _ => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Interface Language")).font(semibold(12.0))); + // Each language by its own name, so the list reads the same in every interface language. + let current = crate::i18n::LANGUAGES.iter().find(|(code, _)| *code == app.ui.language).map_or("English", |(_, name)| *name); + egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui( + ui, + |ui| { + for (code, name) in crate::i18n::LANGUAGES { + if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() { + // Applied by `show` right after this frame, which has `&mut DesignApp`. + d.fields.insert("applyLanguage".into(), json!(code)); + } + } + }, + ); + ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "When Scaling")).font(semibold(12.0))); check(ui, d, "scaleStrokes", crate::i18n::tr(&app.ui.language, "Include Stroke Weight")); ui.add_space(6.0); @@ -1728,6 +1743,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); }); }); + // Preferences › General › Interface Language takes effect at once, like any other app setting. + if let Some(lang) = d.fields.remove("applyLanguage") + && let Err(e) = app.run("app.language", json!({"lang": lang})) + { + d.fields.insert("status".into(), json!(e)); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -3832,6 +3853,54 @@ mod tests { } } + #[test] + fn preferences_first_setting_switches_interface_language() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("app.preferences", json!({})).unwrap(); + let ctx = dialog_context(""); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let heading = visible_label(&labels, screen, "Interface Language"); + let combo = visible_label(&labels, screen, "English"); + let scaling = visible_label(&labels, screen, "When Scaling"); + assert!(heading.bottom() <= combo.top() && combo.bottom() <= scaling.top(), "the language comes first: {heading:?} {combo:?} {scaling:?}"); + let pane = labels.iter().filter(|(_, rect, _)| rect.left() >= heading.left() - 1.0 && rect.top() < heading.top()); + assert!(pane.clone().all(|(text, _, _)| text == "Preferences"), "nothing above it: {:?}", pane.collect::>()); + + // Choosing a language applies it at once; the dialog stays open. + click_dialog(&mut app, &ctx, screen, combo.center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for (_, name) in crate::i18n::LANGUAGES { + assert!(labels.iter().any(|(text, _, _)| text == name), "{name} is listed by its own name"); + } + let deutsch = visible_label(&labels, screen, "Deutsch").center(); + click_dialog(&mut app, &ctx, screen, deutsch); + assert_eq!(app.ui.language, "de"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let heading = visible_label(&labels, screen, "Sprache der Oberfläche"); + let combo = visible_label(&labels, screen, "Deutsch"); + + // The names stay endonyms in the German interface. + click_dialog(&mut app, &ctx, screen, combo.center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for name in ["English", "Français", "日本語", "Українська"] { + assert!(labels.iter().any(|(text, _, _)| text == name), "{name}"); + } + click_dialog(&mut app, &ctx, screen, heading.center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!labels.iter().any(|(text, _, _)| text == "Français"), "the list closes"); + + // The control channel's command and the combo show the same setting. + app.run("app.language", json!({"lang": "ja"})).unwrap(); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(labels.iter().any(|(text, _, _)| text == "日本語")); + assert!(labels.iter().any(|(text, _, _)| text == crate::i18n::tr("ja", "Interface Language"))); + } + #[test] fn preferences_sidebar_scroll_keeps_selected_options_visible() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3870,6 +3939,9 @@ mod tests { } let labels = dialog_frame(&mut app, &ctx, screen, vec![]); let body = visible_label(&labels, screen, "When Scaling").center(); + // The last General option sits below this short viewport. + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); let option = "Absolute Numbering (instead of Section Numbering)"; let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == option).unwrap(); assert!(rect.right() > clip.right(), "rightmost option starts horizontally clipped: {rect:?}, {clip:?}"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..de8deccb8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,5 +1,6 @@ -//! Interface language (Edit › Interface Language): translations of menu titles, the most used -//! menu items and panel names. Untranslated strings stay English. The translations are our own. +//! Interface language (Preferences › General › Interface Language): translations of menu titles, +//! the most used menu items and panel names. Untranslated strings stay English. The translations +//! are our own. use std::{collections::HashMap, sync::OnceLock}; diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..c39ec7f34 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1515,7 +1515,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Step and Repeat", "間隔を指定して複製"), ("Table of Contents", "目次"), ("Type on a Path Options", "パス上のテキストのオプション"), - ("Edit/Interface Language", "編集/インターフェイスの言語"), ("Edit/Spelling", "編集/スペルチェック"), ("Edit/Transparency Blend Space", "編集/透明合成のカラースペース"), ("Edit/Transparency Flattener Presets", "編集/透明の統合プリセット"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c003154ad..ff033c1d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,7 +195,7 @@ pub struct UiState { pub dynamic_spelling: bool, /// Preferences › Story Editor Display: text size (points). pub story_editor_size: f32, - /// Edit › Interface Language: supported codes are listed in `i18n::LANGUAGES`. + /// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`. pub language: String, /// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export. pub flattener: String, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..85abacc15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -38,7 +38,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "app.language", "Interface Language", None, - "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch)", + "{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch); without lang, opens Preferences, where it is the first setting", ), ( "app.flattener", @@ -254,18 +254,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:edit.transparencyBlendSpace|Document CMYK|{\"space\": \"cmyk\"}", "<", "ui:app.colorSettings", - ">Interface Language", - "ui:app.language|English|{\"lang\": \"\"}", - "ui:app.language|Deutsch|{\"lang\": \"de\"}", - "ui:app.language|Français|{\"lang\": \"fr\"}", - "ui:app.language|Español|{\"lang\": \"es\"}", - "ui:app.language|日本語|{\"lang\": \"ja\"}", - "ui:app.language|简体中文|{\"lang\": \"zh\"}", - "ui:app.language|العربية|{\"lang\": \"ar\"}", - "ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}", - "ui:app.language|Italiano|{\"lang\": \"it\"}", - "ui:app.language|Українська|{\"lang\": \"uk\"}", - "<", ">Transparency Flattener Presets", "ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}", "ui:app.flattener|High Resolution|{\"preset\": \"high\"}", @@ -859,7 +847,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let v = p.get("lang").and_then(Value::as_str).unwrap_or(""); + let Some(v) = p.get("lang").and_then(Value::as_str) else { return Some(app.run("app.preferences", json!({}))) }; if !crate::i18n::LANGUAGES.iter().any(|(c, _)| *c == v) { return Some(Err(format!("unknown language `{v}`"))); } @@ -2643,6 +2631,16 @@ mod tests { assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true)); assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster"); assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err()); + // The language is set in Preferences, not from a menu; without `lang` the command opens it. + let mut items = Vec::new(); + for (_, entries) in menu_tree() { + walk(&entries, &mut items); + } + assert!(!items.iter().any(|(_, id, _)| id == "app.language"), "no menu item sets the language"); + run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); + assert_eq!(app.ui.language, "de"); + app.ui.dialog = None; run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap(); assert_eq!(app.ui.language, "zh"); @@ -2702,11 +2700,7 @@ mod tests { assert_eq!(restored.language, "uk"); assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(menu_tree().iter().flat_map(|(_, entries)| entries.iter()).any(|entry| { - matches!(entry, Item::Sub(label, children) if label == "Interface Language" && children.iter().any(|child| { - matches!(child, Item::Cmd { label, id, params, .. } if label == "Українська" && id == "app.language" && params["lang"] == "uk") - })) - })); + assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..76d5f2497 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -362,7 +362,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `