diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 81343f9f..8b8b01b5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -125,19 +125,24 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } - match language { - "ja" => lookup(JAPANESE, command, source), - "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), - "zh-hant" => lookup(TRADITIONAL_CHINESE, command, source), - "uk" => lookup(UKRAINIAN, command, source), - "es" => lookup(SPANISH, command, source), - "pt-br" => lookup(PORTUGUESE, command, source), - "ru" => lookup(RUSSIAN, command, source), - "fr" => lookup(FRENCH, command, source), - "de" => lookup(GERMAN, command, source), - "cs" => lookup(CZECH, command, source), - _ => source, - } + menu_catalog(language).map_or(source, |catalog| lookup(catalog, command, source)) +} + +/// The menu catalog for `language`; `None` for English. +fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str, &'static str)]> { + Some(match language { + "ja" => JAPANESE, + "zh-hans" => SIMPLIFIED_CHINESE, + "zh-hant" => TRADITIONAL_CHINESE, + "uk" => UKRAINIAN, + "es" => SPANISH, + "pt-br" => PORTUGUESE, + "ru" => RUSSIAN, + "fr" => FRENCH, + "de" => GERMAN, + "cs" => CZECH, + _ => return None, + }) } /// `source` looked up in `catalog` by (command, English label); the source itself when the pair is @@ -146,6 +151,26 @@ fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated).unwrap_or(source) } +/// The text a context menu draws for an item that runs `command` with `params` (#1308). +/// +/// The Timeline's and the Composition panel's context menus repeat menu-bar commands, so they read +/// the menu bar's row for the same command, and an `effect.apply` item draws the effect's name as +/// the Effect menu does. The menu tree writes the ellipsis as `...` where the context menus write +/// `…`. A label with no menu row (Delete, which the menu bar calls Clear) goes through the panel +/// catalog. Only the drawn text is translated: the automation label stays the English `source`. +pub(crate) fn context_item(command: &str, params: &serde_json::Value, source: &str) -> String { + if command == "effect.apply" + && let Some(id) = params.get("effect").and_then(|v| v.as_str()) + { + return effects::name(id, source); + } + let ascii = source.strip_suffix('…').map(|s| format!("{s}...")); + let key = ascii.as_deref().unwrap_or(source); + menu_catalog(ui::current()) + .and_then(|catalog| catalog.iter().find(|(id, en, _)| *id == command && *en == key)) + .map_or_else(|| tr(source).to_string(), |(_, _, translated)| (*translated).to_string()) +} + pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> String { // The generated Effect menu's entries label themselves with the effect's registry name; the // catalog replaces it with the interface language's (#821). The entry keeps its command and diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 4d75ccc8..5c061ecd 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -221,11 +221,16 @@ const JAPANESE: &[(&str, &str)] = &[ ("Drag to slip the source", "ドラッグしてソースをスリップ"), ("Drop Frame", "ドロップフレーム"), ("Duplicate", "複製"), + ("Copy", "コピー"), + ("Paste", "ペースト"), + ("Edit Value…", "値を編集…"), + ("Remove Expression", "エクスプレッションを削除"), ("Duration:", "デュレーション:"), ("Edit Gradient…", "グラデーションを編集…"), ("Edit Target: {}", "編集対象:{}"), ("Edit in the Timeline", "タイムラインで編集"), ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), + ("Effects", "エフェクト"), ("Effects & Presets options", "エフェクト&プリセットのオプション"), ("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"), ("Copy Source Path", "ソースパスをコピー"), @@ -768,11 +773,16 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Drag to slip the source", "拖动可滑移源"), ("Drop Frame", "丢帧"), ("Duplicate", "重复"), + ("Copy", "复制"), + ("Paste", "粘贴"), + ("Edit Value…", "编辑值…"), + ("Remove Expression", "移除表达式"), ("Duration:", "持续时间:"), ("Edit Gradient…", "编辑渐变…"), ("Edit Target: {}", "编辑目标:{}"), ("Edit in the Timeline", "在时间轴中编辑"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"), + ("Effects", "效果"), ("Effects & Presets options", "效果和预设选项"), ("Copy Presets Folder Path", "复制预设文件夹路径"), ("Copy Source Path", "复制源路径"), @@ -1503,11 +1513,16 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Drag to slip the source", "拖曳可滑移源"), ("Drop Frame", "丟格"), ("Duplicate", "重複"), + ("Copy", "拷貝"), + ("Paste", "貼上"), + ("Edit Value…", "編輯值…"), + ("Remove Expression", "移除運算式"), ("Duration:", "持續時間:"), ("Edit Gradient…", "編輯漸變…"), ("Edit Target: {}", "編輯目標:{}"), ("Edit in the Timeline", "在時間軸中編輯"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"), + ("Effects", "效果"), ("Effects & Presets options", "效果和預設集選項"), ("Copy Presets Folder Path", "複製預設集資料夾路徑"), ("Copy Source Path", "複製來源路徑"), @@ -2241,11 +2256,16 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Drag to slip the source", "Перетягніть, щоб зсунути матеріал у межах шару"), ("Drop Frame", "Пропуск номерів"), ("Duplicate", "Дублювати"), + ("Copy", "Копіювати"), + ("Paste", "Вставити"), + ("Edit Value…", "Редагувати значення…"), + ("Remove Expression", "Вилучити вираз"), ("Duration:", "Тривалість:"), ("Edit Gradient…", "Редагувати градієнт…"), ("Edit Target: {}", "Ціль редагування: {}"), ("Edit in the Timeline", "Редагувати на шкалі часу"), ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), + ("Effects", "Ефекти"), ("Effects & Presets options", "Параметри ефектів і наборів"), ("Copy Presets Folder Path", "Копіювати шлях до папки наборів"), ("Copy Source Path", "Копіювати шлях до джерела"), @@ -2796,11 +2816,16 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Drag to slip the source", "Arraste para deslizar a origem"), ("Drop Frame", "Drop frame"), ("Duplicate", "Duplicar"), + ("Copy", "Copiar"), + ("Paste", "Colar"), + ("Edit Value…", "Editar valor…"), + ("Remove Expression", "Remover expressão"), ("Duration:", "Duração:"), ("Edit Gradient…", "Editar gradiente…"), ("Edit Target: {}", "Editar destino: {}"), ("Edit in the Timeline", "Editar na linha do tempo"), ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), + ("Effects", "Efeitos"), ("Effects & Presets options", "Opções de Efeitos e predefinições"), ("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"), ("Copy Source Path", "Copiar caminho da origem"), @@ -3249,11 +3274,16 @@ const CZECH: &[(&str, &str)] = &[ ("Drag to slip the source", "Tažením posunete zdroj v čase"), ("Drop Frame", "Vynechávat čísla snímků"), ("Duplicate", "Duplikovat"), + ("Copy", "Kopírovat"), + ("Paste", "Vložit"), + ("Edit Value…", "Upravit hodnotu…"), + ("Remove Expression", "Odstranit výraz"), ("Duration:", "Délka trvání:"), ("Edit Gradient…", "Upravit přechod…"), ("Edit Target: {}", "Upravit cíl: {}"), ("Edit in the Timeline", "Upravit na časové ose"), ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), + ("Effects", "Efekty"), ("Effects & Presets options", "Možnosti efektů a přednastavení"), ("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"), ("Copy Source Path", "Kopírovat cestu ke zdroji"), @@ -3809,11 +3839,16 @@ const SPANISH: &[(&str, &str)] = &[ ("Drag to slip the source", "Arrastra para deslizar el origen"), ("Drop Frame", "Drop frame"), ("Duplicate", "Duplicar"), + ("Copy", "Copiar"), + ("Paste", "Pegar"), + ("Edit Value…", "Editar valor…"), + ("Remove Expression", "Quitar expresión"), ("Duration:", "Duración:"), ("Edit Gradient…", "Editar degradado…"), ("Edit Target: {}", "Destino de la edición: {}"), ("Edit in the Timeline", "Editar en la cronología"), ("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."), + ("Effects", "Efectos"), ("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"), ("Effects: {}", "Efectos: {}"), ("Empty folder", "Carpeta vacía"), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39..05b58d61 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1279,7 +1279,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let lid = layer.id.0; let mut picked_label: Option = None; let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| { - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + if ui.add_enabled(enabled, egui::Button::new(crate::i18n::context_item(cmd, ¶ms, label))).clicked() { actions.push((cmd.into(), params)); ui.close(); } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8a4febc6..1a6f3d34 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2986,7 +2986,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("shape", "Shape Layer", "layer.newShape", json!({"kind": "none"})), ("adjustment", "Adjustment Layer", "layer.newAdjustment", json!({})), ] { - let response = ui.button(label); + let response = ui.button(crate::i18n::context_item(command, ¶ms, label)); app.auto.add(&format!("timeline.context.{area}.{key}"), response.rect, label); if response.clicked() { actions.push((command.into(), params)); @@ -3832,7 +3832,7 @@ fn prop_menu( ) { let (l, uid) = (layer.id.0, prop.uid); let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| { - let resp = ui.add(egui::Button::new(label).selected(selected)); + let resp = ui.add(egui::Button::new(crate::i18n::context_item(cmd, ¶ms, label)).selected(selected)); auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label); if resp.clicked() { actions.push((cmd.into(), params)); @@ -3927,7 +3927,7 @@ pub(crate) fn layer_menu( let Some(&id) = layers.first() else { return }; let (auto, prefix) = auto; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { - if ui.button(label).clicked() { + if ui.button(crate::i18n::context_item(cmd, ¶ms, label)).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); actions.push((cmd.into(), params)); ui.close(); @@ -3935,7 +3935,7 @@ pub(crate) fn layer_menu( }; ui.menu_button(crate::i18n::tr("New"), |ui| { item(ui, "Text", "layer.newText", json!({})); - item(ui, "Solid…", "app.solidSettings", json!({})); + item(ui, "Solid…", "layer.newSolid", json!({})); item(ui, "Light…", "layer.newLight", json!({})); item(ui, "Camera…", "layer.newCamera", json!({})); item(ui, "Null Object", "layer.newNull", json!({})); @@ -3966,7 +3966,7 @@ pub(crate) fn layer_menu( item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": layers})); item(ui, "Time Stretch…", "layer.timeStretch", json!({})); item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": layers})); - item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers})); + item(ui, "Freeze On Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers})); }); ui.menu_button(crate::i18n::tr("Markers"), |ui| { item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); @@ -4048,7 +4048,7 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut widgets::menu_scroll(ui, |ui| { for (cat, mut fx) in cats { fx.sort_by_key(|e| e.name.to_ascii_lowercase()); - ui.menu_button(cat, |ui| { + ui.menu_button(crate::i18n::effects::category(cat), |ui| { widgets::menu_scroll(ui, |ui| { for e in fx { item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); @@ -4620,6 +4620,81 @@ mod tests { assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); } + /// Every string drawn by one frame of `draw`. + fn drawn_text(mut draw: impl FnMut(&mut egui::Ui)) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + ctx.run_ui(Default::default(), |ui| draw(ui)).textures_delta.clear(); + let mut frame = ctx.run_ui(Default::default(), |ui| draw(ui)); + frame.textures_delta.clear(); + let mut out = Vec::new(); + frame.shapes.iter().for_each(|c| collect(&c.shape, &mut out)); + out + } + + /// #1308: the layer context menu draws its items in the interface language, from the menu + /// bar's rows for the same commands (whose ellipsis is `...`), and its Effects submenu draws + /// the categories the Effect menu does; the actions it records stay the same. + #[test] + fn the_layer_context_menu_draws_in_the_interface_language() { + let caps = LayerMenuCaps { vector: true, text: false }; + let mut actions = Vec::new(); + crate::i18n::set_current("zh-hans"); + let menu = drawn_text(|ui| layer_menu(ui, &[7], caps, &mut actions)); + let effects = drawn_text(|ui| effects_submenu(ui, &[7], &mut |_: &mut egui::Ui, _: &str, _: &str, _: serde_json::Value| {})); + crate::i18n::set_current("en"); + let english = drawn_text(|ui| layer_menu(ui, &[7], caps, &mut actions)); + for label in ["新建", "图层设置...", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] { + assert!(menu.iter().any(|t| t == label), "{label} missing from {menu:?}"); + } + assert!(!menu.iter().any(|t| t.is_ascii()), "English left in {menu:?}"); + assert!(effects.iter().any(|t| t == "模糊和锐化"), "{effects:?}"); + assert!(!effects.iter().any(|t| t == "Blur & Sharpen"), "{effects:?}"); + for label in ["Layer Settings…", "Duplicate", "Delete"] { + assert!(english.iter().any(|t| t == label), "{label} missing from {english:?}"); + } + assert!(actions.is_empty()); + } + + /// The property context menu draws its items in the interface language too: the commands the + /// menu bar has from its rows, Reset, Edit Value… and Remove Expression from the panel catalog. + /// The automation labels stay English. + #[test] + fn the_property_context_menu_draws_in_the_interface_language() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let opacity = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute("prop.setExpression", json!({"layer": l, "prop": opacity})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let (position, opacity) = (layer.props.prop("transform/position").unwrap(), layer.props.prop("transform/opacity").unwrap()); + let (mut auto, mut actions) = (crate::automation::Registry::default(), Vec::new()); + let mut menu = |language| { + crate::i18n::set_current(language); + let mut drawn = drawn_text(|ui| prop_menu(ui, &mut auto, comp, &layer, position, &mut actions)); + drawn.extend(drawn_text(|ui| prop_menu(ui, &mut auto, comp, &layer, opacity, &mut actions))); + drawn + }; + let chinese = menu("zh-hans"); + let english = menu("en"); + for label in ["重置", "编辑值…", "单独尺寸", "添加表达式", "移除表达式", "向基本图形添加属性"] { + assert!(chinese.iter().any(|t| t == label), "{label} missing from {chinese:?}"); + } + assert!(!chinese.iter().any(|t| t.is_ascii()), "English left in {chinese:?}"); + for label in ["Reset", "Edit Value…", "Separate Dimensions", "Remove Expression", "Add Property to Essential Graphics"] { + assert!(english.iter().any(|t| t == label), "{label} missing from {english:?}"); + } + assert!(auto.elements.iter().any(|e| e.label == "Remove Expression"), "the automation label stays English"); + assert!(actions.is_empty()); + } + /// UI fidelity (M13.5): compact After Effects-like rows and muted label-coloured bars. #[test] fn rows_and_bars_follow_after_effects_proportions() { diff --git a/crates/ui-egui/tests/ui_effect_names.rs b/crates/ui-egui/tests/ui_effect_names.rs index c65d2cea..28d1a1f3 100644 --- a/crates/ui-egui/tests/ui_effect_names.rs +++ b/crates/ui-egui/tests/ui_effect_names.rs @@ -85,6 +85,41 @@ fn the_generated_effect_menu_is_translated() { assert_eq!(menu_label(&en, "Effect", "Blur & Sharpen", BLUR).as_deref(), Some(BLUR_EN), "the English menu is unchanged"); } +/// Right-click the Effect Controls header of effect `fx`. +fn open_effect_menu(h: &mut Harness<'_, EffectcraftApp>, fx: u64) { + let e = h.state().auto.find(&format!("effectControls.effect.{fx}")).expect("the effect's header").rect; + let pos = egui::pos2(e[0] + e[2] / 2.0, e[1] + e[3] / 2.0); + h.event(egui::Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); +} + +/// The effect header's context menu draws its items in the interface language, like the layer +/// menu (#1308): it used to draw Copy, Paste, Duplicate, Reset, Remove, Move Up and Move Down in +/// English whatever the language. +#[test] +fn the_effect_context_menu_follows_the_interface_language() { + const ENGLISH: [&str; 7] = ["Copy", "Paste", "Duplicate", "Reset", "Remove", "Move Up", "Move Down"]; + let (mut h, fx) = harness("zh-hans"); + open_effect_menu(&mut h, fx); + for label in ["复制", "粘贴", "重复", "重命名", "重置", "移除", "上移", "下移"] { + assert!(h.query_by_label(label).is_some(), "{label} missing from the effect menu"); + } + for label in ENGLISH { + assert!(h.query_by_label(label).is_none(), "{label} drawn in English"); + } + + let (mut en, fx) = harness("en"); + open_effect_menu(&mut en, fx); + for label in ENGLISH { + assert!(en.query_by_label(label).is_some(), "{label} missing from the English menu"); + } +} + /// Everything an agent or a script addresses stays English: the automation label, the property /// tree's group name and `effect.list`. #[test]