Merge pull request #1424

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:33 -04:00
5 changed files with 190 additions and 20 deletions
+38 -13
View File
@@ -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
+35
View File
@@ -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"),
+1 -1
View File
@@ -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<usize> = 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, &params, label))).clicked() {
actions.push((cmd.into(), params));
ui.close();
}
+81 -6
View File
@@ -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, &params, 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, &params, 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, &params, 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<String> {
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
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() {
+35
View File
@@ -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]