mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
shortcuts: physical-key bindings for AZERTY layouts; Go to Work Area Start/End
Shortcut presets accept Physical+<key> (egui US-position names) so layouts that report the same logical key for two physical keys (AZERTY's - and )) can bind them separately; physical bindings win for that press, and a focused panel's binding wins over an application command on the same chord. ui.key accepts the same notation. Adds time.workAreaStart / time.workAreaEnd. (cherry picked from commit 209543e9e0ba32f809f9ff333abb8b8ec4125cea) (cherry picked from commit f8301a9e3e0b60e200e8a233455398741658481e) (cherry picked from commit 4d002fabf992934e1bb73a0c32bc9f3fd3ae5447) (cherry picked from commit 5d466c5955ee934d5347c194ec7d15fb5f58bba4)
This commit is contained in:
1 parent
470ea7a3af
commit
0553180092
6 files changed
+130
-14
No files matched your search
@@ -180,6 +180,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.workAreaStart", "Go to Work Area Start", [], None, "{}", has_comp, |s, _| go(s, &json!({"to": "workStart"}))),
|
||||
cmd!("time.workAreaEnd", "Go to Work Area End", [], None, "{}", has_comp, |s, _| go(s, &json!({"to": "workEnd"}))),
|
||||
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!(
|
||||
|
||||
@@ -150,6 +150,7 @@ pub fn normalize(s: &str) -> Option<String> {
|
||||
}
|
||||
let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false);
|
||||
let mut key: Option<String> = None;
|
||||
let mut physical = false;
|
||||
// `Cmd++` / `+` name the plus key.
|
||||
let (head, plus) = match s.strip_suffix("++") {
|
||||
Some(h) => (h, true),
|
||||
@@ -176,6 +177,7 @@ pub fn normalize(s: &str) -> Option<String> {
|
||||
}
|
||||
for p in parts {
|
||||
match p.to_ascii_lowercase().as_str() {
|
||||
"physical" => physical = true,
|
||||
"cmd" | "command" | "meta" | "super" | "win" => cmd = true,
|
||||
"ctrl" | "control" => ctrl = true,
|
||||
"alt" | "opt" | "option" => alt = true,
|
||||
@@ -187,7 +189,7 @@ pub fn normalize(s: &str) -> Option<String> {
|
||||
key = Some("+".into());
|
||||
}
|
||||
let key = key?;
|
||||
let mut out = String::new();
|
||||
let mut out = if physical { "Physical+".to_string() } else { String::new() };
|
||||
for (on, m) in [(ctrl, "Ctrl+"), (cmd, "Cmd+"), (alt, "Alt+"), (shift, "Shift+")] {
|
||||
if on {
|
||||
out.push_str(m);
|
||||
|
||||
@@ -382,8 +382,9 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
|
||||
let extra = modifiers(p);
|
||||
m |= extra;
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
let physical_key = name.starts_with("Physical+").then_some(key);
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.type" => {
|
||||
|
||||
+33
-11
@@ -1539,6 +1539,7 @@ pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> {
|
||||
m.ctrl = true;
|
||||
}
|
||||
}
|
||||
"Physical" => {}
|
||||
"Shift" => m.shift = true,
|
||||
"Alt" => m.alt = true,
|
||||
"Ctrl" => m.ctrl = true,
|
||||
@@ -1586,6 +1587,9 @@ pub type Binding = (egui::Modifiers, egui::Key, String, Value);
|
||||
pub fn bindings(session: &effectcraft_engine::Session) -> Vec<Binding> {
|
||||
let mut v: Vec<Binding> = Vec::new();
|
||||
for (sc, b) in session.shortcuts().bindings() {
|
||||
if sc.starts_with("Physical+") {
|
||||
continue;
|
||||
}
|
||||
// Off macOS `Cmd` is Ctrl, so a custom Ctrl+Cmd shortcut would
|
||||
// steal the plain Cmd one: such macOS-only shortcuts aren't bound there.
|
||||
if !cfg!(target_os = "macos") && sc.split('+').any(|p| p == "Ctrl") && sc.split('+').any(|p| p == "Cmd") {
|
||||
@@ -1699,7 +1703,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
|
||||
let pasted =
|
||||
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
|
||||
let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
|
||||
let mut events: Vec<(egui::Key, egui::Modifiers, bool, Option<egui::Key>)> = ctx.input(|i| {
|
||||
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
|
||||
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
|
||||
// Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and
|
||||
@@ -1715,31 +1719,31 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
egui::Event::ModifiersChanged(m) => at = Some(*m),
|
||||
// egui forgets the modifiers held when the window loses focus.
|
||||
egui::Event::WindowFocused(false) => at = Some(egui::Modifiers::NONE),
|
||||
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
|
||||
egui::Event::Key { key, physical_key, pressed: true, modifiers, repeat, .. }
|
||||
if !*repeat
|
||||
|| matches!(
|
||||
key,
|
||||
egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown
|
||||
) =>
|
||||
{
|
||||
out.push((*key, *modifiers, true));
|
||||
out.push((*key, *modifiers, true, *physical_key));
|
||||
}
|
||||
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false)),
|
||||
egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true)),
|
||||
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false, None)),
|
||||
egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true, None)),
|
||||
// On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project
|
||||
// panel has no clipboard: there a Cut with only Shift held is Shift+Delete (Delete
|
||||
// Project Items Without Confirmation). Never when the modifiers aren't known.
|
||||
egui::Event::Cut if clipboard && project && at.is_some_and(only_shift) => {
|
||||
out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true));
|
||||
out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true, None));
|
||||
}
|
||||
egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true)),
|
||||
egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true)),
|
||||
egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true, None)),
|
||||
egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true, None)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
events.retain(|(key, _, _)| (*key == egui::Key::Tab) == tab_only);
|
||||
events.retain(|(key, _, _, _)| (*key == egui::Key::Tab) == tab_only);
|
||||
// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
|
||||
// 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 == "*"))) {
|
||||
@@ -1754,7 +1758,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
return;
|
||||
}
|
||||
let binds = bindings(&app.session);
|
||||
for (key, mods, pressed) in events {
|
||||
for (key, mods, pressed, physical_key) in events {
|
||||
// Plain Spacebar runs its shortcut (Play Current Preview) on the release: held, it is
|
||||
// the Hand tool (After Effects), and a drag with it must not start or stop playback.
|
||||
if key == egui::Key::Space && (!pressed || !mods.any()) {
|
||||
@@ -1812,7 +1816,25 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
let _ = invoke(app, ctx, "edit.clear", json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {
|
||||
// Explicit physical bindings distinguish keys that a layout reports with the same
|
||||
// logical name (for example AZERTY's minus and right parenthesis).
|
||||
let physical = physical_key.and_then(|key| {
|
||||
app.session.shortcuts().bindings().into_iter().find_map(|(sc, b)| {
|
||||
let (m, k) = parse_shortcut(sc.strip_prefix("Physical+")?)?;
|
||||
(k == key && mods_match(m, mods)).then(|| (b.command.clone(), b.params.clone()))
|
||||
})
|
||||
});
|
||||
if let Some((id, params)) = physical {
|
||||
if let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((_, _, id, params)) = binds.iter().filter(|(m, k, ..)| *k == key && mods_match(*m, mods)).min_by_key(|(_, _, id, params)| {
|
||||
// The focused panel wins over an application command sharing its chord.
|
||||
let key = effectcraft_engine::shortcuts::binding_key(id, params);
|
||||
app.session.shortcuts().find(&key).is_none_or(|b| b.scope != app.ui.focused.id())
|
||||
}) {
|
||||
let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() });
|
||||
if id == "edit.paste"
|
||||
&& let Some(t) = &pasted
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Layout collisions must not dispatch both the physical and logical shortcuts.
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn physical_shortcuts_distinguish_azerty_zoom_keys() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 60})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.ui.timeline.pps = Some(100.0);
|
||||
for (command, keys) in [("timeline.zoomIn", "Physical+6"), ("timeline.zoomOut", "Physical+Minus")] {
|
||||
app.session.execute("shortcuts.set", json!({"command": command, "keys": keys})).unwrap();
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.timeline.pps = Some(100.0);
|
||||
for (physical, expected) in [(Key::Num6, 150.0), (Key::Minus, 100.0)] {
|
||||
h.event(Event::Key { key: Key::Minus, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(Event::Key { key: Key::Minus, physical_key: Some(physical), pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
assert!(
|
||||
(h.state().ui.timeline.pps.unwrap() - expected).abs() < 0.01,
|
||||
"physical={physical:?} expected={expected} actual={:?}",
|
||||
h.state().ui.timeline.pps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_area_navigation_uses_last_included_frame() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 10, "frameRate": 30})).unwrap();
|
||||
s.execute("comp.workArea", json!({"start": 2.0, "end": 5.0})).unwrap();
|
||||
assert_eq!(s.execute("time.workAreaStart", json!({})).unwrap()["frame"], 60);
|
||||
assert_eq!(s.execute("time.workAreaEnd", json!({})).unwrap()["frame"], 149);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_one_toggles_only_selected_video_and_iso_key_maximizes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Unselected"})).unwrap();
|
||||
let selected = s.execute("layer.newSolid", json!({"name": "Selected"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [selected]})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.session.execute("shortcuts.set", json!({"command": "layer.setSwitch", "params": {"switch": "video"}, "keys": "Shift+1"})).unwrap();
|
||||
app.session.execute("shortcuts.set", json!({"command": "window.maximizePanel", "keys": "IntlBackslash"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
|
||||
h.run_steps(3);
|
||||
for visible in [false, true] {
|
||||
h.event(Event::Key { key: Key::Num1, physical_key: Some(Key::Num1), pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
h.step();
|
||||
h.event(Event::Key { key: Key::Num1, physical_key: Some(Key::Num1), pressed: false, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
h.step();
|
||||
for layer in &h.state().session.active_comp().unwrap().layers {
|
||||
assert_eq!(layer.switches.video, layer.id.0 != selected || visible);
|
||||
}
|
||||
}
|
||||
h.event(Event::Key { key: Key::IntlBackslash, physical_key: Some(Key::IntlBackslash), pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
assert!(h.state().ui.maximized.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_shift_p_reveals_instead_of_adding_a_text_animator() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box"})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.session.execute("shortcuts.set", json!({"command": "layer.addTextAnimator", "params": {"properties": ["position"]}, "keys": "Shift+P"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.focused = effectcraft_ui_egui::dock::PanelKind::Timeline;
|
||||
h.event(Event::Key { key: Key::P, physical_key: Some(Key::P), pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
h.step();
|
||||
assert!(h.state().ui.timeline.reveal.contains(&"position".to_string()));
|
||||
assert!(h.state().ui.status.is_empty());
|
||||
}
|
||||
@@ -97,6 +97,13 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
|
||||
| `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` |
|
||||
| `ui.type` | `{text}`: text input into the focused field |
|
||||
|
||||
Shortcut presets also accept `Physical+<shortcut>` for a keyboard position, using egui's
|
||||
US-position names (for example `Physical+6` and `Physical+Minus` distinguish AZERTY's
|
||||
`-` and `)` keys). Physical bindings take precedence over logical bindings for that key
|
||||
press. `ui.key` accepts the same notation to test them. Ordinary shortcuts remain layout-aware.
|
||||
`time.workAreaStart` and `time.workAreaEnd` navigate to the first and last included work-area
|
||||
frames and can be assigned through `shortcuts.set`.
|
||||
|
||||
## Locating things
|
||||
|
||||
| Method | Params | Result |
|
||||
|
||||
Reference in new issue
Block a user