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 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-12 17:37:18 +02:00
co-authored by Claude Opus 5.5
parent 6f5bd0c272
commit eec84a8095
8 changed files with 90 additions and 34 deletions
+1 -1
View File
@@ -878,7 +878,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),
+1 -1
View File
@@ -998,7 +998,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
+2 -2
View File
@@ -178,8 +178,8 @@ pub fn specs() -> Vec<CommandSpec> {
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!(
+21 -11
View File
@@ -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<String>,
/// Further default shortcuts (the menus show `shortcut`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub alt_shortcuts: Vec<String>,
}
impl MenuEntry {
@@ -434,8 +438,9 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, 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<Vec<MenuNode>, String> {
fn effect_categories() -> Vec<MenuNode> {
let mut cats: Vec<(&str, Vec<MenuNode>)> = 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"}
+2 -1
View File
@@ -90,7 +90,8 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
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,
+7 -1
View File
@@ -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: "));
+20
View File
@@ -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() {
+36 -17
View File
@@ -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"