mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 18:22:06 +01:00
* 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.
* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools
* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one
* docs: reserved keys per platform
* 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
* 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
* 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
360 lines
16 KiB
Rust
360 lines
16 KiB
Rust
//! Edit › Keyboard Shortcuts › Set: named shortcut sets, as in Photoshop. The bindings in use
|
||
//! stay in [`Preferences::shortcuts`] / [`Preferences::alternate_shortcuts`] (what dispatch
|
||
//! reads); a set is a saved copy of them, `Preferences::shortcut_sets` keyed by name, and
|
||
//! `Preferences::shortcut_set` names the one in use ("" for the defaults). The bindings differ
|
||
//! from their set after an edit until it is saved ("modified", as Photoshop marks the set).
|
||
//! A set saves to and loads from a `.kys` file (`crate::kys::write` / the import), and
|
||
//! [`summarize`] writes the HTML table of Photoshop's Summarize button.
|
||
//!
|
||
//! `edit.keyboardShortcuts` takes the set parameters: `useSet` (load a set's bindings, before
|
||
//! the call's other changes), then `saveSet` (save the bindings as they end up under a name and
|
||
//! use it), `deleteSet`, `sets` (replace every saved set: the dialog's OK) and `activeSet`.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::prefs::{Preferences, alternate_keys, normalize_shortcut};
|
||
|
||
/// The built-in set's name: the default bindings.
|
||
pub const DEFAULTS_NAME: &str = "PhotoCraft Defaults";
|
||
/// Saved sets are small; the cap keeps a hostile settings file or call bounded.
|
||
pub const MAX_SETS: usize = 64;
|
||
/// Longest set name, in characters.
|
||
pub const MAX_NAME: usize = 64;
|
||
|
||
/// A saved set: the overrides of the default bindings, in the shape the preferences keep them.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct ShortcutSet {
|
||
/// Command id → main key; "" removes the default key.
|
||
pub shortcuts: BTreeMap<String, String>,
|
||
/// Command id → further keys, replacing the defaults.
|
||
pub alternate_shortcuts: BTreeMap<String, Vec<String>>,
|
||
}
|
||
|
||
impl ShortcutSet {
|
||
/// The bindings `p` uses now.
|
||
pub fn of(p: &Preferences) -> Self {
|
||
Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() }
|
||
}
|
||
|
||
/// Use this set's bindings in `p`.
|
||
pub fn load_into(&self, p: &mut Preferences) {
|
||
p.shortcuts = self.shortcuts.clone();
|
||
p.alternate_shortcuts = self.alternate_shortcuts.clone();
|
||
}
|
||
|
||
/// Check every key (a set from a call or a hand-edited file must not hold garbage).
|
||
fn validate(&self) -> Result<(), String> {
|
||
for (id, k) in &self.shortcuts {
|
||
if !k.is_empty() && normalize_shortcut(k).is_none() {
|
||
return Err(format!("`{k}` for `{id}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"));
|
||
}
|
||
}
|
||
for (id, keys) in &self.alternate_shortcuts {
|
||
alternate_keys(&json!(keys)).map_err(|e| format!("`{id}`: {e}"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// A set name as given, trimmed; an error for an empty, overlong or reserved name.
|
||
pub fn set_name(name: &str) -> Result<String, String> {
|
||
let n = name.trim();
|
||
if n.is_empty() {
|
||
return Err("a set needs a name".into());
|
||
}
|
||
if n.chars().count() > MAX_NAME {
|
||
return Err(format!("a set name is at most {MAX_NAME} characters"));
|
||
}
|
||
if n.chars().any(char::is_control) {
|
||
return Err("a set name can't contain control characters".into());
|
||
}
|
||
if n.eq_ignore_ascii_case(DEFAULTS_NAME) {
|
||
return Err(format!("`{DEFAULTS_NAME}` is the built-in set"));
|
||
}
|
||
Ok(n.to_string())
|
||
}
|
||
|
||
/// The saved copy of the set in use: `None` for the defaults (or a set that no longer exists).
|
||
fn saved(p: &Preferences) -> Option<&ShortcutSet> {
|
||
p.shortcut_sets.get(&p.shortcut_set)
|
||
}
|
||
|
||
/// Do the bindings differ from the set in use?
|
||
pub fn modified(p: &Preferences) -> bool {
|
||
let now = ShortcutSet::of(p);
|
||
match saved(p) {
|
||
Some(s) => *s != now,
|
||
None => now != ShortcutSet::default(),
|
||
}
|
||
}
|
||
|
||
/// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the
|
||
/// defaults) and makes it the one in use. Returns whether it changed anything.
|
||
pub fn load(next: &mut Preferences, p: &Value) -> Result<bool, String> {
|
||
let Some(v) = p.get("useSet") else { return Ok(false) };
|
||
let name = v.as_str().ok_or("`useSet` is a set name")?.trim();
|
||
if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) {
|
||
ShortcutSet::default().load_into(next);
|
||
next.shortcut_set = String::new();
|
||
} else {
|
||
let set = next.shortcut_sets.get(name).cloned().ok_or_else(|| format!("no shortcut set named `{name}`"))?;
|
||
set.load_into(next);
|
||
next.shortcut_set = name.to_string();
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// After the call's binding changes: `sets` (replace every saved set), `deleteSet`, `saveSet`
|
||
/// (save the bindings under a name and use it) and `activeSet`. Returns whether it changed
|
||
/// anything.
|
||
pub fn store(next: &mut Preferences, p: &Value) -> Result<bool, String> {
|
||
let mut changed = false;
|
||
if let Some(v) = p.get("sets") {
|
||
let obj = v.as_object().ok_or("`sets` maps set names to {shortcuts, alternateShortcuts}")?;
|
||
if obj.len() > MAX_SETS {
|
||
return Err(format!("at most {MAX_SETS} shortcut sets"));
|
||
}
|
||
let mut sets = BTreeMap::new();
|
||
for (name, s) in obj {
|
||
let name = set_name(name)?;
|
||
let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?;
|
||
set.validate().map_err(|e| format!("set `{name}`: {e}"))?;
|
||
sets.insert(name, set);
|
||
}
|
||
next.shortcut_sets = sets;
|
||
changed = true;
|
||
}
|
||
if let Some(v) = p.get("deleteSet") {
|
||
let name = v.as_str().ok_or("`deleteSet` is a set name")?.trim();
|
||
if next.shortcut_sets.remove(name).is_none() {
|
||
return Err(format!("no shortcut set named `{name}`"));
|
||
}
|
||
if next.shortcut_set == name {
|
||
next.shortcut_set = String::new();
|
||
}
|
||
changed = true;
|
||
}
|
||
if let Some(v) = p.get("saveSet") {
|
||
let name = set_name(v.as_str().ok_or("`saveSet` is a set name")?)?;
|
||
if !next.shortcut_sets.contains_key(&name) && next.shortcut_sets.len() >= MAX_SETS {
|
||
return Err(format!("at most {MAX_SETS} shortcut sets"));
|
||
}
|
||
next.shortcut_sets.insert(name.clone(), ShortcutSet::of(next));
|
||
next.shortcut_set = name;
|
||
changed = true;
|
||
}
|
||
if let Some(v) = p.get("activeSet") {
|
||
let name = v.as_str().ok_or("`activeSet` is a set name")?.trim();
|
||
if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) {
|
||
next.shortcut_set = String::new();
|
||
} else if next.shortcut_sets.contains_key(name) {
|
||
next.shortcut_set = name.to_string();
|
||
} else {
|
||
return Err(format!("no shortcut set named `{name}`"));
|
||
}
|
||
changed = true;
|
||
}
|
||
Ok(changed)
|
||
}
|
||
|
||
/// The sets, the one in use and whether the bindings differ from it, for the command's reply.
|
||
pub fn describe(p: &Preferences) -> Value {
|
||
json!({
|
||
"sets": p.shortcut_sets.keys().collect::<Vec<_>>(),
|
||
"activeSet": if saved(p).is_some() { p.shortcut_set.as_str() } else { "" },
|
||
"defaultsName": DEFAULTS_NAME,
|
||
"modified": modified(p),
|
||
})
|
||
}
|
||
|
||
/// A key as the Summarize table shows it on `mac` (⌘ spelled Cmd, ⌥ Option) or elsewhere
|
||
/// (Ctrl, Alt).
|
||
fn key_label(sc: &str, mac: bool) -> String {
|
||
let Some(n) = normalize_shortcut(sc) else { return sc.to_string() };
|
||
// `Cmd++` splits into an empty part before the key, which joins back as `++`.
|
||
let mut parts: Vec<&str> = Vec::new();
|
||
for part in n.split('+') {
|
||
let shown = match (part, mac) {
|
||
("Ctrl", true) => "Control",
|
||
("Cmd", false) | ("Ctrl", false) => "Ctrl",
|
||
("Alt", true) => "Option",
|
||
(p, _) => p,
|
||
};
|
||
// Off the Mac, ⌘ and Control are both Ctrl: say it once.
|
||
if shown == "Ctrl" && parts.contains(&"Ctrl") {
|
||
continue;
|
||
}
|
||
parts.push(shown);
|
||
}
|
||
parts.join("+")
|
||
}
|
||
|
||
fn html_escape(s: &str) -> String {
|
||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||
}
|
||
|
||
/// A Summarize row: a command's label and its keys, the main one first.
|
||
pub type Row = (String, Vec<String>);
|
||
/// A Summarize section: its title and rows.
|
||
pub type Section = (String, Vec<Row>);
|
||
|
||
/// Photoshop's Summarize: an HTML page listing every command with a key, by section
|
||
/// (`sections` in display order: section title, then (command label, keys with the main one
|
||
/// first)). Commands without a key are left out.
|
||
pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
|
||
let mut out = String::new();
|
||
out.push_str("<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><title>");
|
||
out.push_str(&html_escape(set));
|
||
out.push_str("</title>\n<style>body{font-family:sans-serif;margin:2em}table{border-collapse:collapse;margin-bottom:2em}th,td{border:1px solid #bbb;padding:4px 10px;text-align:left}th{background:#eee}</style>\n</head><body>\n<h1>");
|
||
out.push_str(&html_escape(set));
|
||
out.push_str("</h1>\n");
|
||
for (title, rows) in sections {
|
||
let rows: Vec<&Row> = rows.iter().filter(|(_, keys)| !keys.is_empty()).collect();
|
||
if rows.is_empty() {
|
||
continue;
|
||
}
|
||
out.push_str("<h2>");
|
||
out.push_str(&html_escape(title));
|
||
out.push_str("</h2>\n<table><tr><th>Command</th><th>Shortcut</th></tr>\n");
|
||
for (label, keys) in rows {
|
||
let keys: Vec<String> = keys.iter().map(|k| html_escape(&key_label(k, mac))).collect();
|
||
out.push_str("<tr><td>");
|
||
out.push_str(&html_escape(label));
|
||
out.push_str("</td><td>");
|
||
out.push_str(&keys.join(", "));
|
||
out.push_str("</td></tr>\n");
|
||
}
|
||
out.push_str("</table>\n");
|
||
}
|
||
out.push_str("</body></html>\n");
|
||
out
|
||
}
|
||
|
||
/// 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));
|
||
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()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn prefs() -> Preferences {
|
||
Preferences::default()
|
||
}
|
||
|
||
#[test]
|
||
fn save_use_modify_and_delete_a_set() {
|
||
let mut p = prefs();
|
||
assert!(!modified(&p));
|
||
p.shortcuts.insert("edit.copy".into(), "F9".into());
|
||
assert!(modified(&p), "an edit to the defaults");
|
||
assert!(store(&mut p, &json!({"saveSet": " Mine "})).unwrap());
|
||
assert_eq!((p.shortcut_set.as_str(), modified(&p)), ("Mine", false));
|
||
p.shortcuts.insert("edit.cut".into(), "F8".into());
|
||
assert!(modified(&p));
|
||
// Back to the defaults, then to the saved set as it was saved.
|
||
assert!(load(&mut p, &json!({"useSet": ""})).unwrap());
|
||
assert!(p.shortcuts.is_empty() && p.shortcut_set.is_empty() && !modified(&p));
|
||
load(&mut p, &json!({"useSet": "Mine"})).unwrap();
|
||
assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F9"));
|
||
assert_eq!(p.shortcuts.get("edit.cut"), None, "the unsaved edit isn't in the set");
|
||
assert_eq!(describe(&p)["sets"], json!(["Mine"]));
|
||
// Deleting the set in use falls back to the defaults' name; the bindings stay.
|
||
store(&mut p, &json!({"deleteSet": "Mine"})).unwrap();
|
||
assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty());
|
||
assert_eq!(describe(&p)["activeSet"], "");
|
||
assert!(modified(&p), "F9 differs from the defaults");
|
||
}
|
||
|
||
#[test]
|
||
fn bad_names_sets_and_params_are_errors() {
|
||
let mut p = prefs();
|
||
for name in ["", " ", "PhotoCraft defaults", "a\u{7}b", &"x".repeat(65)] {
|
||
assert!(store(&mut p, &json!({"saveSet": name})).is_err(), "{name:?}");
|
||
}
|
||
assert!(load(&mut p, &json!({"useSet": "Nope"})).is_err());
|
||
assert!(load(&mut p, &json!({"useSet": 3})).is_err());
|
||
assert!(store(&mut p, &json!({"deleteSet": "Nope"})).is_err());
|
||
assert!(store(&mut p, &json!({"activeSet": "Nope"})).is_err());
|
||
assert!(store(&mut p, &json!({"sets": []})).is_err());
|
||
assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}})).is_err(), "no key");
|
||
let nine: Vec<String> = (1..=9).map(|i| format!("F{i}")).collect();
|
||
assert!(store(&mut p, &json!({"sets": {"A": {"alternateShortcuts": {"edit.copy": nine}}}})).is_err());
|
||
assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": 5}}})).is_err());
|
||
let many: serde_json::Map<String, Value> = (0..=MAX_SETS).map(|i| (format!("S{i}"), json!({}))).collect();
|
||
assert!(store(&mut p, &json!({ "sets": many })).is_err());
|
||
for i in 0..MAX_SETS {
|
||
store(&mut p, &json!({"saveSet": format!("S{i}")})).unwrap();
|
||
}
|
||
assert!(store(&mut p, &json!({"saveSet": "One more"})).is_err());
|
||
assert!(store(&mut p, &json!({"saveSet": "S3"})).is_ok(), "saving over a set is fine at the cap");
|
||
}
|
||
|
||
#[test]
|
||
fn sets_replace_all_and_active_set_follows() {
|
||
let mut p = prefs();
|
||
store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "F9"}}, "B": {}}, "activeSet": "A"})).unwrap();
|
||
assert_eq!(p.shortcut_set, "A");
|
||
assert!(modified(&p), "activeSet names the set; it doesn't load it");
|
||
load(&mut p, &json!({"useSet": "A"})).unwrap();
|
||
assert!(!modified(&p));
|
||
store(&mut p, &json!({"activeSet": "PhotoCraft Defaults"})).unwrap();
|
||
assert_eq!(p.shortcut_set, "");
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_lists_bound_commands_by_section_for_each_platform() {
|
||
let sections = vec![
|
||
("Edit".to_string(), vec![("Copy".to_string(), vec!["Cmd+C".to_string(), "F3".to_string()]), ("Unbound".to_string(), vec![])]),
|
||
("Empty".to_string(), vec![("Nothing".to_string(), vec![])]),
|
||
("Filter <x>".to_string(), vec![("Gaussian Blur & more".to_string(), vec!["Ctrl+Alt+Cmd+G".to_string()])]),
|
||
];
|
||
let mac = summarize("My <Set>", §ions, true);
|
||
assert!(mac.contains("<title>My <Set></title>") && mac.contains("<td>Copy</td><td>Cmd+C, F3</td>"), "{mac}");
|
||
assert!(mac.contains("Cmd+Control+Option+G") && mac.contains("Gaussian Blur & more") && mac.contains("Filter <x>"), "{mac}");
|
||
assert!(!mac.contains("Unbound") && !mac.contains("Empty"), "{mac}");
|
||
let pc = summarize("S", §ions, false);
|
||
assert!(pc.contains("<td>Ctrl+C, F3</td>") && pc.contains("Ctrl+Alt+G"), "{pc}");
|
||
assert_eq!(key_label("Cmd++", true), "Cmd++");
|
||
}
|
||
|
||
#[test]
|
||
fn bound_lists_main_keys_then_alternates() {
|
||
let p = prefs();
|
||
let b = bound(&p);
|
||
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");
|
||
}
|
||
}
|