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:
parent
f1059f1885
commit
234ae9ff4f
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue