diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 8e0d3b21..d5031a13 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -305,6 +305,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • フォント:Inter、JetBrains Mono (OFL)"), ("Magnification ratio popup", "拡大率のポップアップ"), ("Make Comp Size", "コンポジションサイズにする"), + ("Markers", "マーカー"), ("Masks", "マスク"), ("Maximize Panel", "パネルを最大化"), ("Media Replacement", "メディアの置き換え"), @@ -841,6 +842,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT 或 Apache-2.0 • 字体:Inter、JetBrains Mono(OFL)"), ("Magnification ratio popup", "放大倍率弹出菜单"), ("Make Comp Size", "设为合成长宽"), + ("Markers", "标记"), ("Masks", "蒙版"), ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒体替换"), @@ -1565,6 +1567,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT 或 Apache-2.0 • 字型:Inter、JetBrains Mono(OFL)"), ("Magnification ratio popup", "放大倍率快顯選單"), ("Make Comp Size", "設為合成長寬"), + ("Markers", "標記"), ("Masks", "遮色片"), ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒體替換"), @@ -2292,6 +2295,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Шрифти: Inter, JetBrains Mono (OFL)"), ("Magnification ratio popup", "Меню коефіцієнта збільшення"), ("Make Comp Size", "За розміром композиції"), + ("Markers", "Маркери"), ("Masks", "Маски"), ("Maximize Panel", "Розгорнути панель"), ("Media Replacement", "Заміна матеріалу"), @@ -2831,6 +2835,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OU Apache-2.0 • Fontes: Inter, JetBrains Mono (OFL)"), ("Magnification ratio popup", "Menu de proporção de ampliação"), ("Make Comp Size", "Ajustar ao tamanho da composição"), + ("Markers", "Marcadores"), ("Masks", "Máscaras"), ("Maximize Panel", "Maximizar painel"), ("Media Replacement", "Substituição de mídia"), @@ -3277,6 +3282,7 @@ const CZECH: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Písma: Inter, JetBrains Mono (OFL)"), ("Magnification ratio popup", "Nabídka míry přiblížení"), ("Make Comp Size", "Nastavit velikost kompozice"), + ("Markers", "Značky"), ("Masks", "Masky"), ("Maximize Panel", "Maximalizovat panel"), ("Media Replacement", "Nahrazení médií"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 56606f87..eff827f2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1836,6 +1836,11 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only // comes as text. if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); + // As in After Effects, it marks the selected layers; only with none selected does it add + // a composition marker (#976). + let bound = bound.map(|(id, params)| { + if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } + }); if let Some((id, params)) = bound && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 55edb4ba..27641e41 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3798,6 +3798,9 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, action item(ui, "Freeze Frame", "layer.freezeFrame", 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})); + }); ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index c1533fa3..96789fe7 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -259,3 +259,32 @@ fn timeline_layer_context_menu_has_the_effects_submenu_and_applies_an_effect() { .unwrap_or_default(); assert_eq!(names, ["Gaussian Blur"]); } + +/// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that +/// was right-clicked (not the one selected before, and not the composition) at the current time. +#[test] +fn layer_context_menu_adds_a_layer_marker() { + let mut h = harness(); + let solid = |h: &mut Harness<'_, EffectcraftApp>, name: &str| -> u64 { + h.state_mut().session.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap() + }; + let (below, top) = (solid(&mut h, "Below"), solid(&mut h, "Top")); + h.state_mut().session.execute("layer.select", json!({"layers": [below]})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 1.5})).unwrap(); + h.run_steps(3); + let r = rect(&h, &format!("timeline.layer.{top}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Markers ⏵").click(); + h.run_steps(3); + snapshot(&mut h, "layer-markers"); + h.get_by_label("Add Marker").click(); + h.run_steps(3); + let now = h.state().session.time().seconds(); + let comp = h.state().session.active_comp().unwrap(); + let markers = |id: u64| -> Vec { comp.layer(effectcraft_engine::project::LayerId(id)).unwrap().markers.iter().map(|m| m.time.seconds()).collect() }; + assert_eq!(markers(top), vec![now]); + assert!((now - 1.5).abs() < 0.01, "at the current time: {now}"); + assert!(markers(below).is_empty(), "{:?}", markers(below)); + assert!(comp.markers.is_empty(), "no composition marker"); + assert_eq!(h.state().session.history.undo.last().map(|u| u.0.as_str()), Some("Add Marker")); +} diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index a68ee0e0..5f731e11 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -356,13 +356,38 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() { assert_eq!(markers(&h), vec![13.0]); let history = &h.state().session.history.undo; assert_eq!((history.len(), history.last().map(|u| u.0.as_str())), (undo + 1, Some("Add Marker")), "one undo step"); - // Numpad * (it arrives as text): Add Marker at the current time. + // Numpad * (it arrives as text) with no layer selected: Add Marker at the current time. + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); h.state_mut().session.execute("time.set", json!({"time": 20.0})).unwrap(); h.event(Event::Text("*".into())); h.run_steps(2); assert_eq!(markers(&h), vec![13.0, 20.0]); } +/// #976: as in After Effects, Numpad * marks the selected layers at the current time; only with +/// no layer selected does it add a composition marker. +#[test] +fn numpad_star_marks_the_selected_layer() { + let (mut h, layer) = harness(); + let comp_markers = + |h: &Harness<'_, EffectcraftApp>| -> Vec { h.state().session.active_comp().unwrap().markers.iter().map(|m| m.time.seconds()).collect() }; + let layer_markers = |h: &Harness<'_, EffectcraftApp>| -> Vec { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().markers.iter().map(|m| m.time.seconds()).collect() + }; + let star = |h: &mut Harness<'_, EffectcraftApp>, time: f64| { + h.state_mut().session.execute("time.set", json!({"time": time})).unwrap(); + h.event(Event::Text("*".into())); + h.run_steps(2); + }; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + star(&mut h, 2.0); + assert_eq!((layer_markers(&h), comp_markers(&h)), (vec![2.0], vec![])); + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + star(&mut h, 3.0); + assert_eq!((layer_markers(&h), comp_markers(&h)), (vec![2.0], vec![3.0])); +} + /// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the /// CTI rides the edge), past the left end scrolls it back; during playback the view pages to /// keep the CTI in sight.