mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
Merge PR #1266
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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 },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,7 +121,7 @@ impl EffectcraftApp {
|
||||
fn with_ui_commands(mut self) -> Self {
|
||||
let cmds = menus::UI_COMMANDS
|
||||
.iter()
|
||||
.map(|c| effectcraft_engine::shortcuts::UiCommand { id: c.id.into(), label: c.label.into(), shortcut: c.shortcut.map(str::to_string) })
|
||||
.map(|c| effectcraft_engine::shortcuts::UiCommand { id: c.id.into(), label: c.label.into(), shortcut: c.shortcut.map(str::to_string), scope: None })
|
||||
.collect();
|
||||
self.session.set_ui_commands(cmds);
|
||||
self
|
||||
|
||||
@@ -137,6 +137,13 @@ impl PanelKind {
|
||||
pub fn id(self) -> String {
|
||||
format!("{self:?}")
|
||||
}
|
||||
/// The shortcut scope of the panel: an additional viewer is a Composition panel.
|
||||
pub fn shortcut_scope(self) -> String {
|
||||
match self {
|
||||
PanelKind::Viewer(_) => PanelKind::Composition.id(),
|
||||
p => p.id(),
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<PanelKind> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-', '&'], "");
|
||||
Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace([' ', '&', '-'], "") == n)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::frontend::run_engine;
|
||||
use super::invoke::invoke;
|
||||
use super::{EffectcraftApp, PanelKind, Value, json};
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere).
|
||||
pub fn shortcut_text(s: &str) -> String {
|
||||
@@ -179,6 +180,24 @@ pub(super) fn modifiers_at_start(ctx: &egui::Context) -> Option<egui::Modifiers>
|
||||
})
|
||||
}
|
||||
|
||||
/// The shortcut contexts active now (`effectcraft_engine::shortcuts::CONTEXT_SCOPES`): text
|
||||
/// being edited in the viewer, a paint tool or a camera tool active, a mask selected.
|
||||
pub fn active_contexts(app: &EffectcraftApp) -> Vec<&'static str> {
|
||||
let mut out = vec![];
|
||||
if app.session.state.text_edit.is_some() {
|
||||
out.push("TextEditing");
|
||||
}
|
||||
match app.ui.tool {
|
||||
Tool::Brush | Tool::Clone | Tool::Eraser => out.push("Paint"),
|
||||
Tool::Orbit | Tool::PanCamera | Tool::Dolly => out.push("Camera"),
|
||||
_ => {}
|
||||
}
|
||||
if app.session.selected_mask().is_some() {
|
||||
out.push("Mask");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Only Shift held (Shift+Delete, not Ctrl/Alt+Shift+Delete).
|
||||
fn only_shift(m: egui::Modifiers) -> bool {
|
||||
m.shift && !m.alt && !m.ctrl && !m.command && !m.mac_cmd
|
||||
@@ -361,6 +380,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
}
|
||||
let binds = bindings(&app.session);
|
||||
for (key, mods, pressed, physical_key) in events {
|
||||
// (Per event: a shortcut can change the tool, the selection or the focused panel.)
|
||||
let (focused, contexts) = (app.ui.focused.shortcut_scope(), active_contexts(app));
|
||||
// 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()) {
|
||||
@@ -405,26 +426,29 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
let _ = invoke(app, ctx, "edit.clear", json!({}));
|
||||
continue;
|
||||
}
|
||||
// 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.
|
||||
// The narrowest active scope wins a chord: an active context, the focused panel, the
|
||||
// application. A context's shortcut doesn't run outside it. Explicit physical bindings
|
||||
// distinguish keys that a layout reports with the same logical name (for example AZERTY's
|
||||
// minus and right parenthesis); they win over logical ones of the same scope rank.
|
||||
let table = app.session.shortcuts();
|
||||
let scope_of = |id: &str, params: &Value| {
|
||||
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() });
|
||||
table.find(&key).map_or(effectcraft_engine::shortcuts::APP_SCOPE, |b| b.scope.as_str())
|
||||
};
|
||||
let physical = table.bindings().into_iter().filter_map(|(sc, b)| {
|
||||
let (m, k) = parse_shortcut(sc.strip_prefix("Physical+")?)?;
|
||||
(Some(k) == physical_key && mods_match(m, mods)).then_some((b.scope.as_str(), true, (&b.command, &b.params)))
|
||||
});
|
||||
let logical = binds.iter().filter(|(m, k, ..)| *k == key && mods_match(*m, mods)).map(|(_, _, id, params)| (scope_of(id, params), false, (id, params)));
|
||||
let chosen = match effectcraft_engine::shortcuts::pick(physical.chain(logical), &focused, &contexts) {
|
||||
Ok(c) => c.map(|(id, params)| (id.clone(), if params.is_null() { json!({}) } else { params.clone() })),
|
||||
Err(scopes) => {
|
||||
let panels: Vec<String> = scopes.iter().map(|s| effectcraft_engine::shortcuts::scope_label(s)).collect();
|
||||
app.ui.status = format!("This shortcut belongs to {}: focus one of them", panels.join(" and "));
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some((id, params)) = chosen {
|
||||
if id == "edit.paste"
|
||||
&& let Some(t) = &pasted
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! `shortcuts.resetPreset`, `shortcuts.close`.
|
||||
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use effectcraft_engine::shortcuts::{APP_SCOPE, Bindable, DEFAULT_PRESET, normalize};
|
||||
use effectcraft_engine::shortcuts::{APP_SCOPE, Bindable, DEFAULT_PRESET, normalize, scope_label};
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -517,7 +517,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
return;
|
||||
};
|
||||
ui.label(RichText::new(&b.label).font(Tokens::semibold(13.0)).color(t.tab_text_active));
|
||||
ui.label(RichText::new(format!("{} • {}", b.command, b.scope)).color(t.text_faint));
|
||||
ui.label(RichText::new(format!("{} • {}", b.command, scope_label(&b.scope))).color(t.text_faint));
|
||||
ui.add_space(6.0);
|
||||
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
|
||||
if keys.is_empty() {
|
||||
@@ -565,7 +565,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(tr_args("⚠ {} is used by:", &[&(crate::menus::shortcut_text(&k))])).color(t.warning));
|
||||
for c in conflicts.iter().take(4) {
|
||||
ui.label(RichText::new(format!(" {} ({})", c.label, c.scope)).color(t.warning));
|
||||
ui.label(RichText::new(format!(" {} ({})", c.label, scope_label(&c.scope))).color(t.warning));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ mod ui_scrolling;
|
||||
mod ui_self_drop;
|
||||
mod ui_settings_info;
|
||||
mod ui_settings_keyboard;
|
||||
mod ui_shortcut_scopes;
|
||||
mod ui_tabs;
|
||||
mod ui_tail;
|
||||
mod ui_text_edit;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Shortcut scopes: one chord can run a different command in a panel than in the rest of the
|
||||
//! application, and the Timeline's keys work with the Composition panel focused.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::json;
|
||||
|
||||
/// A comp with one solid selected at 1 s, and one render item.
|
||||
fn app() -> EffectcraftApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"output": "/out/main.mov"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
EffectcraftApp::new(s)
|
||||
}
|
||||
|
||||
/// Press `key` with `modifiers` and run the shortcut dispatcher on it.
|
||||
fn press(app: &mut EffectcraftApp, key: Key, modifiers: Modifiers) {
|
||||
let ctx = egui::Context::default();
|
||||
let raw = egui::RawInput { events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], ..Default::default() };
|
||||
let mut output = ctx.run_ui(raw, |ui| effectcraft_ui_egui::menus::handle_shortcuts(app, ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn layers(app: &EffectcraftApp) -> usize {
|
||||
app.session.active_comp().map_or(0, |c| c.layers.len())
|
||||
}
|
||||
|
||||
fn render_items(app: &EffectcraftApp) -> usize {
|
||||
app.session.project.render_queue.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panels_shortcut_wins_in_its_panel_and_the_applications_everywhere_else() {
|
||||
let mut app = app();
|
||||
// Duplicate Render Item (Render Queue) on Split Layer's chord.
|
||||
app.session.execute("shortcuts.set", json!({"command": "renderQueue.duplicate", "keys": "Cmd+Shift+D"})).unwrap();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
|
||||
app.ui.focused = PanelKind::Timeline;
|
||||
press(&mut app, Key::D, chord);
|
||||
assert_eq!((layers(&app), render_items(&app)), (2, 1), "in the Timeline the chord splits the layer");
|
||||
|
||||
app.ui.focused = PanelKind::RenderQueue;
|
||||
press(&mut app, Key::D, chord);
|
||||
assert_eq!((layers(&app), render_items(&app)), (2, 2), "in the Render Queue it duplicates the render item");
|
||||
|
||||
app.ui.focused = PanelKind::Project;
|
||||
app.session.execute("edit.selectAll", json!({})).unwrap();
|
||||
app.session.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
press(&mut app, Key::D, chord);
|
||||
assert_eq!((layers(&app), render_items(&app)), (3, 2), "and in any other panel it is Split Layer again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panels_shortcut_that_nothing_else_claims_runs_from_any_panel() {
|
||||
let mut app = app();
|
||||
let unclaimed = "Cmd+Alt+Shift+F12";
|
||||
assert!(app.session.shortcuts().bindings().iter().all(|(sc, _)| *sc != unclaimed), "{unclaimed} has a default now; pick a free chord");
|
||||
app.session.execute("shortcuts.set", json!({"command": "renderQueue.duplicate", "keys": unclaimed})).unwrap();
|
||||
app.ui.focused = PanelKind::Timeline;
|
||||
press(&mut app, Key::F12, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT);
|
||||
assert_eq!(render_items(&app), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timelines_keys_work_with_the_composition_panel_focused() {
|
||||
for focused in [PanelKind::Timeline, PanelKind::Composition] {
|
||||
let mut app = app();
|
||||
app.ui.focused = focused;
|
||||
// B: the work area begins at the current time (a Timeline shortcut).
|
||||
press(&mut app, Key::B, Modifiers::NONE);
|
||||
let start = app.session.active_comp().map(|c| c.work_area.0.seconds());
|
||||
assert_eq!(start, Some(1.0), "{focused:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_preset_has_no_two_commands_on_one_chord_in_one_scope() {
|
||||
let app = app();
|
||||
let c = app.session.shortcuts().all_conflicts();
|
||||
assert!(c.is_empty(), "{c:?}");
|
||||
}
|
||||
+9
-2
@@ -203,8 +203,15 @@ commands), and a preset menu.
|
||||
- **Assigning.** Select a command, then press keys in the shortcut field (or type them), or
|
||||
click a key on the keyboard with the modifiers toggled. Conflicts with other commands in an
|
||||
overlapping scope are listed before you assign.
|
||||
- **Scope.** Menu and engine commands are application-wide; timeline commands work in the
|
||||
Timeline and viewer commands in the Composition panel.
|
||||
- **Scope.** A shortcut belongs to the application, to a panel (Project, Timeline, Composition,
|
||||
Render Queue…) or to a context (editing text, a paint tool or a camera tool active, a mask
|
||||
selected). One chord can be bound once per scope, and the narrowest active scope runs it: an
|
||||
active context, then the panel with the keyboard, then the application. The Timeline and the
|
||||
Composition panel share their shortcuts. A context's shortcuts work only while it is active;
|
||||
a panel's shortcut that nothing nearer claims still works from the other panels.
|
||||
- **Conflicts.** Two commands of one scope on one chord are a conflict (`shortcuts.conflicts`
|
||||
lists them; the default preset has none). The same chord in two scopes is an override: both
|
||||
keep working, and `shortcuts.set` reports it with `overrides: true`.
|
||||
|
||||
Commands: `shortcuts.list {query?, keys?, assigned?}`, `shortcuts.set {command, params?, keys}`,
|
||||
`shortcuts.reset {command?}`, `shortcuts.preset {op: select|new|duplicate|delete|rename, name?,
|
||||
|
||||
Reference in New Issue
Block a user