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:
parent
47f7712364
commit
35f429363e
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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, ¶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<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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<()>;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue