mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 07:04:10 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f35509c194 | ||
|
|
0c96a57bf7 | ||
|
|
0a8f32a280 | ||
|
|
95789b7c4d | ||
|
|
ed2cdbc5f9 | ||
|
|
84e4d0ad80 | ||
|
|
061ae5c92b |
No files matched your search
@@ -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
@@ -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()] }]);
|
||||
|
||||
@@ -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
@@ -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, §ions, mac));
|
||||
let html = crate::shortcut_sets::summarize(set_name, §ions, 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
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
|
||||
Reference in new issue
Block a user