From eec84a8095242c315eaa348c908e5eca61e46a37 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:37:18 +0200 Subject: [PATCH] Second default keys, also in menu lines F2 Deselect All, Cmd+Shift+T Effect Controls, Cmd+Alt+Left / Right Go to Start / End, Cmd+Shift+/ Add to Render Queue. Effect Controls is a menu entry with bound parameters, which could hold one key: a menu line can now give further default keys after a comma (F3, Cmd+Shift+T); the menus show the first. MenuEntry gains alt_shortcuts. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 2 +- crates/engine/src/commands/render_queue.rs | 2 +- crates/engine/src/commands/time.rs | 4 +- crates/engine/src/menus.rs | 32 ++++++++----- crates/engine/src/shortcuts.rs | 3 +- crates/engine/src/tests_m145.rs | 8 +++- crates/engine/src/tests_settings.rs | 20 ++++++++ crates/ui-egui/src/i18n.rs | 53 +++++++++++++++------- 8 files changed, 90 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index d28f3412..06ba43b1 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -878,7 +878,7 @@ pub fn specs() -> Vec { cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift), cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all), - cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all), + cmd!("edit.deselectAll", "Deselect All", ["Edit"], ["Cmd+Shift+A", "F2"], "{}", always_ok, deselect_all), cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?, keys?, target?: layers}", always_ok, label), cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group), cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge), diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 93a56ced..09a7c7d2 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -998,7 +998,7 @@ pub fn specs() -> Vec { "renderQueue.add", "Add to Render Queue", ["Composition"], - Some("Cmd+M"), + ["Cmd+M", "Cmd+Shift+/"], "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 1bc4638d..a0060ad9 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -178,8 +178,8 @@ pub fn specs() -> Vec { cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))), cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))), cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step), - cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))), - cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), + cmd!("time.start", "Go to Start", [], ["Home", "Cmd+Alt+ArrowLeft"], "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))), + cmd!("time.end", "Go to End", [], ["End", "Cmd+Alt+ArrowRight"], "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))), cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))), cmd!( diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 5fedf782..1fb215a3 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -18,10 +18,11 @@ //! ``` //! //! An entry's shortcut is the command's default shortcut unless the line gives one (needed for -//! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media -//! Encoder, Behance, Creative Cloud…) are intentionally absent; the Essential Graphics workspace -//! uses the Properties panel and the Essential Graphics panel, whose templates use EffectCraft's -//! own open `.ectemplate` format. +//! entries with bound parameters). A line can give further default keys after a comma +//! (`| F3, Cmd+Shift+T`); the menus show the first. Adobe-service entries (Team Projects, +//! Libraries, Bridge, Media Encoder, Behance, Creative Cloud…) are intentionally absent; the +//! Essential Graphics workspace uses the Properties panel and the Essential Graphics panel, whose +//! templates use EffectCraft's own open `.ectemplate` format. use std::sync::OnceLock; @@ -61,7 +62,7 @@ fn file_name(p: &str) -> String { } fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry { - MenuEntry { label, command: command.into(), params, shortcut: None } + MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] } } /// The bindable holding shortcut `keys` in the active preset (its label). @@ -175,6 +176,9 @@ pub struct MenuEntry { #[serde(skip_serializing_if = "Value::is_null")] pub params: Value, pub shortcut: Option, + /// Further default shortcuts (the menus show `shortcut`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub alt_shortcuts: Vec, } impl MenuEntry { @@ -434,8 +438,9 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { }; // Entries with bound params state their own shortcut; plain entries use the command's. let inherited = if params.is_null() { commands::find(&command).and_then(|c| c.shortcut).map(str::to_string) } else { None }; - let shortcut = parts.next().map(|s| s.trim().to_string()).or(inherited); - out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut })); + let mut keys = parts.next().into_iter().flat_map(|s| s.split(", ")).map(|s| s.trim().to_string()); + let shortcut = keys.next().or(inherited); + out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut, alt_shortcuts: keys.collect() })); } None => { let children = build(lines, i, depth + 1, mac)?; @@ -456,8 +461,13 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { fn effect_categories() -> Vec { let mut cats: Vec<(&str, Vec)> = vec![]; for e in crate::effects::all() { - let entry = - MenuNode::Item(MenuEntry { label: e.name.into(), command: "effect.apply".into(), params: serde_json::json!({"effect": e.id}), shortcut: None }); + let entry = MenuNode::Item(MenuEntry { + label: e.name.into(), + command: "effect.apply".into(), + params: serde_json::json!({"effect": e.id}), + shortcut: None, + alt_shortcuts: vec![], + }); match cats.iter_mut().find(|(c, _)| *c == e.category) { Some((_, v)) => v.push(entry), None => cats.push((e.category, vec![entry])), @@ -954,7 +964,7 @@ Layer Pre-compose... | layer.precompose Scene Edit Detection... | layer.sceneEditDetection Effect - Effect Controls | window.panel {"panel":"effectControls"} | F3 + Effect Controls | window.panel {"panel":"effectControls"} | F3, Cmd+Shift+T Last Effect | effect.applyLast Remove All | effect.removeAll --- @@ -1203,7 +1213,7 @@ Window Wiggler | window.panel {"panel":"wiggler"} --- Composition | window.panel {"panel":"composition"} - Effect Controls | window.panel {"panel":"effectControls"} | F3 + Effect Controls | window.panel {"panel":"effectControls"} | F3, Cmd+Shift+T Flowchart | window.panel {"panel":"flowchart"} | Cmd+F11 Footage | window.panel {"panel":"footage"} Layer | window.panel {"panel":"layer"} diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 942184e3..630bfc2d 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -90,7 +90,8 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { let key = binding_key(&e.command, &e.params); // A plain entry also gets its command's further default keys. let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] }; - let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect(); + let defaults = + e.shortcut.as_deref().into_iter().chain(e.alt_shortcuts.iter().map(String::as_str)).chain(alt.iter().copied()).filter_map(normalize).collect(); push( Bindable { key, diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index 183ae65f..64bd9ebb 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -661,7 +661,13 @@ fn dynamic_menus_and_window_viewer_labels() { assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len()); let a = solid(&mut s, "#ff0000"); s.state.selected_layers = vec![LayerId(a)]; - let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { label: "x".into(), command: cmd.into(), params: json!({"panel": panel}), shortcut: None }; + let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { + label: "x".into(), + command: cmd.into(), + params: json!({"panel": panel}), + shortcut: None, + alt_shortcuts: vec![], + }; assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main"); assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main"); assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: ")); diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index b425e098..fea7709e 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -430,6 +430,26 @@ fn default_keys_for_commands_that_had_none() { assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); } +/// Second default keys: on the command, or after a comma in a menu line with bound parameters. +#[test] +fn second_default_keys() { + let s = Session::default(); + let t = s.shortcuts(); + for (key, want) in [ + ("edit.deselectAll", ["Cmd+Shift+A", "F2"]), + ("time.start", ["Home", "Cmd+Alt+ArrowLeft"]), + ("time.end", ["End", "Cmd+Alt+ArrowRight"]), + ("renderQueue.add", ["Cmd+M", "Cmd+Shift+/"]), + (r#"window.panel {"panel":"effectControls"}"#, ["F3", "Cmd+Shift+T"]), + ] { + assert_eq!(t.keys.get(key).map(Vec::as_slice), Some(want.map(String::from).as_slice()), "{key}"); + } + // The menus keep showing the first key. + assert_eq!(t.shortcut_of("window.panel", &json!({"panel": "effectControls"})), Some("F3")); + assert_eq!(t.shortcut_of("edit.deselectAll", &json!(null)), Some("Cmd+Shift+A")); + assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); +} + /// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). #[test] fn cmd_arrows_step_frames_like_page_keys() { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4e814ea2..9c10a4e9 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6591,7 +6591,8 @@ mod tests { assert_eq!(label(&app, "", source), translated); } assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - let panel = MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None }; + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &panel, "Layer: File Čeština".into()), "Vrstva: File Čeština"); assert_eq!( submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Čeština” Workspace".into()), @@ -6607,8 +6608,8 @@ mod tests { fn czech_undo_redo_keeps_history_labels_verbatim() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); - let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }; - let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None }; + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Není co vrátit"); assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Není co zopakovat"); app.session.history.undo.push(("File Čeština".into(), app.session.project.clone())); @@ -6627,7 +6628,7 @@ mod tests { ("Reveal in Explorer", "Zobrazit v Průzkumníku"), ("Reveal in File Manager", "Zobrazit ve správci souborů"), ] { - let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None }; + let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(label(&app, command, source), translated); assert_eq!(entry(&app, &e, source.into()), translated); assert_eq!(label(&app, "file.openRecent", source), source); @@ -6722,7 +6723,8 @@ mod tests { ("Reveal in Explorer", "Показати у Провіднику"), ("Reveal in File Manager", "Показати у файловому менеджері"), ] { - let e = MenuEntry { label: source.into(), command: command.into(), params: json!({"item": "My file.mp4"}), shortcut: None }; + let e = + MenuEntry { label: source.into(), command: command.into(), params: json!({"item": "My file.mp4"}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(label(&app, command, source), translated); assert_eq!(entry(&app, &e, source.into()), translated); assert_eq!(label(&app, "file.openRecent", source), source); @@ -6751,7 +6753,7 @@ mod tests { assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "レイヤー: File" @@ -6854,13 +6856,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "无可撤销的操作" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "图层: File" @@ -6894,13 +6900,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "沒有可復原的操作" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "圖層: File" @@ -6943,7 +6953,8 @@ mod tests { assert_eq!(label(&app, "layer.addTextAnimator", "Tracking"), "Міжлітерний інтервал"); assert_eq!(label(&app, "edit.purgeUndo", "Undo"), "Історія скасувань"); assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - let panel = MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }; + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &panel, "Layer: File".into()), "Шар: File"); assert_eq!( submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Україна” Workspace".into()), @@ -6958,8 +6969,8 @@ mod tests { fn ukrainian_undo_redo_keeps_history_labels_verbatim() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); app.session.execute("prefs.set", json!({"key":"general.language", "value":"uk"})).unwrap(); - let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }; - let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None }; + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Немає дій для скасування"); assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Немає дій для повторення"); app.session.history.undo.push(("File Україна".into(), app.session.project.clone())); @@ -6995,13 +7006,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "No se puede deshacer" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "Capa: File" @@ -7036,13 +7051,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "Não há nada para desfazer" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "Camada: File"