Compare commits

...
Author SHA1 Message Date
Tanish Manku f35509c194 Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front 2026-10-15 15:14:51 -07:00
Tanish Manku 0c96a57bf7 Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc 2026-10-15 14:52:02 -07:00
Tanish Manku 0a8f32a280 Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched 2026-10-15 14:29:59 -07:00
Tanish Manku 95789b7c4d docs: reserved keys per platform 2026-10-15 14:25:11 -07:00
Tanish Manku ed2cdbc5f9 Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one 2026-10-15 14:25:11 -07:00
Tanish Manku 84e4d0ad80 Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools 2026-10-15 14:25:11 -07:00
Tanish Manku 061ae5c92b Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files
A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.
2026-10-15 14:25:11 -07:00
13 changed files with 815 additions and 173 deletions

No files matched your search

+10
View File
@@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
}
"plugin.reload" => true,
_ => false,
}
@@ -712,6 +716,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
for key in ["importKys", "exportKys", "summarize"] {
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
}
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
+14 -13
View File
@@ -160,12 +160,14 @@ fn key(label: &str) -> String {
l.trim_end_matches('…').trim().to_lowercase()
}
/// The engine's own candidates: every command in a menu, in command order.
/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in
/// command order.
pub fn command_candidates() -> Vec<Candidate> {
crate::command_specs()
.iter()
.filter(|c| !c.menu.is_empty())
.map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) })
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut));
engine
.chain(crate::shell_commands::SHELL_COMMANDS.iter().copied())
.filter(|c| !c.2.is_empty())
.map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) })
.collect()
}
@@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
}
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped.
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
let mut keys = BTreeMap::new();
@@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set:
unknown.push(row.name.clone());
continue;
};
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
if let (Some(c), None) = (letters.next(), letters.next())
&& c.is_ascii_alphanumeric()
{
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
// One key without modifiers (Photoshop's letters; our tools may also take an F-key).
let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim()));
if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") {
keys.insert((*id).to_string(), k);
}
}
(keys, unknown)
@@ -448,7 +449,7 @@ mod tests {
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers");
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
// Our own export reads back by id.
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
+1
View File
@@ -103,6 +103,7 @@ pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod selection_cmds;
pub mod shell_commands;
pub mod shortcut_sets;
pub mod slice_cmds;
pub mod smart_cmds;
+353 -31
View File
@@ -1495,9 +1495,65 @@ pub fn default_alternates(id: &str) -> impl Iterator<Item = &'static str> + '_ {
ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc)
}
/// Every bindable id with its default: commands, then the temporary tools.
/// Keys the system takes before the app sees them (switching apps, closing the window, force
/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation,
/// where `Cmd` is Ctrl off the Mac.
pub fn reserved_shortcut(sc: &str) -> bool {
let Some(n) = normalize_shortcut(sc) else { return false };
let reserved: &[&str] = if cfg!(target_os = "macos") {
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
} else if cfg!(target_os = "windows") {
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
} else {
// Linux and the BSDs: window switching and closing, and the virtual consoles.
&[
"Alt+Tab",
"Alt+Shift+Tab",
"Alt+F4",
"Cmd+Alt+Delete",
"Cmd+Alt+F1",
"Cmd+Alt+F2",
"Cmd+Alt+F3",
"Cmd+Alt+F4",
"Cmd+Alt+F5",
"Cmd+Alt+F6",
"Cmd+Alt+F7",
"Cmd+Alt+F8",
"Cmd+Alt+F9",
"Cmd+Alt+F10",
"Cmd+Alt+F11",
"Cmd+Alt+F12",
]
};
reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n)
}
/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BindKind {
Command,
Tool,
Hold,
}
/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the
/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools,
/// so Edit › Keyboard Shortcuts works the same with or without the app's UI.
pub fn bindable_items() -> impl Iterator<Item = (&'static str, &'static str, &'static [&'static str], Option<&'static str>, BindKind)> {
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command));
let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command));
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool));
let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold));
engine.chain(shell).chain(tools).chain(hold)
}
/// Every bindable id with its default.
fn bindable() -> impl Iterator<Item = (&'static str, Option<&'static str>)> {
crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2))))
bindable_items().map(|i| (i.0, i.3))
}
fn is_tool(id: &str) -> bool {
crate::shell_commands::tool_default(id).is_some()
}
/// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut).
@@ -1866,18 +1922,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
let imported;
let mut import = None;
let p = match p.get("importKys") {
Some(xml) => {
let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?;
Some(v) => {
// The file's text, or {"path": …} to read it.
let read;
let xml = match v {
Value::String(s) => s.as_str(),
Value::Object(o) => {
let path =
o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?;
read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
read.as_str()
}
_ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")),
};
let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?;
let plan = crate::kys::plan(crate::kys::command_candidates(), &set);
let mut merged = serde_json::Map::new();
for (id, sc) in &plan.set {
// A key the system reserves is left out (reported as `reserved`), not a failed import.
let mut skipped: Vec<String> = Vec::new();
for (id, sc) in plan.set.iter().filter(|(id, sc)| {
let bad = reserved_shortcut(sc);
if bad {
skipped.push(format!("{id}: {sc}"));
}
!bad
}) {
let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut);
merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) });
}
// Tool keys: a tool's own letter restores it (and its Shift cycling).
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1));
let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set);
for (id, key) in &tool_keys {
let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key));
merged.insert(id.clone(), if own { Value::Null } else { json!(key) });
}
merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default());
let mut alternates = serde_json::Map::new();
for (id, keys) in &plan.alternate_keys {
let keys: &Vec<String> = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect();
let main = plan.set.get(id).map(String::as_str);
let default = Preferences::default().alternates(id, main);
alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) });
@@ -1892,6 +1975,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
"tools": tool_keys,
"unknownTools": unknown_tools,
"reserved": skipped,
"windows": set.windows,
}));
let mut with_import = p.clone();
@@ -1914,6 +2000,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
if !set.is_empty() || !alts.is_empty() {
let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false);
changed = true;
// Reserved system keys can't be bound, by hand or from a file.
let reserved = |v: &Value| -> Option<String> {
let keys = match v {
Value::String(s) => vec![s.clone()],
other => alternate_keys(other).unwrap_or_default(),
};
keys.into_iter().find(|k| reserved_shortcut(k))
};
if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) {
return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned")));
}
for (id, v) in set {
if !bindable().any(|(b, _)| b == id) && !allow_unknown {
return Err(bad(cmd, format!("unknown command `{id}`")));
@@ -1935,9 +2032,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
.chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k))))
.collect();
for (id, sc) in assigned {
// Shell commands (View › Zoom In) have alternates but no engine default.
let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None));
for (c, def) in bindable().chain(shell) {
for (c, def) in bindable() {
// Tools may share a key: they cycle with Shift, as Photoshop's groups do.
if is_tool(id) && is_tool(c) {
continue;
}
// Never strip a command that this same call is assigning:
// two entries for one key are a clash inside the call, which
// the returned conflicts list reports (#719). Stripping
@@ -1959,16 +2058,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
}
// Then the sets are saved, deleted or switched, from the bindings as they now stand.
changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?;
if changed {
s.edit_prefs(|p| *p = next);
}
let prefs = s.prefs();
// The reply and the exports read the bindings as they will be; they are committed only once
// the exports are written, so a bad export target changes nothing.
let prefs = &next;
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true));
let list: Vec<Value> = crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
.chain(temporary)
let list: Vec<Value> = bindable_items()
.filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f)))
.filter(|c| {
p.get("list").and_then(Value::as_bool).unwrap_or(false)
@@ -1976,15 +2070,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|| prefs.shortcuts.contains_key(c.0)
|| prefs.alternate_shortcuts.contains_key(c.0)
})
.map(|(id, label, menu, def, hold)| {
.map(|(id, label, menu, def, kind)| {
let main = prefs.shortcut(id, def);
let defaults: Vec<&str> = default_alternates(id).collect();
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)});
if !defaults.is_empty() {
v["defaultAlternates"] = json!(defaults);
}
if hold {
v["hold"] = json!(true);
match kind {
BindKind::Hold => v["hold"] = json!(true),
BindKind::Tool => v["tool"] = json!(true),
BindKind::Command => {}
}
v
})
@@ -1995,30 +2091,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
bindings.extend(main.map(|m| (id, m.to_string())));
bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k)));
}
let conflicts: Vec<Value> =
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
// Tools sharing a key are a cycling group, not a conflict.
let conflicts: Vec<Value> = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str())))
.into_iter()
.filter(|(_, ids)| !ids.iter().all(|id| is_tool(id)))
.map(|(sc, ids)| json!({"shortcut": sc, "commands": ids}))
.collect();
let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts});
if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) {
o.extend(sets);
}
// The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the
// app's dialog writes both with the shell's commands and tools added).
// The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the
// engine's commands, the shell's commands and the tools).
let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() };
if p.get("exportKys").and_then(Value::as_bool) == Some(true) {
// Both targets are resolved and their texts made before anything is written, and a path that
// can't be written is refused before either file is: a bad export changes nothing.
let kys_target = file_target(cmd, p.get("exportKys"))?;
let html_target = file_target(cmd, p.get("summarize"))?;
let mut files: Vec<(&str, String, String)> = Vec::new();
if let Some(target) = kys_target {
let entries: Vec<crate::kys::Entry> =
crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect();
out["kys"] = json!(crate::kys::write(set_name, &entries));
crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect();
let text = crate::kys::write(set_name, &entries);
match target {
Some(path) => files.push(("kysPath", path, text)),
None => out["kys"] = json!(text),
}
}
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
if let Some(target) = html_target {
let v = p.get("summarize").cloned().unwrap_or(Value::Null);
let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos"));
let mut sections: Vec<crate::shortcut_sets::Section> = Vec::new();
for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) {
for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) {
match sections.iter_mut().find(|(m, _)| *m == menu) {
Some((_, rows)) => rows.push((label, keys)),
None => sections.push((menu, vec![(label, keys)])),
}
}
out["html"] = json!(crate::shortcut_sets::summarize(set_name, &sections, mac));
let html = crate::shortcut_sets::summarize(set_name, &sections, mac);
match target {
Some(path) => files.push(("htmlPath", path, html)),
None => out["html"] = json!(html),
}
}
for (_, path, _) in &files {
writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
}
let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect();
write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?;
for (key, path, _) in &files {
out[*key] = json!(path);
}
if changed {
s.edit_prefs(|p| *p = next);
}
if let Some(import) = import {
out["import"] = import;
@@ -2026,6 +2151,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object
/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` →
/// `Some(Some(path))`, written there.
fn file_target(cmd: &str, v: Option<&Value>) -> Result<Option<Option<String>>> {
match v {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some(None)),
Some(Value::Object(o)) => match o.get("path") {
None | Some(Value::Null) => Ok(Some(None)),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))),
Some(_) => Err(bad(cmd, "`path` must be a non-empty string")),
},
Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_text_file(path: &str, max: usize) -> std::result::Result<String, String> {
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(format!("not a file of at most {} KiB", max / 1024));
}
std::fs::read_to_string(path).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
fn read_text_file(_: &str, _: usize) -> std::result::Result<String, String> {
Err("paths can't be read on the web; pass the file's text instead".into())
}
/// Whether `path` can be written: not a folder, in a folder that exists.
#[cfg(not(target_arch = "wasm32"))]
fn writable(path: &str) -> std::result::Result<(), String> {
let p = std::path::Path::new(path);
if p.is_dir() {
return Err("is a folder".into());
}
if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) {
return Err("is read-only".into());
}
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()),
_ => Ok(()),
}
}
#[cfg(target_arch = "wasm32")]
fn writable(_: &str) -> std::result::Result<(), String> {
Err("paths can't be written on the web; omit `path` to get the text back".into())
}
#[cfg(not(target_arch = "wasm32"))]
fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::rename(from, to)
}
#[cfg(target_arch = "wasm32")]
fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> {
Err(std::io::Error::other("paths can't be written on the web"))
}
/// Where `path` points, for telling whether two targets are one file: its folder resolved, its
/// name compared without case where the file system ignores it.
#[cfg(not(target_arch = "wasm32"))]
fn path_key(path: &str) -> String {
let p = std::path::Path::new(path);
let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned();
if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key }
}
/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging
/// (`kind` "tmp") or a backup ("bak").
#[cfg(not(target_arch = "wasm32"))]
fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> {
for n in 0..1000u32 {
let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id()));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) {
Ok(f) => return Ok((p, f)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::other("no free name beside it"))
}
/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its
/// target; once every one is written, each existing target is moved aside to a backup and the
/// staged file moved in. Any failure moves the backups back and removes what was staged, so the
/// user's files end as they began; on success the backups go. Two targets naming one file are
/// refused. `rename` is `std::fs::rename` (a test fails it on purpose).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_files_atomically(
files: &[(&str, &str)],
rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
) -> std::result::Result<(), String> {
use std::io::Write;
use std::path::{Path, PathBuf};
for (i, (a, _)) in files.iter().enumerate() {
if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) {
return Err(format!("{a}: two exports to the same file"));
}
}
let mut staged: Vec<PathBuf> = Vec::new();
let drop_all = |paths: &[PathBuf]| {
for p in paths {
let _ = std::fs::remove_file(p);
}
};
for (path, text) in files {
let (tmp, mut f) = match sibling(path, "tmp") {
Ok(v) => v,
Err(e) => {
drop_all(&staged);
return Err(format!("{path}: {e}"));
}
};
staged.push(tmp);
if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) {
drop_all(&staged);
return Err(format!("{path}: {e}"));
}
}
// (target, its backup) of each file moved in so far.
let mut moved: Vec<(&Path, Option<PathBuf>)> = Vec::new();
// Put each backup back over its target (a target that had none is removed). What couldn't be
// put back is named, so the user can find their previous file.
let undo = |moved: &[(&Path, Option<PathBuf>)]| -> String {
let mut stranded = Vec::new();
for (target, backup) in moved.iter().rev() {
match backup {
Some(b) => {
if rename(b, target).is_err() {
stranded.push(format!("the previous {} is at {}", target.display(), b.display()));
}
}
None => {
let _ = std::fs::remove_file(target);
}
}
}
if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) }
};
for ((path, _), tmp) in files.iter().zip(&staged) {
let target = Path::new(*path);
let backup = if target.exists() {
let moved_aside = sibling(path, "bak").and_then(|(b, f)| {
drop(f);
match rename(target, &b) {
Ok(()) => Ok(b),
Err(e) => {
// The placeholder the backup name reserved goes too.
let _ = std::fs::remove_file(&b);
Err(e)
}
}
});
match moved_aside {
Ok(b) => Some(b),
Err(e) => {
let stranded = undo(&moved);
drop_all(&staged);
return Err(format!("{path}: {e}{stranded}"));
}
}
} else {
None
};
if let Err(e) = rename(tmp, target) {
let mut stranded = undo(&moved);
if let Some(b) = &backup
&& rename(b, target).is_err()
{
stranded = format!("{stranded} (the previous {path} is at {})", b.display());
}
drop_all(&staged);
return Err(format!("{path}: {e}{stranded}"));
}
moved.push((target, backup));
}
for (_, backup) in moved {
if let Some(b) = backup {
let _ = std::fs::remove_file(b);
}
}
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_files_atomically(
files: &[(&str, &str)],
_: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
) -> std::result::Result<(), String> {
if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) }
}
/// Edit › Menus: hide/show items and give them colours.
fn menus(s: &mut Session, p: &Value) -> Result<Value> {
let ids = |k: &str| -> Vec<String> {
@@ -2151,7 +2473,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##,
keyboard_shortcuts,
true
),
+51
View File
@@ -827,3 +827,54 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() {
let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap();
assert!(!stored.use_shift_key_for_tool_switch);
}
/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was
/// moved in puts the user's files back as they were and leaves nothing staged.
#[test]
fn exports_are_written_all_or_nothing() {
use super::write_files_atomically;
let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
std::fs::write(&a, "old A").unwrap();
std::fs::write(&b, "old B").unwrap();
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
let staged_moves = std::cell::Cell::new(0);
let failing = |from: &std::path::Path, to: &std::path::Path| {
if from.to_string_lossy().ends_with("photocraft-tmp") {
staged_moves.set(staged_moves.get() + 1);
if staged_moves.get() == 2 {
return Err(std::io::Error::other("disk gone"));
}
}
std::fs::rename(from, to)
};
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err());
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count();
assert_eq!(leftovers(), 0);
// A backup move that fails leaves no empty placeholder behind.
let backups = std::cell::Cell::new(0);
let failing_backup = |from: &std::path::Path, to: &std::path::Path| {
if to.to_string_lossy().ends_with("photocraft-bak") {
backups.set(backups.get() + 1);
if backups.get() == 2 {
return Err(std::io::Error::other("locked"));
}
}
std::fs::rename(from, to)
};
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err());
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
assert_eq!(leftovers(), 0);
// The same target twice is refused before anything is written.
assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err());
assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A");
// A target named like a staging file is just a file.
let odd = dir.join("c.kys.photocraft-tmp");
std::fs::write(&odd, "old C").unwrap();
write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap();
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into()));
assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself");
let _ = std::fs::remove_dir_all(&dir);
}
+194
View File
@@ -0,0 +1,194 @@
//! The commands the app's shell carries out itself (view, panels, windows: they change no
//! document), and the tools, as data: what they're called and which keys they have by default.
//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine
//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts'
//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on
//! top). The app's tests keep these tables equal to its own.
/// Shell commands: id, label, menu path, default shortcut.
pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.symmetryTransform", "Transform Symmetry", &[], None),
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
("tool.reset", "Reset Tool", &[], None),
("tool.resetAll", "Reset All Tools", &[], None),
("window.panel.group", "Change Panel Group", &[], None),
("window.panel.layout", "Change Panel Layout", &[], None),
("window.panel.move", "Move Panel", &[], None),
("window.panel.float", "Float Panel", &[], None),
("window.panel.dock", "Dock Panel", &[], None),
("window.panel.activate", "Activate Panel", &[], None),
("window.panel.close", "Close Panel", &[], None),
("file.open", "Open…", &["File"], Some("Cmd+O")),
("file.save", "Save", &["File"], Some("Cmd+S")),
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
("view.extras", "Extras", &["View"], Some("Cmd+H")),
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
("type.editText", "Edit Type", &[], None),
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
("view.rotateView", "Rotate View", &[], None),
("view.resetView", "Reset View", &["View"], None),
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
("window.toggle.history", "History", &["Window"], None),
("window.toggle.properties", "Properties", &["Window"], None),
("window.toggle.color", "Color", &["Window"], Some("F6")),
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
("window.toggle.brushes", "Brushes", &["Window"], None),
("window.toggle.navigator", "Navigator", &["Window"], None),
("window.toggle.toolbar", "Tools", &["Window"], None),
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
("window.toggle.options", "Options", &["Window"], None),
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
("help.website", "PhotoCraft Website", &["Help"], None),
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
("help.github", "PhotoCraft on GitHub", &["Help"], None),
("help.reportIssue", "Report an Issue…", &["Help"], None),
("help.systemInfo", "System Info…", &["Help"], None),
("help.about", "About PhotoCraft", &["Help"], None),
];
/// The tools, in toolbar order: shortcut id (`tools.select.<tool>`), Photoshop's name for it
/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's
/// tool groups), so a shared default letter isn't a conflict.
pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.move", "Move Tool", Some("V")),
("tools.select.artboard", "Artboard Tool", Some("V")),
("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")),
("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")),
("tools.select.singleRowMarquee", "Single Row Marquee Tool", None),
("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None),
("tools.select.lasso", "Lasso Tool", Some("L")),
("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")),
("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")),
("tools.select.magicWand", "Magic Wand Tool", Some("W")),
("tools.select.crop", "Crop Tool", Some("C")),
("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")),
("tools.select.eyedropper", "Eyedropper Tool", Some("I")),
("tools.select.colorSampler", "Color Sampler Tool", Some("I")),
("tools.select.ruler", "Ruler Tool", Some("I")),
("tools.select.note", "Note Tool", Some("I")),
("tools.select.count", "Count Tool", Some("I")),
("tools.select.brush", "Brush Tool", Some("B")),
("tools.select.pencil", "Pencil Tool", Some("B")),
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
("tools.select.colorReplacement", "Color Replacement Tool", Some("B")),
("tools.select.eraser", "Eraser Tool", Some("E")),
("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")),
("tools.select.magicEraser", "Magic Eraser Tool", Some("E")),
("tools.select.gradient", "Gradient Tool", Some("G")),
("tools.select.paintBucket", "Paint Bucket Tool", Some("G")),
("tools.select.type", "Horizontal Type Tool", Some("T")),
("tools.select.verticalType", "Vertical Type Tool", Some("T")),
("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")),
("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")),
("tools.select.hand", "Hand Tool", Some("H")),
("tools.select.rotateView", "Rotate View Tool", Some("R")),
("tools.select.zoom", "Zoom Tool", Some("Z")),
("tools.select.remove", "Remove Tool", Some("J")),
("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")),
("tools.select.healing", "Healing Brush Tool", Some("J")),
("tools.select.patch", "Patch Tool", Some("J")),
("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")),
("tools.select.redEye", "Red Eye Tool", Some("J")),
("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")),
("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")),
("tools.select.historyBrush", "History Brush Tool", Some("Y")),
("tools.select.blur", "Blur Tool", None),
("tools.select.sharpen", "Sharpen Tool", None),
("tools.select.smudge", "Smudge Tool", None),
("tools.select.dodge", "Dodge Tool", Some("O")),
("tools.select.burn", "Burn Tool", Some("O")),
("tools.select.sponge", "Sponge Tool", Some("O")),
("tools.select.quickSelection", "Quick Selection Tool", Some("W")),
("tools.select.objectSelection", "Object Selection Tool", Some("W")),
("tools.select.pen", "Pen Tool", Some("P")),
("tools.select.freeformPen", "Freeform Pen Tool", Some("P")),
("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")),
("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")),
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")),
("tools.select.convertPoint", "Convert Point Tool", Some("P")),
("tools.select.pathSelection", "Path Selection Tool", Some("A")),
("tools.select.directSelection", "Direct Selection Tool", Some("A")),
("tools.select.rectangle", "Rectangle Tool", Some("U")),
("tools.select.ellipseShape", "Ellipse Tool", Some("U")),
("tools.select.triangle", "Triangle Tool", Some("U")),
("tools.select.polygon", "Polygon Tool", Some("U")),
("tools.select.line", "Line Tool", Some("U")),
("tools.select.customShape", "Custom Shape Tool", Some("U")),
("tools.select.slice", "Slice Tool", Some("C")),
("tools.select.sliceSelect", "Slice Select Tool", Some("C")),
("tools.select.frame", "Frame Tool", Some("K")),
];
/// The default key of tool shortcut `id`, if it's a tool.
pub fn tool_default(id: &str) -> Option<Option<&'static str>> {
TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_unique_and_keys_are_valid() {
let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect();
let n = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate ids");
for (id, _, _, sc) in SHELL_COMMANDS {
assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}");
}
for (id, name, key) in TOOLS {
assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}");
assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}");
}
assert_eq!(tool_default("tools.select.move"), Some(Some("V")));
assert_eq!(tool_default("edit.copy"), None);
}
}
+26 -14
View File
@@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
out
}
/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id,
/// label, top-level menu, keys with the main one first). The app's dialog adds the commands
/// only the shell knows (View › Zoom In, the tools).
pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec<String>)> {
let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2)));
crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut))
.chain(temporary)
.filter_map(|(id, label, menu, def)| {
/// A command (or tool) with a key under some bindings.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bound {
pub id: String,
pub label: String,
/// Its top-level menu ("Tools" for tools).
pub menu: String,
/// Main key first, then the further keys.
pub keys: Vec<String>,
pub tool: bool,
}
/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's
/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]).
pub fn bound(p: &Preferences) -> Vec<Bound> {
crate::prefs::bindable_items()
.filter_map(|(id, label, menu, def, kind)| {
let main = p.shortcut(id, def);
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
keys.extend(p.alternates(id, main));
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
let menu = menu.first().copied().unwrap_or("Other").to_string();
(!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool })
})
.collect()
}
@@ -340,8 +348,12 @@ mod tests {
fn bound_lists_main_keys_then_alternates() {
let p = prefs();
let b = bound(&p);
let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand"));
let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|x| x.id == "tools.temporary.hand"));
// The shell's commands and the tools too: a set saved headless is the whole set.
assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++");
let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool");
assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools");
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable,
//! listed, imported and exported; tools share keys without conflicts; system keys are refused;
//! sets read and write files by path.
use photocraft_engine::Session;
use serde_json::{Value, json};
fn ks(s: &mut Session, p: Value) -> Value {
s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts")
}
fn listed<'a>(r: &'a Value, id: &str) -> &'a Value {
r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed"))
}
#[test]
fn shell_commands_and_tools_are_bindable_without_the_ui() {
let mut s = Session::new();
let r = ks(&mut s, json!({"list": true}));
assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In");
let mv = listed(&r, "tools.select.move");
assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"])));
assert!(
r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"]
.as_array()
.unwrap()
.iter()
.any(|id| !id.as_str().unwrap().starts_with("tools.select."))),
"tools sharing a letter cycle, they don't conflict: {}",
r["conflicts"]
);
// No allowUnknown needed any more.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z")));
}
#[test]
fn tools_may_share_a_key_but_a_command_takes_it() {
let mut s = Session::new();
// Pencil joins the V group: Move and Artboard keep V.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}}));
assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]);
// A command taking V removes it from every tool that held it.
let r = ks(&mut s, json!({"set": {"edit.copy": "V"}}));
for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] {
assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]);
}
}
#[test]
fn system_keys_are_refused_and_dropped_from_imports() {
let mut s = Session::new();
let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" };
let before = s.prefs().clone();
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err());
assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err());
assert_eq!(s.prefs(), &before);
let set = format!(
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F9</shortcut><shortcut>{key}</shortcut></command><command name=\"Cut\"><shortcut>{key}</shortcut></command><tool name=\"Pencil Tool\">N</tool><tool name=\"Move Tool\">V</tool></photoshop-keyboard-shortcuts>"
);
let r = ks(&mut s, json!({"importKys": set}));
assert_eq!(r["overrides"]["edit.copy"], json!("F9"));
assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]);
assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1));
assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless");
assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling");
}
#[test]
fn sets_read_and_write_files_and_export_everything() {
let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let kys = dir.join("mine.kys");
let html = dir.join("mine.html");
let mut s = Session::new();
ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"}));
let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}}));
assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}");
let text = std::fs::read_to_string(&kys).unwrap();
assert!(text.contains("pc-id=\"tools.select.pencil\">F6</tool>") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}");
assert!(std::fs::read_to_string(&html).unwrap().contains("<h2>Tools</h2>"));
// Back to the defaults, then the file restores the set, tools included.
ks(&mut s, json!({"useSet": ""}));
let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9")));
// Bad paths and shapes are errors, not panics.
for p in [
json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}),
json!({"importKys": {"path": ""}}),
json!({"importKys": 5}),
json!({"exportKys": "yes"}),
json!({"exportKys": {"path": 3}}),
json!({"summarize": {"path": ""}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
}
// A change paired with an export that fails (a folder that doesn't exist, a bad summarize
// value) changes nothing.
let before = ks(&mut s, json!({}))["overrides"].clone();
for p in [
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}),
json!({"set": {"edit.copy": "F10"}, "summarize": 7}),
json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
// A good .kys path with a bad summarize: neither file is written.
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
let r = ks(&mut s, json!({}));
assert_eq!(r["overrides"], before, "{p}");
assert!(r["sets"].get("Other").is_none(), "{p}: {r}");
assert!(!dir.join("both.kys").exists(), "{p}: no file written");
}
// A second file that passes the checks but can't be written (a NUL in its name): the user's
// existing first file is left exactly as it was, and no staged file stays behind.
let mine = dir.join("existing.kys");
std::fs::write(&mine, "the user's own file").unwrap();
let html = format!("{}/a\u{0}.html", dir.to_str().unwrap());
let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}});
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file");
let left: Vec<_> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect();
assert!(left.is_empty(), "{left:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_read_only_target_is_refused_before_anything_is_written() {
let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ro = dir.join("locked.kys");
std::fs::write(&ro, "locked").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&ro, perms.clone()).unwrap();
let mut s = Session::new();
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}");
assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked");
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
let _ = std::fs::set_permissions(&ro, perms);
let _ = std::fs::remove_dir_all(&dir);
}
+1 -2
View File
@@ -35,8 +35,7 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan {
/// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool
/// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have.
pub fn plan_tools(set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect();
photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set)
photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set)
}
/// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as
+3 -85
View File
@@ -20,91 +20,9 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"];
/// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut.
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.symmetryTransform", "Transform Symmetry", &[], None),
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
("tool.reset", "Reset Tool", &[], None),
("tool.resetAll", "Reset All Tools", &[], None),
("window.panel.group", "Change Panel Group", &[], None),
("window.panel.layout", "Change Panel Layout", &[], None),
("window.panel.move", "Move Panel", &[], None),
("window.panel.float", "Float Panel", &[], None),
("window.panel.dock", "Dock Panel", &[], None),
("window.panel.activate", "Activate Panel", &[], None),
("window.panel.close", "Close Panel", &[], None),
("file.open", "Open…", &["File"], Some("Cmd+O")),
("file.save", "Save", &["File"], Some("Cmd+S")),
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
("view.extras", "Extras", &["View"], Some("Cmd+H")),
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
("type.editText", "Edit Type", &[], None),
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
("view.rotateView", "Rotate View", &[], None),
("view.resetView", "Reset View", &["View"], None),
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
("window.toggle.history", "History", &["Window"], None),
("window.toggle.properties", "Properties", &["Window"], None),
("window.toggle.color", "Color", &["Window"], Some("F6")),
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
("window.toggle.brushes", "Brushes", &["Window"], None),
("window.toggle.navigator", "Navigator", &["Window"], None),
("window.toggle.toolbar", "Tools", &["Window"], None),
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
("window.toggle.options", "Options", &["Window"], None),
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
("help.website", "PhotoCraft Website", &["Help"], None),
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
("help.github", "PhotoCraft on GitHub", &["Help"], None),
("help.reportIssue", "Report an Issue…", &["Help"], None),
("help.systemInfo", "System Info…", &["Help"], None),
("help.about", "About PhotoCraft", &["Help"], None),
];
/// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so the
/// commands and their keys exist without this UI (headless shortcut sets, lists, conflicts).
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = photocraft_engine::shell_commands::SHELL_COMMANDS;
/// Window › <document>: the open documents, listed at the end of the Window menu as in Photoshop.
/// The id ends in the document's index, which is its tab's position.
+1 -27
View File
@@ -816,33 +816,7 @@ fn conflict_state(overrides: &BTreeMap<String, String>, alternates: &BTreeMap<St
/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our
/// notation, where `Cmd` is Ctrl off the Mac.
pub fn reserved_shortcut(sc: &str) -> bool {
let Some(n) = prefs::normalize_shortcut(sc) else { return false };
let reserved: &[&str] = if cfg!(target_os = "macos") {
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
} else if cfg!(target_os = "windows") {
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
} else {
// Linux and the BSDs: window switching and closing, and the virtual consoles.
&[
"Alt+Tab",
"Alt+Shift+Tab",
"Alt+F4",
"Cmd+Alt+Delete",
"Cmd+Alt+F1",
"Cmd+Alt+F2",
"Cmd+Alt+F3",
"Cmd+Alt+F4",
"Cmd+Alt+F5",
"Cmd+Alt+F6",
"Cmd+Alt+F7",
"Cmd+Alt+F8",
"Cmd+Alt+F9",
"Cmd+Alt+F10",
"Cmd+Alt+F11",
"Cmd+Alt+F12",
]
};
reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n)
prefs::reserved_shortcut(sc)
}
/// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers.
+14
View File
@@ -94,3 +94,17 @@ mod tests {
assert_eq!(h.state().ui.status, "q");
}
}
#[cfg(test)]
mod engine_table_tests {
use super::*;
/// The engine knows the tools as data (headless shortcut sets); it must match the toolbar.
#[test]
fn the_engines_tool_table_matches_the_tools() {
let ours: Vec<(String, &str, Option<String>)> = Tool::ALL.into_iter().map(|t| (id(t), t.label(), default(t))).collect();
let engine: Vec<(String, &str, Option<String>)> =
photocraft_engine::shell_commands::TOOLS.iter().map(|(i, n, k)| (i.to_string(), *n, k.map(str::to_string))).collect();
assert_eq!(ours, engine, "update photocraft_engine::shell_commands::TOOLS");
}
}
+1 -1
View File
@@ -227,7 +227,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca
| `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key |
| `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.<id>` or `menus.colors.<id>` reset removes the override and returns `null`, including when it is already absent |
| `edit.preferences.<section>` | the section's values; in the app (no params) it opens the Preferences dialog on that section |
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by name in the app's dialog import (a tool's own letter keeps its Shift cycling); this command doesn't know the tools and leaves them. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"<name>": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of the engine's commands as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. The app's dialog exports and summarizes the shell's commands and tools as well. A bad parameter anywhere in the call changes nothing |
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by `pc-id` or name (a tool's own letter keeps its Shift cycling); `import.tools` lists the keys given. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"<name>": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of every bindable command and tool as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. A bad parameter anywhere in the call, an export path that can't be written included, changes nothing. **Headless parity:** the shell's commands (View › Zoom In, the panels; `photocraft_engine::shell_commands::SHELL_COMMANDS`) and the tools (`tools.select.<tool>`, listed with `"tool": true`) are bindable, listed, imported, exported and checked for conflicts without the UI; tools may share a key (they cycle with Shift, so a shared letter is no conflict), while a command given a tool's key takes it from the tools. Keys the system takes first (Alt+Tab, Alt+F4, Ctrl+Alt+Delete everywhere but the Mac, plus Ctrl+Esc and Ctrl+Shift+Esc on Windows and Ctrl+Alt+F1–F12 on Linux; ⌘Tab, ⌘`, ⌘⌥Esc on the Mac) are refused, and left out of an imported set (`import.reserved`). Files: `importKys: {"path": …}` reads a `.kys`, `exportKys: {"path": …}` and `summarize: {"path": …}` write the file (the reply has `kysPath` / `htmlPath`); automation (MCP) refuses these paths, as it does `actions.save`'s |
| `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` |
| `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt |