diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 56f18096..32d922fa 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -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()); diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index b719e4e3..8c6b8550 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -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 { 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 { + 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, ¶ms); + 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> { + 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 { + 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 = 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + let c: Vec = + 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 { 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 { 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), ] } diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index dd9c5805..d84eeb7c 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -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; fn write(&self, name: &str, data: &str) -> std::io::Result<()>; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2a244bfc..9bd475f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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>, + /// Keyboard shortcut presets. + pub keymaps: shortcuts::Keymaps, + /// Frontend-only commands offered for binding. + pub ui_commands: Vec, + /// Cache of the resolved active preset (read it with [`Session::shortcuts`]). + pub shortcut_table: std::sync::OnceLock, } 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(), } } } diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index f9b7ac47..3e3d90d9 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -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) { + 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)) + } } diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs new file mode 100644 index 00000000..cd628b9f --- /dev/null +++ b/crates/engine/src/shortcuts.rs @@ -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, +} + +/// 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, + /// `Application` or the panel the shortcut works in (`Timeline`, `Composition`…). + pub scope: String, + /// After Effects default keys. + pub defaults: Vec, +} + +/// 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 { + let mut out: Vec = vec![]; + let mut seen: HashMap = HashMap::new(); + let mut push = |b: Bindable, out: &mut Vec| { + 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 { + 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 = 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 = 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>, +} + +/// 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, +} + +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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + let overrides: BTreeMap> = + 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, + /// Effective keys per binding key. + pub keys: HashMap>, +} + +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 { + 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 + } +} diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 5e1525de..c045e560 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -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")); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 111e6fae..0e6ed058 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,6 +71,8 @@ pub struct Hooks { pub audio_devices: Option Vec>>, /// Picks a folder (Settings paths). pub pick_folder: Option Option>>, + /// Save dialog for other file kinds: (default name, extension). + pub pick_save_file: Option Option>>, } #[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. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c727a379..466f2e3e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + 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 { let v = &app.ui.viewer; @@ -632,7 +637,7 @@ pub fn menu_items(app: &EffectcraftApp) -> Vec { 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 { /// 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 { +/// All bindings of the active keyboard shortcut preset, most modifiers first. +pub fn bindings(session: &effectcraft_engine::Session) -> Vec { let mut v: Vec = 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::>(); + 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()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 690c297d..366a8575 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -24,6 +24,8 @@ pub struct DialogState { pub settings_page: String, /// Settings when the dialog opened (Cancel restores them). pub prefs_snapshot: Option, + /// 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), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 74124780..65599478 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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| { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 5c8100b4..3fe7196d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs new file mode 100644 index 00000000..4b21b30b --- /dev/null +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -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.`, +//! `shortcuts.key.`, `shortcuts.row.`, `shortcuts.record`, `shortcuts.assign`, +//! `shortcuts.removeKey.`, `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, + /// Keyboard key selected on the on-screen keyboard (shortcut key name). + pub key: Option, + /// 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, +} + +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 { + 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> { + let mut rows: Vec> = 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 = 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 = + 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 = 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 = 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())); + } +} diff --git a/docs/preferences.md b/docs/preferences.md index 8e79dbf1..65111034 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -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`.