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:
Elias authored and Elias committed 2026-10-12 19:21:40 +01:00
1 parent 470ea7a3af
commit 0553180092
6 files changed
+130 -14

No files matched your search

+2
View File
@@ -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!(
+3 -1
View File
@@ -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);
+3 -2
View File
@@ -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
View File
@@ -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());
}
+7
View File
@@ -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 |