PSD: write tiff blocks without copies, inflate merged ZIP once (#5221)

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 00:32:55 -04:00
1 parent 5dafbeeafc
commit 4446ec6d65
41 files changed
+1601 -71

No files matched your search

+252 -8
View File
@@ -12,8 +12,11 @@
//! 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.
//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys match our
//! tools by name ([`plan_tools`]; the app knows the tools). A set saved on Windows spells the
//! primary modifier Ctrl where a Mac set says Cmd ([`KysSet::windows`], [`to_ours`]).
//! [`write`] saves a set the same way, with our command id on each row (`pc-id`, which
//! Photoshop ignores), so PhotoCraft reads its own sets back exactly.
use std::collections::{BTreeMap, HashMap};
@@ -23,10 +26,12 @@ use crate::prefs::normalize_shortcut;
pub const MAX_BYTES: usize = 4 << 20;
/// One `<command>` row: Photoshop's label and its shortcuts, first one primary.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KysCommand {
pub name: String,
pub shortcuts: Vec<String>,
/// Our command id, in sets PhotoCraft wrote ([`write`]'s `pc-id`): matched before the label.
pub id: Option<String>,
}
/// The parsed set: its name, its command rows and how many tools carry a key.
@@ -37,6 +42,11 @@ pub struct KysSet {
pub name: Option<String>,
pub commands: Vec<KysCommand>,
pub tool_keys: usize,
/// The `<tool>` rows with a key: the tool's name, its key, our id in sets PhotoCraft wrote.
pub tools: Vec<KysCommand>,
/// Saved by Photoshop on Windows: no key spells ⌘ or Opt, and Ctrl is the primary modifier,
/// so `Ctrl` here is our `Cmd` (a Mac set's `Control` stays the Control key). See [`to_ours`].
pub windows: bool,
}
/// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows
@@ -63,16 +73,62 @@ pub fn parse(text: &str) -> Result<KysSet, String> {
.filter_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string))
.collect();
if !shortcuts.is_empty() {
set.commands.push(KysCommand { name: name.to_string(), shortcuts });
let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string);
set.commands.push(KysCommand { name: name.to_string(), shortcuts, id });
}
}
"tool" => {
let key = node.text().map(str::trim).filter(|t| !t.is_empty());
let name = node.attribute("name").map(str::trim).filter(|n| !n.is_empty());
if let Some(key) = key {
set.tool_keys += 1;
if let Some(name) = name {
let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string);
set.tools.push(KysCommand { name: name.to_string(), shortcuts: vec![key.to_string()], id });
}
}
}
"tool" if node.text().is_some_and(|t| !t.trim().is_empty()) => set.tool_keys += 1,
_ => {}
}
}
set.windows = windows_set(&set);
Ok(set)
}
/// The modifier words of `sc` (`Ctrl+Alt+K` → Ctrl, Alt), whatever the key is.
fn modifiers(sc: &str) -> impl Iterator<Item = String> + '_ {
let mods = sc.strip_suffix("++").unwrap_or_else(|| sc.rsplit_once('+').map_or("", |(m, _)| m));
mods.split('+').filter(|m| !m.is_empty()).map(|m| m.trim().to_ascii_lowercase())
}
/// Photoshop on Windows writes Ctrl for the primary modifier, where a Mac set writes Cmd (and
/// Control for the Control key): a set with no ⌘ or Opt anywhere but some Ctrl is a Windows set.
fn windows_set(set: &KysSet) -> bool {
let keys = || set.commands.iter().flat_map(|c| c.shortcuts.iter());
let mac = keys().flat_map(|k| modifiers(k).collect::<Vec<_>>()).any(|m| matches!(m.as_str(), "cmd" | "command" | "opt" | "option"));
let ctrl = keys().flat_map(|k| modifiers(k).collect::<Vec<_>>()).any(|m| matches!(m.as_str(), "ctrl" | "control"));
!mac && ctrl
}
/// A key of `set` in our notation, normalised: on a Windows set, Ctrl (or Control) is our `Cmd`
/// (Ctrl on Windows and Linux, ⌘ on the Mac), so `Ctrl+L` is the `Cmd+L` default rather than a
/// literal Control key.
pub fn to_ours(set: &KysSet, sc: &str) -> Option<String> {
if !set.windows {
return normalize_shortcut(sc);
}
let (mods, key) = match sc.strip_suffix("++") {
Some(m) => (m, "+"),
None => sc.rsplit_once('+').unwrap_or(("", sc)),
};
let mods: Vec<&str> = mods
.split('+')
.filter(|m| !m.is_empty())
.map(|m| if matches!(m.trim().to_ascii_lowercase().as_str(), "ctrl" | "control") { "Cmd" } else { m })
.collect();
normalize_shortcut(&mods.into_iter().chain(std::iter::once(key)).collect::<Vec<_>>().join("+"))
}
/// What an import would change.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
@@ -124,18 +180,21 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
entry.push((c.id, c.default));
}
}
let by_id: HashMap<&str, &(String, Option<String>)> = by_label.values().flatten().map(|c| (c.0.as_str(), c)).collect();
let mut plan = Plan::default();
for row in &set.commands {
let Some(candidates) = by_label.get(&key(&row.name)) else {
// A set PhotoCraft wrote names the command itself, whatever its label says.
let own = row.id.as_deref().and_then(|id| by_id.get(id)).map(|c| vec![(*c).clone()]);
let Some(candidates) = own.as_ref().or_else(|| by_label.get(&key(&row.name))) else {
plan.unknown.push(row.name.clone());
continue;
};
let Some(sc) = row.shortcuts.first().and_then(|s| normalize_shortcut(s)) else {
let Some(sc) = row.shortcuts.first().and_then(|s| to_ours(set, s)) else {
plan.unreadable.push(row.name.clone());
continue;
};
plan.alternates += row.shortcuts.len().saturating_sub(1);
let imported: Vec<String> = row.shortcuts.iter().filter_map(|s| normalize_shortcut(s)).collect();
let imported: Vec<String> = row.shortcuts.iter().filter_map(|s| to_ours(set, s)).collect();
let pick = candidates
.iter()
.find(|(_, d)| d.as_deref().and_then(normalize_shortcut).is_some_and(|d| imported.contains(&d)))
@@ -159,6 +218,92 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
plan
}
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
let mut keys = BTreeMap::new();
let mut unknown = Vec::new();
for row in &set.tools {
let found =
row.id.as_deref().and_then(|id| tools.iter().find(|(t, _)| *t == id)).or_else(|| tools.iter().find(|(_, name)| key(name) == key(&row.name)));
let Some((id, _)) = found else {
unknown.push(row.name.clone());
continue;
};
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
if let (Some(c), None) = (letters.next(), letters.next())
&& c.is_ascii_alphanumeric()
{
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
}
}
(keys, unknown)
}
/// The attribute naming our command id on the rows PhotoCraft writes (Photoshop ignores it).
pub const PC_ID: &str = "pc-id";
/// One row to write: a command (or tool), its label and its keys, main key first.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub id: String,
pub label: String,
pub tool: bool,
pub keys: Vec<String>,
}
/// A key in the spelling Photoshop's macOS sets use (`Control+Opt+Shift+Cmd+G`): our `Ctrl` is
/// Control, `Alt` is Opt. `None` for a key that isn't one.
pub fn photoshop_key(sc: &str) -> Option<String> {
let n = normalize_shortcut(sc)?;
// The key is the last part; `Cmd++` ends in an empty part, so take it from the string.
let (mods, key) = match n.strip_suffix("++") {
Some(m) => (m, "+"),
None => n.rsplit_once('+').unwrap_or(("", n.as_str())),
};
let has = |m: &str| mods.split('+').any(|x| x == m);
let mut out: Vec<&str> =
[("Ctrl", "Control"), ("Alt", "Opt"), ("Shift", "Shift"), ("Cmd", "Cmd")].iter().filter(|(ours, _)| has(ours)).map(|(_, ps)| *ps).collect();
out.push(key);
Some(out.join("+"))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&apos;")
}
/// A `.kys` file for the set `name` (Edit › Keyboard Shortcuts › Save Set): every entry with a
/// key, commands then tools, labels with Photoshop's `...`, keys in [`photoshop_key`] spelling,
/// and our id on each row so PhotoCraft reads its own sets back exactly. Rows without a key are
/// left out, and a key that isn't one is skipped.
pub fn write(name: &str, entries: &[Entry]) -> String {
let name = xml_escape(name.trim());
let mut out = format!(
"\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<photoshop-keyboard-shortcuts version=\"4\" filename=\"$$$/FileName/Presets/KeyboardCustomization/{name}={name}\">\n"
);
let row = |e: &Entry| -> Option<String> {
let keys: Vec<String> = e.keys.iter().filter_map(|k| photoshop_key(k)).collect();
let first = keys.first()?;
let label = xml_escape(&e.label.replace('…', "..."));
let id = xml_escape(&e.id);
Some(if e.tool {
format!("\t<tool name=\"{label}\" {PC_ID}=\"{id}\">{}</tool>\n", xml_escape(first))
} else {
let shortcuts: String = keys.iter().map(|k| format!("<shortcut>{}</shortcut>", xml_escape(k))).collect();
format!("\t<command kind=\"static\" name=\"{label}\" {PC_ID}=\"{id}\">{shortcuts}</command>\n")
})
};
for e in entries.iter().filter(|e| !e.tool).chain(entries.iter().filter(|e| e.tool)) {
if let Some(r) = row(e) {
out.push_str(&r);
}
}
out.push_str("</photoshop-keyboard-shortcuts>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,4 +356,103 @@ mod tests {
assert!(parse(&big).unwrap_err().contains("4 MiB"));
assert!(parse("<other/>").unwrap_err().contains("not a keyboard shortcut set"));
}
#[test]
fn written_sets_read_back_exactly_and_keys_use_photoshops_spelling() {
assert_eq!(photoshop_key("Cmd+Alt+Shift+Ctrl+g").as_deref(), Some("Control+Opt+Shift+Cmd+G"));
assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++"));
assert_eq!(photoshop_key("F3").as_deref(), Some("F3"));
assert_eq!(photoshop_key("Cmd+Shift+Ctrl"), None);
let entries = vec![
Entry { id: "edit.copy".into(), label: "Copy".into(), tool: false, keys: vec!["Cmd+C".into(), "F3".into()] },
Entry { id: "x.odd".into(), label: "Levels…".into(), tool: false, keys: vec!["Cmd+Alt+L".into()] },
Entry { id: "tools.select.move".into(), label: "Move Tool".into(), tool: true, keys: vec!["V".into()] },
Entry { id: "none".into(), label: "Unbound".into(), tool: false, keys: vec![] },
Entry { id: "bad".into(), label: "A <\"weird\"> & 'name'".into(), tool: false, keys: vec!["Cmd+Shift+Ctrl".into(), "F9".into()] },
];
let xml = write(" My & Set ", &entries);
assert!(xml.contains("KeyboardCustomization/My &amp; Set=My &amp; Set"), "{xml}");
assert!(xml.contains("<command kind=\"static\" name=\"Levels...\" pc-id=\"x.odd\"><shortcut>Opt+Cmd+L</shortcut></command>"), "{xml}");
assert!(!xml.contains("Unbound"), "{xml}");
assert!(xml.find("Move Tool").unwrap() > xml.find("Levels").unwrap(), "tools after commands");
let set = parse(&xml).unwrap();
assert_eq!(set.name.as_deref(), Some("My & Set"));
assert_eq!(set.tool_keys, 1);
assert_eq!(set.commands.len(), 3);
assert_eq!(set.commands[0], KysCommand { name: "Copy".into(), shortcuts: vec!["Cmd+C".into(), "F3".into()], id: Some("edit.copy".into()) });
assert_eq!(set.commands[2].name, "A <\"weird\"> & 'name'");
assert_eq!(set.commands[2].shortcuts, vec!["F9".to_string()]);
// The id wins over the label: a renamed or translated label still finds its command.
let cands = || {
vec![
Candidate { id: "x.odd".into(), label: "Something Else".into(), default: None },
Candidate { id: "y.levels".into(), label: "Levels".into(), default: Some("Cmd+L".into()) },
]
};
let p = plan(cands(), &set);
assert_eq!(p.set.get("x.odd").map(String::as_str), Some("Cmd+Alt+L"));
assert!(!p.set.contains_key("y.levels"));
// Without the id, the label matches as before.
let mut plain = set.clone();
plain.commands[1].id = None;
assert!(plan(cands(), &plain).set.contains_key("y.levels"));
}
#[test]
fn a_windows_set_means_cmd_by_ctrl_and_a_mac_set_keeps_control() {
let win = "<photoshop-keyboard-shortcuts version=\"4\">
\t<command name=\"Levels...\"><shortcut>Ctrl+L</shortcut></command>
\t<command name=\"Gaussian Blur...\"><shortcut>Ctrl+Alt+Shift+G</shortcut></command>
\t<command name=\"Zoom In\"><shortcut>Ctrl++</shortcut><shortcut>Ctrl+=</shortcut></command>
</photoshop-keyboard-shortcuts>";
let set = parse(win).unwrap();
assert!(set.windows);
assert_eq!(to_ours(&set, "Ctrl+L").as_deref(), Some("Cmd+L"));
assert_eq!(to_ours(&set, "Ctrl+Alt+Shift+G").as_deref(), normalize_shortcut("Cmd+Alt+Shift+G").as_deref());
assert_eq!(to_ours(&set, "Ctrl++").as_deref(), Some("Cmd++"));
assert_eq!(to_ours(&set, "F3").as_deref(), Some("F3"));
let cands = vec![Candidate { id: "image.levels".into(), label: "Levels…".into(), default: Some("Cmd+L".into()) }];
assert_eq!(plan(cands, &set).set.get("image.levels").map(String::as_str), Some("Cmd+L"), "the default, not a Control override");
// A Mac set spells ⌘ Cmd: its Control is the Control key.
let mac = parse(
"<photoshop-keyboard-shortcuts version=\"4\"><command name=\"Levels...\"><shortcut>Cmd+L</shortcut></command><command name=\"X\"><shortcut>Control+Opt+Cmd+G</shortcut></command><command name=\"Y\"><shortcut>Control+K</shortcut></command></photoshop-keyboard-shortcuts>",
)
.unwrap();
assert!(!mac.windows);
assert_eq!(to_ours(&mac, "Control+K"), normalize_shortcut("Ctrl+K"));
// No modifiers at all: nothing to tell, nothing to change.
let plain = parse("<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F3</shortcut></command></photoshop-keyboard-shortcuts>").unwrap();
assert!(!plain.windows);
}
#[test]
fn tool_keys_match_by_name_or_id_and_take_one_letter() {
let xml = "<photoshop-keyboard-shortcuts version=\"4\">
\t<tool name=\"Move Tool\" type=\"1\">V</tool>
\t<tool name=\"pencil tool\" type=\"1\">n</tool>
\t<tool name=\"Renamed\" pc-id=\"tools.select.brush\">B</tool>
\t<tool name=\"Imaginary Tool\">Q</tool>
\t<tool name=\"Lasso Tool\">Shift+L</tool>
\t<tool name=\"Default Colors\"></tool>
\t<tool>Z</tool>
</photoshop-keyboard-shortcuts>";
let set = parse(xml).unwrap();
assert_eq!((set.tool_keys, set.tools.len()), (6, 5), "a key without a name counts but can't be matched");
let tools = [
("tools.select.move", "Move Tool"),
("tools.select.pencil", "Pencil Tool"),
("tools.select.brush", "Brush Tool"),
("tools.select.lasso", "Lasso Tool"),
];
let (keys, unknown) = plan_tools(tools, &set);
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
// Our own export reads back by id.
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
let (keys, _) = plan_tools(tools, &parse(&out).unwrap());
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("F"));
}
}
+1
View File
@@ -103,6 +103,7 @@ pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod selection_cmds;
pub mod shortcut_sets;
pub mod slice_cmds;
pub mod smart_cmds;
pub mod smartselect_cmds;
+32 -2
View File
@@ -938,6 +938,10 @@ pub struct Preferences {
/// ([`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>>,
/// Edit › Keyboard Shortcuts › Set: saved shortcut sets by name (`crate::shortcut_sets`).
pub shortcut_sets: BTreeMap<String, crate::shortcut_sets::ShortcutSet>,
/// The saved set in use ("" for the defaults).
pub shortcut_set: String,
pub menus: MenuCustomization,
pub toolbar: ToolbarCustomization,
/// Edit › Check Spelling: words added to the dictionary ("Add").
@@ -1837,7 +1841,8 @@ 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;
// A set loads first, so the call's other changes apply on top of it.
let mut changed = crate::shortcut_sets::load(&mut next, p).map_err(|e| bad(cmd, e))?;
if let Some(r) = p.get("reset") {
match r {
Value::Bool(true) => {
@@ -1887,6 +1892,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
"windows": set.windows,
}));
let mut with_import = p.clone();
with_import["set"] = Value::Object(merged);
@@ -1951,6 +1957,8 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
// Then the sets are saved, deleted or switched, from the bindings as they now stand.
changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?;
if changed {
s.edit_prefs(|p| *p = next);
}
@@ -1990,6 +1998,28 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
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(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) {
o.extend(sets);
}
// The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the
// app's dialog writes both with the shell's commands and tools added).
let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() };
if p.get("exportKys").and_then(Value::as_bool) == Some(true) {
let entries: Vec<crate::kys::Entry> =
crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect();
out["kys"] = json!(crate::kys::write(set_name, &entries));
}
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos"));
let mut sections: Vec<crate::shortcut_sets::Section> = Vec::new();
for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) {
match sections.iter_mut().find(|(m, _)| *m == menu) {
Some((_, rows)) => rows.push((label, keys)),
None => sections.push((menu, vec![(label, keys)])),
}
}
out["html"] = json!(crate::shortcut_sets::summarize(set_name, &sections, mac));
}
if let Some(import) = import {
out["import"] = import;
}
@@ -2121,7 +2151,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##,
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
keyboard_shortcuts,
true
),
+347
View File
@@ -0,0 +1,347 @@
//! Edit › Keyboard Shortcuts › Set: named shortcut sets, as in Photoshop. The bindings in use
//! stay in [`Preferences::shortcuts`] / [`Preferences::alternate_shortcuts`] (what dispatch
//! reads); a set is a saved copy of them, `Preferences::shortcut_sets` keyed by name, and
//! `Preferences::shortcut_set` names the one in use ("" for the defaults). The bindings differ
//! from their set after an edit until it is saved ("modified", as Photoshop marks the set).
//! A set saves to and loads from a `.kys` file (`crate::kys::write` / the import), and
//! [`summarize`] writes the HTML table of Photoshop's Summarize button.
//!
//! `edit.keyboardShortcuts` takes the set parameters: `useSet` (load a set's bindings, before
//! the call's other changes), then `saveSet` (save the bindings as they end up under a name and
//! use it), `deleteSet`, `sets` (replace every saved set: the dialog's OK) and `activeSet`.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::prefs::{Preferences, alternate_keys, normalize_shortcut};
/// The built-in set's name: the default bindings.
pub const DEFAULTS_NAME: &str = "PhotoCraft Defaults";
/// Saved sets are small; the cap keeps a hostile settings file or call bounded.
pub const MAX_SETS: usize = 64;
/// Longest set name, in characters.
pub const MAX_NAME: usize = 64;
/// A saved set: the overrides of the default bindings, in the shape the preferences keep them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ShortcutSet {
/// Command id → main key; "" removes the default key.
pub shortcuts: BTreeMap<String, String>,
/// Command id → further keys, replacing the defaults.
pub alternate_shortcuts: BTreeMap<String, Vec<String>>,
}
impl ShortcutSet {
/// The bindings `p` uses now.
pub fn of(p: &Preferences) -> Self {
Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() }
}
/// Use this set's bindings in `p`.
pub fn load_into(&self, p: &mut Preferences) {
p.shortcuts = self.shortcuts.clone();
p.alternate_shortcuts = self.alternate_shortcuts.clone();
}
/// Check every key (a set from a call or a hand-edited file must not hold garbage).
fn validate(&self) -> Result<(), String> {
for (id, k) in &self.shortcuts {
if !k.is_empty() && normalize_shortcut(k).is_none() {
return Err(format!("`{k}` for `{id}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"));
}
}
for (id, keys) in &self.alternate_shortcuts {
alternate_keys(&json!(keys)).map_err(|e| format!("`{id}`: {e}"))?;
}
Ok(())
}
}
/// A set name as given, trimmed; an error for an empty, overlong or reserved name.
pub fn set_name(name: &str) -> Result<String, String> {
let n = name.trim();
if n.is_empty() {
return Err("a set needs a name".into());
}
if n.chars().count() > MAX_NAME {
return Err(format!("a set name is at most {MAX_NAME} characters"));
}
if n.chars().any(char::is_control) {
return Err("a set name can't contain control characters".into());
}
if n.eq_ignore_ascii_case(DEFAULTS_NAME) {
return Err(format!("`{DEFAULTS_NAME}` is the built-in set"));
}
Ok(n.to_string())
}
/// The saved copy of the set in use: `None` for the defaults (or a set that no longer exists).
fn saved(p: &Preferences) -> Option<&ShortcutSet> {
p.shortcut_sets.get(&p.shortcut_set)
}
/// Do the bindings differ from the set in use?
pub fn modified(p: &Preferences) -> bool {
let now = ShortcutSet::of(p);
match saved(p) {
Some(s) => *s != now,
None => now != ShortcutSet::default(),
}
}
/// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the
/// defaults) and makes it the one in use. Returns whether it changed anything.
pub fn load(next: &mut Preferences, p: &Value) -> Result<bool, String> {
let Some(v) = p.get("useSet") else { return Ok(false) };
let name = v.as_str().ok_or("`useSet` is a set name")?.trim();
if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) {
ShortcutSet::default().load_into(next);
next.shortcut_set = String::new();
} else {
let set = next.shortcut_sets.get(name).cloned().ok_or_else(|| format!("no shortcut set named `{name}`"))?;
set.load_into(next);
next.shortcut_set = name.to_string();
}
Ok(true)
}
/// After the call's binding changes: `sets` (replace every saved set), `deleteSet`, `saveSet`
/// (save the bindings under a name and use it) and `activeSet`. Returns whether it changed
/// anything.
pub fn store(next: &mut Preferences, p: &Value) -> Result<bool, String> {
let mut changed = false;
if let Some(v) = p.get("sets") {
let obj = v.as_object().ok_or("`sets` maps set names to {shortcuts, alternateShortcuts}")?;
if obj.len() > MAX_SETS {
return Err(format!("at most {MAX_SETS} shortcut sets"));
}
let mut sets = BTreeMap::new();
for (name, s) in obj {
let name = set_name(name)?;
let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?;
set.validate().map_err(|e| format!("set `{name}`: {e}"))?;
sets.insert(name, set);
}
next.shortcut_sets = sets;
changed = true;
}
if let Some(v) = p.get("deleteSet") {
let name = v.as_str().ok_or("`deleteSet` is a set name")?.trim();
if next.shortcut_sets.remove(name).is_none() {
return Err(format!("no shortcut set named `{name}`"));
}
if next.shortcut_set == name {
next.shortcut_set = String::new();
}
changed = true;
}
if let Some(v) = p.get("saveSet") {
let name = set_name(v.as_str().ok_or("`saveSet` is a set name")?)?;
if !next.shortcut_sets.contains_key(&name) && next.shortcut_sets.len() >= MAX_SETS {
return Err(format!("at most {MAX_SETS} shortcut sets"));
}
next.shortcut_sets.insert(name.clone(), ShortcutSet::of(next));
next.shortcut_set = name;
changed = true;
}
if let Some(v) = p.get("activeSet") {
let name = v.as_str().ok_or("`activeSet` is a set name")?.trim();
if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) {
next.shortcut_set = String::new();
} else if next.shortcut_sets.contains_key(name) {
next.shortcut_set = name.to_string();
} else {
return Err(format!("no shortcut set named `{name}`"));
}
changed = true;
}
Ok(changed)
}
/// The sets, the one in use and whether the bindings differ from it, for the command's reply.
pub fn describe(p: &Preferences) -> Value {
json!({
"sets": p.shortcut_sets.keys().collect::<Vec<_>>(),
"activeSet": if saved(p).is_some() { p.shortcut_set.as_str() } else { "" },
"defaultsName": DEFAULTS_NAME,
"modified": modified(p),
})
}
/// A key as the Summarize table shows it on `mac` (⌘ spelled Cmd, ⌥ Option) or elsewhere
/// (Ctrl, Alt).
fn key_label(sc: &str, mac: bool) -> String {
let Some(n) = normalize_shortcut(sc) else { return sc.to_string() };
// `Cmd++` splits into an empty part before the key, which joins back as `++`.
let mut parts: Vec<&str> = Vec::new();
for part in n.split('+') {
let shown = match (part, mac) {
("Ctrl", true) => "Control",
("Cmd", false) | ("Ctrl", false) => "Ctrl",
("Alt", true) => "Option",
(p, _) => p,
};
// Off the Mac, ⌘ and Control are both Ctrl: say it once.
if shown == "Ctrl" && parts.contains(&"Ctrl") {
continue;
}
parts.push(shown);
}
parts.join("+")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// A Summarize row: a command's label and its keys, the main one first.
pub type Row = (String, Vec<String>);
/// A Summarize section: its title and rows.
pub type Section = (String, Vec<Row>);
/// Photoshop's Summarize: an HTML page listing every command with a key, by section
/// (`sections` in display order: section title, then (command label, keys with the main one
/// first)). Commands without a key are left out.
pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
let mut out = String::new();
out.push_str("<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><title>");
out.push_str(&html_escape(set));
out.push_str("</title>\n<style>body{font-family:sans-serif;margin:2em}table{border-collapse:collapse;margin-bottom:2em}th,td{border:1px solid #bbb;padding:4px 10px;text-align:left}th{background:#eee}</style>\n</head><body>\n<h1>");
out.push_str(&html_escape(set));
out.push_str("</h1>\n");
for (title, rows) in sections {
let rows: Vec<&Row> = rows.iter().filter(|(_, keys)| !keys.is_empty()).collect();
if rows.is_empty() {
continue;
}
out.push_str("<h2>");
out.push_str(&html_escape(title));
out.push_str("</h2>\n<table><tr><th>Command</th><th>Shortcut</th></tr>\n");
for (label, keys) in rows {
let keys: Vec<String> = keys.iter().map(|k| html_escape(&key_label(k, mac))).collect();
out.push_str("<tr><td>");
out.push_str(&html_escape(label));
out.push_str("</td><td>");
out.push_str(&keys.join(", "));
out.push_str("</td></tr>\n");
}
out.push_str("</table>\n");
}
out.push_str("</body></html>\n");
out
}
/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id,
/// label, top-level menu, keys with the main one first). The app's dialog adds the commands
/// only the shell knows (View › Zoom In, the tools).
pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec<String>)> {
let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2)));
crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut))
.chain(temporary)
.filter_map(|(id, label, menu, def)| {
let main = p.shortcut(id, def);
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
keys.extend(p.alternates(id, main));
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn prefs() -> Preferences {
Preferences::default()
}
#[test]
fn save_use_modify_and_delete_a_set() {
let mut p = prefs();
assert!(!modified(&p));
p.shortcuts.insert("edit.copy".into(), "F9".into());
assert!(modified(&p), "an edit to the defaults");
assert!(store(&mut p, &json!({"saveSet": " Mine "})).unwrap());
assert_eq!((p.shortcut_set.as_str(), modified(&p)), ("Mine", false));
p.shortcuts.insert("edit.cut".into(), "F8".into());
assert!(modified(&p));
// Back to the defaults, then to the saved set as it was saved.
assert!(load(&mut p, &json!({"useSet": ""})).unwrap());
assert!(p.shortcuts.is_empty() && p.shortcut_set.is_empty() && !modified(&p));
load(&mut p, &json!({"useSet": "Mine"})).unwrap();
assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F9"));
assert_eq!(p.shortcuts.get("edit.cut"), None, "the unsaved edit isn't in the set");
assert_eq!(describe(&p)["sets"], json!(["Mine"]));
// Deleting the set in use falls back to the defaults' name; the bindings stay.
store(&mut p, &json!({"deleteSet": "Mine"})).unwrap();
assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty());
assert_eq!(describe(&p)["activeSet"], "");
assert!(modified(&p), "F9 differs from the defaults");
}
#[test]
fn bad_names_sets_and_params_are_errors() {
let mut p = prefs();
for name in ["", " ", "PhotoCraft defaults", "a\u{7}b", &"x".repeat(65)] {
assert!(store(&mut p, &json!({"saveSet": name})).is_err(), "{name:?}");
}
assert!(load(&mut p, &json!({"useSet": "Nope"})).is_err());
assert!(load(&mut p, &json!({"useSet": 3})).is_err());
assert!(store(&mut p, &json!({"deleteSet": "Nope"})).is_err());
assert!(store(&mut p, &json!({"activeSet": "Nope"})).is_err());
assert!(store(&mut p, &json!({"sets": []})).is_err());
assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}})).is_err(), "no key");
let nine: Vec<String> = (1..=9).map(|i| format!("F{i}")).collect();
assert!(store(&mut p, &json!({"sets": {"A": {"alternateShortcuts": {"edit.copy": nine}}}})).is_err());
assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": 5}}})).is_err());
let many: serde_json::Map<String, Value> = (0..=MAX_SETS).map(|i| (format!("S{i}"), json!({}))).collect();
assert!(store(&mut p, &json!({ "sets": many })).is_err());
for i in 0..MAX_SETS {
store(&mut p, &json!({"saveSet": format!("S{i}")})).unwrap();
}
assert!(store(&mut p, &json!({"saveSet": "One more"})).is_err());
assert!(store(&mut p, &json!({"saveSet": "S3"})).is_ok(), "saving over a set is fine at the cap");
}
#[test]
fn sets_replace_all_and_active_set_follows() {
let mut p = prefs();
store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "F9"}}, "B": {}}, "activeSet": "A"})).unwrap();
assert_eq!(p.shortcut_set, "A");
assert!(modified(&p), "activeSet names the set; it doesn't load it");
load(&mut p, &json!({"useSet": "A"})).unwrap();
assert!(!modified(&p));
store(&mut p, &json!({"activeSet": "PhotoCraft Defaults"})).unwrap();
assert_eq!(p.shortcut_set, "");
}
#[test]
fn summarize_lists_bound_commands_by_section_for_each_platform() {
let sections = vec![
("Edit".to_string(), vec![("Copy".to_string(), vec!["Cmd+C".to_string(), "F3".to_string()]), ("Unbound".to_string(), vec![])]),
("Empty".to_string(), vec![("Nothing".to_string(), vec![])]),
("Filter <x>".to_string(), vec![("Gaussian Blur & more".to_string(), vec!["Ctrl+Alt+Cmd+G".to_string()])]),
];
let mac = summarize("My <Set>", &sections, true);
assert!(mac.contains("<title>My &lt;Set&gt;</title>") && mac.contains("<td>Copy</td><td>Cmd+C, F3</td>"), "{mac}");
assert!(mac.contains("Cmd+Control+Option+G") && mac.contains("Gaussian Blur &amp; more") && mac.contains("Filter &lt;x&gt;"), "{mac}");
assert!(!mac.contains("Unbound") && !mac.contains("Empty"), "{mac}");
let pc = summarize("S", &sections, false);
assert!(pc.contains("<td>Ctrl+C, F3</td>") && pc.contains("Ctrl+Alt+G"), "{pc}");
assert_eq!(key_label("Cmd++", true), "Cmd++");
}
#[test]
fn bound_lists_main_keys_then_alternates() {
let p = prefs();
let b = bound(&p);
let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand"));
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Edit › Keyboard Shortcuts › Set through `edit.keyboardShortcuts`: save, switch, delete,
//! export to `.kys` and back, Summarize, and bad params as errors.
use photocraft_engine::Session;
use serde_json::{Value, json};
fn ks(s: &mut Session, p: Value) -> Value {
s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts")
}
#[test]
fn a_set_saves_switches_exports_and_imports_back() {
let mut s = Session::new();
let r = ks(&mut s, json!({}));
assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["defaultsName"].as_str()), (Some(""), Some(false), Some("PhotoCraft Defaults")));
// Edit, then save the bindings as they end up after this call's edits.
let r = ks(&mut s, json!({"set": {"edit.copy": "F9"}, "alternates": {"edit.copy": []}, "saveSet": "Mine"}));
assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["sets"].clone()), (Some("Mine"), Some(false), json!(["Mine"])));
// Export the set, go back to the defaults, then import the file: the bindings return.
let xml = ks(&mut s, json!({"exportKys": true}))["kys"].as_str().expect("kys").to_string();
assert!(xml.contains("KeyboardCustomization/Mine=Mine") && xml.contains("pc-id=\"edit.copy\"><shortcut>F9</shortcut></command>"), "{xml}");
let r = ks(&mut s, json!({"useSet": ""}));
assert_eq!((r["overrides"].clone(), r["activeSet"].as_str()), (json!({}), Some("")));
let r = ks(&mut s, json!({"reset": true, "importKys": xml}));
assert_eq!(r["overrides"]["edit.copy"], "F9");
assert_eq!(r["alternateOverrides"]["edit.copy"], json!([]), "Copy's F3 stays removed");
assert_eq!(r["modified"], true, "imported over the defaults");
// Switch back to the saved set; a later unsaved edit marks it modified.
ks(&mut s, json!({"useSet": "Mine"}));
let r = ks(&mut s, json!({"set": {"edit.cut": "F8"}}));
assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool()), (Some("Mine"), Some(true)));
// Summarize lists what's bound, per platform.
let html = ks(&mut s, json!({"summarize": {"mac": false}}))["html"].as_str().expect("html").to_string();
assert!(html.contains("<title>Mine</title>") && html.contains("<td>Copy</td><td>F9</td>") && html.contains("<td>Cut</td><td>F8"), "{html}");
// Delete the set in use: the defaults' name, the bindings kept.
let r = ks(&mut s, json!({"deleteSet": "Mine"}));
assert_eq!((r["sets"].clone(), r["activeSet"].as_str(), r["overrides"]["edit.copy"].as_str()), (json!([]), Some(""), Some("F9")));
}
#[test]
fn bad_set_params_change_nothing() {
let mut s = Session::new();
ks(&mut s, json!({"saveSet": "Keep"}));
let before = s.prefs().clone();
for p in [
json!({"useSet": "Missing"}),
json!({"useSet": 7}),
json!({"saveSet": ""}),
json!({"saveSet": "PhotoCraft Defaults"}),
json!({"deleteSet": "Missing"}),
json!({"activeSet": "Missing"}),
json!({"sets": "nope"}),
json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}}),
// A bad key elsewhere in the call keeps the set from being saved too.
json!({"set": {"edit.copy": "Cmd+Shift+Ctrl"}, "saveSet": "New"}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
assert_eq!(s.prefs(), &before, "{p} changed the preferences");
}
}
+3 -2
View File
@@ -273,13 +273,14 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false });
}
if is_psd(bytes) {
let file = PsdFile::from_bytes(bytes)?;
// A merged image the import reads is decoded with the parse: a ZIP one is inflated once.
let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?;
// Nesting past the document model's cap could never be saved; reject the file with the
// actionable limit.
psd_import::check_group_depth(&file)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(0.05);
let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?;
let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?;
document.name = name.to_string();
text_import::prepare(&mut document);
return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false });
+22 -3
View File
@@ -737,6 +737,26 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
/// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports
/// progress. `None` when cancelled.
pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec<String>)> {
psd_to_document_decoded(file, ctl, None)
}
/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is*
/// the image), a file without layers, or extra alpha/spot channels behind the colour ones.
pub(crate) fn merged_needed(file: &PsdFile) -> bool {
let h = &file.header;
let mode = doc_mode(h.color_mode).unwrap_or(ColorMode::Rgb);
// A layered mode is its own pixel format, so these are the document's colour channels.
let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1;
!layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha())
}
/// [`psd_to_document_with`] with the merged image already decoded (by
/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again.
pub(crate) fn psd_to_document_decoded(
file: &PsdFile,
ctl: &photocraft_raster::Interrupt,
decoded: Option<photocraft_psd::Result<Vec<u8>>>,
) -> Option<(Document, Vec<String>)> {
let h = &file.header;
let mut warnings = Vec::new();
let mode = doc_mode(h.color_mode).unwrap_or_else(|| {
@@ -859,9 +879,8 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
// multichannel file (it *is* the image), or extra alpha/spot channels behind the colour
// ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly
// half the file's bytes, decoded and thrown away on every open before this.
let extra = usize::from(h.channels) > cc + usize::from(file.merged_has_alpha());
let merged = if !layered || file.layers().is_empty() || extra {
let m = file.decode_merged();
let merged = if merged_needed(file) {
let m = decoded.unwrap_or_else(|| file.decode_merged());
if ctl.cancelled() {
return None;
}
+2
View File
@@ -20,6 +20,8 @@ A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB
```rust
// Top level
PsdFile::from_bytes(&[u8]) -> Result<PsdFile>
PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option<Result<Vec<u8>>>)>
// + decode_merged() when want(&file): ZIP inflated once
PsdFile::to_bytes(&self) -> Result<Vec<u8>>
PsdFile::open(path) / save(path) // native only
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement,
+5 -2
View File
@@ -84,7 +84,8 @@ impl PixelData {
}
fn composite_plane(&self, c: usize, n: usize) -> Vec<u8> {
// Photoshop stores transparent RGB composites against white; layer channels stay straight.
// Photoshop stores transparent RGB composites against white; layer channels stay straight,
// and so do Gray and CMYK composites here (see `PsdBuilder::composite`).
match self {
Self::Rgba8(v) if c < 3 => v
.as_chunks::<4>()
@@ -290,7 +291,9 @@ impl PsdBuilder {
Ok(self)
}
/// Supplies the merged composite (straight samples, same format as layers, full canvas).
/// Transparent RGB is stored against white, quantized at the document depth.
/// Transparent RGB is stored against white, quantized at the document depth. Gray and CMYK
/// composites with alpha are stored straight (not matted against white as Photoshop would):
/// a test that reads them as a Photoshop merged image has to allow for that.
pub fn composite(&mut self, pixels: PixelData) -> &mut Self {
self.composite = Some(pixels);
self
+8
View File
@@ -279,10 +279,18 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl
}
}
#[cfg(test)]
thread_local! {
/// ZIP streams inflated on this thread (tests count passes over the merged image).
pub(crate) static INFLATES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// Keep only the requested pixels, but consume the trailer to verify Adler-32.
// A bounded surplus tolerates padding without unbounded decompression work.
fn inflate_checked(data: &[u8], expected: u64, keep: bool) -> Result<Vec<u8>> {
const OOM: PsdError = PsdError::LimitExceeded("not enough memory for the decoded channel data");
#[cfg(test)]
INFLATES.with(|n| n.set(n.get() + 1));
let mut decoder = flate2::Decompress::new(true);
let mut out = Vec::new();
if keep {
+79 -2
View File
@@ -1,5 +1,6 @@
//! The top-level [`PsdFile`] model, parser and writer.
use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, validate_planes};
use crate::error::{PsdError, Result};
use crate::header::{ColorMode, Header, Version};
use crate::image_data::ImageData;
@@ -90,6 +91,17 @@ pub struct PsdFile {
impl PsdFile {
/// Parses a PSD or PSB file.
pub fn from_bytes(data: &[u8]) -> Result<Self> {
Self::from_bytes_and_merged(data, |_| false).map(|(file, _)| file)
}
/// Parses like [`Self::from_bytes`] and, when `want` asks for it, decodes the merged image
/// (as [`Self::decode_merged`] would) in the same pass. `want` sees the file with everything
/// but the merged image parsed (its `image_data` is still empty). A ZIP merged image is then
/// inflated once for both the parse's check of the stream and the decode, not twice.
///
/// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but
/// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it.
pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option<Result<Vec<u8>>>)> {
let mut r = Reader::new(data);
let header = Header::read(&mut r)?;
let psb = header.version.is_psb();
@@ -146,8 +158,39 @@ impl PsdFile {
layer_mask_trailing = s.peek_rest().to_vec();
}
}
let image_data = ImageData::read(&mut r, &header)?;
Ok(PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data })
let image_data = ImageData { compression: Compression::Raw, data: Vec::new() };
let mut file =
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data };
let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None };
if merged.is_none() {
file.image_data = ImageData::read(&mut r, &file.header)?;
}
Ok((file, merged))
}
/// Reads the merged image into `self.image_data` and decodes it. A ZIP stream within the
/// decode limit is inflated once: by the decode, whose stream check is the one
/// [`ImageData::read`] makes. Only when the decode fails is that check run on its own, to
/// tell a file the parse rejects (the outer `Err`) from one it accepts (the inner `Err`).
fn read_merged_decoded(&mut self, r: &mut Reader<'_>) -> Result<Result<Vec<u8>>> {
let layout = ImageData::layout(&self.header);
let compression = Compression::from_u16(r.clone().u16()?);
let zip = matches!(compression, Compression::Zip | Compression::ZipPrediction);
if !zip || layout.total_bytes()? > MAX_DECODED_BYTES {
// Nothing is inflated twice here: the parse's check reads sizes only (or skips an
// over-limit stream, which the decode then refuses).
self.image_data = ImageData::read(r, &self.header)?;
return Ok(self.decode_merged());
}
r.u16()?;
let data = r.peek_rest();
let decoded = decode_planes(compression, data, &layout);
if decoded.is_err() {
validate_planes(compression, data, &layout)?;
}
self.image_data = ImageData { compression, data: data.to_vec() };
r.skip(data.len())?;
Ok(decoded)
}
/// Serializes the file.
@@ -374,6 +417,40 @@ mod write_tests {
let file = PsdFile::from_bytes(&bytes).unwrap();
assert!(!file.layers().is_empty());
assert!(matches!(file.decode_merged(), Err(PsdError::LimitExceeded(_))));
let (same, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| true).unwrap();
assert_eq!(same, file);
assert!(matches!(merged, Some(Err(PsdError::LimitExceeded(_)))));
}
/// Parsing and decoding a ZIP merged image together inflates it once (the parse's stream
/// check is the decode's), with the same model, pixels and errors as the two steps apart.
#[test]
fn merged_zip_image_is_inflated_once_when_decoded_with_the_parse() {
use crate::compression::INFLATES;
let inflates = |f: &dyn Fn()| {
let before = INFLATES.with(std::cell::Cell::get);
f();
INFLATES.with(std::cell::Cell::get) - before
};
for compression in [Compression::Zip, Compression::ZipPrediction] {
let bytes = testgen::small(Version::Psd, compression).to_bytes().unwrap();
let file = PsdFile::from_bytes(&bytes).unwrap();
let pixels = file.decode_merged().unwrap();
let apart = inflates(&|| drop(PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap()));
let together = inflates(&|| {
let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |f| f.image_data.data.is_empty()).unwrap();
assert_eq!(parsed, file);
assert_eq!(merged.unwrap().unwrap(), pixels);
});
assert_eq!(together + 1, apart, "{compression:?}");
let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| false).unwrap();
assert_eq!((parsed, merged), (file, None));
// A broken stream (its checksum) is still the parse's error.
let mut broken = bytes.clone();
*broken.last_mut().unwrap() ^= 0xff;
let alone = PsdFile::from_bytes(&broken).unwrap_err().to_string();
assert_eq!(PsdFile::from_bytes_and_merged(&broken, |_| true).unwrap_err().to_string(), alone);
}
}
#[test]
+20 -6
View File
@@ -325,13 +325,27 @@ impl TaggedBlock {
}
pub(crate) fn write(&self, out: &mut Vec<u8>, version: Version, alignment: usize) -> Result<()> {
out.put(&self.signature);
out.put(&self.key);
out.put_len(self.data.len() as u64, uses_long_length(version, &self.key))?;
out.put(&self.data);
match &self.padding {
Self::write_parts(out, self.signature, self.key, &self.data, self.padding.as_deref(), version, alignment)
}
/// Writes a block from its parts (no owned [`TaggedBlock`] needed). `padding: None` writes
/// zeros up to `alignment`.
pub(crate) fn write_parts(
out: &mut Vec<u8>,
signature: [u8; 4],
key: [u8; 4],
data: &[u8],
padding: Option<&[u8]>,
version: Version,
alignment: usize,
) -> Result<()> {
out.put(&signature);
out.put(&key);
out.put_len(data.len() as u64, uses_long_length(version, &key))?;
out.put(data);
match padding {
Some(p) => out.put(p),
None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(self.data.len(), alignment))),
None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(data.len(), alignment))),
}
Ok(())
}
+111 -29
View File
@@ -33,7 +33,7 @@ use crate::image_data::ImageData;
use crate::io::{Reader, WriteExt};
use crate::layer::{LayerInfo, LayerMask, mask_parameter_bytes};
use crate::resources::ImageResource;
use crate::tagged::{TaggedBlock, read_blocks, uses_long_length, write_blocks};
use crate::tagged::{TaggedBlock, read_blocks, uses_long_length};
/// The byte order of a TIFF container (and so of the Photoshop data inside it).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
@@ -153,7 +153,7 @@ impl ImageSourceData {
/// dropped because they could not be converted to a little-endian layout.
pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec<u8>, Vec<String>)> {
let version = self.version;
let mut blocks = Vec::with_capacity(self.global_blocks.len() + 2);
let mut body = Vec::new();
if let Some(info) = &self.layer_info {
// Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`.
let pad = info.pad_len(version, 4)?;
@@ -161,21 +161,17 @@ impl ImageSourceData {
data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?;
info.write_unpadded(&mut data, version)?;
data.resize(data.len() + pad, 0);
blocks.push(padded_block(self.layer_info_key, data));
padded_block(self.layer_info_key, data).write(&mut body, version, 4)?;
}
// Blocks without explicit padding are padded to 4 as they are written (alignment 4), so
// the global mask and blocks are written from the model, without a copy of their data.
// The signature stays (a foreign-order marker included).
if let Some(m) = &self.global_layer_mask {
blocks.push(padded_block(*b"LMsk", m.data.clone()));
TaggedBlock::write_parts(&mut body, *b"8BIM", *b"LMsk", &m.data, None, version, 4)?;
}
for b in &self.global_blocks {
let mut b = b.clone();
if b.padding.is_none() {
// Padded to 4 like the others; the signature stays (a foreign-order marker included).
b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]);
}
blocks.push(b);
b.write(&mut body, version, 4)?;
}
let mut body = Vec::new();
write_blocks(&mut body, &blocks, version, 2)?;
body.put(&self.trailing);
let (body, warnings) =
if order.is_little() { transcode::transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little)? } else { (body, Vec::new()) };
@@ -344,6 +340,19 @@ pub mod transcode {
(self.out, self.warnings)
}
/// A walker over `data` (a nested structure) that writes into this walker's output: it
/// takes the buffer, so a nested structure is written once, not copied up per level. Hand
/// the buffer back with [`Self::adopt`] before returning, error or not.
fn nested<'b>(&mut self, data: &'b [u8], depth: usize) -> Tx<'b> {
let r = if self.from.is_little() { Reader::new_little(data) } else { Reader::new(data) };
Tx { r, out: std::mem::take(&mut self.out), from: self.from, to: self.to, version: self.version, warnings: Vec::new(), depth }
}
/// Takes the output buffer back from a [`Self::nested`] walker.
fn adopt(&mut self, child: &mut Tx<'_>) {
self.out = std::mem::take(&mut child.out);
}
fn same_order(&self) -> bool {
self.from == self.to
}
@@ -469,15 +478,15 @@ pub mod transcode {
/// Transcodes the next `len` bytes with `f`; whatever `f` leaves is copied verbatim.
fn region(&mut self, len: u64, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> {
let data = self.r.bytes_u64(len)?;
let mut child = Tx::new(data, self.version, self.from, self.to);
child.depth = self.depth;
f(&mut child)?;
child.rest()?;
if child.out.len() != data.len() {
let start = self.out.len();
let mut child = self.nested(data, self.depth);
let res = f(&mut child).and_then(|()| child.rest());
self.adopt(&mut child);
res?;
if self.out.len() - start != data.len() {
return Err(PsdError::invalid("byte-order conversion changed a structure's length"));
}
self.warnings.append(&mut child.warnings);
self.out.extend_from_slice(&child.out);
Ok(())
}
@@ -500,13 +509,13 @@ pub mod transcode {
len = len.checked_sub(4).ok_or_else(|| PsdError::invalid("length shorter than its own field"))?;
}
let data = self.r.bytes_u64(len)?;
let mut child = Tx::new(data, self.version, self.from, self.to);
child.depth = self.depth;
f(&mut child)?;
child.rest()?;
let start = self.out.len();
let mut child = self.nested(data, self.depth);
let res = f(&mut child).and_then(|()| child.rest());
self.adopt(&mut child);
res?;
self.warnings.append(&mut child.warnings);
self.patch_len(at, long, child.out.len() as u64 + if inclusive { 4 } else { 0 })?;
self.out.extend_from_slice(&child.out);
self.patch_len(at, long, (self.out.len() - start) as u64 + if inclusive { 4 } else { 0 })?;
Ok(())
}
@@ -704,8 +713,9 @@ pub mod transcode {
let len_at = self.out.len();
self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] });
let data = self.r.bytes_u64(len)?;
let mut child = Tx::new(data, self.version, self.from, self.to);
child.depth = self.depth + 1;
// The body is transcoded straight into this output, after the length placeholder.
let data_at = self.out.len();
let mut child = self.nested(data, self.depth + 1);
let handled = if child.depth > MAX_DEPTH {
Err(PsdError::LimitExceeded("tagged block nesting too deep"))
} else if data.is_empty() {
@@ -713,16 +723,21 @@ pub mod transcode {
} else {
child.block_body(&key)
};
let rest = if matches!(handled, Ok(true)) { child.rest() } else { Ok(()) };
self.adopt(&mut child);
rest?;
if !matches!(handled, Ok(true)) {
// A body that was not transcoded leaves nothing: it is copied as stored or dropped.
self.out.truncate(data_at);
}
// Padding: the smallest k in 0..=3 after which the next block (or the end) starts.
let rest = self.r.peek_rest();
let pad = (0..=3usize).find(|&k| k == rest.len() || (k < rest.len() && is_sig(&rest[k..], self.from.is_little()))).unwrap_or(0);
let pad_bytes = self.r.bytes(pad)?;
match handled {
Ok(true) => {
child.rest()?;
self.warnings.append(&mut child.warnings);
self.patch_len(len_at, long, child.out.len() as u64)?;
self.out.extend_from_slice(&child.out);
self.patch_len(len_at, long, (self.out.len() - data_at) as u64)?;
self.out.extend_from_slice(pad_bytes);
}
Ok(false) if self.same_order() => {
@@ -2330,4 +2345,71 @@ mod tests {
assert_eq!(again.global_blocks, expected.global_blocks);
assert_eq!(again.layer_tree().len(), file.layer_tree().len());
}
/// Global blocks are padded as they are written, not on a copy: the bytes are those of the
/// former path (each block cloned with explicit padding to 4, written with `write_blocks`).
#[test]
fn global_blocks_written_without_copies_keep_their_bytes() {
for version in [Version::Psd, Version::Psb] {
let mut d = ImageSourceData { version, global_layer_mask: Some(GlobalLayerMask { data: vec![0, 1, 0, 2, 0, 3, 0, 4, 9] }), ..Default::default() };
for n in 0..8u8 {
d.global_blocks.push(TaggedBlock::new(*b"FMsk", (0..n).collect()));
}
let mut explicit = TaggedBlock::new(*b"FMsk", vec![1, 2, 3]);
explicit.padding = Some(vec![]);
d.global_blocks.push(explicit);
let mut odd = TaggedBlock::new(*b"FMsk", vec![1, 2]);
odd.padding = Some(vec![0, 0, 0, 0, 0]);
d.global_blocks.push(odd);
let mut copies = vec![padded_block(*b"LMsk", d.global_layer_mask.as_ref().unwrap().data.clone())];
for b in &d.global_blocks {
let mut b = b.clone();
if b.padding.is_none() {
b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]);
}
copies.push(b);
}
let mut body = Vec::new();
crate::tagged::write_blocks(&mut body, &copies, version, 2).unwrap();
let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE };
assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat());
let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap();
assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat());
}
}
/// Nested structures are transcoded into one shared buffer. A block whose body fails
/// part-way leaves no partial bytes: dropped on the way out (with the enclosing lengths
/// patched as if it had never been there), or kept exactly as stored on the way in.
#[test]
fn a_nested_block_that_fails_part_way_leaves_nothing_behind() {
let clean = sample_layers();
let mut fx = Vec::new();
fx.put_u32(0);
fx.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("Opct", Value::Double(1.0))).to_bytes());
// A whole number of 4-byte units, so dropping it cannot change any padding.
fx.resize(fx.len().next_multiple_of(4), 0);
let mut d = clean.clone();
d.layer_info.as_mut().unwrap().layers[0].blocks.insert(0, TaggedBlock::new(*b"lfx2", fx.clone()));
let (mut little, w) = d.to_bytes(ByteOrder::Little).unwrap();
assert!(!w.iter().any(|w| w.starts_with("lfx2")), "{w:?}");
// An unknown value type after the descriptor's header: parsing fails part-way.
let at = little.windows(4).position(|w| w == b"buod").unwrap();
little[at..at + 4].copy_from_slice(b"????");
let (back, w) = ImageSourceData::from_bytes(&little).unwrap();
assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}");
let kept = &back.layer_info.as_ref().unwrap().layers[0].blocks[0];
assert!(kept.is_foreign_order());
let stored = little.windows(4).position(|w| w == b"2xfl").unwrap() + 8;
assert_eq!(kept.data, little[stored..stored + fx.len()]);
// Big-endian data that fails part-way cannot go into a little-endian file: dropped.
let mut big_bad = d.clone();
let mut broken = fx.clone();
let at = broken.windows(4).position(|w| w == b"doub").unwrap();
broken[at..at + 4].copy_from_slice(b"????");
big_bad.layer_info.as_mut().unwrap().layers[0].blocks[0].data = broken;
let (little, w) = big_bad.to_bytes(ByteOrder::Little).unwrap();
assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}");
assert_eq!(little, clean.to_bytes(ByteOrder::Little).unwrap().0);
}
}
+14
View File
@@ -3783,3 +3783,17 @@ navigator Zoom Zvětšení
# View › Guides › right-click a guide
Edit Selected Guides Upravit vybraná vodítka
Clear Selected Guides Vymazat vybraná vodítka
Delete this set Smazat tuto sadu
New Set… Nová sada…
PhotoCraft Defaults Výchozí PhotoCraft
Save a list of these shortcuts as a web page Uložit seznam těchto zkratek jako webovou stránku
Save the changes to this set Uložit změny do této sady
Save these shortcuts as a .kys file Uložit tyto zkratky jako soubor .kys
Save these shortcuts as a new set Uložit tyto zkratky jako novou sadu
Set deleted. Sada smazána.
Set saved. Sada uložena.
Set: Sada:
Summarize… Shrnout…
There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte.
set name název sady
{set} (modified) {set} (změněno)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3177,3 +3177,17 @@ curves Smooth Glätten
# View › Guides › right-click a guide
Edit Selected Guides Ausgewählte Hilfslinien bearbeiten
Clear Selected Guides Ausgewählte Hilfslinien löschen
Delete this set Diesen Satz löschen
New Set… Neuer Satz…
PhotoCraft Defaults PhotoCraft-Standard
Save a list of these shortcuts as a web page Liste dieser Kurzbefehle als Webseite speichern
Save the changes to this set Änderungen in diesem Satz speichern
Save these shortcuts as a .kys file Diese Kurzbefehle als .kys-Datei speichern
Save these shortcuts as a new set Diese Kurzbefehle als neuen Satz speichern
Set deleted. Satz gelöscht.
Set saved. Satz gespeichert.
Set: Satz:
Summarize… Zusammenfassen…
There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen.
set name Name des Satzes
{set} (modified) {set} (geändert)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3875,3 +3875,17 @@ curves Smooth Εξομάλυνση
# View › Guides › right-click a guide
Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών
Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών
Delete this set Διαγραφή αυτού του συνόλου
New Set… Νέο σύνολο…
PhotoCraft Defaults Προεπιλογές PhotoCraft
Save a list of these shortcuts as a web page Αποθήκευση λίστας αυτών των συντομεύσεων ως ιστοσελίδας
Save the changes to this set Αποθήκευση των αλλαγών σε αυτό το σύνολο
Save these shortcuts as a .kys file Αποθήκευση αυτών των συντομεύσεων ως αρχείου .kys
Save these shortcuts as a new set Αποθήκευση αυτών των συντομεύσεων ως νέου συνόλου
Set deleted. Το σύνολο διαγράφηκε.
Set saved. Το σύνολο αποθηκεύτηκε.
Set: Σύνολο:
Summarize… Σύνοψη…
There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα.
set name όνομα συνόλου
{set} (modified) {set} (τροποποιημένο)
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+14
View File
@@ -4137,3 +4137,17 @@ navigator Zoom Zoom
# View › Guides › right-click a guide
Edit Selected Guides Editar guías seleccionadas
Clear Selected Guides Borrar guías seleccionadas
Delete this set Eliminar este conjunto
New Set… Nuevo conjunto…
PhotoCraft Defaults Valores por defecto de PhotoCraft
Save a list of these shortcuts as a web page Guardar una lista de estos métodos abreviados como página web
Save the changes to this set Guardar los cambios en este conjunto
Save these shortcuts as a .kys file Guardar estos métodos abreviados como archivo .kys
Save these shortcuts as a new set Guardar estos métodos abreviados como un conjunto nuevo
Set deleted. Conjunto eliminado.
Set saved. Conjunto guardado.
Set: Conjunto:
Summarize… Resumir…
There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero.
set name nombre del conjunto
{set} (modified) {set} (modificado)
Can't render this file because it contains an unexpected character in line 4 and column 47.
+14
View File
@@ -3179,4 +3179,18 @@ curves Smooth Lisser
# View › Guides › right-click a guide
Edit Selected Guides Modifier les repères sélectionnés
Clear Selected Guides Effacer les repères sélectionnés
Delete this set Supprimer ce jeu
New Set… Nouveau jeu…
PhotoCraft Defaults Valeurs par défaut de PhotoCraft
Save a list of these shortcuts as a web page Enregistrer la liste de ces raccourcis en page web
Save the changes to this set Enregistrer les modifications de ce jeu
Save these shortcuts as a .kys file Enregistrer ces raccourcis dans un fichier .kys
Save these shortcuts as a new set Enregistrer ces raccourcis comme nouveau jeu
Set deleted. Jeu supprimé.
Set saved. Jeu enregistré.
Set: Jeu :
Summarize… Résumer…
There are too many sets: delete one first. Il y a trop de jeux : supprimez-en un d’abord.
set name nom du jeu
{set} (modified) {set} (modifié)
Random Aléatoire
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -4094,3 +4094,17 @@ cameraRaw Yellow Sárga
# View › Guides › right-click a guide
Edit Selected Guides Kijelölt segédvonalak szerkesztése
Clear Selected Guides Kijelölt segédvonalak törlése
Delete this set Készlet törlése
New Set… Új készlet…
PhotoCraft Defaults PhotoCraft alapértelmezések
Save a list of these shortcuts as a web page A billentyűparancsok listájának mentése weboldalként
Save the changes to this set A készlet változásainak mentése
Save these shortcuts as a .kys file A billentyűparancsok mentése .kys fájlként
Save these shortcuts as a new set A billentyűparancsok mentése új készletként
Set deleted. Készlet törölve.
Set saved. Készlet mentve.
Set: Készlet:
Summarize… Összegzés…
There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet.
set name készlet neve
{set} (modified) {set} (módosítva)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3166,4 +3166,18 @@ curves Smooth Haluskan
# View › Guides › right-click a guide
Edit Selected Guides Edit Panduan Terpilih
Clear Selected Guides Hapus Panduan Terpilih
Delete this set Hapus set ini
New Set… Set Baru…
PhotoCraft Defaults Bawaan PhotoCraft
Save a list of these shortcuts as a web page Simpan daftar pintasan ini sebagai halaman web
Save the changes to this set Simpan perubahan ke set ini
Save these shortcuts as a .kys file Simpan pintasan ini sebagai file .kys
Save these shortcuts as a new set Simpan pintasan ini sebagai set baru
Set deleted. Set dihapus.
Set saved. Set disimpan.
Set: Set:
Summarize… Ringkas…
There are too many sets: delete one first. Terlalu banyak set: hapus satu terlebih dahulu.
set name nama set
{set} (modified) {set} (diubah)
Random Acak
Can't render this file because it contains an unexpected character in line 4 and column 47.
+14
View File
@@ -4296,3 +4296,17 @@ shape Appearance Aspetto
# View › Guides › right-click a guide
Edit Selected Guides Modifica guide selezionate
Clear Selected Guides Cancella guide selezionate
Delete this set Elimina questo set
New Set… Nuovo set…
PhotoCraft Defaults Predefiniti di PhotoCraft
Save a list of these shortcuts as a web page Salva un elenco di queste scorciatoie come pagina web
Save the changes to this set Salva le modifiche in questo set
Save these shortcuts as a .kys file Salva queste scorciatoie come file .kys
Save these shortcuts as a new set Salva queste scorciatoie come nuovo set
Set deleted. Set eliminato.
Set saved. Set salvato.
Set: Set:
Summarize… Riepiloga…
There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno.
set name nome del set
{set} (modified) {set} (modificato)
Can't render this file because it contains an unexpected character in line 2654 and column 53.
+14
View File
@@ -3851,3 +3851,17 @@ curves Smooth スムーズ
# View › Guides › right-click a guide
Edit Selected Guides 選択したガイドを編集
Clear Selected Guides 選択したガイドを消去
Delete this set このセットを削除
New Set… 新規セット…
PhotoCraft Defaults PhotoCraft 初期設定
Save a list of these shortcuts as a web page これらのショートカットの一覧を Web ページとして保存
Save the changes to this set このセットへの変更を保存
Save these shortcuts as a .kys file これらのショートカットを .kys ファイルとして保存
Save these shortcuts as a new set これらのショートカットを新規セットとして保存
Set deleted. セットを削除しました。
Set saved. セットを保存しました。
Set: セット:
Summarize… 概要…
There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。
set name セット名
{set} (modified) {set}(変更あり)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3353,3 +3353,17 @@ curves Smooth 매끄럽게
# View › Guides › right-click a guide
Edit Selected Guides 선택한 안내선 편집
Clear Selected Guides 선택한 안내선 지우기
Delete this set 이 세트 삭제
New Set… 새 세트…
PhotoCraft Defaults PhotoCraft 기본값
Save a list of these shortcuts as a web page 이 단축키 목록을 웹 페이지로 저장
Save the changes to this set 이 세트에 변경 사항 저장
Save these shortcuts as a .kys file 이 단축키를 .kys 파일로 저장
Save these shortcuts as a new set 이 단축키를 새 세트로 저장
Set deleted. 세트를 삭제했습니다.
Set saved. 세트를 저장했습니다.
Set: 세트:
Summarize… 요약…
There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요.
set name 세트 이름
{set} (modified) {set}(수정됨)
Can't render this file because it contains an unexpected character in line 3166 and column 53.
+14
View File
@@ -3935,3 +3935,17 @@ curves Smooth Vloeiend maken
# View › Guides › right-click a guide
Edit Selected Guides Geselecteerde hulplijnen bewerken
Clear Selected Guides Geselecteerde hulplijnen wissen
Delete this set Deze set verwijderen
New Set… Nieuwe set…
PhotoCraft Defaults PhotoCraft-standaard
Save a list of these shortcuts as a web page Een lijst van deze sneltoetsen als webpagina opslaan
Save the changes to this set De wijzigingen in deze set opslaan
Save these shortcuts as a .kys file Deze sneltoetsen als .kys-bestand opslaan
Save these shortcuts as a new set Deze sneltoetsen als nieuwe set opslaan
Set deleted. Set verwijderd.
Set saved. Set opgeslagen.
Set: Set:
Summarize… Samenvatten…
There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een.
set name naam van de set
{set} (modified) {set} (gewijzigd)
Can't render this file because it contains an unexpected character in line 8 and column 34.
+14
View File
@@ -3773,3 +3773,17 @@ curves Smooth Wygładź
# View › Guides › right-click a guide
Edit Selected Guides Edytuj zaznaczone prowadnice
Clear Selected Guides Usuń zaznaczone prowadnice
Delete this set Usuń ten zestaw
New Set… Nowy zestaw…
PhotoCraft Defaults Domyślne PhotoCraft
Save a list of these shortcuts as a web page Zapisz listę tych skrótów jako stronę internetową
Save the changes to this set Zapisz zmiany w tym zestawie
Save these shortcuts as a .kys file Zapisz te skróty jako plik .kys
Save these shortcuts as a new set Zapisz te skróty jako nowy zestaw
Set deleted. Zestaw usunięty.
Set saved. Zestaw zapisany.
Set: Zestaw:
Summarize… Podsumuj…
There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden.
set name nazwa zestawu
{set} (modified) {set} (zmieniony)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3965,3 +3965,17 @@ layerLabel No Color Sem cor
# View › Guides › right-click a guide
Edit Selected Guides Editar guias selecionadas
Clear Selected Guides Limpar guias selecionadas
Delete this set Excluir este conjunto
New Set… Novo conjunto…
PhotoCraft Defaults Padrões do PhotoCraft
Save a list of these shortcuts as a web page Salvar uma lista destes atalhos como página da web
Save the changes to this set Salvar as alterações neste conjunto
Save these shortcuts as a .kys file Salvar estes atalhos como arquivo .kys
Save these shortcuts as a new set Salvar estes atalhos como um novo conjunto
Set deleted. Conjunto excluído.
Set saved. Conjunto salvo.
Set: Conjunto:
Summarize… Resumir…
There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro.
set name nome do conjunto
{set} (modified) {set} (modificado)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3144,4 +3144,18 @@ curves Smooth Сгладить
# View › Guides › right-click a guide
Edit Selected Guides Изменить выбранные направляющие
Clear Selected Guides Удалить выбранные направляющие
Delete this set Удалить этот набор
New Set… Новый набор…
PhotoCraft Defaults Стандартные PhotoCraft
Save a list of these shortcuts as a web page Сохранить список этих комбинаций как веб-страницу
Save the changes to this set Сохранить изменения в этом наборе
Save these shortcuts as a .kys file Сохранить эти комбинации как файл .kys
Save these shortcuts as a new set Сохранить эти комбинации как новый набор
Set deleted. Набор удалён.
Set saved. Набор сохранён.
Set: Набор:
Summarize… Сводка…
There are too many sets: delete one first. Слишком много наборов: сначала удалите один.
set name имя набора
{set} (modified) {set} (изменён)
Random Случайно
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -4248,3 +4248,17 @@ shape Appearance Izgled
# View › Guides › right-click a guide
Edit Selected Guides Uredi izabrane vodilje
Clear Selected Guides Ukloni izabrane vodilje
Delete this set Obriši ovaj skup
New Set… Novi skup…
PhotoCraft Defaults Podrazumevano za PhotoCraft
Save a list of these shortcuts as a web page Sačuvaj listu ovih prečica kao veb stranicu
Save the changes to this set Sačuvaj izmene u ovom skupu
Save these shortcuts as a .kys file Sačuvaj ove prečice kao .kys datoteku
Save these shortcuts as a new set Sačuvaj ove prečice kao novi skup
Set deleted. Skup je obrisan.
Set saved. Skup je sačuvan.
Set: Skup:
Summarize… Rezime…
There are too many sets: delete one first. Previše skupova: prvo obrišite jedan.
set name ime skupa
{set} (modified) {set} (izmenjen)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -3905,3 +3905,17 @@ cameraData Lens Lens
# View › Guides › right-click a guide
Edit Selected Guides Seçili Kılavuzları Düzenle
Clear Selected Guides Seçili Kılavuzları Temizle
Delete this set Bu Kümeyi Sil
New Set… Yeni Küme…
PhotoCraft Defaults PhotoCraft Varsayılanları
Save a list of these shortcuts as a web page Bu kısayolların listesini web sayfası olarak kaydet
Save the changes to this set Bu kümedeki değişiklikleri kaydet
Save these shortcuts as a .kys file Bu kısayolları .kys dosyası olarak kaydet
Save these shortcuts as a new set Bu kısayolları yeni bir küme olarak kaydet
Set deleted. Küme silindi.
Set saved. Küme kaydedildi.
Set: Küme:
Summarize… Özetle…
There are too many sets: delete one first. Çok fazla küme var: önce birini silin.
set name küme adı
{set} (modified) {set} (değiştirildi)
Can't render this file because it contains an unexpected character in line 8 and column 34.
+14
View File
@@ -3874,3 +3874,17 @@ curves Smooth Згладити
# View › Guides › right-click a guide
Edit Selected Guides Редагувати вибрані напрямні
Clear Selected Guides Очистити вибрані напрямні
Delete this set Видалити цей набір
New Set… Новий набір…
PhotoCraft Defaults Стандартні PhotoCraft
Save a list of these shortcuts as a web page Зберегти список цих клавіатурних скорочень як вебсторінку
Save the changes to this set Зберегти зміни в цьому наборі
Save these shortcuts as a .kys file Зберегти ці клавіатурні скорочення як файл .kys
Save these shortcuts as a new set Зберегти ці клавіатурні скорочення як новий набір
Set deleted. Набір видалено.
Set saved. Набір збережено.
Set: Набір:
Summarize… Зведення…
There are too many sets: delete one first. Забагато наборів: спершу видаліть один.
set name назва набору
{set} (modified) {set} (змінено)
Can't render this file because it contains an unexpected character in line 1388 and column 18.
+14
View File
@@ -3661,3 +3661,17 @@ curves Smooth Làm mượt
# View › Guides › right-click a guide
Edit Selected Guides Sửa đường căn đã chọn
Clear Selected Guides Xóa đường căn đã chọn
Delete this set Xóa bộ này
New Set… Bộ mới…
PhotoCraft Defaults Mặc định của PhotoCraft
Save a list of these shortcuts as a web page Lưu danh sách các phím tắt này thành trang web
Save the changes to this set Lưu thay đổi vào bộ này
Save these shortcuts as a .kys file Lưu các phím tắt này thành tệp .kys
Save these shortcuts as a new set Lưu các phím tắt này thành bộ mới
Set deleted. Đã xóa bộ.
Set saved. Đã lưu bộ.
Set: Bộ:
Summarize… Tóm tắt…
There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước.
set name tên bộ
{set} (modified) {set} (đã sửa đổi)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -2790,4 +2790,18 @@ curves Smooth 平滑
# View › Guides › right-click a guide
Edit Selected Guides 编辑所选参考线
Clear Selected Guides 清除所选参考线
Delete this set 删除此组
New Set… 新建组…
PhotoCraft Defaults PhotoCraft 默认值
Save a list of these shortcuts as a web page 将这些快捷键的列表存储为网页
Save the changes to this set 将更改存储到此组
Save these shortcuts as a .kys file 将这些快捷键存储为 .kys 文件
Save these shortcuts as a new set 将这些快捷键存储为新组
Set deleted. 已删除组。
Set saved. 已存储组。
Set: 组:
Summarize… 摘要…
There are too many sets: delete one first. 组太多:请先删除一个。
set name 组名称
{set} (modified) {set}(已修改)
Random 随机
Can't render this file because it contains an unexpected character in line 2600 and column 53.
+14
View File
@@ -2775,4 +2775,18 @@ curves Smooth 平滑
# View › Guides › right-click a guide
Edit Selected Guides 編輯所選參考線
Clear Selected Guides 清除所選參考線
Delete this set 刪除此組合
New Set… 新增組合…
PhotoCraft Defaults PhotoCraft 預設值
Save a list of these shortcuts as a web page 將這些快速鍵的清單儲存為網頁
Save the changes to this set 將變更儲存到此組合
Save these shortcuts as a .kys file 將這些快速鍵儲存為 .kys 檔案
Save these shortcuts as a new set 將這些快速鍵儲存為新組合
Set deleted. 已刪除組合。
Set saved. 已儲存組合。
Set: 組合:
Summarize… 摘要…
There are too many sets: delete one first. 組合太多:請先刪除一個。
set name 組合名稱
{set} (modified) {set}(已修改)
Random 隨機
Can't render this file because it contains an unexpected character in line 6 and column 47.
+25 -3
View File
@@ -32,12 +32,20 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan {
photocraft_engine::kys::plan(menu.chain(specs), set)
}
/// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool
/// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have.
pub fn plan_tools(set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect();
photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set)
}
/// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as
/// overrides the OK button applies: a key equal to the command's default clears its override.
/// Returns the dialog id and the plan as JSON.
pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Result<Value, String> {
let set = parse(text).map_err(|e| format!("{file_name}: {e}"))?;
let plan = plan(app, &set);
let (tool_keys, unknown_tools) = plan_tools(&set);
let id = match app.ui.dialogs.iter().find(|d| d.fields.get("__prefsui").and_then(Value::as_str) == Some("shortcuts")) {
Some(d) => d.id,
None => crate::prefs_ui::open_shortcuts(app, 0),
@@ -52,6 +60,17 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu
overrides.insert(cmd.clone(), sc.clone());
}
}
// Tool keys (#5036): a letter other than the tool's own becomes its override; its own
// letter clears one, so the tool keeps cycling with the others that share it.
for tool in crate::state::Tool::ALL {
let id = crate::tool_shortcuts::id(tool);
let Some(key) = tool_keys.get(&id) else { continue };
if crate::tool_shortcuts::default(tool).is_some_and(|d| d.eq_ignore_ascii_case(key)) {
overrides.remove(&id);
} else {
overrides.insert(id, key.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 {
@@ -63,10 +82,10 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu
}
}
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 n = (plan.set.len() + tool_keys.len()).to_string();
let mut message = crate::i18n::fmt(tl!("Imported {n} shortcuts from {file}. Click OK to keep them."), &[("n", &n), ("file", &shown)]);
if !plan.unknown.is_empty() {
let u = plan.unknown.len().to_string();
if !plan.unknown.is_empty() || !unknown_tools.is_empty() {
let u = (plan.unknown.len() + unknown_tools.len()).to_string();
message.push(' ');
message.push_str(&crate::i18n::fmt(tl!("{n} commands are not in PhotoCraft."), &[("n", &u)]));
}
@@ -87,6 +106,9 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
"tools": tool_keys,
"unknownTools": unknown_tools,
"windows": set.windows,
}))
}
+38 -3
View File
@@ -61,7 +61,7 @@ fn app() -> PhotocraftApp {
fn parses_rows_shortcuts_entities_and_tool_keys() {
let set = parse(SAMPLE).unwrap();
assert_eq!(set.commands.len(), 11);
assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()] });
assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()], id: None });
assert_eq!(set.commands[7].name, "Black & White...");
assert_eq!(set.tool_keys, 1, "the empty Default Colors row carries no key");
}
@@ -104,7 +104,12 @@ fn rows_match_our_commands_by_label_with_photoshop_tie_breaks() {
#[test]
fn an_unreadable_shortcut_is_reported_not_imported() {
let app = app();
let set = super::KysSet { name: None, commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()] }], tool_keys: 0 };
let set = super::KysSet {
name: None,
commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()], id: None }],
tool_keys: 0,
..Default::default()
};
let p = plan(&app, &set);
assert!(p.set.is_empty());
assert_eq!(p.unreadable, vec!["Undo".to_string()]);
@@ -143,7 +148,9 @@ fn import_button_fills_the_dialog_and_ok_applies_the_set() {
assert!(overrides.get("select.all").is_none(), "already the default: {overrides}");
assert!(overrides.get("view.zoomIn").is_none(), "Cmd+= is among the keys, so the default stays: {overrides}");
let message = field(&h, "message");
assert_eq!(message, json!("Imported 9 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft."));
// 9 commands and the Move Tool's V (its own letter: no override).
assert_eq!(message, json!("Imported 10 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft."));
assert!(overrides.get("tools.select.move").is_none(), "{overrides}");
assert_eq!(field(&h, "capture"), json!(false));
assert_eq!(field(&h, "messageWarning"), json!(false), "an import clears the earlier shortcut conflict warning");
// Nothing is applied until OK.
@@ -320,3 +327,31 @@ fn an_oversized_file_is_refused() {
assert!(e.contains("larger than 4 MiB"), "{e}");
assert!(app.ui.dialogs.is_empty());
}
#[test]
fn a_windows_set_with_tool_keys_imports_as_photoshop_meant_it() {
// Photoshop on Windows writes Ctrl for the primary modifier; the tool rows give letters.
let set = "<photoshop-keyboard-shortcuts version=\"4\">
\t<command kind=\"static\" name=\"Copy\" id=\"103\"><shortcut>Ctrl+C</shortcut><shortcut>F3</shortcut></command>
\t<command kind=\"static\" name=\"Levels...\" id=\"1801\"><shortcut>Ctrl+Shift+Alt+L</shortcut></command>
\t<tool name=\"Move Tool\" type=\"1\">V</tool>
\t<tool name=\"Pencil Tool\" type=\"1\">N</tool>
\t<tool name=\"Imaginary Tool\" type=\"1\">Q</tool>
</photoshop-keyboard-shortcuts>";
let mut app = app();
let r = super::import_text(&mut app, "win.kys", set).unwrap();
assert_eq!((r["windows"].as_bool(), r["unknownTools"].clone()), (Some(true), json!(["Imaginary Tool"])));
assert_eq!(r["tools"], json!({"tools.select.move": "V", "tools.select.pencil": "N"}));
let id = r["dialog"].as_u64().unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert!(f["overrides"].get("edit.copy").is_none(), "Ctrl+C on Windows is the default ⌘C: {}", f["overrides"]);
assert!(f["overrides"].get("tools.select.move").is_none(), "Move's own V keeps it cycling");
assert_eq!(f["overrides"]["tools.select.pencil"], json!("N"));
let levels = f["overrides"].as_object().unwrap().iter().find(|(id, _)| id.contains("levels")).map(|(_, v)| v.clone());
assert_eq!(
levels.as_ref().and_then(Value::as_str).and_then(photocraft_engine::prefs::normalize_shortcut),
photocraft_engine::prefs::normalize_shortcut("Cmd+Alt+Shift+L")
);
crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(crate::tool_shortcuts::shortcut(&app, crate::state::Tool::Pencil).as_deref(), Some("N"));
}
+183 -7
View File
@@ -1006,6 +1006,8 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 {
"filter": "",
"overrides": p.shortcuts,
"alternates": p.alternate_shortcuts,
"sets": p.shortcut_sets,
"activeSet": p.shortcut_set,
"hidden": p.menus.hidden,
"colors": p.menus.colors,
"toolbarHidden": p.toolbar.hidden,
@@ -1675,12 +1677,6 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
toolbar_tab(ui, f);
return;
}
let mut filter = f.get("filter").and_then(Value::as_str).unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Search")).color(t.text_dim));
ui.add(egui::TextEdit::singleline(&mut filter).desired_width(260.0).hint_text(tl!("command or shortcut")));
});
f.insert("filter".into(), json!(filter));
// A malformed map (set over automation) is left as it is, so OK rejects it instead of
// saving the empty map drawn in its place.
let parsed = f.get("overrides").map(|v| serde_json::from_value::<BTreeMap<String, String>>(v.clone()));
@@ -1698,6 +1694,17 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
let mut message = f.get("message").and_then(Value::as_str).unwrap_or("").to_string();
let mut message_warning = f.get("messageWarning").and_then(Value::as_bool).unwrap_or(false);
let items = shortcut_items(app);
// The Set row, then the search (the Menus tab searches too).
if tab == 0 {
set_row(app, ui, f, &mut overrides, &mut alt_overrides, &items, &mut message, &mut message_warning);
ui.add_space(4.0);
}
let mut filter = f.get("filter").and_then(Value::as_str).unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Search")).color(t.text_dim));
ui.add(egui::TextEdit::singleline(&mut filter).desired_width(260.0).hint_text(tl!("command or shortcut")));
});
f.insert("filter".into(), json!(filter));
// Menu path and command label as shown in the menus, in the UI language.
let shown = |id: &str, label: &str, path: &[String]| -> (Vec<String>, String) {
let lang = crate::i18n::current();
@@ -1933,6 +1940,167 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
f.insert("messageWarning".into(), json!(message_warning));
}
/// A command's keys as the dialog stands, main key first: the edited main key (or its default)
/// and its further keys.
fn dialog_keys(overrides: &BTreeMap<String, String>, alternates: &BTreeMap<String, Vec<String>>, id: &str, default: Option<&str>) -> Vec<String> {
let main = match overrides.get(id) {
Some(s) if s.is_empty() => None,
Some(s) => Some(s.as_str()),
None => default,
};
let mut p = prefs::Preferences::default();
if let Some(list) = alternates.get(id) {
p.alternate_shortcuts.insert(id.to_string(), list.clone());
}
let mut keys: Vec<String> = main.and_then(prefs::normalize_shortcut).into_iter().collect();
keys.extend(p.alternates(id, main));
keys
}
/// Edit › Keyboard Shortcuts › Set, as in Photoshop: choose a saved set (its bindings replace
/// the dialog's), save the bindings as a new set or over the one in use, delete it, export it as
/// a `.kys` file or summarize it as an HTML page. Everything but Export and Summarize waits for
/// OK, like the rest of the dialog.
#[allow(clippy::too_many_arguments)]
fn set_row(
app: &mut PhotocraftApp,
ui: &mut egui::Ui,
f: &mut Map<String, Value>,
overrides: &mut BTreeMap<String, String>,
alternates: &mut BTreeMap<String, Vec<String>>,
items: &[(String, String, Vec<String>, Option<String>)],
message: &mut String,
warning: &mut bool,
) {
use photocraft_engine::shortcut_sets::{self, ShortcutSet};
let mut sets: BTreeMap<String, ShortcutSet> = f.get("sets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let mut active = f.get("activeSet").and_then(Value::as_str).unwrap_or("").to_string();
if !active.is_empty() && !sets.contains_key(&active) {
active.clear();
}
let now = ShortcutSet { shortcuts: overrides.clone(), alternate_shortcuts: alternates.clone() };
let modified = match sets.get(&active) {
Some(s) => *s != now,
None => now != ShortcutSet::default(),
};
let defaults = tl!("PhotoCraft Defaults").to_string();
let shown = |name: &str| -> String {
let base = if name.is_empty() { defaults.clone() } else { name.to_string() };
if name == active && modified { crate::i18n::fmt(tl!("{set} (modified)"), &[("set", &base)]) } else { base }
};
let names: Vec<(String, String)> = std::iter::once(String::new()).chain(sets.keys().cloned()).map(|n| (n.clone(), shown(&n))).collect();
let mut new_name: Option<String> = f.get("newSetName").and_then(Value::as_str).map(str::to_string);
let set_label = if active.is_empty() { defaults.clone() } else { active.clone() };
let mut export = false;
let mut summarize = false;
ui.horizontal(|ui| {
ui.label(tl!("Set:"));
let mut chosen = active.clone();
let pairs: Vec<(String, &str)> = names.iter().map(|(n, l)| (n.clone(), l.as_str())).collect();
if crate::widgets::dropdown(ui, "shortcut-set", &mut chosen, &pairs, 220.0) && chosen != active {
let set = sets.get(&chosen).cloned().unwrap_or_default();
*overrides = set.shortcuts;
*alternates = set.alternate_shortcuts;
active = chosen;
message.clear();
}
if ui.button(tl!("New Set…")).on_hover_text(tl!("Save these shortcuts as a new set")).clicked() {
new_name = Some(String::new());
}
if ui.add_enabled(!active.is_empty() && modified, egui::Button::new(tl!("Save"))).on_hover_text(tl!("Save the changes to this set")).clicked() {
sets.insert(active.clone(), now.clone());
*message = tl!("Set saved.").into();
*warning = false;
}
if ui.add_enabled(!active.is_empty(), egui::Button::new(tl!("Delete"))).on_hover_text(tl!("Delete this set")).clicked() {
sets.remove(&active);
active.clear();
*message = tl!("Set deleted.").into();
*warning = false;
}
export = ui.button(tl!("Export…")).on_hover_text(tl!("Save these shortcuts as a .kys file")).clicked();
summarize = ui.button(tl!("Summarize…")).on_hover_text(tl!("Save a list of these shortcuts as a web page")).clicked();
});
if let Some(name) = new_name.as_mut() {
let mut create = false;
let mut cancel = false;
ui.horizontal(|ui| {
ui.label(tl!("Name:"));
let r = ui.add(egui::TextEdit::singleline(name).desired_width(220.0).hint_text(tl!("set name")));
create = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
create |= ui.button(tl!("Create")).clicked();
cancel = ui.button(tl!("Cancel")).clicked();
});
if create {
match shortcut_sets::set_name(name) {
Ok(n) if !sets.contains_key(&n) && sets.len() >= shortcut_sets::MAX_SETS => {
*message = tl!("There are too many sets: delete one first.").into();
*warning = true;
}
Ok(n) => {
sets.insert(n.clone(), now.clone());
active = n;
new_name = None;
*message = tl!("Set saved.").into();
*warning = false;
}
Err(e) => {
*message = e;
*warning = true;
}
}
} else if cancel {
new_name = None;
}
}
if export || summarize {
let result = if export {
let entries: Vec<photocraft_engine::kys::Entry> = items
.iter()
.map(|(id, label, _, def)| photocraft_engine::kys::Entry {
id: id.clone(),
label: label.clone(),
tool: id.starts_with("tools.select."),
keys: dialog_keys(overrides, alternates, id, def.as_deref()),
})
.collect();
let text = photocraft_engine::kys::write(&set_label, &entries);
save_text(app, &format!("{set_label}.kys"), text)
} else {
let mut sections: Vec<shortcut_sets::Section> = Vec::new();
for (id, label, path, def) in items {
let section = path.first().cloned().unwrap_or_else(|| tl!("Other").to_string());
let name = path.iter().skip(1).chain(std::iter::once(label)).cloned().collect::<Vec<_>>().join(" › ");
let row = (name, dialog_keys(overrides, alternates, id, def.as_deref()));
match sections.iter_mut().find(|(s, _)| *s == section) {
Some((_, rows)) => rows.push(row),
None => sections.push((section, vec![row])),
}
}
let html = shortcut_sets::summarize(&set_label, &sections, cfg!(target_os = "macos"));
save_text(app, &format!("{set_label}.html"), html)
};
if let Err(e) = result {
*message = e;
*warning = true;
}
}
f.insert("sets".into(), json!(sets));
f.insert("activeSet".into(), json!(active));
f.insert("newSetName".into(), new_name.map_or(Value::Null, Value::String));
}
/// Save `text` through the save dialog under the suggested file name.
fn save_text(app: &mut PhotocraftApp, suggested: &str, text: String) -> Result<Value, String> {
// A set name can hold characters a file name can't.
let name: String = suggested.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect();
app.pick_save(&name, move |app, path| {
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, text.as_bytes())?;
Ok(json!({"path": path}))
})
}
fn toolbar_tab(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut hidden: Vec<String> = f.get("toolbarHidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
egui::ScrollArea::vertical().max_height(380.0).id_salt("toolbar-scroll").show(ui, |ui| {
@@ -2131,7 +2299,12 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
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, "alternates": alt, "allowUnknown": true, "removeConflicts": false}))?;
let sets = f.get("sets").cloned().unwrap_or(json!({}));
let active = f.get("activeSet").cloned().unwrap_or(json!(""));
app.run(
"edit.keyboardShortcuts",
json!({"reset": true, "set": ov, "alternates": alt, "allowUnknown": true, "removeConflicts": false, "sets": sets, "activeSet": active}),
)?;
app.run("prefs.set", json!({"values": {"menus": {"hidden": hidden, "colors": colors}, "toolbar": {"hidden": toolbar}}}))
}
"presets" => Ok(Value::Null),
@@ -2174,6 +2347,9 @@ mod recovery_tests;
#[cfg(test)]
#[path = "shortcut_capture_tests.rs"]
mod shortcut_capture_tests;
#[cfg(test)]
#[path = "shortcut_set_tests.rs"]
mod shortcut_set_tests;
#[cfg(test)]
mod tests {
+128
View File
@@ -0,0 +1,128 @@
//! Edit › Keyboard Shortcuts › Set in the dialog: a new set saves the dialog's bindings, the
//! dropdown switches sets, Save and Delete wait for OK, and Export / Summarize write the bindings
//! as the dialog shows them.
use std::cell::RefCell;
use std::rc::Rc;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::file_dialog::FileDialogAnswer;
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// The dialog, with the save picker answering `answers` and writes recorded.
fn dialog(answers: Vec<&str>) -> (Harness<'static, PhotocraftApp>, u64, Written) {
let written: Written = Rc::default();
let log = written.clone();
let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut services = crate::Services::default();
let (picker, _) = crate::file_dialog::fake(answers.iter().map(|a| Some(FileDialogAnswer::SaveTo((*a).into()))).collect());
services.file_dialog = Some(picker);
services.write = Some(Box::new(move |path: &str, bytes: &[u8]| {
log.borrow_mut().push((path.to_string(), bytes.to_vec()));
Ok(())
}));
PhotocraftApp::new(photocraft_engine::Session::new(), services)
});
let ctx = h.ctx.clone();
let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap();
h.run_steps(4);
(h, id, written)
}
fn field(h: &Harness<'_, PhotocraftApp>, id: u64, name: &str) -> Value {
h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null)
}
fn set_field(h: &mut Harness<'_, PhotocraftApp>, id: u64, name: &str, v: Value) {
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(name.into(), v);
h.run_steps(2);
}
fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) {
// The first match: the set row's buttons come before the dialog's own (its Cancel).
h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).next().expect("the button").click();
h.run_steps(3);
}
#[test]
fn a_new_set_saves_the_dialogs_bindings_and_ok_keeps_it() {
let (mut h, id, _) = dialog(vec![]);
// An edit in the dialog, then New Set… with a name.
set_field(&mut h, id, "overrides", json!({"edit.copy": "F9"}));
click(&mut h, "New Set…");
set_field(&mut h, id, "newSetName", json!(" Painting "));
click(&mut h, "Create");
assert_eq!(field(&h, id, "activeSet"), json!("Painting"));
assert_eq!(field(&h, id, "sets")["Painting"]["shortcuts"], json!({"edit.copy": "F9"}));
assert!(field(&h, id, "newSetName").is_null());
// A bad name is refused with a message.
click(&mut h, "New Set…");
set_field(&mut h, id, "newSetName", json!("PhotoCraft Defaults"));
click(&mut h, "Create");
assert!(field(&h, id, "message").as_str().unwrap_or_default().contains("built-in"), "{:?}", field(&h, id, "message"));
click(&mut h, "Cancel");
// Nothing is saved before OK; OK saves the set and keeps it in use.
assert!(h.state().session.prefs().shortcut_sets.is_empty());
crate::dialogs::confirm(h.state_mut(), id).unwrap();
let p = h.state().session.prefs();
assert_eq!((p.shortcut_set.as_str(), p.shortcuts.get("edit.copy").map(String::as_str)), ("Painting", Some("F9")));
assert_eq!(p.shortcut_sets["Painting"].shortcuts.get("edit.copy").map(String::as_str), Some("F9"));
}
#[test]
fn choosing_a_set_loads_it_and_save_and_delete_wait_for_ok() {
let (mut h, first, _) = dialog(vec![]);
h.state_mut().ui.dialogs.retain(|d| d.id != first);
h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F9"}, "saveSet": "A"})).unwrap();
h.state_mut().run("edit.keyboardShortcuts", json!({"useSet": ""})).unwrap();
let ctx = h.ctx.clone();
let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap();
h.run_steps(4);
assert_eq!(field(&h, id, "overrides"), json!({}));
// Choosing set A in the dropdown (as its row sets it) loads its bindings.
set_field(&mut h, id, "activeSet", json!("A"));
let set = field(&h, id, "sets")["A"].clone();
set_field(&mut h, id, "overrides", set["shortcuts"].clone());
// An edit marks it modified; Save stores it in the dialog's sets.
set_field(&mut h, id, "overrides", json!({"edit.copy": "F8"}));
click(&mut h, "Save");
assert_eq!(field(&h, id, "sets")["A"]["shortcuts"]["edit.copy"], "F8");
assert_eq!(h.state().session.prefs().shortcut_sets["A"].shortcuts["edit.copy"], "F9", "not before OK");
click(&mut h, "Delete");
assert_eq!((field(&h, id, "sets"), field(&h, id, "activeSet")), (json!({}), json!("")));
crate::dialogs::confirm(h.state_mut(), id).unwrap();
let p = h.state().session.prefs();
assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty());
assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F8"), "the bindings stay");
}
#[test]
fn export_and_summarize_write_the_dialogs_bindings() {
let (mut h, id, written) = dialog(vec!["Mine.kys", "Mine.html"]);
set_field(&mut h, id, "overrides", json!({"edit.copy": "F9", "tools.select.pencil": "F6"}));
set_field(&mut h, id, "alternates", json!({"edit.copy": []}));
let ctx = h.ctx.clone();
click(&mut h, "Export…");
h.state_mut().poll_file_dialog(&ctx, None);
click(&mut h, "Summarize…");
h.state_mut().poll_file_dialog(&ctx, None);
let written = written.borrow();
assert_eq!(written.len(), 2, "{:?}", written.iter().map(|w| &w.0).collect::<Vec<_>>());
let kys = String::from_utf8(written[0].1.clone()).unwrap();
assert!(kys.contains("pc-id=\"edit.copy\"><shortcut>F9</shortcut></command>"), "{kys}");
assert!(kys.contains("pc-id=\"tools.select.pencil\">F6</tool>"), "tools as <tool> rows: {kys}");
assert!(kys.contains("KeyboardCustomization/PhotoCraft Defaults="), "{kys}");
// The file reads back with the same bindings.
let set = photocraft_engine::kys::parse(&kys).unwrap();
assert!(set.commands.iter().any(|c| c.id.as_deref() == Some("edit.copy") && c.shortcuts == ["F9"]));
let html = String::from_utf8(written[1].1.clone()).unwrap();
assert!(html.contains("<td>Copy</td><td>F9</td>") && html.contains("<h2>Edit</h2>"), "{html}");
// Nothing changed the preferences.
assert!(h.state().session.prefs().shortcuts.is_empty());
}
+1 -1
View File
@@ -227,7 +227,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca
| `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key |
| `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.<id>` or `menus.colors.<id>` reset removes the override and returns `null`, including when it is already absent |
| `edit.preferences.<section>` | the section's values; in the app (no params) it opens the Preferences dialog on that section |
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once |
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by name in the app's dialog import (a tool's own letter keeps its Shift cycling); this command doesn't know the tools and leaves them. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"<name>": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of the engine's commands as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. The app's dialog exports and summarizes the shell's commands and tools as well. A bad parameter anywhere in the call changes nothing |
| `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` |
| `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt |
+1 -1
View File
@@ -129,7 +129,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Patterns `.pat` | R/W | R/W | |
| Swatches `.aco`, `.ase` | R/W | R/W | FILE-215-14 |
| LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` |
| Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` |
| Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) |
| ICC profiles | R | R | Own CMS |
| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) |
| Styles `.asl` | R/W | no | #1693 |
+2 -2
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) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 |
| [Tools](#tools) | 33 | 11 | 0 | |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) |
| [Type](#type) | 4 | 3 | 2 | |
| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -177,7 +177,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 26 of 164** (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: 26 of 166** (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_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `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_.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`.
+1
View File
@@ -187,6 +187,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| PhotoCraft | ~199 shortcut assignments, 113 distinct modifier/F-key combos, 20 tool letters, 3 temporary-tool bindings | measured (source) |
| Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here |
| `.kys` import | done (`engine/src/kys.rs`) | |
| Shortcut sets: Set menu, New Set…, Save, Delete, `.kys` Export…, Summarize… | done (`engine/src/shortcut_sets.rs`, `prefs_ui::set_row`); sets live in the preferences. Photoshop reading our exported `.kys` is unverified | `shortcut_sets` tests, `shortcut_set_tests.rs` |
| Shortcut glyphs per platform | done (UI-217-19) | |
| Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` |
| Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | |