diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 1bc4638d..eaa149d9 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -180,6 +180,8 @@ pub fn specs() -> Vec { 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!( diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 942184e3..b114a0b2 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -150,6 +150,7 @@ pub fn normalize(s: &str) -> Option { } let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false); let mut key: Option = 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 { } 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 { 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); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 113c8bc0..bb45d110 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31..c66df7d5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { let mut v: Vec = 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)> = 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 diff --git a/crates/ui-egui/tests/ui_azerty_shortcuts.rs b/crates/ui-egui/tests/ui_azerty_shortcuts.rs new file mode 100644 index 00000000..dde4b190 --- /dev/null +++ b/crates/ui-egui/tests/ui_azerty_shortcuts.rs @@ -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()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f5487981..0c6691eb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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+` 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 |