M14.3: keyboard shortcut editor

Keyboard shortcut presets in the engine (`engine::shortcuts`): every menu entry (including
ones with bound parameters), registry command and frontend command is bindable, scoped
application-wide or to a panel. "EffectCraft Default" is After Effects' layout and
read-only; editing it creates a custom preset that stores only its differences. Presets
are saved in the config store; the shortcut dispatcher and the menus read the active one.

- Commands: shortcuts.list, shortcuts.set {command, params?, keys}, shortcuts.reset,
  shortcuts.preset {select|new|duplicate|delete|rename}, shortcuts.export/import (JSON),
  shortcuts.conflicts.
- Edit > Keyboard Shortcuts: preset bar, modifier toggles (or held keys), an on-screen
  keyboard coloured by scope (application-wide / panel-specific / both), key details,
  a searchable list of every command, record-or-type assignment with conflict warnings,
  reset to default; all widgets have automation ids.
- Tests: normalization, defaults conflict-free, conflict detection, custom preset creation
  and persistence, preset lifecycle + export/import, dispatch uses the custom preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:14:54 -04:00
parent 47f7712364
commit 35f429363e
14 changed files with 1439 additions and 99 deletions

View File

@ -46,7 +46,7 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut session = effectcraft_host::session();
// Settings live in the platform config directory.
// Settings and shortcut presets live in the platform config directory.
if let Some(dir) = config_dir() {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
@ -79,6 +79,9 @@ fn main() -> eframe::Result {
app.hooks.audio_device = Some(Box::new(audio_out::open));
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
if let Some(port) = control_port {
disable_app_nap();
let rx = control_server::start(port, cc.egui_ctx.clone());

View File

@ -1,9 +1,10 @@
//! Settings (`prefs.*`).
//! Settings (`prefs.*`) and keyboard shortcut presets (`shortcuts.*`).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{Result, Session, cmd, query};
use crate::shortcuts::{DEFAULT_PRESET, binding_key, normalize};
use crate::{EngineError, Result, Session, cmd, query};
// ---------------------------------------------------------------- prefs
@ -57,6 +58,146 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
Ok(crate::prefs::pages_json())
}
// ---------------------------------------------------------------- shortcuts
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let t = s.shortcuts();
let key = binding_key(c, &params);
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
}
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
Ok(match p.get("keys") {
None | Some(Value::Null) => vec![],
Some(Value::String(k)) if k.trim().is_empty() => vec![],
Some(Value::String(k)) => vec![k.clone()],
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(v) => return Err(bad(cmd, format!("`keys` must be a string or a list, not {v}"))),
})
}
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
let q = str_p(p, "query").unwrap_or("").to_lowercase();
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
let keys_q = str_p(p, "keys").and_then(normalize);
let t = s.shortcuts();
let rows: Vec<Value> = t
.bindables
.iter()
.filter_map(|b| {
let keys = t.keys.get(&b.key).cloned().unwrap_or_default();
if assigned && keys.is_empty() {
return None;
}
if let Some(k) = &keys_q
&& !keys.contains(k)
{
return None;
}
let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys.join(" ")).to_lowercase();
if !q.split_whitespace().all(|w| hay.contains(w)) {
return None;
}
let mut o = serde_json::to_value(b).unwrap_or_default();
o["keys"] = json!(keys);
Some(o)
})
.collect();
Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names(), "count": rows.len(), "commands": rows}))
}
fn sc_set(s: &mut Session, p: &Value) -> Result<Value> {
let b = bindable(s, p, "shortcuts.set")?;
let keys = keys_param(p, "shortcuts.set")?;
let conflicts: Vec<_> = keys.iter().flat_map(|k| s.shortcuts().conflicts(&b, k)).collect();
let created = s.keymaps.set(&b, &keys).map_err(|e| bad("shortcuts.set", e))?;
s.shortcuts_changed();
let now = s.shortcuts().keys.get(&b.key).cloned().unwrap_or_default();
if let Some(n) = &created {
s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\""));
}
if !conflicts.is_empty() {
let names: Vec<String> = conflicts.iter().map(|c| format!("{} ({})", c.label, c.keys)).collect();
s.toast(format!("Shortcut conflict: also used by {}", names.join(", ")));
}
Ok(json!({"command": b.key, "keys": now, "preset": s.keymaps.active, "created": created, "conflicts": conflicts}))
}
fn sc_reset(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = str_p(p, "command") {
let b = bindable(s, p, "shortcuts.reset")?;
let defaults = b.defaults.clone();
s.keymaps.set(&b, &defaults).map_err(|e| bad("shortcuts.reset", e))?;
s.shortcuts_changed();
return Ok(json!({"command": c, "keys": defaults}));
}
s.keymaps.reset_active();
s.shortcuts_changed();
Ok(json!({"preset": s.keymaps.active}))
}
fn sc_preset(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").unwrap_or("select");
let name = str_p(p, "name");
let e = |m: String| bad("shortcuts.preset", m);
let k = &mut s.keymaps;
match op {
"select" => k.select(name.unwrap_or(DEFAULT_PRESET)).map_err(e)?,
"new" => {
k.duplicate(DEFAULT_PRESET, Some(name.unwrap_or("Custom"))).map_err(e)?;
}
"duplicate" => {
let from = str_p(p, "from").map(str::to_string).unwrap_or_else(|| k.active.clone());
k.duplicate(&from, name).map_err(e)?;
}
"delete" => {
let n = name.map(str::to_string).unwrap_or_else(|| k.active.clone());
k.delete(&n).map_err(e)?;
}
"rename" => {
let to = str_p(p, "newName").ok_or_else(|| bad("shortcuts.preset", "missing `newName`"))?;
let from = name.map(str::to_string).unwrap_or_else(|| k.active.clone());
k.rename(&from, to).map_err(e)?;
}
_ => return Err(bad("shortcuts.preset", "op must be select, new, duplicate, delete or rename")),
}
s.shortcuts_changed();
Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names()}))
}
fn sc_export(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.keymaps.export(str_p(p, "preset")).map_err(|e| bad("shortcuts.export", e))?;
if let Some(path) = str_p(p, "path") {
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
return Ok(json!({"path": path, "preset": doc["name"]}));
}
Ok(doc)
}
fn sc_import(s: &mut Session, p: &Value) -> Result<Value> {
let doc = match (str_p(p, "path"), p.get("preset")) {
(Some(path), _) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
serde_json::from_slice(&bytes).map_err(|e| bad("shortcuts.import", format!("{path}: {e}")))?
}
(None, Some(v)) if v.is_object() => v.clone(),
_ => return Err(bad("shortcuts.import", "give `path` or `preset` (an exported document)")),
};
let name = s.keymaps.import(&doc, str_p(p, "name")).map_err(|e| bad("shortcuts.import", e))?;
s.shortcuts_changed();
Ok(json!({"preset": name}))
}
fn sc_conflicts(s: &mut Session, _: &Value) -> Result<Value> {
let c: Vec<Value> =
s.shortcuts().all_conflicts().into_iter().map(|(a, c)| json!({"command": a, "keys": c.keys, "with": c.key, "scope": c.scope})).collect();
Ok(json!(c))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get),
@ -72,5 +213,12 @@ pub fn specs() -> Vec<CommandSpec> {
prefs_open
),
query!("prefs.pages", "Settings Pages", "{}", prefs_pages),
query!("shortcuts.list", "List Keyboard Shortcuts", "{query?, keys?, assigned?: bool}", sc_list),
cmd!("shortcuts.set", "Set Keyboard Shortcut", [], None, "{command, params?, keys: \"Cmd+Shift+K\" | [keys] | null}", always, sc_set),
cmd!("shortcuts.reset", "Reset Keyboard Shortcuts", [], None, "{command?, params?}", always, sc_reset),
cmd!("shortcuts.preset", "Keyboard Shortcut Preset", [], None, "{op: select|new|duplicate|delete|rename, name?, from?, newName?}", always, sc_preset),
cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export),
cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import),
query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts),
]
}

View File

@ -1,13 +1,13 @@
//! Persistent configuration storage (Settings). The engine only sees the [`ConfigStore`] trait;
//! frontends choose where it lives: the desktop app uses a [`DirConfig`] in the platform config
//! directory, the web app can back it with `localStorage`, tests and headless runs use
//! [`MemoryConfig`] (or none at all).
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// Named text blobs (`prefs.json`).
/// Named text blobs (`prefs.json`, `shortcuts.json`, `session.lock`).
pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;

View File

@ -16,6 +16,7 @@ pub mod menus;
pub mod prefs;
pub mod render_queue;
mod session_settings;
pub mod shortcuts;
use std::sync::Arc;
@ -215,8 +216,15 @@ pub struct Session {
pub prefs: prefs::Prefs,
/// Bumped whenever settings change (frontends re-apply theme, labels…).
pub prefs_revision: u64,
/// Where settings are stored (`None` = nothing persists).
/// Where settings, shortcut presets and the crash-recovery sentinel are stored (`None` =
/// nothing persists).
pub config: Option<Arc<dyn config::ConfigStore>>,
/// Keyboard shortcut presets.
pub keymaps: shortcuts::Keymaps,
/// Frontend-only commands offered for binding.
pub ui_commands: Vec<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
}
impl Default for Session {
@ -241,6 +249,9 @@ impl Default for Session {
prefs: prefs::Prefs::default(),
prefs_revision: 0,
config: None,
keymaps: shortcuts::Keymaps::default(),
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
}
}
}

View File

@ -1,17 +1,22 @@
//! Session side of Settings.
//! Session side of Settings and keyboard shortcut presets.
use crate::Session;
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand};
impl Session {
/// Load settings from the config store and apply them.
/// Load settings and shortcut presets from the config store and apply them.
pub fn load_settings(&mut self) {
if let Some(c) = &self.config
&& let Some(t) = c.read(PREFS_FILE)
{
self.prefs = Prefs::from_json(&t);
if let Some(c) = &self.config {
if let Some(t) = c.read(PREFS_FILE) {
self.prefs = Prefs::from_json(&t);
}
if let Some(t) = c.read(SHORTCUTS_FILE) {
self.keymaps = Keymaps::from_json(&t);
}
}
self.prefs_changed();
self.shortcuts_changed();
}
/// Settings changed: apply what the engine owns (cache budgets) and tell frontends.
@ -36,4 +41,25 @@ impl Session {
log::warn!("saving settings: {e}");
}
}
/// Shortcut presets changed: rebuild the table and save them.
pub fn shortcuts_changed(&mut self) {
self.shortcut_table = std::sync::OnceLock::new();
if let Some(c) = &self.config
&& let Err(e) = c.write(SHORTCUTS_FILE, &self.keymaps.to_json())
{
log::warn!("saving keyboard shortcuts: {e}");
}
}
/// The frontend's own bindable commands (tools, timeline navigation…).
pub fn set_ui_commands(&mut self, cmds: Vec<UiCommand>) {
self.ui_commands = cmds;
self.shortcut_table = std::sync::OnceLock::new();
}
/// The active shortcut preset resolved against every bindable command.
pub fn shortcuts(&self) -> &ShortcutTable {
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
}
}

View File

@ -0,0 +1,468 @@
//! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts).
//!
//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key
//! (the command id, plus its bound parameters for entries such as `app.settings
//! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in
//! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom
//! presets store only their differences from it. Editing the default preset first duplicates it
//! (as After Effects asks you to "Save As" a custom set). The shortcut dispatcher and the menus
//! read the active preset through [`ShortcutTable`].
//!
//! Shortcut text is `Ctrl+Cmd+Alt+Shift+Key` (`Cmd` is ⌘ on macOS and Ctrl elsewhere).
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Preset file in the config store.
pub const SHORTCUTS_FILE: &str = "shortcuts.json";
/// The built-in (read-only) preset.
pub const DEFAULT_PRESET: &str = "EffectCraft Default";
/// Scope of application-wide shortcuts.
pub const APP_SCOPE: &str = "Application";
/// A frontend-only command offered for binding (tools, timeline navigation…).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UiCommand {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
}
/// One thing a shortcut can run.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Bindable {
/// Binding key: `command` or `command {params}`.
pub key: String,
pub command: String,
#[serde(skip_serializing_if = "Value::is_null")]
pub params: Value,
pub label: String,
/// Menu path (`["Layer", "New"]`) or a category for commands outside the menus.
pub path: Vec<String>,
/// `Application` or the panel the shortcut works in (`Timeline`, `Composition`…).
pub scope: String,
/// After Effects default keys.
pub defaults: Vec<String>,
}
/// The binding key of a command with bound parameters.
pub fn binding_key(command: &str, params: &Value) -> String {
match params {
Value::Null => command.to_string(),
Value::Object(m) if m.is_empty() => command.to_string(),
p => format!("{command} {p}"),
}
}
/// Scope of a frontend command from its id.
fn ui_scope(id: &str) -> &'static str {
if id.starts_with("timeline.") {
"Timeline"
} else if id.starts_with("view.") {
"Composition"
} else {
APP_SCOPE
}
}
/// Every bindable command: menu entries, registry commands outside the menus, then the
/// frontend's own commands.
pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
let mut out: Vec<Bindable> = vec![];
let mut seen: HashMap<String, usize> = HashMap::new();
let mut push = |b: Bindable, out: &mut Vec<Bindable>| {
if let Some(&i) = seen.get(&b.key) {
// Same command in two menus: merge defaults.
for d in b.defaults {
if !out[i].defaults.contains(&d) {
out[i].defaults.push(d);
}
}
return;
}
seen.insert(b.key.clone(), out.len());
out.push(b);
};
let entries = crate::menus::entries();
for (path, e) in &entries {
let key = binding_key(&e.command, &e.params);
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
push(
Bindable {
key,
command: e.command.clone(),
params: e.params.clone(),
label: e.label.clone(),
path: path.clone(),
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
);
}
for c in crate::command_specs() {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
}
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
push(
Bindable {
key: c.id.to_string(),
command: c.id.to_string(),
params: Value::Null,
label: c.label.to_string(),
path: vec!["Commands".into()],
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
);
}
for c in ui {
let scope = ui_scope(&c.id);
let cat = if scope == APP_SCOPE { "Tools & Panels" } else { scope };
push(
Bindable {
key: c.id.clone(),
command: c.id.clone(),
params: Value::Null,
label: c.label.clone(),
path: vec![cat.into()],
scope: scope.into(),
defaults: c.shortcut.as_deref().and_then(normalize).into_iter().collect(),
},
&mut out,
);
}
out
}
/// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it
/// has no key.
pub fn normalize(s: &str) -> Option<String> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false);
let mut key: Option<String> = None;
// `Cmd++` / `+` name the plus key.
let (head, plus) = match s.strip_suffix("++") {
Some(h) => (h, true),
None if s == "+" => ("", true),
None => (s, false),
};
let mut parts: Vec<String> = head.split('+').filter(|p| !p.is_empty()).map(str::to_string).collect();
// macOS glyph notation.
if parts.len() == 1 && parts[0].chars().any(|c| "⌃⌥⇧⌘".contains(c)) {
let p = parts.remove(0);
let mut rest = String::new();
for c in p.chars() {
match c {
'⌃' => ctrl = true,
'⌥' => alt = true,
'⇧' => shift = true,
'⌘' => cmd = true,
c => rest.push(c),
}
}
if !rest.is_empty() {
parts.push(rest);
}
}
for p in parts {
match p.to_ascii_lowercase().as_str() {
"cmd" | "command" | "meta" | "super" | "win" => cmd = true,
"ctrl" | "control" => ctrl = true,
"alt" | "opt" | "option" => alt = true,
"shift" => shift = true,
_ => key = Some(key_name(&p)),
}
}
if plus {
key = Some("+".into());
}
let key = key?;
let mut out = String::new();
for (on, m) in [(ctrl, "Ctrl+"), (cmd, "Cmd+"), (alt, "Alt+"), (shift, "Shift+")] {
if on {
out.push_str(m);
}
}
out.push_str(&key);
Some(out)
}
fn key_name(k: &str) -> String {
let l = k.to_ascii_lowercase();
match l.as_str() {
"space" | "spacebar" => "Space".into(),
"esc" | "escape" => "Escape".into(),
"enter" | "return" => "Enter".into(),
"tab" => "Tab".into(),
"backspace" => "Backspace".into(),
"delete" | "del" => "Delete".into(),
"home" => "Home".into(),
"end" => "End".into(),
"pageup" | "pgup" => "PageUp".into(),
"pagedown" | "pgdn" => "PageDown".into(),
"up" | "arrowup" => "ArrowUp".into(),
"down" | "arrowdown" => "ArrowDown".into(),
"left" | "arrowleft" => "ArrowLeft".into(),
"right" | "arrowright" => "ArrowRight".into(),
_ if l.starts_with("num") => format!("Num{}", &k[3..]),
_ if l.len() > 1 && l.starts_with('f') && l[1..].chars().all(|c| c.is_ascii_digit()) => l.to_ascii_uppercase(),
_ if k.chars().count() == 1 => k.to_uppercase(),
_ => k.to_string(),
}
}
/// A custom preset: differences from the default (`[]` removes a default shortcut).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Preset {
pub name: String,
#[serde(default)]
pub overrides: BTreeMap<String, Vec<String>>,
}
/// The saved presets and which one is active.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Keymaps {
pub active: String,
pub presets: Vec<Preset>,
}
impl Default for Keymaps {
fn default() -> Self {
Keymaps { active: DEFAULT_PRESET.into(), presets: vec![] }
}
}
/// A conflict: another command already using the keys in an overlapping scope.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Conflict {
pub keys: String,
pub key: String,
pub label: String,
pub scope: String,
}
fn scopes_overlap(a: &str, b: &str) -> bool {
a == b || a == APP_SCOPE || b == APP_SCOPE
}
impl Keymaps {
pub fn from_json(text: &str) -> Keymaps {
let mut k: Keymaps = serde_json::from_str(text).unwrap_or_default();
if !k.presets.iter().any(|p| p.name == k.active) {
k.active = DEFAULT_PRESET.into();
}
k
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
/// Preset names, the default first.
pub fn names(&self) -> Vec<String> {
std::iter::once(DEFAULT_PRESET.to_string()).chain(self.presets.iter().map(|p| p.name.clone())).collect()
}
pub fn active_preset(&self) -> Option<&Preset> {
self.presets.iter().find(|p| p.name == self.active)
}
/// Effective keys of a bindable in the active preset.
pub fn keys_of(&self, b: &Bindable) -> Vec<String> {
match self.active_preset().and_then(|p| p.overrides.get(&b.key)) {
Some(k) => k.clone(),
None => b.defaults.clone(),
}
}
fn unique_name(&self, base: &str) -> String {
if !self.names().iter().any(|n| n == base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !self.names().contains(n)).unwrap_or_else(|| base.to_string())
}
/// Make sure a custom preset is active (editing the default duplicates it as "Custom").
/// Returns the name of a preset created for the edit.
fn ensure_custom(&mut self) -> Option<String> {
if self.active_preset().is_some() {
return None;
}
let name = self.unique_name("Custom");
self.presets.push(Preset { name: name.clone(), overrides: BTreeMap::new() });
self.active = name.clone();
Some(name)
}
/// Set the keys of a bindable (normalized; empty = no shortcut). Returns a newly created
/// preset name when the default was active.
pub fn set(&mut self, b: &Bindable, keys: &[String]) -> Result<Option<String>, String> {
let mut norm = vec![];
for k in keys {
let n = normalize(k).ok_or_else(|| format!("`{k}` is not a shortcut"))?;
if !norm.contains(&n) {
norm.push(n);
}
}
let created = self.ensure_custom();
let p = self.presets.iter_mut().find(|p| p.name == self.active).ok_or("no active preset")?;
if norm == b.defaults {
p.overrides.remove(&b.key);
} else {
p.overrides.insert(b.key.clone(), norm);
}
Ok(created)
}
/// Clear the active preset's changes (its shortcuts become the defaults).
pub fn reset_active(&mut self) {
let active = self.active.clone();
if let Some(p) = self.presets.iter_mut().find(|p| p.name == active) {
p.overrides.clear();
}
}
pub fn select(&mut self, name: &str) -> Result<(), String> {
if !self.names().iter().any(|n| n == name) {
return Err(format!("no shortcut preset `{name}`"));
}
self.active = name.to_string();
Ok(())
}
/// Duplicate a preset (the default or a custom one) under a new name and activate it.
pub fn duplicate(&mut self, from: &str, name: Option<&str>) -> Result<String, String> {
let overrides = if from == DEFAULT_PRESET {
BTreeMap::new()
} else {
self.presets.iter().find(|p| p.name == from).ok_or_else(|| format!("no shortcut preset `{from}`"))?.overrides.clone()
};
let name = self.unique_name(name.unwrap_or(&format!("{from} copy")));
self.presets.push(Preset { name: name.clone(), overrides });
self.active = name.clone();
Ok(name)
}
pub fn delete(&mut self, name: &str) -> Result<(), String> {
if name == DEFAULT_PRESET {
return Err("the default preset can't be deleted".into());
}
let n = self.presets.len();
self.presets.retain(|p| p.name != name);
if self.presets.len() == n {
return Err(format!("no shortcut preset `{name}`"));
}
if self.active == name {
self.active = DEFAULT_PRESET.into();
}
Ok(())
}
pub fn rename(&mut self, from: &str, to: &str) -> Result<(), String> {
if from == DEFAULT_PRESET || to == DEFAULT_PRESET {
return Err("the default preset can't be renamed".into());
}
if self.names().iter().any(|n| n == to) {
return Err(format!("a preset named `{to}` exists"));
}
let p = self.presets.iter_mut().find(|p| p.name == from).ok_or_else(|| format!("no shortcut preset `{from}`"))?;
p.name = to.to_string();
if self.active == from {
self.active = to.to_string();
}
Ok(())
}
/// A preset as a portable JSON document.
pub fn export(&self, name: Option<&str>) -> Result<Value, String> {
let name = name.unwrap_or(&self.active);
let overrides = if name == DEFAULT_PRESET {
BTreeMap::new()
} else {
self.presets.iter().find(|p| p.name == name).ok_or_else(|| format!("no shortcut preset `{name}`"))?.overrides.clone()
};
Ok(json!({"format": "effectcraft-shortcuts", "version": 1, "name": name, "base": DEFAULT_PRESET, "overrides": overrides}))
}
/// Import an exported preset (renamed if the name is taken) and activate it.
pub fn import(&mut self, doc: &Value, name: Option<&str>) -> Result<String, String> {
let overrides: BTreeMap<String, Vec<String>> =
serde_json::from_value(doc.get("overrides").cloned().unwrap_or_else(|| json!({}))).map_err(|e| format!("bad shortcut preset: {e}"))?;
let overrides = overrides.into_iter().map(|(k, v)| (k, v.iter().filter_map(|s| normalize(s)).collect())).collect();
let base = name.or_else(|| doc.get("name").and_then(Value::as_str)).unwrap_or("Imported");
let base = if base == DEFAULT_PRESET { "Imported" } else { base };
let name = self.unique_name(base);
self.presets.push(Preset { name: name.clone(), overrides });
self.active = name.clone();
Ok(name)
}
}
/// The active preset resolved against every bindable command.
#[derive(Clone, Debug, Default)]
pub struct ShortcutTable {
pub bindables: Vec<Bindable>,
/// Effective keys per binding key.
pub keys: HashMap<String, Vec<String>>,
}
impl ShortcutTable {
pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {
let bindables = bindables(ui);
let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();
ShortcutTable { bindables, keys }
}
pub fn find(&self, key: &str) -> Option<&Bindable> {
self.bindables.iter().find(|b| b.key == key)
}
/// First shortcut of a command / menu entry (for menus).
pub fn shortcut_of(&self, command: &str, params: &Value) -> Option<&str> {
self.keys.get(&binding_key(command, params)).and_then(|k| k.first()).map(String::as_str)
}
/// Every active binding: (keys, bindable).
pub fn bindings(&self) -> Vec<(&str, &Bindable)> {
self.bindables.iter().flat_map(|b| self.keys.get(&b.key).into_iter().flatten().map(move |k| (k.as_str(), b))).collect()
}
/// Other bindables using `keys` in an overlapping scope.
pub fn conflicts(&self, b: &Bindable, keys: &str) -> Vec<Conflict> {
let Some(keys) = normalize(keys) else { return vec![] };
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() })
.collect()
}
/// Every pair of commands sharing keys in an overlapping scope.
pub fn all_conflicts(&self) -> Vec<(String, Conflict)> {
let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new();
for (k, b) in self.bindings() {
if k != "Num*" {
by_keys.entry(k).or_default().push(b);
}
}
let mut out = vec![];
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() }));
}
}
}
}
out
}
}

View File

@ -1,4 +1,4 @@
//! Settings.
//! Settings and keyboard shortcut presets.
use std::path::PathBuf;
use std::sync::Arc;
@ -8,6 +8,7 @@ use serde_json::json;
use crate::Session;
use crate::config::{ConfigStore, MemoryConfig};
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize};
fn tmp(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
@ -233,3 +234,95 @@ fn new_project_template_and_default_renderer() {
assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D);
let _ = std::fs::remove_dir_all(d);
}
// ---------------------------------------------------------------- shortcuts
#[test]
fn shortcut_text_normalizes() {
assert_eq!(normalize("shift+cmd+k").as_deref(), Some("Cmd+Shift+K"));
assert_eq!(normalize("Opt+Ctrl+f5").as_deref(), Some("Ctrl+Alt+F5"));
assert_eq!(normalize("⌘⇧S").as_deref(), Some("Cmd+Shift+S"));
assert_eq!(normalize("Cmd++").as_deref(), Some("Cmd++"));
assert_eq!(normalize("space").as_deref(), Some("Space"));
assert_eq!(normalize("Cmd+").as_deref(), None);
}
#[test]
fn defaults_have_no_conflicts_and_match_the_menus() {
let s = Session::default();
let t = s.shortcuts();
assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts());
assert_eq!(t.shortcut_of("file.save", &json!(null)), Some("Cmd+S"));
assert_eq!(t.shortcut_of("app.settings", &json!({"page": "general"})), Some("Cmd+Alt+;"));
assert!(t.bindables.len() > 500);
}
#[test]
fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
let r = s.execute_checked("shortcuts.set", json!({"command": "layer.newNull", "keys": "cmd+s"})).unwrap();
assert_eq!(r["keys"], json!(["Cmd+S"]));
assert_eq!(r["created"], json!("Custom"));
assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["key"] == "file.save"), "{r}");
assert_eq!(s.keymaps.active, "Custom");
assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+S"));
assert!(!s.execute("shortcuts.conflicts", json!({})).unwrap().as_array().unwrap().is_empty());
// The default preset is untouched.
s.execute("shortcuts.preset", json!({"op": "select", "name": DEFAULT_PRESET})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+Alt+Shift+Y"));
// Remove a shortcut; reset one.
s.execute("shortcuts.preset", json!({"op": "select", "name": "Custom"})).unwrap();
s.execute("shortcuts.set", json!({"command": "file.save", "keys": null})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), None);
s.execute("shortcuts.reset", json!({"command": "file.save"})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), Some("Cmd+S"));
// Bound-parameter entries are bindable too.
s.execute("shortcuts.set", json!({"command": "app.settings", "params": {"page": "labels"}, "keys": ["F9"]})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("app.settings", &json!({"page": "labels"})), Some("F9"));
// Persisted.
let k = Keymaps::from_json(&store.read(crate::shortcuts::SHORTCUTS_FILE).unwrap());
assert_eq!(k.active, "Custom");
assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "F1"})).is_err());
assert!(s.execute("shortcuts.set", json!({"command": "file.save", "keys": "Cmd+"})).is_err());
let list = s.execute("shortcuts.list", json!({"keys": "F9"})).unwrap();
assert!(list["commands"].as_array().unwrap().iter().any(|c| c["command"] == "app.settings" && c["params"]["page"] == "labels"), "{list}");
}
#[test]
fn shortcut_presets_duplicate_rename_delete_export_import() {
let d = tmp("presets");
let mut s = Session::default();
s.execute("shortcuts.preset", json!({"op": "new", "name": "Mine"})).unwrap();
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": ["Cmd+Z", "F2"]})).unwrap();
s.execute("shortcuts.preset", json!({"op": "duplicate", "name": "Mine 2"})).unwrap();
assert_eq!(s.keymaps.names(), vec![DEFAULT_PRESET, "Mine", "Mine 2"]);
s.execute("shortcuts.preset", json!({"op": "rename", "name": "Mine 2", "newName": "Other"})).unwrap();
assert!(s.execute("shortcuts.preset", json!({"op": "delete", "name": DEFAULT_PRESET})).is_err());
let path = d.join("mine.json");
s.execute("shortcuts.export", json!({"preset": "Mine", "path": path.to_string_lossy()})).unwrap();
s.execute("shortcuts.preset", json!({"op": "delete", "name": "Mine"})).unwrap();
s.execute("shortcuts.preset", json!({"op": "delete", "name": "Other"})).unwrap();
assert_eq!(s.keymaps.active, DEFAULT_PRESET);
let r = s.execute("shortcuts.import", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(r["preset"], json!("Mine"));
assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string(), "F2".into()]);
s.execute("shortcuts.reset", json!({})).unwrap();
assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string()]);
let _ = std::fs::remove_dir_all(d);
}
#[test]
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()) },
]);
let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone();
assert_eq!(b.scope, "Timeline");
assert_eq!(s.shortcuts().shortcut_of("tool.hand", &json!(null)), Some("H"));
// 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"));
}

View File

@ -71,6 +71,8 @@ pub struct Hooks {
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
/// Picks a folder (Settings paths).
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
/// Save dialog for other file kinds: (default name, extension).
pub pick_save_file: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
}
#[derive(Default)]
@ -166,6 +168,17 @@ impl EffectcraftApp {
last_reveal: None,
applied_prefs: None,
}
.with_ui_commands()
}
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.
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) })
.collect();
self.session.set_ui_commands(cmds);
self
}
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.

View File

@ -346,7 +346,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
"app.keyboardShortcuts" => {
app.dialog = Some(crate::Dialog::Shortcuts);
crate::panels::shortcut_editor::open(app);
Value::Null
}
"app.templates" => {
@ -584,6 +584,11 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
}
}
/// The entry's shortcut in the active keyboard shortcut preset.
fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<String> {
app.session.shortcuts().shortcut_of(&e.command, &e.params).map(str::to_string)
}
/// Check-mark state of frontend toggles (engine state is answered by the engine).
fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
let v = &app.ui.viewer;
@ -632,7 +637,7 @@ pub fn menu_items(app: &EffectcraftApp) -> Vec<MenuItem> {
id: e.command.clone(),
label: entry_label(app, e),
path,
shortcut: e.shortcut.clone(),
shortcut: entry_shortcut(app, e),
enabled: entry_enabled(app, e),
checked: entry_checked(app, e),
params: e.params.clone(),
@ -727,36 +732,17 @@ fn shifted_alias(k: egui::Key) -> Option<egui::Key> {
/// A key binding: modifiers, key, command id and bound params.
pub type Binding = (egui::Modifiers, egui::Key, String, Value);
/// All active bindings, most modifiers first.
pub fn bindings() -> Vec<Binding> {
/// All bindings of the active keyboard shortcut preset, most modifiers first.
pub fn bindings(session: &effectcraft_engine::Session) -> Vec<Binding> {
let mut v: Vec<Binding> = Vec::new();
let mut push = |sc: &str, id: &str, params: Value| {
for (sc, b) in session.shortcuts().bindings() {
if let Some((m, k)) = parse_shortcut(sc) {
if m.shift
&& let Some(alias) = shifted_alias(k)
{
v.push((m, alias, id.to_string(), params.clone()));
v.push((m, alias, b.command.clone(), b.params.clone()));
}
v.push((m, k, id.to_string(), params));
}
};
// Menu entries (their shortcuts may bind params), then command defaults not in the menus.
let entries = effectcraft_engine::menus::entries();
for (_, e) in &entries {
if let Some(sc) = &e.shortcut {
push(sc, &e.command, e.params.clone());
}
}
for c in effectcraft_engine::command_specs() {
if let Some(sc) = c.shortcut
&& !entries.iter().any(|(_, e)| e.command == c.id)
{
push(sc, c.id, Value::Null);
}
}
for c in UI_COMMANDS {
if let Some(sc) = c.shortcut {
push(sc, c.id, Value::Null);
v.push((m, k, b.command.clone(), b.params.clone()));
}
}
v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8));
@ -788,10 +774,14 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
if events.is_empty() {
return;
}
let binds = bindings();
let binds = bindings(&app.session);
for (key, mods) in events {
// Escape closes dialogs.
if key == egui::Key::Escape && app.dialog.is_some() {
// Escape while recording a shortcut cancels the recording, not the editor.
if crate::panels::shortcut_editor::recording(app) {
continue;
}
// Escape in Settings is Cancel: restore the settings from when it opened.
if app.dialog == Some(crate::Dialog::Settings)
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
@ -878,8 +868,8 @@ fn gutter(checked: bool) -> egui::Atom<'static> {
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
let label = entry_label(app, e);
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label));
if let Some(s) = &e.shortcut {
b = b.shortcut_text(shortcut_text(s));
if let Some(s) = entry_shortcut(app, e) {
b = b.shortcut_text(shortcut_text(&s));
}
ui.add_enabled(entry_enabled(app, e), b).clicked()
}
@ -904,8 +894,9 @@ mod tests {
#[test]
fn ui_and_menu_shortcuts_do_not_collide() {
let app = EffectcraftApp::new(effectcraft_engine::Session::default());
let mut seen: std::collections::BTreeMap<(String, String), (String, String)> = Default::default();
for (m, k, id, params) in bindings() {
for (m, k, id, params) in bindings(&app.session) {
let key = (format!("{m:?}"), format!("{k:?}"));
let target = (id.clone(), params.to_string());
if let Some(prev) = seen.get(&key) {
@ -916,6 +907,27 @@ mod tests {
}
}
#[test]
fn dispatch_uses_the_active_custom_preset() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let find = |app: &EffectcraftApp, id: &str| bindings(&app.session).into_iter().filter(|b| b.2 == id).map(|b| (b.0, b.1)).collect::<Vec<_>>();
let (m, k) = parse_shortcut("Cmd+Alt+Shift+Y").unwrap();
assert!(find(&app, "layer.newNull").contains(&(m, k)));
// A UI command rebound in a custom preset.
app.session.execute("shortcuts.set", json!({"command": "tool.hand", "keys": "Ctrl+Alt+H"})).unwrap();
assert_eq!(app.session.keymaps.active, "Custom");
let (m2, k2) = parse_shortcut("Ctrl+Alt+H").unwrap();
assert_eq!(find(&app, "tool.hand"), vec![(m2, k2)]);
app.session.execute("shortcuts.set", json!({"command": "layer.newNull", "keys": "F6"})).unwrap();
assert_eq!(find(&app, "layer.newNull"), vec![(egui::Modifiers::NONE, egui::Key::F6)]);
// The menus show the preset's shortcut.
let item = menu_items(&app).into_iter().find(|i| i.id == "layer.newNull").unwrap();
assert_eq!(item.shortcut.as_deref(), Some("F6"));
// Back to the default preset.
app.session.execute("shortcuts.preset", json!({"op": "select", "name": effectcraft_engine::shortcuts::DEFAULT_PRESET})).unwrap();
assert!(find(&app, "layer.newNull").contains(&(m, k)));
}
#[test]
fn renamed_label_shows_in_the_label_menu() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());

View File

@ -24,6 +24,8 @@ pub struct DialogState {
pub settings_page: String,
/// Settings when the dialog opened (Cancel restores them).
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
/// Keyboard Shortcuts editor.
pub shortcuts: super::shortcut_editor::EditorState,
/// The open parameter form.
pub form: super::forms::Form,
/// Message box (title, body).
@ -101,7 +103,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::SolidSettings => solid(app, ctx, &t),
Dialog::CommandPalette => palette(app, ctx, &t),
Dialog::Settings => super::settings::show(app, ctx, &t),
Dialog::Shortcuts => super::forms::show_shortcuts(app, ctx, &t),
Dialog::Shortcuts => super::shortcut_editor::show(app, ctx, &t),
Dialog::Form => super::forms::show_form(app, ctx, &t),
Dialog::Info => super::forms::show_info(app, ctx, &t),
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),

View File

@ -1,7 +1,7 @@
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
//! Settings, placeholders…), Keyboard Shortcuts, View Options and simple message boxes
//! (Settings: `settings`). Every form runs an engine command with the collected parameters, so whatever a
//! Settings, placeholders…), View Options and simple message boxes (Settings: `settings`;
//! Keyboard Shortcuts: `shortcut_editor`). Every form runs an engine command with the collected parameters, so whatever a
//! dialog does an agent can do with one `engine.execute`.
use egui::{Color32, vec2};
@ -359,55 +359,6 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
}
pub fn show_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut q = app.dialog_state.palette_query.clone();
let mut close = false;
// (shortcut, where, label)
let mut rows: Vec<(String, String, String)> = vec![];
for (path, e) in effectcraft_engine::menus::entries() {
if let Some(sc) = &e.shortcut {
rows.push((crate::menus::shortcut_text(sc), path.join(" ▸ "), e.label.clone()));
}
}
for c in effectcraft_engine::command_specs().iter().filter(|c| c.menu.is_empty()) {
if let Some(sc) = c.shortcut
&& !rows.iter().any(|r| r.2 == c.label)
{
rows.push((crate::menus::shortcut_text(sc), "Commands".into(), c.label.to_string()));
}
}
for c in crate::menus::UI_COMMANDS {
if let Some(sc) = c.shortcut {
rows.push((crate::menus::shortcut_text(sc), "Panels & Tools".into(), c.label.to_string()));
}
}
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(640.0, 520.0), t, |ui| {
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or shortcuts").desired_width(f32::INFINITY));
ui.add_space(8.0);
let ql = q.to_lowercase();
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
egui::Grid::new("shortcut-grid").num_columns(3).striped(true).spacing([18.0, 4.0]).show(ui, |ui| {
for (sc, place, label) in rows.iter().filter(|r| ql.is_empty() || format!("{} {} {}", r.0, r.1, r.2).to_lowercase().contains(&ql)) {
ui.label(egui::RichText::new(sc).monospace());
ui.label(label);
ui.label(egui::RichText::new(place).color(t.text_dim));
ui.end_row();
}
});
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
close = true;
}
});
});
app.dialog_state.palette_query = q;
if close {
app.dialog = None;
}
}
pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {

View File

@ -14,6 +14,7 @@ pub mod project;
pub mod properties;
pub mod render_queue;
pub mod settings;
pub mod shortcut_editor;
pub mod text_panels;
pub mod timeline;
pub mod viewer;

View File

@ -0,0 +1,590 @@
//! Edit ▸ Keyboard Shortcuts: After Effects' visual shortcut editor.
//!
//! Top: the preset menu (EffectCraft Default = After Effects' defaults, read-only; custom presets
//! can be duplicated, renamed, deleted, imported and exported) and the search field. Middle: an
//! on-screen keyboard for the toggled (or held) modifiers, keys coloured by what they run in
//! that combination: purple for application-wide commands, green for panel-specific ones, both
//! when a key has each. Clicking a key lists everything bound to it. Bottom: every bindable
//! command with its shortcuts; select one, then record keys (press them in the shortcut field)
//! or assign the key selected on the keyboard. Conflicts are shown before assigning.
//!
//! All changes go through the `shortcuts.*` engine commands, so agents can do the same.
//! Automation ids: `shortcuts.preset`, `shortcuts.search`, `shortcuts.mod.<ctrl|cmd|alt|shift>`,
//! `shortcuts.key.<Key>`, `shortcuts.row.<n>`, `shortcuts.record`, `shortcuts.assign`,
//! `shortcuts.removeKey.<n>`, `shortcuts.resetCommand`, `shortcuts.duplicate`,
//! `shortcuts.rename`, `shortcuts.delete`, `shortcuts.import`, `shortcuts.export`,
//! `shortcuts.resetPreset`, `shortcuts.close`.
use effectcraft_engine::shortcuts::{APP_SCOPE, Bindable, DEFAULT_PRESET, normalize};
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Editor state (in `DialogState`).
#[derive(Clone, Debug, Default)]
pub struct EditorState {
pub query: String,
/// Binding key of the selected command.
pub selected: Option<String>,
/// Keyboard key selected on the on-screen keyboard (shortcut key name).
pub key: Option<String>,
/// Modifier toggles: Ctrl, Cmd, Alt, Shift.
pub mods: [bool; 4],
/// Recording a shortcut from the keyboard.
pub recording: bool,
/// Recorded (or typed) shortcut waiting to be assigned.
pub pending: String,
/// Rename field for the active preset.
pub rename: Option<String>,
}
const APP_COLOR: Color32 = Color32::from_rgb(0x8e, 0x6c, 0xe8);
const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c);
pub fn open(app: &mut EffectcraftApp) {
app.dialog_state.shortcuts = EditorState::default();
app.dialog = Some(Dialog::Shortcuts);
}
/// `true` while the editor records keys (the dispatcher must not close the dialog on Escape).
pub fn recording(app: &EffectcraftApp) -> bool {
app.dialog == Some(Dialog::Shortcuts) && app.dialog_state.shortcuts.recording
}
/// Shortcut text of a key event (`Cmd+Shift+K`).
pub fn combo_text(m: egui::Modifiers, k: egui::Key) -> Option<String> {
use egui::Key::*;
let key = match k {
Semicolon => ";",
Quote => "'",
Comma => ",",
Period => ".",
Slash => "/",
Backslash => "\\",
Equals => "=",
Minus => "-",
Backtick => "`",
OpenBracket => "[",
CloseBracket => "]",
Plus => "+",
Colon => ";",
_ => k.name(),
};
let mut s = String::new();
if cfg!(target_os = "macos") {
if m.ctrl {
s.push_str("Ctrl+");
}
if m.mac_cmd || m.command {
s.push_str("Cmd+");
}
} else if m.ctrl || m.command {
s.push_str("Cmd+");
}
if m.alt {
s.push_str("Alt+");
}
if m.shift {
s.push_str("Shift+");
}
if key == "+" {
// `Cmd++` names the plus key.
return normalize(&format!("{s}+"));
}
s.push_str(key);
normalize(&s)
}
/// Split normalized shortcut text into (ctrl, cmd, alt, shift) and the key.
fn split(sc: &str) -> ([bool; 4], String) {
let (head, key) = match sc.strip_suffix("++") {
Some(h) => (h.to_string(), "+".to_string()),
None if sc == "+" => (String::new(), "+".into()),
None => match sc.rsplit_once('+') {
Some((h, k)) => (h.to_string(), k.to_string()),
None => (String::new(), sc.to_string()),
},
};
let has = |m: &str| head.split('+').any(|p| p == m);
([has("Ctrl"), has("Cmd"), has("Alt"), has("Shift")], key)
}
fn mods_text(m: [bool; 4]) -> String {
let mut s = String::new();
for (on, n) in m.iter().zip(["Ctrl+", "Cmd+", "Alt+", "Shift+"]) {
if *on {
s.push_str(n);
}
}
s
}
/// The on-screen keyboard: rows of (label, key name, width in key units).
fn keyboard_rows() -> Vec<Vec<(&'static str, &'static str, f32)>> {
let mut rows: Vec<Vec<(&str, &str, f32)>> = vec![];
rows.push(vec![
("Esc", "Escape", 1.0),
("", "", 0.5),
("F1", "F1", 1.0),
("F2", "F2", 1.0),
("F3", "F3", 1.0),
("F4", "F4", 1.0),
("", "", 0.3),
("F5", "F5", 1.0),
("F6", "F6", 1.0),
("F7", "F7", 1.0),
("F8", "F8", 1.0),
("", "", 0.3),
("F9", "F9", 1.0),
("F10", "F10", 1.0),
("F11", "F11", 1.0),
("F12", "F12", 1.0),
]);
let mut r: Vec<(&str, &str, f32)> = vec![("`", "`", 1.0)];
for d in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] {
r.push((d, d, 1.0));
}
r.extend([("-", "-", 1.0), ("=", "=", 1.0), ("Delete", "Backspace", 1.6)]);
rows.push(r);
let mut r: Vec<(&str, &str, f32)> = vec![("Tab", "Tab", 1.5)];
for c in ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"] {
r.push((c, c, 1.0));
}
r.extend([("[", "[", 1.0), ("]", "]", 1.0), ("\\", "\\", 1.1)]);
rows.push(r);
let mut r: Vec<(&str, &str, f32)> = vec![("Caps", "", 1.8)];
for c in ["A", "S", "D", "F", "G", "H", "J", "K", "L"] {
r.push((c, c, 1.0));
}
r.extend([(";", ";", 1.0), ("'", "'", 1.0), ("Return", "Enter", 1.8)]);
rows.push(r);
let mut r: Vec<(&str, &str, f32)> = vec![("Shift", "#shift", 2.3)];
for c in ["Z", "X", "C", "V", "B", "N", "M"] {
r.push((c, c, 1.0));
}
r.extend([(",", ",", 1.0), (".", ".", 1.0), ("/", "/", 1.0), ("Shift", "#shift", 2.3)]);
rows.push(r);
let (cmd, alt) = if cfg!(target_os = "macos") { ("⌘ Cmd", "⌥ Opt") } else { ("Ctrl", "Alt") };
rows.push(vec![
("Ctrl", if cfg!(target_os = "macos") { "#ctrl" } else { "#cmd" }, 1.4),
(alt, "#alt", 1.4),
(cmd, "#cmd", 1.6),
("Space", "Space", 6.0),
(cmd, "#cmd", 1.6),
(alt, "#alt", 1.4),
]);
rows
}
/// Navigation and numeric keypad block: (label, key name, column, row).
const SIDE_KEYS: &[(&str, &str, f32, f32)] = &[
("Home", "Home", 0.0, 1.0),
("PgUp", "PageUp", 1.0, 1.0),
("Del", "Delete", 0.0, 2.0),
("End", "End", 1.0, 2.0),
("PgDn", "PageDown", 2.0, 2.0),
("↑", "ArrowUp", 1.0, 4.0),
("←", "ArrowLeft", 0.0, 5.0),
("↓", "ArrowDown", 1.0, 5.0),
("→", "ArrowRight", 2.0, 5.0),
];
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut st = std::mem::take(&mut app.dialog_state.shortcuts);
let mut run: Vec<(String, Value)> = vec![];
let mut close = false;
// Record keys while the shortcut field listens.
if st.recording {
let got = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
});
if let Some((k, m)) = got {
st.recording = false;
if k != egui::Key::Escape
&& let Some(txt) = combo_text(m, k)
{
st.pending = txt;
}
}
}
// Modifiers: toggles, or the ones held on the keyboard.
let held = ctx.input(|i| i.modifiers);
let held = [held.ctrl && cfg!(target_os = "macos"), held.command, held.alt, held.shift];
let mods = if held.iter().any(|m| *m) && !st.recording { held } else { st.mods };
let table = app.session.shortcuts().clone();
let presets = app.session.keymaps.names();
let active = app.session.keymaps.active.clone();
// key name → (app?, panel?) for the current modifiers.
let mut keymap: std::collections::HashMap<String, (bool, bool)> = Default::default();
for (sc, b) in table.bindings() {
let (m, k) = split(sc);
if m == mods {
let e = keymap.entry(k).or_default();
if b.scope == APP_SCOPE {
e.0 = true;
} else {
e.1 = true;
}
}
}
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(1000.0, 720.0), t, |ui| {
// ---- preset bar
ui.horizontal(|ui| {
ui.label("Preset:");
let r = egui::ComboBox::from_id_salt("sc-preset").selected_text(&active).width(220.0).show_ui(ui, |ui| {
for n in &presets {
if ui.selectable_label(*n == active, n).clicked() {
run.push(("shortcuts.preset".into(), json!({"op": "select", "name": n})));
}
}
});
app.auto.add("shortcuts.preset", r.response.rect, "Preset");
let custom = active != DEFAULT_PRESET;
let b = ui.button("Duplicate");
app.auto.add("shortcuts.duplicate", b.rect, "Duplicate");
if b.clicked() {
run.push(("shortcuts.preset".into(), json!({"op": "duplicate", "from": active, "name": format!("{active} copy")})));
}
if let Some(name) = &mut st.rename {
let r = ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
app.auto.add("shortcuts.renameField", r.rect, "New name");
if ui.button("Rename").clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
run.push(("shortcuts.preset".into(), json!({"op": "rename", "name": active, "newName": name})));
st.rename = None;
}
} else {
let b = ui.add_enabled(custom, egui::Button::new("Rename"));
app.auto.add("shortcuts.rename", b.rect, "Rename");
if b.clicked() {
st.rename = Some(active.clone());
}
}
let b = ui.add_enabled(custom, egui::Button::new("Delete"));
app.auto.add("shortcuts.delete", b.rect, "Delete");
if b.clicked() {
run.push(("shortcuts.preset".into(), json!({"op": "delete", "name": active})));
}
let b = ui.add_enabled(custom, egui::Button::new("Reset Preset"));
app.auto.add("shortcuts.resetPreset", b.rect, "Reset Preset");
if b.clicked() {
run.push(("shortcuts.reset".into(), json!({})));
}
let b = ui.button("Import...");
app.auto.add("shortcuts.import", b.rect, "Import");
if b.clicked()
&& let Some(p) = app.hooks.pick_files.as_ref().and_then(|f| f(&["json"]).into_iter().next())
{
run.push(("shortcuts.import".into(), json!({"path": p})));
}
let b = ui.button("Export...");
app.auto.add("shortcuts.export", b.rect, "Export");
if b.clicked()
&& let Some(p) = app.hooks.pick_save_file.as_ref().and_then(|f| f(&format!("{active}.json"), "json"))
{
run.push(("shortcuts.export".into(), json!({"preset": active, "path": p})));
}
});
ui.add_space(6.0);
// ---- modifiers + legend
ui.horizontal(|ui| {
ui.label("Modifiers:");
let names = if cfg!(target_os = "macos") { ["⌃ Ctrl", "⌘ Cmd", "⌥ Opt", "⇧ Shift"] } else { ["", "Ctrl", "Alt", "Shift"] };
for (i, (n, id)) in names.iter().zip(["ctrl", "cmd", "alt", "shift"]).enumerate() {
if n.is_empty() {
continue;
}
let r = ui.add(egui::Button::selectable(mods[i], *n));
app.auto.add(&format!("shortcuts.mod.{id}"), r.rect, n);
if r.clicked() {
st.mods[i] = !st.mods[i];
}
}
ui.add_space(20.0);
for (c, l) in [(APP_COLOR, "Application-wide"), (PANEL_COLOR, "Panel-specific")] {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, c);
ui.label(RichText::new(l).color(t.text_dim));
}
});
ui.add_space(6.0);
// ---- keyboard
let unit = 40.0;
let gap = 3.0;
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), unit * 6.0 + gap * 6.0 + 4.0), Sense::hover());
let p = ui.painter_at(area);
let draw_key = |app: &mut EffectcraftApp, r: Rect, label: &str, name: &str, st: &mut EditorState| {
let mod_idx = match name {
"#ctrl" => Some(0),
"#cmd" => Some(1),
"#alt" => Some(2),
"#shift" => Some(3),
_ => None,
};
let (a, pn) = keymap.get(name).copied().unwrap_or((false, false));
let fill = match mod_idx {
Some(i) if mods[i] => t.accent,
_ => t.field_bg,
};
p.rect_filled(r, 4.0, fill);
if a && pn {
let half = Rect::from_min_max(r.min, pos2(r.center().x, r.max.y));
p.rect_filled(half, 4.0, APP_COLOR);
p.rect_filled(Rect::from_min_max(pos2(r.center().x, r.min.y), r.max), 4.0, PANEL_COLOR);
} else if a {
p.rect_filled(r, 4.0, APP_COLOR);
} else if pn {
p.rect_filled(r, 4.0, PANEL_COLOR);
}
let sel = st.key.as_deref() == Some(name) && !name.is_empty();
p.rect_stroke(r, 4.0, Stroke::new(if sel { 2.0 } else { 1.0 }, if sel { Color32::WHITE } else { t.field_border }), StrokeKind::Inside);
p.text(
r.left_top() + vec2(5.0, 4.0),
Align2::LEFT_TOP,
label,
Tokens::ui(11.0),
if a || pn || mod_idx.is_some_and(|i| mods[i]) { Color32::WHITE } else { t.text },
);
if name.is_empty() {
return;
}
let id = format!("shortcuts.key.{}", name.trim_start_matches('#'));
app.auto.add(&id, r, label);
let resp = ui.interact(r, egui::Id::new(("sc-key", name, r.min.x as i32)), Sense::click());
if resp.clicked() {
if let Some(i) = mod_idx {
st.mods[i] = !st.mods[i];
} else {
st.key = Some(name.to_string());
st.pending = format!("{}{}", mods_text(mods), name);
}
}
if !name.starts_with('#') {
let who: Vec<String> =
table.bindings().into_iter().filter(|(sc, _)| split(sc) == (mods, name.to_string())).map(|(_, b)| b.label.clone()).collect();
if !who.is_empty() {
resp.on_hover_text(who.join("\n"));
}
}
};
let mut y = area.min.y + 2.0;
for row in keyboard_rows() {
let mut x = area.min.x + 2.0;
for (label, name, w) in row {
let r = Rect::from_min_size(pos2(x, y), vec2(unit * w - gap, unit - gap));
if !(label.is_empty() && name.is_empty()) {
draw_key(app, r, label, name, &mut st);
}
x += unit * w;
}
y += unit + gap;
}
let side_x = area.min.x + unit * 16.0;
for (label, name, c, r) in SIDE_KEYS {
let rect = Rect::from_min_size(pos2(side_x + c * unit, area.min.y + 2.0 + r * (unit + gap)), vec2(unit - gap, unit - gap));
draw_key(app, rect, label, name, &mut st);
}
// Key details: everything bound to the selected key.
let info_x = side_x + unit * 3.5;
let info = Rect::from_min_max(pos2(info_x, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0));
p.rect_filled(info, 4.0, t.row);
let p = ui.painter_at(info.shrink(2.0));
if let Some(k) = &st.key {
p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, format!("Key: {k}"), Tokens::semibold(12.0), t.tab_text_active);
let mut yy = info.min.y + 26.0;
let mut list: Vec<(String, String, String)> =
table.bindings().into_iter().filter(|(sc, _)| split(sc).1 == *k).map(|(sc, b)| (sc.to_string(), b.label.clone(), b.scope.clone())).collect();
list.sort();
if list.is_empty() {
p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, "Not assigned", Tokens::ui(11.5), t.text_dim);
}
for (sc, label, scope) in list.into_iter().take(9) {
let col = if scope == APP_SCOPE { APP_COLOR } else { PANEL_COLOR };
p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, crate::menus::shortcut_text(&sc), Tokens::ui(11.5), col);
p.text(pos2(info.min.x + 78.0, yy), Align2::LEFT_TOP, label, Tokens::ui(11.5), t.text);
yy += 17.0;
}
} else {
p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, "Click a key to see its commands.", Tokens::ui(11.5), t.text_dim);
}
ui.add_space(4.0);
// ---- search + command list + editor
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text("Search commands or shortcuts").desired_width(360.0));
app.auto.add("shortcuts.search", r.rect, "Search");
});
ui.add_space(4.0);
let q = st.query.to_lowercase();
let rows: Vec<&Bindable> = table
.bindables
.iter()
.filter(|b| {
if q.is_empty() {
return true;
}
let keys = table.keys.get(&b.key).map(|k| k.join(" ")).unwrap_or_default();
let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys).to_lowercase();
q.split_whitespace().all(|w| hay.contains(w))
})
.collect();
ui.allocate_ui_with_layout(vec2(ui.available_width(), 270.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_height(270.0);
// Command list.
ui.vertical(|ui| {
ui.set_width(620.0);
let row_h = 20.0;
egui::ScrollArea::vertical().id_salt("sc-rows").max_height(230.0).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
for i in range {
let b = rows[i];
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
let sel = st.selected.as_deref() == Some(b.key.as_str());
if sel {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if i % 2 == 1 {
ui.painter().rect_filled(r, 0.0, t.row_alt);
}
let pp = ui.painter();
pp.text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, &b.label, Tokens::ui(11.5), t.text);
pp.text(pos2(r.min.x + 230.0, r.center().y), Align2::LEFT_CENTER, b.path.join(" > "), Tokens::ui(10.5), t.text_faint);
let ks: Vec<String> = keys.iter().map(|k| crate::menus::shortcut_text(k)).collect();
let col = if b.scope == APP_SCOPE { APP_COLOR } else { PANEL_COLOR };
pp.text(
pos2(r.max.x - 8.0, r.center().y),
Align2::RIGHT_CENTER,
ks.join(", "),
Tokens::ui(11.5),
if keys.is_empty() { t.text_faint } else { col },
);
app.auto.add(&format!("shortcuts.row.{i}"), r, &b.label);
if resp.clicked() {
st.selected = Some(b.key.clone());
st.pending.clear();
}
}
});
ui.label(RichText::new(format!("{} of {} commands", rows.len(), table.bindables.len())).color(t.text_faint));
});
ui.separator();
// Selected command editor.
ui.vertical(|ui| {
ui.set_width(310.0);
let Some(b) = st.selected.as_ref().and_then(|k| table.find(k)).cloned() else {
ui.label(RichText::new("Select a command to change its shortcut.").color(t.text_dim));
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.add_space(6.0);
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
if keys.is_empty() {
ui.label(RichText::new("No shortcut").color(t.text_dim));
}
for (n, k) in keys.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(RichText::new(crate::menus::shortcut_text(k)).monospace());
let r = ui.small_button("Remove");
app.auto.add(&format!("shortcuts.removeKey.{n}"), r.rect, "Remove");
if r.clicked() {
let rest: Vec<&String> = keys.iter().filter(|x| *x != k).collect();
run.push(("shortcuts.set".into(), json!({"command": b.command, "params": b.params, "keys": rest})));
}
});
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let hint = if st.recording { "Press keys…" } else { "Click, then press keys" };
let r = ui.add(egui::TextEdit::singleline(&mut st.pending).hint_text(hint).desired_width(170.0));
app.auto.add("shortcuts.record", r.rect, "Shortcut");
if r.gained_focus() || (r.clicked() && !st.recording) {
st.recording = true;
}
if r.lost_focus() {
st.recording = false;
}
let norm = normalize(&st.pending);
let b2 = ui.add_enabled(norm.is_some(), egui::Button::new("Assign"));
app.auto.add("shortcuts.assign", b2.rect, "Assign");
if b2.clicked()
&& let Some(k) = norm
{
let mut all = keys.clone();
if !all.contains(&k) {
all.push(k);
}
run.push(("shortcuts.set".into(), json!({"command": b.command, "params": b.params, "keys": all})));
st.pending.clear();
}
});
if let Some(k) = normalize(&st.pending) {
let conflicts = table.conflicts(&b, &k);
if !conflicts.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new(format!("⚠ {} 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.add_space(6.0);
let r = ui.add_enabled(keys != b.defaults, egui::Button::new("Reset to Default"));
app.auto.add("shortcuts.resetCommand", r.rect, "Reset to Default");
if r.clicked() {
run.push(("shortcuts.reset".into(), json!({"command": b.command, "params": b.params})));
}
if !b.defaults.is_empty() {
let d: Vec<String> = b.defaults.iter().map(|k| crate::menus::shortcut_text(k)).collect();
ui.label(RichText::new(format!("Default: {}", d.join(", "))).color(t.text_faint));
}
if active == DEFAULT_PRESET {
ui.add_space(6.0);
ui.label(RichText::new("Changing a shortcut creates a custom preset; the default stays as it is.").color(t.text_faint).small());
}
});
});
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
app.auto.add("shortcuts.close", r.rect, "OK");
close |= r.clicked();
});
});
app.dialog_state.shortcuts = st;
for (cmd, params) in run {
if let Err(e) = app.session.execute(&cmd, params) {
app.ui.status = e.to_string();
}
}
if close {
app.dialog = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combo_text_round_trips_through_the_parser() {
let cases = [
(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::K),
(egui::Modifiers::ALT, egui::Key::OpenBracket),
(egui::Modifiers::NONE, egui::Key::F9),
];
for (m, k) in cases {
let txt = combo_text(m, k).unwrap();
let (pm, pk) = crate::menus::parse_shortcut(&txt).unwrap();
assert_eq!(pk, k, "{txt}");
assert_eq!((pm.command, pm.alt, pm.shift), (m.command, m.alt, m.shift), "{txt}");
}
assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into()));
assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into()));
assert_eq!(split("F9"), ([false; 4], "F9".into()));
}
}

View File

@ -1,4 +1,4 @@
# Settings
# Settings and keyboard shortcuts
## Settings (Preferences)
@ -135,3 +135,25 @@ These rows are in the dialog for After Effects parity and are stored, but nothin
- `scripting.bracketMatching`: Bracket Matching
- `scripting.wordWrap`: Word Wrap
- `scripting.errorBanner`: Show Expression Error Banner
## Keyboard shortcuts
Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing
which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific,
both: split), modifier toggles, a searchable list of every command (all engine commands, menu
entries with bound parameters and the frontend's tool / panel commands), and a preset menu.
- **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing
it creates a "Custom" copy. Custom presets store only their differences from the default and
can be duplicated, renamed, deleted, exported and imported (JSON). The active preset is what
the menus show and what the shortcut dispatcher runs.
- **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.
Commands: `shortcuts.list {query?, keys?, assigned?}`, `shortcuts.set {command, params?, keys}`,
`shortcuts.reset {command?}`, `shortcuts.preset {op: select|new|duplicate|delete|rename, name?,
from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? | preset?}`,
`shortcuts.conflicts`.