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) <noreply@anthropic.com>
This commit is contained in:
David Mann 2026-10-12 00:48:08 +02:00
parent f1059f1885
commit 234ae9ff4f
26 changed files with 663 additions and 77 deletions

View File

@ -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<String>,
/// Rows whose shortcut could not be read (`unknown` does not list them).
pub unreadable: Vec<String>,
/// 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<String, Vec<String>>,
}
/// One command a row may match: its id, its label and its default shortcut.
@ -141,6 +146,13 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, 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<String> = 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 =
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F3</shortcut><shortcut>Cmd+C</shortcut></command></photoshop-keyboard-shortcuts>";
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]

View File

@ -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<String, String>,
/// 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<String, Vec<String>>,
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.<id>`, `menus.colors.<id>`.
/// Map keys that are command ids (which contain dots): `shortcuts.<id>`,
/// `alternateShortcuts.<id>`, `menus.colors.<id>`.
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<String> {
let main = primary.and_then(normalize_shortcut);
let keys: Vec<String> = 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<String> = 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<Vec<String>, 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<String> = 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<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.
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))))
@ -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<Value> {
/// Edit › Keyboard Shortcuts (engine part): list, change and reset application shortcuts.
fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
"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<Value> {
.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<Value> = 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<Value> =
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<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##,
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>"?}"##,
keyboard_shortcuts,
true
),

View File

@ -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 = "<photoshop-keyboard-shortcuts>\
<command name=\"Copy\"><shortcut>Cmd+C</shortcut><shortcut>F3</shortcut></command>\
<command name=\"Cut\"><shortcut>Cmd+X</shortcut></command>\
<command name=\"Undo\"><shortcut>Cmd+Z</shortcut><shortcut>F1</shortcut></command>\
</photoshop-keyboard-shortcuts>";
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"]);
}

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -739,6 +739,7 @@
Default restored. Έγινε επαναφορά των προεπιλογών.
Define at least one variable first. Ορίστε πρώτα τουλάχιστον μία μεταβλητή.
Delete Group Διαγραφή ομάδας
Add Shortcut Προσθήκη συντόμευσης
Delete Shortcut Διαγραφή συντόμευσης
Delete Timeline Διαγραφή χρονογραμμής
Delete count group Διαγραφή ομάδας καταμέτρησης

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 16.

View File

@ -996,6 +996,7 @@
Delete Group グループを削除
Delete Layer Mask レイヤーマスクを削除
Delete Layers レイヤーを削除
Add Shortcut ショートカットを追加
Delete Shortcut ショートカットを削除
Delete Timeline タイムラインを削除
Delete Tool Preset ツールプリセットを削除

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -971,6 +971,7 @@
Delete Group 그룹 삭제
Delete Layer Mask 레이어 마스크 삭제
Delete Layers 레이어 삭제
Add Shortcut 단축키 추가
Delete Shortcut 단축키 삭제
Delete Timeline 타임라인 삭제
Delete Tool Preset 도구 사전 설정 삭제

Can't render this file because it has a wrong number of fields in line 11.

View File

@ -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

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 комбинация

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 клавіатурне скорочення

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -997,6 +997,7 @@
Delete Group 删除组
Delete Layer Mask 删除图层蒙版
Delete Layers 删除图层
Add Shortcut 添加快捷键
Delete Shortcut 删除快捷键
Delete Timeline 删除时间轴
Delete Tool Preset 删除工具预设

Can't render this file because it has a wrong number of fields in line 15.

View File

@ -1000,6 +1000,7 @@
Delete Group 刪除群組
Delete Layer Mask 刪除圖層遮色片
Delete Layers 刪除圖層
Add Shortcut 新增快速鍵
Delete Shortcut 刪除快速鍵
Delete Timeline 刪除時間軸
Delete Tool Preset 刪除工具預設集

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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<String, Vec<String>> = 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,
}))
}

View File

@ -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 = "<photoshop-keyboard-shortcuts>\
<command name=\"Copy\"><shortcut>F3</shortcut><shortcut>Cmd+C</shortcut></command>\
<command name=\"Cut\"><shortcut>Cmd+X</shortcut></command>\
<command name=\"Undo\"><shortcut>Cmd+Z</shortcut><shortcut>F1</shortcut></command>\
</photoshop-keyboard-shortcuts>";
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<std::sync::Mutex<Option<String>>>;
/// An app beside a Photoshop whose live set is `set`; `saved_prefs` is this app's own saved

View File

@ -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<String
let parsed = f.get("overrides").map(|v| serde_json::from_value::<BTreeMap<String, String>>(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::<BTreeMap<String, Vec<String>>>(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<String> = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let mut colors: BTreeMap<String, String> = 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<String
None => 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<String, Vec<String>>, id: &str, main: Option<&str>| -> Vec<String> {
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<String
if key == egui::Key::Escape {
capture = false;
} else if matches!(key, egui::Key::Backspace | egui::Key::Delete) && m.is_none() {
overrides.insert(selected.clone(), String::new());
if capture_alt {
alt_overrides.insert(selected.clone(), Vec::new());
} else {
overrides.insert(selected.clone(), String::new());
}
capture = false;
message = tl!("Shortcut removed.").into();
message_warning = false;
} else if let Some(sc) = shortcut_text(key, m) {
let clash: Vec<String> = 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<String
egui::Grid::new("shortcut-grid").num_columns(3).spacing([12.0, 3.0]).striped(true).show(ui, |ui| {
for (id, label, path, def) in &items {
let cur = eff(&overrides, id, def);
let alts = eff_alts(&alt_overrides, id, cur.as_deref());
let (shown_path, shown_label) = shown(id, label, path);
// Match what is shown and the English name (commands are documented in English).
let hay = format!("{} {} {} {} {}", shown_path.join(" "), shown_label, path.join(" "), label, cur.clone().unwrap_or_default()).to_lowercase();
let hay =
format!("{} {} {} {} {} {}", shown_path.join(" "), shown_label, path.join(" "), label, cur.clone().unwrap_or_default(), alts.join(" "))
.to_lowercase();
if !needle.is_empty() && !hay.contains(&needle) && !id.to_ascii_lowercase().contains(&needle) {
continue;
}
if tab == 0 && path.is_empty() && def.is_none() && cur.is_none() {
if tab == 0 && path.is_empty() && def.is_none() && cur.is_none() && alts.is_empty() {
continue;
}
let top = shown_path.first().cloned().unwrap_or_else(|| tl!("Other").into());
@ -1680,18 +1716,41 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
selected = id.clone();
}
if tab == 0 {
let text = if sel && capture { tl!("Press keys…").to_string() } else { cur.as_deref().map(crate::shortcuts::pretty).unwrap_or_default() };
let changed = overrides.contains_key(id);
let text = if sel && capture && !capture_alt {
tl!("Press keys…").to_string()
} else {
cur.as_deref().map(crate::shortcuts::pretty).unwrap_or_default()
};
let changed = overrides.contains_key(id) || alt_overrides.contains_key(id);
let r = ui.add(
egui::Button::new(RichText::new(tl!(&text)).size(12.0).color(if changed { t.accent_text } else { t.text })).min_size(vec2(120.0, 18.0)),
);
if r.clicked() {
selected = id.clone();
capture = true;
capture_alt = false;
message.clear();
message_warning = false;
}
ui.label(if changed { RichText::new(tl!("modified")).color(t.text_faint).size(10.5) } else { RichText::new("") });
// The further keys (F3 beside ⌘C), each removable on its own, as Photoshop
// lists them as rows of their own (#3665).
ui.horizontal(|ui| {
for k in &alts {
let b = egui::Button::new(RichText::new(format!("{} ×", crate::shortcuts::pretty(k))).size(11.0).color(t.text)).small();
if ui.add(b).on_hover_text(tl!("Delete Shortcut")).clicked() {
alt_overrides.insert(id.clone(), alts.iter().filter(|x| *x != k).cloned().collect());
selected = id.clone();
message = tl!("Shortcut removed.").into();
message_warning = false;
}
}
if sel && capture && capture_alt {
ui.label(RichText::new(tl!("Press keys…")).size(11.0).color(t.accent_text));
}
if changed {
ui.label(RichText::new(tl!("modified")).color(t.text_faint).size(10.5));
}
});
} else {
let mut visible = !hidden.contains(id);
crate::widgets::checkbox(ui, &mut visible, tl!("Visible"));
@ -1718,8 +1777,15 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
if tab == 0 {
ui.horizontal(|ui| {
let has_sel = !selected.is_empty();
if ui.add_enabled(has_sel, egui::Button::new(tl!("Add Shortcut"))).clicked() {
capture = true;
capture_alt = true;
message.clear();
message_warning = false;
}
if ui.add_enabled(has_sel, egui::Button::new(tl!("Use Default"))).clicked() {
overrides.remove(&selected);
alt_overrides.remove(&selected);
message = tl!("Default restored.").into();
message_warning = false;
}
@ -1730,6 +1796,7 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
}
if ui.button(tl!("Reset All to Defaults")).clicked() {
overrides.clear();
alt_overrides.clear();
message = tl!("All shortcuts reset to PhotoCraft defaults.").into();
message_warning = false;
}
@ -1752,6 +1819,10 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
if overrides_ok {
f.insert("overrides".into(), json!(overrides));
}
if alternates_ok {
f.insert("alternates".into(), json!(alt_overrides));
}
f.insert("captureAlt".into(), json!(capture && capture_alt));
f.insert("hidden".into(), json!(hidden));
f.insert("colors".into(), json!(colors));
f.insert("selected".into(), json!(selected));
@ -1889,33 +1960,67 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
// The map replaces every stored override, so a malformed one is an error, not "none".
let mut ov: BTreeMap<String, String> =
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<String, Vec<String>> =
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<String, String>, id: &str, def: Option<&str>| -> Option<String> {
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.

View File

@ -39,19 +39,17 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> {
ctx.data_mut(|d| d.remove_temp::<Vec<(String, Outcome)>>(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<String> = 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 › <panel> 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<String>, 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));
}

View File

@ -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() {

View File

@ -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<String> {
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<String> {
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<KeyboardShortcut> =
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}");
}

View File

@ -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`.