This commit is contained in:
Brandon Thomas
2026-11-01 21:50:53 -05:00
10 changed files with 330 additions and 37 deletions
+12
View File
@@ -39,6 +39,18 @@ fn deselect_keys(s: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Null)
}
impl Session {
/// The selected mask (its group, or a property inside it) of a selected layer, the first
/// one: (layer, mask group).
pub fn selected_mask(&self) -> Option<(effectcraft_project::LayerId, effectcraft_project::Uid)> {
let comp = self.active_comp()?;
self.state.selected_props.iter().find_map(|(lid, uid)| {
let masks = comp.layer(*lid)?.masks()?;
masks.groups().find(|g| g.uid == *uid || g.find_group(*uid).is_some() || g.find(*uid).is_some()).map(|g| (*lid, g.uid))
})
}
}
/// Alt+` / Alt+Shift+`: select the next (previous) mask of the layer, wrapping around; the first
/// (last) one when none is selected.
fn step_mask(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
+123 -10
View File
@@ -9,6 +9,11 @@
//! (as After Effects asks you to "Save As" a custom set). The shortcut dispatcher and the menus
//! read the active preset through [`ShortcutTable`].
//!
//! Scopes, narrowest first: a *context* ([`CONTEXT_SCOPES`]: a state of the app such as editing
//! text; its shortcuts work only while it is active), a *panel* (`Project`, `Timeline`,
//! `RenderQueue`…: the panel with the keyboard) and the application. One chord can be bound once
//! per scope; the narrowest active scope runs it ([`scope_rank`]).
//!
//! Shortcut text is `Ctrl+Cmd+Alt+Shift+Key` (`Cmd` is ⌘ on macOS and Ctrl elsewhere).
use std::collections::{BTreeMap, HashMap};
@@ -29,6 +34,97 @@ pub struct UiCommand {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
/// Its scope; none: from its id (`timeline.*` → Timeline, `view.*` → Composition).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
/// Scopes that are a state of the app rather than a panel: editing text in the viewer, a paint
/// tool active, a mask selected, a camera tool active.
pub const CONTEXT_SCOPES: &[&str] = &["TextEditing", "Paint", "Mask", "Camera"];
/// Engine commands and menu entries whose shortcuts belong to one panel or context (binding key
/// → scope). Every other one is application-wide; frontend commands state their own.
const SCOPES: &[(&str, &str)] = &[
("file.rememberInterpretation", "Project"),
("file.applyInterpretation", "Project"),
("project.deleteWithoutConfirmation", "Project"),
("renderQueue.duplicate", "RenderQueue"),
];
/// Scope of an engine command or menu entry from its binding key.
pub fn scope_of(key: &str) -> &'static str {
SCOPES.iter().find(|(k, _)| *k == key).map_or(APP_SCOPE, |(_, s)| s)
}
pub fn is_context(scope: &str) -> bool {
CONTEXT_SCOPES.contains(&scope)
}
/// The Timeline and the Composition panel show the same composition and share their shortcuts:
/// the layer keys work with either one focused.
pub fn panels_share(a: &str, b: &str) -> bool {
matches!((a, b), ("Timeline", "Composition") | ("Composition", "Timeline"))
}
/// A scope as shown to people (`RenderQueue` → `Render Queue`).
pub fn scope_label(scope: &str) -> String {
let mut out = String::with_capacity(scope.len() + 2);
for (i, c) in scope.chars().enumerate() {
if i > 0 && c.is_uppercase() {
out.push(' ');
}
out.push(c);
}
out
}
/// How strongly a binding in `scope` claims a chord while the panel `focused` has the keyboard
/// and `contexts` are active: lower wins, `None` doesn't run at all. An active context, then the
/// focused panel, then the panel sharing its shortcuts, then the application. A shortcut of
/// another panel still runs when nothing nearer claims the chord (the reveal keys work from the
/// Effect Controls panel too); a context's shortcuts never run outside it. Two other panels (or
/// two active contexts) can claim a chord at the same rank: [`pick`] runs neither.
pub fn scope_rank(scope: &str, focused: &str, contexts: &[&str]) -> Option<u8> {
if is_context(scope) {
return contexts.contains(&scope).then_some(0);
}
Some(if scope == focused {
1
} else if panels_share(scope, focused) {
2
} else if scope == APP_SCOPE {
3
} else {
4
})
}
/// Which of the bindings matching one key press runs: `candidates` are (scope, physical,
/// binding), in the dispatcher's order. The narrowest scope ([`scope_rank`]) wins; at the same
/// rank an explicit physical-key binding wins, then the first one. `Err`: the best candidates
/// belong to different scopes of the same rank (two other panels, or two active contexts), so
/// none of them runs; it lists those scopes.
pub fn pick<'a, T>(candidates: impl IntoIterator<Item = (&'a str, bool, T)>, focused: &str, contexts: &[&str]) -> Result<Option<T>, Vec<String>> {
let mut best: Option<((u8, bool), &str, T)> = None;
let mut tied: Vec<String> = vec![];
for (scope, physical, b) in candidates {
let Some(rank) = scope_rank(scope, focused, contexts) else { continue };
let at = (rank, !physical);
match &best {
Some((r, s, _)) if *r < at || (*r == at && *s == scope) => {}
Some((r, _, _)) if *r == at => {
if !tied.iter().any(|t| t == scope) {
tied.push(scope.to_string());
}
}
_ => {
tied = vec![scope.to_string()];
best = Some((at, scope, b));
}
}
}
if tied.len() > 1 { Err(tied) } else { Ok(best.map(|(.., b)| b)) }
}
/// One thing a shortcut can run.
@@ -95,12 +191,12 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
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 {
scope: scope_of(&key).into(),
key,
command: e.command.clone(),
params: e.params.clone(),
label: e.label.clone(),
path: path.clone(),
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
@@ -133,22 +229,22 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
params: Value::Null,
label: c.label.to_string(),
path: vec!["Commands".into()],
scope: APP_SCOPE.into(),
scope: scope_of(c.id).into(),
defaults,
},
&mut out,
);
}
for c in ui {
let scope = ui_scope(&c.id);
let cat = if scope == APP_SCOPE { "Tools & Panels" } else { scope };
let scope = c.scope.as_deref().unwrap_or_else(|| ui_scope(&c.id));
let cat = if scope == APP_SCOPE { "Tools & Panels".to_string() } else { scope_label(scope) };
push(
Bindable {
key: c.id.clone(),
command: c.id.clone(),
params: Value::Null,
label: c.label.clone(),
path: vec![cat.into()],
path: vec![cat],
scope: scope.into(),
defaults: c.shortcut.as_deref().and_then(normalize).into_iter().collect(),
},
@@ -290,10 +386,16 @@ pub struct Conflict {
pub key: String,
pub label: String,
pub scope: String,
/// The two are in different scopes, so both keep working: the narrower one runs while its
/// panel or context is active and the other one everywhere else. `false`: the same scope,
/// where only one of them can run.
pub overrides: bool,
}
/// Whether shortcuts of these scopes can be active at once. Two different panels can't, except
/// the pair sharing theirs; a context can be active in any panel.
fn scopes_overlap(a: &str, b: &str) -> bool {
a == b || a == APP_SCOPE || b == APP_SCOPE
a == b || a == APP_SCOPE || b == APP_SCOPE || panels_share(a, b) || is_context(a) || is_context(b)
}
impl Keymaps {
@@ -492,11 +594,19 @@ impl ShortcutTable {
self.bindings()
.into_iter()
.filter(|(k, o)| *k == keys && o.key != b.key && scopes_overlap(&o.scope, &b.scope))
.map(|(k, o)| Conflict { keys: k.to_string(), key: o.key.clone(), label: o.label.clone(), scope: o.scope.clone() })
.map(|(k, o)| Conflict {
keys: k.to_string(),
key: o.key.clone(),
label: o.label.clone(),
scope: o.scope.clone(),
overrides: o.scope != b.scope && !(is_context(&o.scope) && is_context(&b.scope)),
})
.collect()
}
/// Every pair of commands sharing keys in an overlapping scope.
/// Every pair of commands sharing keys in the same scope, or in two contexts (which can be
/// active at once): only one of the two can run, or neither ([`pick`]). (Other pairs in
/// different scopes are intended, see [`Conflict::overrides`].)
pub fn all_conflicts(&self) -> Vec<(String, Conflict)> {
let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new();
for (k, b) in self.bindings() {
@@ -508,8 +618,11 @@ impl ShortcutTable {
for (k, bs) in by_keys {
for (i, a) in bs.iter().enumerate() {
for b in &bs[i + 1..] {
if scopes_overlap(&a.scope, &b.scope) {
out.push((a.key.clone(), Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone() }));
if a.scope == b.scope || (is_context(&a.scope) && is_context(&b.scope)) {
out.push((
a.key.clone(),
Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone(), overrides: false },
));
}
}
}
+45 -2
View File
@@ -597,8 +597,8 @@ fn shortcut_presets_duplicate_rename_delete_export_import() {
fn frontend_commands_are_bindable_with_panel_scope() {
let mut s = Session::default();
s.set_ui_commands(vec![
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()) },
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()) },
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()), scope: None },
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()), scope: None },
]);
let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone();
assert_eq!(b.scope, "Timeline");
@@ -606,6 +606,49 @@ fn frontend_commands_are_bindable_with_panel_scope() {
// A panel shortcut conflicts with an application shortcut on the same keys.
let c = s.shortcuts().conflicts(&b, "H");
assert!(c.iter().any(|c| c.key == "tool.hand"));
// A registry command that only the Project panel runs is scoped to it.
assert_eq!(s.shortcuts().find("project.deleteWithoutConfirmation").unwrap().scope, "Project");
}
/// The binding that runs a key press: the narrowest scope; a physical binding at the same rank;
/// nothing when two other panels (or two active contexts) claim it at the same rank.
#[test]
fn shortcut_dispatch_picks_the_narrowest_scope_deterministically() {
use crate::shortcuts::pick;
// Two unrelated panels, neither focused: neither runs, in either order.
let both = [("Project", false, "delete"), ("RenderQueue", false, "duplicate")];
assert_eq!(pick(both, "Timeline", &[]), Err(vec!["Project".to_string(), "RenderQueue".into()]));
assert_eq!(pick([both[1], both[0]], "Timeline", &[]), Err(vec!["RenderQueue".to_string(), "Project".into()]));
// The focused one of them wins.
assert_eq!(pick(both, "RenderQueue", &[]), Ok(Some("duplicate")));
// A single other panel's shortcut runs when nothing nearer claims the chord.
assert_eq!(pick([("Project", false, "delete")], "Timeline", &[]), Ok(Some("delete")));
// An application binding beats an unrelated panel's binding, also a physical one.
assert_eq!(pick([("RenderQueue", true, "duplicate"), ("Application", false, "app")], "Timeline", &[]), Ok(Some("app")));
// At the same rank a physical binding wins, in either order.
assert_eq!(pick([("Application", false, "logical"), ("Application", true, "physical")], "Timeline", &[]), Ok(Some("physical")));
assert_eq!(pick([("Application", true, "physical"), ("Application", false, "logical")], "Timeline", &[]), Ok(Some("physical")));
// A Composition binding runs in the Composition panel over the application's.
assert_eq!(pick([("Application", false, "app"), ("Composition", false, "comp")], "Composition", &[]), Ok(Some("comp")));
// Two active contexts tie; an inactive one doesn't run.
assert_eq!(pick([("Mask", false, "mask"), ("Paint", false, "paint")], "Composition", &["Paint", "Mask"]), Err(vec!["Mask".to_string(), "Paint".into()]));
assert_eq!(pick([("Mask", false, "mask"), ("Paint", false, "paint")], "Composition", &["Paint"]), Ok(Some("paint")));
// The same scope twice (a reported conflict): the first one.
assert_eq!(pick([("Timeline", false, "a"), ("Timeline", false, "b")], "Timeline", &[]), Ok(Some("a")));
}
/// Two contexts can be active at once, so their shortcuts on the same keys conflict.
#[test]
fn context_shortcuts_on_the_same_keys_conflict() {
let mut s = Session::default();
s.set_ui_commands(vec![
crate::shortcuts::UiCommand { id: "test.mask".into(), label: "Mask".into(), shortcut: Some("F9".into()), scope: Some("Mask".into()) },
crate::shortcuts::UiCommand { id: "test.paint".into(), label: "Paint".into(), shortcut: Some("F9".into()), scope: Some("Paint".into()) },
]);
let t = s.shortcuts();
assert!(t.all_conflicts().iter().any(|(a, c)| a == "test.mask" && c.key == "test.paint" && !c.overrides), "{:?}", t.all_conflicts());
let b = t.find("test.mask").unwrap();
assert!(t.conflicts(b, "F9").iter().any(|c| c.key == "test.paint" && !c.overrides));
}
// ---------------------------------------------------------------- auto-save / recovery