From 234ae9ff4fd221c10d141c558dde7a3d5e76acae Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 00:48:08 +0200 Subject: [PATCH] Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665) Photoshop's default set gives some commands a second key besides the one its menu shows: F2/F3/F4 Cut/Copy/Paste beside Cmd+X/C/V, Shift+F7 Inverse beside Cmd+Shift+I, Cmd++ beside Cmd+= for Zoom In, Shift+Backspace for Fill. A command could hold only one key here, so these couldn't exist and a .kys import kept only a row's first key (a set listing Copy as Cmd+C and F3 could leave Cmd+C unbound). - prefs::ALTERNATE_SHORTCUTS holds Photoshop's second keys; Preferences.alternate_shortcuts (alternateShortcuts) replaces a command's defaults, [] removes them. Old settings files load unchanged. - edit.keyboardShortcuts takes `alternates`; reset, moving a key (taken from another command's main key or alternates) and the conflict list count every key, and a call's changes now apply atomically. - .kys import keeps a row's other keys as the command's alternates. - Dispatch matches every key (a user's main key wins over a default alternate); the UI-only SECONDARY table is folded in, so Fill's Shift+Backspace survives an override of its main key. Menus still show only the main key. - Keyboard Shortcuts dialog shows each command's alternates, removable one by one, with Add Shortcut to add one; Use Default / Reset All restore them. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/kys.rs | 34 ++- crates/engine/src/prefs.rs | 225 +++++++++++++++--- crates/engine/src/prefs/tests.rs | 131 ++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/kys_import.rs | 12 + crates/ui-egui/src/kys_import_tests.rs | 25 ++ crates/ui-egui/src/prefs_ui.rs | 187 +++++++++++++-- crates/ui-egui/src/shortcut_dispatch.rs | 45 ++-- crates/ui-egui/src/shortcut_dispatch/tests.rs | 41 ++++ crates/ui-egui/src/shortcuts.rs | 20 +- docs/scorecard.md | 4 +- 26 files changed, 663 insertions(+), 77 deletions(-) diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index e3343ed1..a274a9cc 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -9,10 +9,11 @@ //! Where one label sits in several menus (Levels… under Image › Adjustments and Layer › New //! Adjustment Layer; All under Select, Edit › Purge, View › Show), the command whose default is //! one of the imported keys wins, then the one that has a default at all, then the first -//! candidate (the app lists them in Photoshop's menu order). Ours are one per command, so a -//! row's first shortcut is the one kept, unless one of its keys is already the command's -//! default (Zoom In lists `Cmd++` before `Cmd+=`). Tool keys are not imported: the tool -//! letters are fixed here. +//! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the +//! command's main key, unless one of its keys is already the command's default (Zoom In lists +//! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is +//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys are not +//! imported: the tool letters are fixed here. use std::collections::{BTreeMap, HashMap}; @@ -81,8 +82,12 @@ pub struct Plan { pub unknown: Vec, /// Rows whose shortcut could not be read (`unknown` does not list them). pub unreadable: Vec, - /// Second and later shortcuts of a row, which have no place here. + /// Second and later shortcuts of the matched rows (counted; kept in `alternate_keys`). pub alternates: usize, + /// Command id → the matched row's other keys (normalised, without the main key in `set`), + /// for every matched row: a set lists each command's whole binding, so an empty list + /// means the command has no alternates in it. + pub alternate_keys: BTreeMap>, } /// One command a row may match: its id, its label and its default shortcut. @@ -141,6 +146,13 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl // (Zoom In is "Cmd++" then "Cmd+=" in Photoshop; ours is Cmd+=). let default = default.as_deref().and_then(normalize_shortcut); let sc = default.filter(|d| imported.contains(d)).unwrap_or(sc); + let mut rest: Vec = Vec::new(); + for k in imported { + if k != sc && !rest.contains(&k) && rest.len() < crate::prefs::MAX_ALTERNATES { + rest.push(k); + } + } + plan.alternate_keys.insert(id.clone(), rest); plan.set.insert(id.clone(), sc); } } @@ -179,6 +191,18 @@ mod tests { assert_eq!(p.unknown, ["Hide Photoshop"]); assert_eq!(p.unreadable, ["Odd"]); assert_eq!(p.alternates, 1); + // #3665: the row's second key is kept as the command's alternate, not dropped. + assert_eq!(p.alternate_keys.get("edit.undo"), Some(&vec!["F1".to_string()])); + assert_eq!(p.alternate_keys.get("image.levels"), Some(&Vec::new())); + } + + #[test] + fn a_row_with_the_default_and_a_function_key_keeps_both() { + let xml = + "F3Cmd+C"; + let p = plan([c("edit.copy", "Copy", Some("Cmd+C"))], &parse(xml).unwrap()); + assert_eq!(p.set.get("edit.copy").map(String::as_str), Some("Cmd+C")); + assert_eq!(p.alternate_keys.get("edit.copy"), Some(&vec!["F3".to_string()])); } #[test] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d30cbd0d..38bf86f3 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -936,6 +936,11 @@ pub struct Preferences { /// Edit › Keyboard Shortcuts: command id → shortcut (`Cmd+Shift+N` notation); an empty /// string removes the default shortcut. pub shortcuts: BTreeMap, + /// Edit › Keyboard Shortcuts: a command's further keys besides its main one (#3665), + /// command id → keys. A list replaces the command's default alternates + /// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this + /// map load with nothing stored, so every command keeps its defaults. + pub alternate_shortcuts: BTreeMap>, pub menus: MenuCustomization, pub toolbar: ToolbarCustomization, /// Edit › Check Spelling: words added to the dictionary ("Add"). @@ -1102,7 +1107,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { /// Is this preference a `#rrggbb` colour? pub fn is_color(path: &str) -> bool { - if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("menus.") { + if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("alternateShortcuts.") || path.starts_with("menus.") { return false; } let leaf = path.rsplit('.').next().unwrap_or(path); @@ -1131,9 +1136,10 @@ pub fn parse_hex(s: &str) -> Option<[u8; 3]> { // ------------------------------------------------------------------ JSON paths -/// Map keys that are command ids (which contain dots): `shortcuts.`, `menus.colors.`. +/// Map keys that are command ids (which contain dots): `shortcuts.`, +/// `alternateShortcuts.`, `menus.colors.`. fn keyed(path: &str) -> Option<(&str, &str)> { - for prefix in ["shortcuts.", "menus.colors."] { + for prefix in ["shortcuts.", "alternateShortcuts.", "menus.colors."] { if let Some(id) = path.strip_prefix(prefix) { return Some((&prefix[..prefix.len() - 1], id)); } @@ -1250,6 +1256,28 @@ impl Preferences { /// Set a value by dotted path, validating it. Unknown paths and wrong types are errors. pub fn set(&mut self, path: &str, value: Value) -> std::result::Result<(), String> { + if path == "alternateShortcuts" { + let obj = value.as_object().ok_or("`alternateShortcuts` maps command ids to lists of shortcuts")?; + let mut map = BTreeMap::new(); + for (id, v) in obj { + map.insert(id.clone(), alternate_keys(v).map_err(|e| format!("`alternateShortcuts.{id}`: {e}"))?); + } + self.alternate_shortcuts = map; + return Ok(()); + } + if let Some(id) = path.strip_prefix("alternateShortcuts.") { + // null = back to the command's default alternates. + match value { + Value::Null => { + self.alternate_shortcuts.remove(id); + } + v => { + let keys = alternate_keys(&v).map_err(|e| format!("`{path}`: {e}"))?; + self.alternate_shortcuts.insert(id.to_string(), keys); + } + } + return Ok(()); + } let value = match (path.strip_prefix("shortcuts."), &value) { // Normalise shortcut spelling so conflicts compare equal. (Some(_), Value::String(s)) if !s.is_empty() => Value::String(normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut"))?), @@ -1280,6 +1308,10 @@ impl Preferences { self.shortcuts.remove(id); return Ok(()); } + Some(("alternateShortcuts", id)) => { + self.alternate_shortcuts.remove(id); + return Ok(()); + } Some(("menus.colors", id)) => { self.menus.colors.remove(id); return Ok(()); @@ -1305,6 +1337,50 @@ impl Preferences { None => default, } } + + /// A command's further keys in effect (#3665): the user's list, else its defaults + /// ([`ALTERNATE_SHORTCUTS`]), normalised, without duplicates and without `primary` (the + /// command's main key in effect, which it already answers). + pub fn alternates(&self, id: &str, primary: Option<&str>) -> Vec { + let main = primary.and_then(normalize_shortcut); + let keys: Vec = match self.alternate_shortcuts.get(id) { + Some(list) => list.iter().filter_map(|s| normalize_shortcut(s)).collect(), + None => default_alternates(id).filter_map(normalize_shortcut).collect(), + }; + let mut out: Vec = Vec::with_capacity(keys.len()); + for k in keys { + if main.as_ref() != Some(&k) && !out.contains(&k) { + out.push(k); + } + } + out + } +} + +/// Most further keys one command may hold; Photoshop's own defaults use one. +pub const MAX_ALTERNATES: usize = 8; + +/// Validate and normalise a command's alternate keys: a list of shortcut strings (a single +/// string is a one-key list, `""` none). +pub fn alternate_keys(v: &Value) -> std::result::Result, String> { + let items: Vec<&Value> = match v { + Value::String(s) if s.is_empty() => Vec::new(), + Value::String(_) => vec![v], + Value::Array(a) => a.iter().collect(), + _ => return Err("alternate shortcuts are a list of shortcuts (e.g. [\"F3\"])".into()), + }; + if items.len() > MAX_ALTERNATES { + return Err(format!("at most {MAX_ALTERNATES} alternate shortcuts (got {})", items.len())); + } + let mut out: Vec = Vec::with_capacity(items.len()); + for item in items { + let s = item.as_str().ok_or_else(|| format!("alternate shortcut `{item}` must be a string"))?; + let n = normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"))?; + if !out.contains(&n) { + out.push(n); + } + } + Ok(out) } // ------------------------------------------------------------------ shortcuts @@ -1390,6 +1466,24 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ ("tools.temporary.zoomOut", "Zoom Out (hold)", "Alt+Space"), ]; +/// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key +/// (the one its menu shows) and answers these as well, as in Photoshop's default set: the +/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace +/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In. +pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ + ("edit.cut", "F2"), + ("edit.copy", "F3"), + ("edit.paste", "F4"), + ("edit.fill", "Shift+Backspace"), + ("select.inverse", "Shift+F7"), + ("view.zoomIn", "Cmd++"), +]; + +/// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]). +pub fn default_alternates(id: &str) -> impl Iterator + '_ { + ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc) +} + /// Every bindable id with its default: commands, then the temporary tools. fn bindable() -> impl Iterator)> { crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2)))) @@ -1531,7 +1625,7 @@ impl Session { return Ok(()); } let mut next = self.prefs().clone(); - if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "menus" | "toolbar") { + if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "alternateShortcuts" | "menus" | "toolbar") { // Whole section (or everything): merge the object key by key so each value is checked. let obj = value.as_object().ok_or_else(|| format!("`{path}` needs an object"))?; for (k, v) in obj { @@ -1540,7 +1634,12 @@ impl Session { self.set_pref("colorSettings", v.clone())?; continue; } - if v.is_object() && !p.starts_with("shortcuts") && !p.ends_with(".colors") && next.get(&p).is_some_and(|c| c.is_object()) { + if v.is_object() + && !p.starts_with("shortcuts") + && !p.starts_with("alternateShortcuts") + && !p.ends_with(".colors") + && next.get(&p).is_some_and(|c| c.is_object()) + { for (k2, v2) in v.as_object().into_iter().flatten() { next.set(&format!("{p}.{k2}"), v2.clone())?; } @@ -1699,20 +1798,29 @@ fn preferences_section(s: &mut Session, p: &Value) -> Result { /// Edit › Keyboard Shortcuts (engine part): list, change and reset application shortcuts. fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let cmd = "edit.keyboardShortcuts"; + // Every change lands at once, after all of it validated: a bad key leaves the set as it was. + let mut next = s.prefs().clone(); + let mut changed = false; if let Some(r) = p.get("reset") { match r { - Value::Bool(true) => s.edit_prefs(|p| p.shortcuts.clear()), - Value::Array(ids) => s.edit_prefs(|p| { + Value::Bool(true) => { + next.shortcuts.clear(); + next.alternate_shortcuts.clear(); + changed = true; + } + Value::Array(ids) => { for id in ids.iter().filter_map(Value::as_str) { - p.shortcuts.remove(id); + next.shortcuts.remove(id); + next.alternate_shortcuts.remove(id); } - }), + changed = true; + } _ => {} } } - // A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` entries, matched by label - // (`crate::kys`); a key equal to the command's default restores the default, and keys given - // in `set` as well win over the file's. + // A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` and `alternates` entries, + // matched by label (`crate::kys`); a key equal to the command's default restores the + // default, and keys given in `set` / `alternates` as well win over the file's. let imported; let mut import = None; let p = match p.get("importKys") { @@ -1726,6 +1834,13 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) }); } 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 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) }); + } + alternates.extend(p.get("alternates").and_then(Value::as_object).cloned().unwrap_or_default()); import = Some(json!({ "name": set.name, "imported": plan.set.len(), @@ -1733,41 +1848,73 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "unknown": plan.unknown, "unreadable": plan.unreadable, "alternates": plan.alternates, + "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, })); - let mut next = p.clone(); - next["set"] = Value::Object(merged); - imported = next; + let mut with_import = p.clone(); + with_import["set"] = Value::Object(merged); + with_import["alternates"] = Value::Object(alternates); + imported = with_import; &imported } None => p, }; - if let Some(m) = p.get("set").and_then(Value::as_object) { - let mut next = s.prefs().clone(); - for (id, v) in m { - if !bindable().any(|(b, _)| b == id) && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) { + let empty = serde_json::Map::new(); + let set = match p.get("set") { + Some(Value::Object(m)) => m, + _ => &empty, + }; + let alts = match p.get("alternates") { + None | Some(Value::Null) => &empty, + Some(v) => v.as_object().ok_or_else(|| bad(cmd, "`alternates` maps command ids to lists of shortcuts"))?, + }; + if !set.is_empty() || !alts.is_empty() { + let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); + changed = true; + for (id, v) in set { + if !bindable().any(|(b, _)| b == id) && !allow_unknown { return Err(bad(cmd, format!("unknown command `{id}`"))); } next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; } - // Like Photoshop, a shortcut moved to a new command is taken away from the old one. + for (id, v) in alts { + if !bindable().any(|(b, _)| b == id) && default_alternates(id).next().is_none() && !allow_unknown { + return Err(bad(cmd, format!("unknown command `{id}`"))); + } + next.set(&format!("alternateShortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; + } + // Like Photoshop, a shortcut moved to a new command is taken away from the old one, + // whether the old one held it as its main key or as an alternate (#3665). if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) { - for (id, v) in m { - let Some(sc) = v.as_str().and_then(normalize_shortcut) else { continue }; - for (c, def) in bindable() { + let assigned: Vec<(&str, String)> = set + .iter() + .filter_map(|(id, v)| Some((id.as_str(), v.as_str().and_then(normalize_shortcut)?))) + .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) { // 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 // each other here unbound both silently. - if c == id || m.contains_key(c) { + if c == id || set.contains_key(c) || alts.contains_key(c) { continue; } - if next.shortcut(c, def).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { + let main = next.shortcut(c, def).map(str::to_string); + if main.as_deref().and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { next.shortcuts.insert(c.to_string(), String::new()); } + let held = next.alternates(c, main.as_deref()); + if held.contains(&sc) { + next.alternate_shortcuts.insert(c.to_string(), held.into_iter().filter(|k| *k != sc).collect()); + } } } } + } + if changed { s.edit_prefs(|p| *p = next); } let prefs = s.prefs(); @@ -1778,18 +1925,34 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { .map(|c| (c.id, c.label, c.menu, c.shortcut, false)) .chain(temporary) .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) || filter.is_some() || prefs.shortcuts.contains_key(c.0)) + .filter(|c| { + p.get("list").and_then(Value::as_bool).unwrap_or(false) + || filter.is_some() + || prefs.shortcuts.contains_key(c.0) + || prefs.alternate_shortcuts.contains_key(c.0) + }) .map(|(id, label, menu, def, hold)| { - let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": prefs.shortcut(id, def)}); + 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); } v }) .collect(); - let bindings: Vec<(&str, &str)> = bindable().filter_map(|(id, def)| Some((id, prefs.shortcut(id, def)?))).collect(); - let conflicts: Vec = conflicts(bindings).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); - let mut out = json!({"overrides": prefs.shortcuts, "commands": list, "conflicts": conflicts}); + let mut bindings: Vec<(&str, String)> = Vec::new(); + for (id, def) in bindable() { + let main = prefs.shortcut(id, def); + bindings.extend(main.map(|m| (id, m.to_string()))); + bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k))); + } + let conflicts: Vec = + conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().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(import) = import { out["import"] = import; } @@ -1918,7 +2081,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index a38a1c4f..a3e235ba 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -607,3 +607,134 @@ fn ruler_units_round_to_their_own_decimal_places() { let r = super::UnitsAndRulers { rulers: Unit::Inches, ..Default::default() }; assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333"); } + +/// #3665: Photoshop's second default keys (F2/F3/F4, Shift+F7, Cmd++) are a command's +/// alternates, beside the main key its menu shows. +#[test] +fn commands_have_photoshops_alternate_keys() { + let p = Preferences::default(); + let main = |id: &str| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!(main("edit.cut"), Some("Cmd+X")); + assert_eq!(p.alternates("edit.cut", main("edit.cut")), ["F2"]); + assert_eq!(p.alternates("edit.copy", main("edit.copy")), ["F3"]); + assert_eq!(p.alternates("edit.paste", main("edit.paste")), ["F4"]); + assert_eq!(main("select.inverse"), Some("Cmd+Shift+I")); + assert_eq!(p.alternates("select.inverse", main("select.inverse")), ["Shift+F7"]); + assert_eq!(p.alternates("edit.fill", main("edit.fill")), ["Shift+Backspace"]); + assert_eq!(p.alternates("view.zoomIn", Some("Cmd+=")), ["Cmd++"]); + assert!(p.alternates("edit.undo", main("edit.undo")).is_empty()); + // No alternate is another command's main key or another command's alternate. + let mut seen = std::collections::HashMap::new(); + for c in crate::command_specs() { + if let Some(sc) = c.shortcut.and_then(normalize_shortcut) { + seen.insert(sc, c.id); + } + } + for (id, sc) in ALTERNATE_SHORTCUTS { + let n = normalize_shortcut(sc).unwrap(); + assert!(seen.insert(n, id).is_none(), "{sc} of {id} is already bound"); + } + // A command's own main key is never repeated as its alternate. + assert!(p.alternates("edit.copy", Some("F3")).is_empty()); +} + +/// Settings files written before alternates existed still load, keeping their overrides and +/// gaining the default alternates. +#[test] +fn old_settings_without_alternates_load() { + let old: Preferences = serde_json::from_str(r#"{"shortcuts": {"edit.copy": "Cmd+K", "edit.undo": ""}}"#).unwrap(); + assert_eq!(old.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K")); + assert!(old.alternate_shortcuts.is_empty()); + // Overriding the main key keeps the alternate (Photoshop edits each row on its own). + assert_eq!(old.alternates("edit.copy", old.shortcut("edit.copy", Some("Cmd+C"))), ["F3"]); + // And a file with alternates round-trips. + let mut p = old.clone(); + p.set("alternateShortcuts.edit.copy", json!(["f9", "F9"])).unwrap(); + let back: Preferences = serde_json::from_value(p.to_json()).unwrap(); + assert_eq!(back.alternate_shortcuts.get("edit.copy"), Some(&vec!["F9".to_string()])); +} + +#[test] +fn alternate_shortcuts_set_reset_and_reject_bad_input() { + let mut s = session(); + // Through the command: a list replaces the defaults, [] removes them, null restores them. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9"]}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F9"]); + let r = s.execute("edit.keyboardShortcuts", json!({"filter": "edit.copy"})).unwrap(); + let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "edit.copy").unwrap().clone(); + assert_eq!(row["alternates"], json!(["F9"])); + assert_eq!(row["defaultAlternates"], json!(["F3"])); + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": []}})).unwrap(); + assert!(s.prefs().alternates("edit.copy", Some("Cmd+C")).is_empty()); + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": null}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F3"]); + // Through prefs.set / prefs.reset by path. + s.execute("prefs.set", json!({"path": "alternateShortcuts.edit.cut", "value": "F10"})).unwrap(); + assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F10"]); + s.execute("prefs.reset", json!({"path": "alternateShortcuts.edit.cut"})).unwrap(); + assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F2"]); + // Reset all clears alternates too. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.paste": []}})).unwrap(); + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + assert!(s.prefs().alternate_shortcuts.is_empty()); + // Bad input is an error, not a panic, and changes nothing. + for bad in [ + json!({"alternates": {"edit.copy": "Cmd+Shift"}}), + json!({"alternates": {"edit.copy": [3]}}), + json!({"alternates": {"edit.copy": {"a": 1}}}), + json!({"alternates": {"edit.copy": vec!["F1"; MAX_ALTERNATES + 1]}}), + json!({"alternates": {"no.such.command": ["F9"]}}), + json!({"alternates": "F3"}), + ] { + assert!(s.execute("edit.keyboardShortcuts", bad.clone()).is_err(), "{bad}"); + } + assert!(s.prefs().alternate_shortcuts.is_empty()); + // A bad key in the same call as a reset leaves every shortcut as it was. + s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "Cmd+K"}})).unwrap(); + assert!(s.execute("edit.keyboardShortcuts", json!({"reset": true, "alternates": {"edit.copy": ["Cmd+Shift"]}})).is_err()); + assert_eq!(s.prefs().shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K")); + assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": {"edit.copy": ["Cmd+Shift"]}})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": 7})).is_err()); +} + +/// A key given to another command is taken from the command that held it as an alternate +/// (Photoshop moves a shortcut rather than duplicating it), and kept duplicates are reported. +#[test] +fn moving_an_alternate_key_takes_it_from_its_holder() { + let mut s = session(); + // F2 is Cut's alternate; Rename Layer may take it (#2405). + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}, "removeConflicts": false})).unwrap(); + assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["shortcut"] == "F2"), "{r}"); + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}})).unwrap(); + assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}"); + assert!(s.prefs().alternates("edit.cut", Some("Cmd+X")).is_empty()); + assert_eq!(s.prefs().shortcut("edit.cut", Some("Cmd+X")), Some("Cmd+X"), "Cut keeps its main key"); + // An alternate assigned to another command takes a main key too. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["Cmd+Z"]}})).unwrap(); + assert_eq!(s.prefs().shortcut("edit.undo", Some("Cmd+Z")), None); + // Overriding the main key keeps the alternates. + s.execute("edit.keyboardShortcuts", json!({"reset": true, "set": {"edit.copy": "Cmd+K"}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", s.prefs().shortcut("edit.copy", Some("Cmd+C"))), ["F3"]); +} + +/// #3665: a `.kys` row listing a command's default and a function key keeps both; the old +/// import kept only the first key. +#[test] +fn kys_import_keeps_a_rows_second_key() { + let mut s = session(); + let xml = "\ + Cmd+CF3\ + Cmd+X\ + Cmd+ZF1\ + "; + let r = s.execute("edit.keyboardShortcuts", json!({"importKys": xml})).unwrap(); + assert_eq!(r["import"]["alternates"], 2); + let p = s.prefs(); + assert_eq!(p.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+C")); + assert_eq!(p.alternates("edit.copy", Some("Cmd+C")), ["F3"]); + assert!(!p.alternate_shortcuts.contains_key("edit.copy"), "the default alternate needs no override"); + // A row without Cut's F2 removes it; Undo gains F1. + assert!(p.alternates("edit.cut", Some("Cmd+X")).is_empty()); + assert_eq!(p.alternates("edit.undo", Some("Cmd+Z")), ["F1"]); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c66ffad5..488201a7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1009,6 +1009,7 @@ Delete Group Odstranit skupinu Delete Layer Mask Odstranit masku vrstvy Delete Layers Odstranit vrstvy + Add Shortcut Přidat zkratku Delete Shortcut Odstranit zkratku Delete Timeline Odstranit časovou osu Delete Tool Preset Odstranit přednastavení nástroje diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f7818e1c..7c2ab421 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -989,6 +989,7 @@ Delete Group Gruppe löschen Delete Layer Mask Ebenenmaske löschen Delete Layers Ebenen löschen + Add Shortcut Kurzbefehl hinzufügen Delete Shortcut Kurzbefehl löschen Delete Timeline Zeitleiste löschen Delete Tool Preset Werkzeugvorgabe löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0f2f3fbc..128cca51 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -739,6 +739,7 @@ Default restored. Έγινε επαναφορά των προεπιλογών. Define at least one variable first. Ορίστε πρώτα τουλάχιστον μία μεταβλητή. Delete Group Διαγραφή ομάδας + Add Shortcut Προσθήκη συντόμευσης Delete Shortcut Διαγραφή συντόμευσης Delete Timeline Διαγραφή χρονογραμμής Delete count group Διαγραφή ομάδας καταμέτρησης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 054ccaf1..cc934898 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -995,6 +995,7 @@ Delete Group Eliminar grupo Delete Layer Mask Eliminar máscara de capa Delete Layers Eliminar capas + Add Shortcut Añadir método abreviado Delete Shortcut Eliminar método abreviado Delete Timeline Eliminar línea de tiempo Delete Tool Preset Eliminar ajuste preestablecido de herramienta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 599a4195..cd0ea280 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -983,6 +983,7 @@ Delete Group Supprimer le groupe Delete Layer Mask Supprimer le masque de calque Delete Layers Supprimer les calques + Add Shortcut Ajouter un raccourci Delete Shortcut Supprimer le raccourci Delete Timeline Supprimer le montage Delete Tool Preset Supprimer le préréglage d'outil diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a791e224..ce7f19f4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -977,6 +977,7 @@ Delete Group Hapus Grup Delete Layer Mask Hapus Mask Layer Delete Layers Hapus Layer + Add Shortcut Tambah Pintasan Delete Shortcut Hapus Pintasan Delete Timeline Hapus Timeline Delete Tool Preset Hapus Preset Alat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 188468b8..8173814b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -996,6 +996,7 @@ Delete Group Elimina gruppo Delete Layer Mask Elimina maschera di livello Delete Layers Elimina livelli + Add Shortcut Aggiungi scorciatoia Delete Shortcut Elimina scorciatoia Delete Timeline Elimina timeline Delete Tool Preset Elimina predefinito strumento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 10b30591..b1d88831 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -996,6 +996,7 @@ Delete Group グループを削除 Delete Layer Mask レイヤーマスクを削除 Delete Layers レイヤーを削除 + Add Shortcut ショートカットを追加 Delete Shortcut ショートカットを削除 Delete Timeline タイムラインを削除 Delete Tool Preset ツールプリセットを削除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index da54b74e..fbd6b509 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -971,6 +971,7 @@ Delete Group 그룹 삭제 Delete Layer Mask 레이어 마스크 삭제 Delete Layers 레이어 삭제 + Add Shortcut 단축키 추가 Delete Shortcut 단축키 삭제 Delete Timeline 타임라인 삭제 Delete Tool Preset 도구 사전 설정 삭제 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ee6c2516..a98570a5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -978,6 +978,7 @@ Delete Group Groep verwijderen Delete Layer Mask Laagmasker verwijderen Delete Layers Lagen verwijderen + Add Shortcut Sneltoets toevoegen Delete Shortcut Sneltoets verwijderen Delete Timeline Tijdlijn verwijderen Delete Tool Preset Gereedschapsvoorinstelling verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dbe2d1cf..90b117c3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -996,6 +996,7 @@ Delete Group Usuń grupę Delete Layer Mask Usuń maskę warstwy Delete Layers Usuń warstwy + Add Shortcut Dodaj skrót Delete Shortcut Usuń skrót Delete Timeline Usuń oś czasu Delete Tool Preset Usuń ustawienie wstępne narzędzia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0c331dbd..c2482b6c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -980,6 +980,7 @@ Delete Group Excluir grupo Delete Layer Mask Excluir máscara de camada Delete Layers Excluir camadas + Add Shortcut Adicionar atalho Delete Shortcut Excluir atalho Delete Timeline Excluir linha do tempo Delete Tool Preset Excluir predefinição de ferramenta diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 868682fe..f9d94f4b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1661,6 +1661,7 @@ These settings aren't available in PhotoCraft yet. Эти параметры пока недоступны в PhotoCraft. Shortcut removed. Комбинация клавиш удалена. All shortcuts reset to PhotoCraft defaults. Все комбинации клавиш сброшены к стандартным PhotoCraft. + Add Shortcut Добавить комбинацию Delete Shortcut Удалить комбинацию Press keys… Нажмите клавиши… shortcut комбинация diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6637561b..b8b7d60a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1642,6 +1642,7 @@ These settings aren't available in PhotoCraft yet. Ці параметри ще недоступні у PhotoCraft. Shortcut removed. Клавіатурне скорочення вилучено. All shortcuts reset to PhotoCraft defaults. Усі клавіатурні скорочення скинуто до типових значень PhotoCraft. + Add Shortcut Додати клавіатурне скорочення Delete Shortcut Видалити клавіатурне скорочення Press keys… Натисніть клавіші… shortcut клавіатурне скорочення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 94ec1a7e..3c87ebee 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -997,6 +997,7 @@ Delete Group 删除组 Delete Layer Mask 删除图层蒙版 Delete Layers 删除图层 + Add Shortcut 添加快捷键 Delete Shortcut 删除快捷键 Delete Timeline 删除时间轴 Delete Tool Preset 删除工具预设 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 13b4fe87..53c24528 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1000,6 +1000,7 @@ Delete Group 刪除群組 Delete Layer Mask 刪除圖層遮色片 Delete Layers 刪除圖層 + Add Shortcut 新增快速鍵 Delete Shortcut 刪除快速鍵 Delete Timeline 刪除時間軸 Delete Tool Preset 刪除工具預設集 diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index 41edaa17..c0344ee8 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -52,6 +52,16 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu overrides.insert(cmd.clone(), sc.clone()); } } + // A row's other keys are the command's further keys (#3665): Copy's F3 beside ⌘C. + let mut alternates: BTreeMap> = f.get("alternates").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (cmd, keys) in &plan.alternate_keys { + let main = plan.set.get(cmd).map(String::as_str); + if photocraft_engine::prefs::Preferences::default().alternates(cmd, main) == *keys { + alternates.remove(cmd); + } else { + alternates.insert(cmd.clone(), keys.clone()); + } + } let shown = std::path::Path::new(file_name).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file_name.to_string()); let n = plan.set.len().to_string(); let mut message = crate::i18n::fmt(tl!("Imported {n} shortcuts from {file}. Click OK to keep them."), &[("n", &n), ("file", &shown)]); @@ -62,6 +72,7 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu } f.insert("tab".into(), json!(0)); f.insert("overrides".into(), json!(overrides)); + f.insert("alternates".into(), json!(alternates)); f.insert("selected".into(), json!("")); f.insert("capture".into(), json!(false)); f.insert("message".into(), json!(message)); @@ -74,6 +85,7 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu "unknown": plan.unknown, "unreadable": plan.unreadable, "alternates": plan.alternates, + "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, })) } diff --git a/crates/ui-egui/src/kys_import_tests.rs b/crates/ui-egui/src/kys_import_tests.rs index cf389e76..08523da7 100644 --- a/crates/ui-egui/src/kys_import_tests.rs +++ b/crates/ui-egui/src/kys_import_tests.rs @@ -180,6 +180,31 @@ fn an_opened_kys_file_opens_the_dialog_filled() { assert!(bad.contains("not a keyboard shortcut set (.kys)"), "{bad}"); } +/// #3665: a row listing Copy's ⌘C and F3 keeps both; the old import kept the first key only, +/// and a row of F3 alone left ⌘C unbound only because the set says so. +#[test] +fn an_imported_row_keeps_its_second_key() { + let mut app = app(); + let set = "\ + F3Cmd+C\ + Cmd+X\ + Cmd+ZF1\ + "; + let r = super::import_text(&mut app, "ps.kys", set).unwrap(); + assert_eq!(r["alternateKeys"]["edit.copy"], json!(["F3"])); + let id = r["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f["overrides"].get("edit.copy").is_none(), "Copy keeps ⌘C: {}", f["overrides"]); + assert!(f["alternates"].get("edit.copy").is_none(), "F3 is Copy's default alternate"); + assert_eq!(f["alternates"]["edit.cut"], json!([]), "the set gives Cut no F2"); + assert_eq!(f["alternates"]["edit.undo"], json!(["F1"])); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(&app, "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C")); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert!(crate::shortcuts::effective_alternates(&app, "edit.cut").is_empty()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.undo"), ["F1"]); +} + type Store = std::sync::Arc>>; /// An app beside a Photoshop whose live set is `set`; `saved_prefs` is this app's own saved diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 4cf7e0ce..cbde9dfc 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -957,6 +957,7 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { "tab": tab, "filter": "", "overrides": p.shortcuts, + "alternates": p.alternate_shortcuts, "hidden": p.menus.hidden, "colors": p.menus.colors, "toolbarHidden": p.toolbar.hidden, @@ -1588,6 +1589,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(v.clone())); let overrides_ok = parsed.as_ref().is_none_or(Result::is_ok); let mut overrides = parsed.and_then(Result::ok).unwrap_or_default(); + // A command's further keys (#3665): id → keys, replacing its default alternates. + let parsed_alts = f.get("alternates").map(|v| serde_json::from_value::>>(v.clone())); + let alternates_ok = parsed_alts.as_ref().is_none_or(Result::is_ok); + let mut alt_overrides = parsed_alts.and_then(Result::ok).unwrap_or_default(); + let mut capture_alt = f.get("captureAlt").and_then(Value::as_bool).unwrap_or(false); let mut hidden: Vec = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut colors: BTreeMap = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1607,7 +1613,15 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map def.clone(), } }; - // Key capture for the selected command. + // A command's further keys as the dialog stands: its edited list, else its defaults. + let eff_alts = |alts: &BTreeMap>, id: &str, main: Option<&str>| -> Vec { + let mut p = prefs::Preferences::default(); + if let Some(list) = alts.get(id) { + p.alternate_shortcuts.insert(id.to_string(), list.clone()); + } + p.alternates(id, main) + }; + // Key capture for the selected command: its main key, or (Add Shortcut) one more key. if capture && tab == 0 && !selected.is_empty() { let pressed = ui.input(|i| { i.events.iter().find_map(|e| match e { @@ -1622,20 +1636,39 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items .iter() - .filter(|(id, _, _, def)| *id != selected && eff(&overrides, id, def).as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())) + .filter(|(id, _, _, def)| { + let cur = eff(&overrides, id, def); + *id != selected + && (cur.as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone()) + || eff_alts(&alt_overrides, id, cur.as_deref()).contains(&sc)) + }) .map(|(id, label, path, _)| { let (path, label) = shown(id, label, path); format!("{} › {}", path.join(" › "), label.trim_end_matches('…')) }) .collect(); - overrides.insert(selected.clone(), sc.clone()); + if capture_alt { + let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone()); + let cur = eff(&overrides, &selected, &def); + let mut keys = eff_alts(&alt_overrides, &selected, cur.as_deref()); + if cur.as_deref().and_then(prefs::normalize_shortcut) != Some(sc.clone()) && !keys.contains(&sc) && keys.len() < prefs::MAX_ALTERNATES { + keys.push(sc.clone()); + } + alt_overrides.insert(selected.clone(), keys); + } else { + overrides.insert(selected.clone(), sc.clone()); + } message_warning = !clash.is_empty(); message = if clash.is_empty() { let template = "{shortcut} assigned."; @@ -1654,13 +1687,16 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result = serde_json::from_value(overrides).map_err(|e| format!("overrides must map command IDs to shortcut strings: {e}"))?; + let alternates = f.get("alternates").cloned().unwrap_or(json!({})); + let mut alt: BTreeMap> = + serde_json::from_value(alternates).map_err(|e| format!("alternates must map command IDs to lists of shortcuts: {e}"))?; let items = shortcut_items(app); // Newly assigned shortcuts are checked first and take theirs from any holder, custom - // overrides included; unchanged overrides only take theirs from defaults. + // overrides included; unchanged overrides only take theirs from defaults. A + // command's further keys (#3665) take and lose keys the same way. let before = &app.session.prefs().shortcuts; - let mut todo: Vec<(String, String, bool)> = - ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone(), before.get(k) != Some(s))).collect(); - todo.sort_by_key(|(_, _, new)| !new); - for (id, sc, new) in todo { - if ov.get(&id).is_some_and(String::is_empty) { - continue; // taken by a newer assignment + let before_alt = &app.session.prefs().alternate_shortcuts; + let mut todo: Vec<(String, String, bool, bool)> = + ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone(), before.get(k) != Some(s), false)).collect(); + for (k, keys) in &alt { + let new = before_alt.get(k) != Some(keys); + todo.extend(keys.iter().map(|s| (k.clone(), s.clone(), new, true))); + } + todo.sort_by_key(|(_, _, new, _)| !new); + let main_of = |ov: &BTreeMap, id: &str, def: Option<&str>| -> Option { + match ov.get(id) { + Some(s) if s.is_empty() => None, + Some(s) => Some(s.clone()), + None => def.map(str::to_string), } + }; + for (id, sc, new, is_alt) in todo { let norm = prefs::normalize_shortcut(&sc); + let Some(norm) = norm else { continue }; + // Taken by a newer assignment? + if (!is_alt && ov.get(&id).is_some_and(String::is_empty)) || (is_alt && alt.get(&id).is_some_and(|k| !k.contains(&norm))) { + continue; + } for (other, _, _, def) in &items { + if *other == id { + continue; + } let held = match ov.get(other) { Some(s) if new => Some(s.as_str()), Some(_) => None, None => def.as_deref(), }; - if *other != id && held.and_then(prefs::normalize_shortcut) == norm { + if held.and_then(prefs::normalize_shortcut).as_deref() == Some(norm.as_str()) { ov.insert(other.clone(), String::new()); } + // Its further keys: the edited list when this assignment is new, else only + // the defaults. + if alt.contains_key(other) && !new { + continue; + } + let mut p = prefs::Preferences::default(); + if let Some(list) = alt.get(other) { + p.alternate_shortcuts.insert(other.clone(), list.clone()); + } + let keys = p.alternates(other, main_of(&ov, other, def.as_deref()).as_deref()); + if keys.contains(&norm) { + alt.insert(other.clone(), keys.into_iter().filter(|k| *k != norm).collect()); + } } } let hidden = f.get("hidden").cloned().unwrap_or(json!([])); let colors = f.get("colors").cloned().unwrap_or(json!({})); let toolbar = f.get("toolbarHidden").cloned().unwrap_or(json!([])); - app.run("edit.keyboardShortcuts", json!({"reset": true, "set": ov, "allowUnknown": true, "removeConflicts": false}))?; + app.run("edit.keyboardShortcuts", json!({"reset": true, "set": ov, "alternates": alt, "allowUnknown": true, "removeConflicts": false}))?; app.run("prefs.set", json!({"values": {"menus": {"hidden": hidden, "colors": colors}, "toolbar": {"hidden": toolbar}}})) } "presets" => Ok(Value::Null), @@ -2966,6 +3071,52 @@ mod tests { assert_eq!(effective_shortcut(&app, "file.open", Some("Cmd+O")), None, "File › Open stays cleared"); } + /// #3665: the dialog keeps a command's further keys: they move like main keys, an override + /// of the main key leaves them, and menus show only the main key. + #[test] + fn shortcut_dialog_moves_and_keeps_alternate_keys() { + let (mut app, _) = app_with_store(); + let ctx = egui::Context::default(); + let open = |app: &mut PhotocraftApp| crate::menus::invoke(app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert_eq!(crate::shortcuts::effective_alternates(&app, "view.zoomIn"), ["Cmd++"]); + // F2 (Cut's alternate) given to Rename Layer is taken from Cut, which keeps ⌘X. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"layer.renameLayer": "F2"})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(effective_shortcut(&app, "layer.renameLayer", None).as_deref(), Some("F2")); + assert!(crate::shortcuts::effective_alternates(&app, "edit.cut").is_empty()); + assert_eq!(effective_shortcut(&app, "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+X")); + // Copy's main key moved to ⌘K keeps F3; the menu shows ⌘K. + let id = open(&mut app); + let mut ov = app.session.prefs().shortcuts.clone(); + ov.insert("edit.copy".into(), "Cmd+K".into()); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!(ov)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert!(crate::menus::menu_items(&app).iter().any(|i| i.id == "edit.copy" && i.shortcut.as_deref() == Some("Cmd+K"))); + // An added alternate takes its key from the main key that held it. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": ["F4", "Cmd+K"]})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.paste"), ["F4", "Cmd+K"]); + assert_eq!(effective_shortcut(&app, "edit.copy", Some("Cmd+C")), None, "taken from Copy"); + // Use Default for everything restores the defaults; a malformed list is an error. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": "F4"})); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": ["Cmd+Shift"]})); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.paste"), ["F4", "Cmd+K"], "nothing changed"); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({})); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.prefs().alternate_shortcuts.is_empty() && app.session.prefs().shortcuts.is_empty()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.cut"), ["F2"]); + } + #[test] fn shortcut_dialog_rejects_malformed_overrides() { // #956: a non-map `overrides` was read as "no overrides" and wiped every custom shortcut. diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index b4bb1b2d..c5bef840 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -39,19 +39,17 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> { ctx.data_mut(|d| d.remove_temp::>(log_id())).unwrap_or_default() } -/// Alternative default shortcuts Photoshop gives some commands besides the one its menu shows: -/// (command, shortcut). Edit › Fill… is Shift+F5 and also Shift+Backspace (Shift+Delete on a Mac). -pub const SECONDARY: &[(&str, &str)] = &[("edit.fill", "Shift+Backspace")]; - /// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command: /// command and shell shortcuts, then the menu catalogue's for live items without their own, -/// then Edit › Keyboard Shortcuts assignments to any other menu item. D and X +/// then Edit › Keyboard Shortcuts assignments to any other menu item, then the commands' +/// alternate keys (#3665: F3 is Copy as well as ⌘C, see +/// [`photocraft_engine::prefs::ALTERNATE_SHORTCUTS`]), which a main key of another command wins. D and X /// (`tools.defaultColors` / `tools.swapColors`) and the fill keys are commands like any other, so /// their overrides apply. Held temporary tools (Space…) are not here: see [`crate::hold_keys`]. pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let prefs = app.session.prefs(); let action_ids: Vec = app.session.actions.list.iter().map(|a| crate::actions::shortcut_id(&a.name)).collect(); - let actions = action_ids.iter().map(|id| (id.as_str(), prefs.shortcut(id, None))); + let actions = action_ids.iter().map(|id| (id.as_str(), prefs.shortcut(id, None).map(str::to_string))); // The Keyboard Shortcuts dialog lists Photoshop's Window › item (`window.panel.*`) // in place of the shell command it runs (`window.toggle.*`, see `menus::panel_alias`), so an // edit to that row reassigns or removes the shell command's default too (#1272). @@ -61,8 +59,8 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { crate::menus::panel_alias(k) == Some(id) && crate::menu_catalog::CATALOG.iter().any(|c| c.3 == k.as_str() && c.2.is_some() && c.2 == sc) }) }; - let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, if aliased(id, *sc) { None } else { prefs.shortcut(id, *sc) })); - let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut))); + let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, if aliased(id, *sc) { None } else { prefs.shortcut(id, *sc).map(str::to_string) })); + let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut).map(str::to_string))); let own: std::collections::HashSet<&str> = crate::menus::UI_COMMANDS .iter() .map(|c| c.0) @@ -71,7 +69,7 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let catalog = crate::menu_catalog::CATALOG .iter() .filter(|(_, _, sc, id)| sc.is_some() && !own.contains(id) && crate::menus::is_live(id)) - .map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc))); + .map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc).map(str::to_string))); let overrides = prefs .shortcuts .iter() @@ -82,17 +80,32 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()) && !crate::hold_keys::is_temporary(id) }) - .map(|(id, sc)| (id.as_str(), Some(sc.as_str()))); - // Photoshop's second shortcuts, kept while the command's main one is the default. - let secondary = SECONDARY.iter().filter(|(id, _)| !prefs.shortcuts.contains_key(*id)).map(|&(id, sc)| (id, Some(sc))); + .map(|(id, sc)| (id.as_str(), Some(sc.clone()))); // A key the user assigned in Edit › Keyboard Shortcuts belongs to that command, ahead of any // default that still names it (#1272): OK clears the old owner's row, but that row can be // another id for the same item, or a default the dialog doesn't list. - let mut candidates: Vec<(&str, Option<&str>)> = actions.chain(ui).chain(engine).chain(catalog).chain(overrides).chain(secondary).collect(); - candidates.sort_by_key(|(id, sc)| !(sc.is_some() && prefs.shortcuts.get(*id).map(String::as_str) == *sc)); + let mut candidates: Vec<(&str, Option, bool)> = actions + .chain(ui) + .chain(engine) + .chain(catalog) + .chain(overrides) + .map(|(id, sc)| { + let user = sc.is_some() && prefs.shortcuts.get(id) == sc.as_ref(); + (id, sc, user) + }) + .collect(); + // Each command's alternates after every main key, the user's ahead of the defaults. + let alternate_ids: std::collections::BTreeSet<&str> = + photocraft_engine::prefs::ALTERNATE_SHORTCUTS.iter().map(|(id, _)| *id).chain(prefs.alternate_shortcuts.keys().map(String::as_str)).collect(); + for id in alternate_ids { + let main = prefs.shortcut(id, crate::shortcuts::default_shortcut(id).as_deref()).map(str::to_string); + let user = prefs.alternate_shortcuts.contains_key(id); + candidates.extend(prefs.alternates(id, main.as_deref()).into_iter().map(|k| (id, Some(k), user))); + } + candidates.sort_by_key(|(_, _, user)| !user); let mut all: Vec<(String, KeyboardShortcut)> = Vec::new(); - for (id, sc) in candidates { - let Some(sc) = sc.and_then(parse) else { continue }; + for (id, sc, _) in candidates { + let Some(sc) = sc.as_deref().and_then(parse) else { continue }; if !all.iter().any(|(_, b)| *b == sc) { all.push((id.to_string(), sc)); } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9174da3e..11a67b88 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -311,6 +311,47 @@ fn a_disabled_shortcut_uses_the_command_id_translation() { }); } +/// #3665: Photoshop's second keys reach their command beside its main key: F2/F3/F4 Cut, Copy +/// and Paste, Shift+F7 Inverse, ⌘+ Zoom In, Shift+Backspace Fill. +#[test] +fn alternate_keys_run_their_command() { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + set_dry_run(&h.ctx, true); + for (key, want) in [ + ("F2", "edit.cut"), + ("F3", "edit.copy"), + ("Cmd+C", "edit.copy"), + ("F4", "edit.paste"), + ("Shift+F7", "select.inverse"), + ("Cmd+Shift+I", "select.inverse"), + ("Cmd++", "view.zoomIn"), + ("Shift+Backspace", "edit.fill"), + ] { + press(&mut h, key); + let log = take_log(&h.ctx); + assert!(matches!(log.as_slice(), [(id, _)] if id == want), "{key}: {log:?}"); + } + // Overriding Copy's main key keeps F3; ⌘C no longer copies. + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("edit.copy".into(), "Cmd+K".into()); + }); + press(&mut h, "F3"); + assert!(matches!(take_log(&h.ctx).as_slice(), [(id, _)] if id == "edit.copy")); + // A key a user gives another command wins over a default alternate. + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("layer.renameLayer".into(), "F2".into()); + }); + assert!(bindings(h.state()).iter().any(|(id, sc)| id == "layer.renameLayer" && *sc == parse("F2").unwrap())); + assert!(!bindings(h.state()).iter().any(|(id, sc)| id == "edit.cut" && *sc == parse("F2").unwrap())); + // A removed alternate no longer fires. + h.state_mut().session.edit_prefs(|p| { + p.alternate_shortcuts.insert("edit.paste".into(), Vec::new()); + }); + press(&mut h, "F4"); + assert!(take_log(&h.ctx).is_empty()); +} + /// #352: ⌥[ ⌥] ⌥, ⌥. and ⇧⌥[ walk the Layers panel's rows from the keyboard. #[test] fn alt_brackets_walk_the_layers_panel() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4266c46a..ef405219 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -98,6 +98,13 @@ pub fn effective_shortcut(app: &PhotocraftApp, id: &str, default: Option<&str>) app.session.prefs().shortcut(id, default).map(str::to_string) } +/// A command's further keys in effect besides its main one (#3665): the user's, else +/// Photoshop's (F3 for Copy, Shift+F7 for Inverse…). Menus show only the main key. +pub fn effective_alternates(app: &PhotocraftApp, id: &str) -> Vec { + let main = effective_shortcut(app, id, default_shortcut(id).as_deref()); + app.session.prefs().alternates(id, main.as_deref()) +} + /// Default (unmodified) shortcut of a command id. pub fn default_shortcut(id: &str) -> Option { crate::menus::UI_COMMANDS @@ -163,10 +170,10 @@ pub const NAV_COMMANDS: [&str; 4] = ["view.zoomIn", "view.zoomOut", "view.fitOnS /// ⌘+ / ⌘- / ⌘0 / ⌘1 (or their Edit › Keyboard Shortcuts overrides). Returns true when one ran. fn nav_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { for id in NAV_COMMANDS { - let sc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| parse(app.session.prefs().shortcut(id, c.3)?)); - if let Some(sc) = sc - && consume(ctx, &sc) - { + let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| app.session.prefs().shortcut(id, c.3)); + let keys: Vec = + main.into_iter().map(str::to_string).chain(app.session.prefs().alternates(id, main)).filter_map(|k| parse(&k)).collect(); + if keys.iter().any(|sc| consume(ctx, sc)) { if crate::menus::is_enabled(app, id) { let _ = crate::menus::invoke(app, ctx, id, json!({})); } @@ -955,8 +962,11 @@ mod tests { let all = photocraft_engine::command_specs() .iter() .filter_map(|c| c.shortcut.map(|s| (c.id, s))) - .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))); + .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))) + // Photoshop's second keys (#3665) are taken by no other command either. + .chain(photocraft_engine::prefs::ALTERNATE_SHORTCUTS.iter().copied()); for (id, s) in all { + assert!(parse(s).is_some(), "{id}: {s}"); if let Some(prev) = seen.insert(s, id) { panic!("shortcut {s} bound to both {prev} and {id}"); } diff --git a/docs/scorecard.md b/docs/scorecard.md index c6d67f8f..e9cd875f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 27 | 14 | 2 | | -| [Workspace and UI](#workspace-and-ui) | 11 | 6 | 6 | settings that do nothing: 30 of 154 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 11 | 6 | 6 | settings that do nothing: 30 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,7 +176,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 30 of 154** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 30 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.