Compare commits

..
Author SHA1 Message Date
Tanish Manku 2b3d05636f Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) 2026-10-15 15:32:08 -07:00
Tanish Manku fdaa9b8316 Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859)
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)

Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.

* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order

* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal

* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied

* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute

* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
2026-10-15 15:26:13 -07:00
Tanish Manku 8376363d8c Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files

A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.

* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools

* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one

* docs: reserved keys per platform

* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched

* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc

* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
2026-10-15 15:26:09 -07:00
Tanish Manku b09aeae2e2 Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274)
* PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343)

Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits
in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing
vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo,
mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over
the platform default. Bad values are logged and ignored. The active mode is logged at start,
kept in GpuInfo.present and shown in every stroke trace line.

RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox
renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame.

* docs: present mode and frame latency overrides

* Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA)

* Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343)

Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it
disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The
surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else
Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the
preference off brings vsync back (smooth, but late). It applies at the next launch, like the
other graphics settings. Where only Immediate exists the canvas may tear while it changes;
frames render as fast as input arrives during a stroke and stop when idle.

* Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency

The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch.

* docs: strokeTrace beside the other ui.set fields

* docs: regenerate the scorecard after rebasing onto main

* Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order

* stroke_trace: from_value doc says ui.set passes only on or off

* Low-Latency Drawing ships off (opt-in), per the owner's decision

* docs: present mode default is vsync (Low-Latency Drawing ships off)
2026-10-15 15:23:47 -07:00
43 changed files with 1257 additions and 190 deletions

No files matched your search

+25
View File
@@ -443,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
}
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
@@ -463,6 +471,23 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_low_latency(Some(&file))
};
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
assert!(!read("not json"));
assert!(!read_low_latency(None), "no settings file: the default (off)");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
+41 -1
View File
@@ -2,7 +2,13 @@
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [files…]`
//!
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
//! `photocraft_ui_egui::stroke_trace`).
//!
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
@@ -44,6 +50,7 @@ mod linux_libs;
mod logging;
mod monitor_profile;
mod photoshop_settings;
mod presentation;
mod screen_color;
mod services;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
@@ -191,6 +198,7 @@ fn main() -> eframe::Result {
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
@@ -211,6 +219,18 @@ fn main() -> eframe::Result {
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
Some("--safe-gpu") => safe_gpu = true,
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => present_mode_arg = Some(v),
"--frame-latency" => frame_latency_arg = Some(v),
_ => stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => in_window_menus = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
@@ -364,6 +384,21 @@ fn main() -> eframe::Result {
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
let uncapped = !matches!(
options.wgpu_options.surface.present_mode,
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
);
log::info!("presentation: {present_desc}");
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
@@ -392,6 +427,7 @@ fn main() -> eframe::Result {
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
@@ -423,6 +459,10 @@ fn main() -> eframe::Result {
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
+121
View File
@@ -0,0 +1,121 @@
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
use eframe::wgpu::PresentMode;
/// The present mode and frame latency this launch asked for (`None`: the default).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Overrides {
pub mode: Option<PresentMode>,
pub latency: Option<u32>,
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
}
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
for w in warnings {
log::warn!("{w}");
}
o
}
/// `surface` with the overrides applied.
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
SurfaceConfig {
present_mode: self.mode.unwrap_or(surface.present_mode),
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
}
}
}
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
let mode = match surface.present_mode {
PresentMode::AutoVsync => "autovsync",
PresentMode::AutoNoVsync => "autonovsync",
PresentMode::Fifo => "fifo",
PresentMode::FifoRelaxed => "fiforelaxed",
PresentMode::Immediate => "immediate",
PresentMode::Mailbox => "mailbox",
};
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
let tag = |set: bool| if set { " (override)" } else { "" };
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
let (o, w) = parse(Some(" Mailbox "), Some("1"));
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
}
for (mode, latency) in
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
{
let (o, w) = parse(mode, latency);
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
}
}
#[test]
fn overrides_replace_only_what_they_set_and_are_described() {
let default = SurfaceConfig::HIGH_THROUGHPUT;
assert_eq!(Overrides::default().apply(default), default);
let latency_only = Overrides { mode: None, latency: Some(1) };
let s = latency_only.apply(default);
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
}
}
+10
View File
@@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
}
"plugin.reload" => true,
_ => false,
}
@@ -712,6 +716,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
for key in ["importKys", "exportKys", "summarize"] {
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
}
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
+14 -13
View File
@@ -160,12 +160,14 @@ fn key(label: &str) -> String {
l.trim_end_matches('…').trim().to_lowercase()
}
/// The engine's own candidates: every command in a menu, in command order.
/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in
/// command order.
pub fn command_candidates() -> Vec<Candidate> {
crate::command_specs()
.iter()
.filter(|c| !c.menu.is_empty())
.map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) })
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut));
engine
.chain(crate::shell_commands::SHELL_COMMANDS.iter().copied())
.filter(|c| !c.2.is_empty())
.map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) })
.collect()
}
@@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
}
/// 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.
/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers 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();
@@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set:
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());
// One key without modifiers (Photoshop's letters; our tools may also take an F-key).
let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim()));
if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") {
keys.insert((*id).to_string(), k);
}
}
(keys, unknown)
@@ -448,7 +449,7 @@ mod tests {
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!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers");
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()] }]);
+7 -4
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 shell_commands;
pub mod shortcut_sets;
pub mod slice_cmds;
pub mod smart_cmds;
@@ -421,8 +422,10 @@ pub struct CopiedStyle {
pub mode: photocraft_color::ColorMode,
}
/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does
/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and
/// The commands whose [`Session::precheck`] is complete: it accepts what `execute` does (Free
/// Transform applies its box before these, and only when they will run), but for the group
/// nesting limit ([`photocraft_doc::MAX_GROUP_DEPTH`]): grouping layers already that deep is
/// refused only as the edit is made. Layer via Copy and
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
/// or fill layer) is known only when they run.
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
@@ -670,8 +673,8 @@ impl Session {
/// something first only when the command will go through: Free Transform applies its box
/// before a new layer is made.
///
/// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute`
/// accepts (a test holds the two together). For others it checks only what they share.
/// Complete for the commands in [`PRECHECKED`]: there it accepts what `execute` accepts, but
/// for the group nesting limit (a test holds the two together). For others it checks only what they share.
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if !(params.is_object() || params.is_null()) {
+361 -31
View File
@@ -529,6 +529,13 @@ pub struct Performance {
/// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off:
/// they render at full resolution.
pub low_resolution_previews: bool,
/// Low-latency drawing (applies at next launch): frames reach the screen without waiting for
/// vsync, so a stroke follows the pen about 50 ms sooner (the swap chain's wait, #1343). wgpu
/// picks Immediate where the driver has it (most Vulkan and DX12 drivers on Windows and X11),
/// so the canvas usually tears while it changes; Mailbox (no tearing) only where Immediate is
/// missing. Frames are capped at 240 fps. Off (the default): vsync, smooth but late. Desktop
/// only.
pub low_latency_drawing: bool,
/// Linux display server (applies at next launch; see [`LinuxDisplayServer`]).
pub linux_display_server: LinuxDisplayServer,
/// Memory budget of the layer-effect cache, in MB.
@@ -560,6 +567,7 @@ impl Default for Performance {
rendering_mode: None,
gpu_backend: GpuBackend::Auto,
low_resolution_previews: true,
low_latency_drawing: false,
linux_display_server: LinuxDisplayServer::Auto,
effect_cache_mb: 768,
legacy_compositing: false,
@@ -1495,9 +1503,65 @@ pub fn default_alternates(id: &str) -> impl Iterator<Item = &'static str> + '_ {
ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc)
}
/// Every bindable id with its default: commands, then the temporary tools.
/// Keys the system takes before the app sees them (switching apps, closing the window, force
/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation,
/// where `Cmd` is Ctrl off the Mac.
pub fn reserved_shortcut(sc: &str) -> bool {
let Some(n) = normalize_shortcut(sc) else { return false };
let reserved: &[&str] = if cfg!(target_os = "macos") {
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
} else if cfg!(target_os = "windows") {
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
} else {
// Linux and the BSDs: window switching and closing, and the virtual consoles.
&[
"Alt+Tab",
"Alt+Shift+Tab",
"Alt+F4",
"Cmd+Alt+Delete",
"Cmd+Alt+F1",
"Cmd+Alt+F2",
"Cmd+Alt+F3",
"Cmd+Alt+F4",
"Cmd+Alt+F5",
"Cmd+Alt+F6",
"Cmd+Alt+F7",
"Cmd+Alt+F8",
"Cmd+Alt+F9",
"Cmd+Alt+F10",
"Cmd+Alt+F11",
"Cmd+Alt+F12",
]
};
reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n)
}
/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BindKind {
Command,
Tool,
Hold,
}
/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the
/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools,
/// so Edit › Keyboard Shortcuts works the same with or without the app's UI.
pub fn bindable_items() -> impl Iterator<Item = (&'static str, &'static str, &'static [&'static str], Option<&'static str>, BindKind)> {
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command));
let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command));
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool));
let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold));
engine.chain(shell).chain(tools).chain(hold)
}
/// Every bindable id with its default.
fn bindable() -> impl Iterator<Item = (&'static str, Option<&'static str>)> {
crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2))))
bindable_items().map(|i| (i.0, i.3))
}
fn is_tool(id: &str) -> bool {
crate::shell_commands::tool_default(id).is_some()
}
/// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut).
@@ -1866,18 +1930,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
let imported;
let mut import = None;
let p = match p.get("importKys") {
Some(xml) => {
let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?;
Some(v) => {
// The file's text, or {"path": …} to read it.
let read;
let xml = match v {
Value::String(s) => s.as_str(),
Value::Object(o) => {
let path =
o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?;
read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
read.as_str()
}
_ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")),
};
let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?;
let plan = crate::kys::plan(crate::kys::command_candidates(), &set);
let mut merged = serde_json::Map::new();
for (id, sc) in &plan.set {
// A key the system reserves is left out (reported as `reserved`), not a failed import.
let mut skipped: Vec<String> = Vec::new();
for (id, sc) in plan.set.iter().filter(|(id, sc)| {
let bad = reserved_shortcut(sc);
if bad {
skipped.push(format!("{id}: {sc}"));
}
!bad
}) {
let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut);
merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) });
}
// Tool keys: a tool's own letter restores it (and its Shift cycling).
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1));
let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set);
for (id, key) in &tool_keys {
let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key));
merged.insert(id.clone(), if own { Value::Null } else { json!(key) });
}
merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default());
let mut alternates = serde_json::Map::new();
for (id, keys) in &plan.alternate_keys {
let keys: &Vec<String> = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect();
let main = plan.set.get(id).map(String::as_str);
let default = Preferences::default().alternates(id, main);
alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) });
@@ -1892,6 +1983,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
"tools": tool_keys,
"unknownTools": unknown_tools,
"reserved": skipped,
"windows": set.windows,
}));
let mut with_import = p.clone();
@@ -1914,6 +2008,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
if !set.is_empty() || !alts.is_empty() {
let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false);
changed = true;
// Reserved system keys can't be bound, by hand or from a file.
let reserved = |v: &Value| -> Option<String> {
let keys = match v {
Value::String(s) => vec![s.clone()],
other => alternate_keys(other).unwrap_or_default(),
};
keys.into_iter().find(|k| reserved_shortcut(k))
};
if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) {
return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned")));
}
for (id, v) in set {
if !bindable().any(|(b, _)| b == id) && !allow_unknown {
return Err(bad(cmd, format!("unknown command `{id}`")));
@@ -1935,9 +2040,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
.chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k))))
.collect();
for (id, sc) in assigned {
// Shell commands (View › Zoom In) have alternates but no engine default.
let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None));
for (c, def) in bindable().chain(shell) {
for (c, def) in bindable() {
// Tools may share a key: they cycle with Shift, as Photoshop's groups do.
if is_tool(id) && is_tool(c) {
continue;
}
// Never strip a command that this same call is assigning:
// two entries for one key are a clash inside the call, which
// the returned conflicts list reports (#719). Stripping
@@ -1959,16 +2066,11 @@ 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);
}
let prefs = s.prefs();
// The reply and the exports read the bindings as they will be; they are committed only once
// the exports are written, so a bad export target changes nothing.
let prefs = &next;
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true));
let list: Vec<Value> = crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
.chain(temporary)
let list: Vec<Value> = bindable_items()
.filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f)))
.filter(|c| {
p.get("list").and_then(Value::as_bool).unwrap_or(false)
@@ -1976,15 +2078,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|| prefs.shortcuts.contains_key(c.0)
|| prefs.alternate_shortcuts.contains_key(c.0)
})
.map(|(id, label, menu, def, hold)| {
.map(|(id, label, menu, def, kind)| {
let main = prefs.shortcut(id, def);
let defaults: Vec<&str> = default_alternates(id).collect();
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)});
if !defaults.is_empty() {
v["defaultAlternates"] = json!(defaults);
}
if hold {
v["hold"] = json!(true);
match kind {
BindKind::Hold => v["hold"] = json!(true),
BindKind::Tool => v["tool"] = json!(true),
BindKind::Command => {}
}
v
})
@@ -1995,30 +2099,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
bindings.extend(main.map(|m| (id, m.to_string())));
bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k)));
}
let conflicts: Vec<Value> =
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
// Tools sharing a key are a cycling group, not a conflict.
let conflicts: Vec<Value> = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str())))
.into_iter()
.filter(|(_, ids)| !ids.iter().all(|id| is_tool(id)))
.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).
// The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the
// engine's commands, the shell's commands and the tools).
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) {
// Both targets are resolved and their texts made before anything is written, and a path that
// can't be written is refused before either file is: a bad export changes nothing.
let kys_target = file_target(cmd, p.get("exportKys"))?;
let html_target = file_target(cmd, p.get("summarize"))?;
let mut files: Vec<(&str, String, String)> = Vec::new();
if let Some(target) = kys_target {
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));
crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect();
let text = crate::kys::write(set_name, &entries);
match target {
Some(path) => files.push(("kysPath", path, text)),
None => out["kys"] = json!(text),
}
}
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
if let Some(target) = html_target {
let v = p.get("summarize").cloned().unwrap_or(Value::Null);
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) {
for crate::shortcut_sets::Bound { 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));
let html = crate::shortcut_sets::summarize(set_name, &sections, mac);
match target {
Some(path) => files.push(("htmlPath", path, html)),
None => out["html"] = json!(html),
}
}
for (_, path, _) in &files {
writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
}
let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect();
write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?;
for (key, path, _) in &files {
out[*key] = json!(path);
}
if changed {
s.edit_prefs(|p| *p = next);
}
if let Some(import) = import {
out["import"] = import;
@@ -2026,6 +2159,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object
/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` →
/// `Some(Some(path))`, written there.
fn file_target(cmd: &str, v: Option<&Value>) -> Result<Option<Option<String>>> {
match v {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some(None)),
Some(Value::Object(o)) => match o.get("path") {
None | Some(Value::Null) => Ok(Some(None)),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))),
Some(_) => Err(bad(cmd, "`path` must be a non-empty string")),
},
Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_text_file(path: &str, max: usize) -> std::result::Result<String, String> {
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(format!("not a file of at most {} KiB", max / 1024));
}
std::fs::read_to_string(path).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
fn read_text_file(_: &str, _: usize) -> std::result::Result<String, String> {
Err("paths can't be read on the web; pass the file's text instead".into())
}
/// Whether `path` can be written: not a folder, in a folder that exists.
#[cfg(not(target_arch = "wasm32"))]
fn writable(path: &str) -> std::result::Result<(), String> {
let p = std::path::Path::new(path);
if p.is_dir() {
return Err("is a folder".into());
}
if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) {
return Err("is read-only".into());
}
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()),
_ => Ok(()),
}
}
#[cfg(target_arch = "wasm32")]
fn writable(_: &str) -> std::result::Result<(), String> {
Err("paths can't be written on the web; omit `path` to get the text back".into())
}
#[cfg(not(target_arch = "wasm32"))]
fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::rename(from, to)
}
#[cfg(target_arch = "wasm32")]
fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> {
Err(std::io::Error::other("paths can't be written on the web"))
}
/// Where `path` points, for telling whether two targets are one file: its folder resolved, its
/// name compared without case where the file system ignores it.
#[cfg(not(target_arch = "wasm32"))]
fn path_key(path: &str) -> String {
let p = std::path::Path::new(path);
let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned();
if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key }
}
/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging
/// (`kind` "tmp") or a backup ("bak").
#[cfg(not(target_arch = "wasm32"))]
fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> {
for n in 0..1000u32 {
let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id()));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) {
Ok(f) => return Ok((p, f)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::other("no free name beside it"))
}
/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its
/// target; once every one is written, each existing target is moved aside to a backup and the
/// staged file moved in. Any failure moves the backups back and removes what was staged, so the
/// user's files end as they began; on success the backups go. Two targets naming one file are
/// refused. `rename` is `std::fs::rename` (a test fails it on purpose).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_files_atomically(
files: &[(&str, &str)],
rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
) -> std::result::Result<(), String> {
use std::io::Write;
use std::path::{Path, PathBuf};
for (i, (a, _)) in files.iter().enumerate() {
if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) {
return Err(format!("{a}: two exports to the same file"));
}
}
let mut staged: Vec<PathBuf> = Vec::new();
let drop_all = |paths: &[PathBuf]| {
for p in paths {
let _ = std::fs::remove_file(p);
}
};
for (path, text) in files {
let (tmp, mut f) = match sibling(path, "tmp") {
Ok(v) => v,
Err(e) => {
drop_all(&staged);
return Err(format!("{path}: {e}"));
}
};
staged.push(tmp);
if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) {
drop_all(&staged);
return Err(format!("{path}: {e}"));
}
}
// (target, its backup) of each file moved in so far.
let mut moved: Vec<(&Path, Option<PathBuf>)> = Vec::new();
// Put each backup back over its target (a target that had none is removed). What couldn't be
// put back is named, so the user can find their previous file.
let undo = |moved: &[(&Path, Option<PathBuf>)]| -> String {
let mut stranded = Vec::new();
for (target, backup) in moved.iter().rev() {
match backup {
Some(b) => {
if rename(b, target).is_err() {
stranded.push(format!("the previous {} is at {}", target.display(), b.display()));
}
}
None => {
let _ = std::fs::remove_file(target);
}
}
}
if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) }
};
for ((path, _), tmp) in files.iter().zip(&staged) {
let target = Path::new(*path);
let backup = if target.exists() {
let moved_aside = sibling(path, "bak").and_then(|(b, f)| {
drop(f);
match rename(target, &b) {
Ok(()) => Ok(b),
Err(e) => {
// The placeholder the backup name reserved goes too.
let _ = std::fs::remove_file(&b);
Err(e)
}
}
});
match moved_aside {
Ok(b) => Some(b),
Err(e) => {
let stranded = undo(&moved);
drop_all(&staged);
return Err(format!("{path}: {e}{stranded}"));
}
}
} else {
None
};
if let Err(e) = rename(tmp, target) {
let mut stranded = undo(&moved);
if let Some(b) = &backup
&& rename(b, target).is_err()
{
stranded = format!("{stranded} (the previous {path} is at {})", b.display());
}
drop_all(&staged);
return Err(format!("{path}: {e}{stranded}"));
}
moved.push((target, backup));
}
for (_, backup) in moved {
if let Some(b) = backup {
let _ = std::fs::remove_file(b);
}
}
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_files_atomically(
files: &[(&str, &str)],
_: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
) -> std::result::Result<(), String> {
if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) }
}
/// Edit › Menus: hide/show items and give them colours.
fn menus(s: &mut Session, p: &Value) -> Result<Value> {
let ids = |k: &str| -> Vec<String> {
@@ -2151,7 +2481,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>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
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>"|{"path":".kys"}?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##,
keyboard_shortcuts,
true
),
+88
View File
@@ -827,3 +827,91 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() {
let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap();
assert!(!stored.use_shift_key_for_tool_switch);
}
/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was
/// moved in puts the user's files back as they were and leaves nothing staged.
#[test]
fn exports_are_written_all_or_nothing() {
use super::write_files_atomically;
let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
std::fs::write(&a, "old A").unwrap();
std::fs::write(&b, "old B").unwrap();
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
let staged_moves = std::cell::Cell::new(0);
let failing = |from: &std::path::Path, to: &std::path::Path| {
if from.to_string_lossy().ends_with("photocraft-tmp") {
staged_moves.set(staged_moves.get() + 1);
if staged_moves.get() == 2 {
return Err(std::io::Error::other("disk gone"));
}
}
std::fs::rename(from, to)
};
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err());
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count();
assert_eq!(leftovers(), 0);
// A backup move that fails leaves no empty placeholder behind.
let backups = std::cell::Cell::new(0);
let failing_backup = |from: &std::path::Path, to: &std::path::Path| {
if to.to_string_lossy().ends_with("photocraft-bak") {
backups.set(backups.get() + 1);
if backups.get() == 2 {
return Err(std::io::Error::other("locked"));
}
}
std::fs::rename(from, to)
};
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err());
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
assert_eq!(leftovers(), 0);
// The same target twice is refused before anything is written.
assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err());
assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A");
// A target named like a staging file is just a file.
let odd = dir.join("c.kys.photocraft-tmp");
std::fs::write(&odd, "old C").unwrap();
write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap();
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into()));
assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself");
let _ = std::fs::remove_dir_all(&dir);
}
/// A backup that can't be put back after a failed export is named in the error, and stays where
/// it is with the user's previous text.
#[test]
fn a_stranded_backup_is_named_in_the_error() {
use super::write_files_atomically;
let dir = std::env::temp_dir().join(format!("photocraft-stranded-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
std::fs::write(&a, "old A").unwrap();
std::fs::write(&b, "old B").unwrap();
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
let staged = std::cell::Cell::new(0);
// The second staged move fails, and so does putting a.kys's backup back.
let rename = |from: &std::path::Path, to: &std::path::Path| {
let f = from.to_string_lossy();
if f.ends_with("photocraft-tmp") {
staged.set(staged.get() + 1);
if staged.get() == 2 {
return Err(std::io::Error::other("disk gone"));
}
}
if f.ends_with("photocraft-bak") && to == a.as_path() {
return Err(std::io::Error::other("locked"));
}
std::fs::rename(from, to)
};
let e = write_files_atomically(&[(pa, "new A"), (pb, "new B")], &rename).unwrap_err();
assert!(e.contains(&format!("the previous {} is at", a.display())), "{e}");
let backup: Vec<_> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with("photocraft-bak")).collect();
assert_eq!(backup.len(), 1, "{backup:?}");
assert!(e.contains(&backup[0].display().to_string()), "{e}");
assert_eq!(std::fs::read_to_string(&backup[0]).unwrap(), "old A");
assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B");
let _ = std::fs::remove_dir_all(&dir);
}
+194
View File
@@ -0,0 +1,194 @@
//! The commands the app's shell carries out itself (view, panels, windows: they change no
//! document), and the tools, as data: what they're called and which keys they have by default.
//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine
//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts'
//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on
//! top). The app's tests keep these tables equal to its own.
/// Shell commands: id, label, menu path, default shortcut.
pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.symmetryTransform", "Transform Symmetry", &[], None),
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
("tool.reset", "Reset Tool", &[], None),
("tool.resetAll", "Reset All Tools", &[], None),
("window.panel.group", "Change Panel Group", &[], None),
("window.panel.layout", "Change Panel Layout", &[], None),
("window.panel.move", "Move Panel", &[], None),
("window.panel.float", "Float Panel", &[], None),
("window.panel.dock", "Dock Panel", &[], None),
("window.panel.activate", "Activate Panel", &[], None),
("window.panel.close", "Close Panel", &[], None),
("file.open", "Open…", &["File"], Some("Cmd+O")),
("file.save", "Save", &["File"], Some("Cmd+S")),
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
("view.extras", "Extras", &["View"], Some("Cmd+H")),
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
("type.editText", "Edit Type", &[], None),
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
("view.rotateView", "Rotate View", &[], None),
("view.resetView", "Reset View", &["View"], None),
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
("window.toggle.history", "History", &["Window"], None),
("window.toggle.properties", "Properties", &["Window"], None),
("window.toggle.color", "Color", &["Window"], Some("F6")),
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
("window.toggle.brushes", "Brushes", &["Window"], None),
("window.toggle.navigator", "Navigator", &["Window"], None),
("window.toggle.toolbar", "Tools", &["Window"], None),
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
("window.toggle.options", "Options", &["Window"], None),
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
("help.website", "PhotoCraft Website", &["Help"], None),
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
("help.github", "PhotoCraft on GitHub", &["Help"], None),
("help.reportIssue", "Report an Issue…", &["Help"], None),
("help.systemInfo", "System Info…", &["Help"], None),
("help.about", "About PhotoCraft", &["Help"], None),
];
/// The tools, in toolbar order: shortcut id (`tools.select.<tool>`), Photoshop's name for it
/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's
/// tool groups), so a shared default letter isn't a conflict.
pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.move", "Move Tool", Some("V")),
("tools.select.artboard", "Artboard Tool", Some("V")),
("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")),
("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")),
("tools.select.singleRowMarquee", "Single Row Marquee Tool", None),
("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None),
("tools.select.lasso", "Lasso Tool", Some("L")),
("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")),
("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")),
("tools.select.magicWand", "Magic Wand Tool", Some("W")),
("tools.select.crop", "Crop Tool", Some("C")),
("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")),
("tools.select.eyedropper", "Eyedropper Tool", Some("I")),
("tools.select.colorSampler", "Color Sampler Tool", Some("I")),
("tools.select.ruler", "Ruler Tool", Some("I")),
("tools.select.note", "Note Tool", Some("I")),
("tools.select.count", "Count Tool", Some("I")),
("tools.select.brush", "Brush Tool", Some("B")),
("tools.select.pencil", "Pencil Tool", Some("B")),
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
("tools.select.colorReplacement", "Color Replacement Tool", Some("B")),
("tools.select.eraser", "Eraser Tool", Some("E")),
("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")),
("tools.select.magicEraser", "Magic Eraser Tool", Some("E")),
("tools.select.gradient", "Gradient Tool", Some("G")),
("tools.select.paintBucket", "Paint Bucket Tool", Some("G")),
("tools.select.type", "Horizontal Type Tool", Some("T")),
("tools.select.verticalType", "Vertical Type Tool", Some("T")),
("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")),
("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")),
("tools.select.hand", "Hand Tool", Some("H")),
("tools.select.rotateView", "Rotate View Tool", Some("R")),
("tools.select.zoom", "Zoom Tool", Some("Z")),
("tools.select.remove", "Remove Tool", Some("J")),
("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")),
("tools.select.healing", "Healing Brush Tool", Some("J")),
("tools.select.patch", "Patch Tool", Some("J")),
("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")),
("tools.select.redEye", "Red Eye Tool", Some("J")),
("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")),
("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")),
("tools.select.historyBrush", "History Brush Tool", Some("Y")),
("tools.select.blur", "Blur Tool", None),
("tools.select.sharpen", "Sharpen Tool", None),
("tools.select.smudge", "Smudge Tool", None),
("tools.select.dodge", "Dodge Tool", Some("O")),
("tools.select.burn", "Burn Tool", Some("O")),
("tools.select.sponge", "Sponge Tool", Some("O")),
("tools.select.quickSelection", "Quick Selection Tool", Some("W")),
("tools.select.objectSelection", "Object Selection Tool", Some("W")),
("tools.select.pen", "Pen Tool", Some("P")),
("tools.select.freeformPen", "Freeform Pen Tool", Some("P")),
("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")),
("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")),
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")),
("tools.select.convertPoint", "Convert Point Tool", Some("P")),
("tools.select.pathSelection", "Path Selection Tool", Some("A")),
("tools.select.directSelection", "Direct Selection Tool", Some("A")),
("tools.select.rectangle", "Rectangle Tool", Some("U")),
("tools.select.ellipseShape", "Ellipse Tool", Some("U")),
("tools.select.triangle", "Triangle Tool", Some("U")),
("tools.select.polygon", "Polygon Tool", Some("U")),
("tools.select.line", "Line Tool", Some("U")),
("tools.select.customShape", "Custom Shape Tool", Some("U")),
("tools.select.slice", "Slice Tool", Some("C")),
("tools.select.sliceSelect", "Slice Select Tool", Some("C")),
("tools.select.frame", "Frame Tool", Some("K")),
];
/// The default key of tool shortcut `id`, if it's a tool.
pub fn tool_default(id: &str) -> Option<Option<&'static str>> {
TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_unique_and_keys_are_valid() {
let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect();
let n = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate ids");
for (id, _, _, sc) in SHELL_COMMANDS {
assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}");
}
for (id, name, key) in TOOLS {
assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}");
assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}");
}
assert_eq!(tool_default("tools.select.move"), Some(Some("V")));
assert_eq!(tool_default("edit.copy"), None);
}
}
+26 -14
View File
@@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
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)| {
/// A command (or tool) with a key under some bindings.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bound {
pub id: String,
pub label: String,
/// Its top-level menu ("Tools" for tools).
pub menu: String,
/// Main key first, then the further keys.
pub keys: Vec<String>,
pub tool: bool,
}
/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's
/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]).
pub fn bound(p: &Preferences) -> Vec<Bound> {
crate::prefs::bindable_items()
.filter_map(|(id, label, menu, def, kind)| {
let main = p.shortcut(id, def);
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
keys.extend(p.alternates(id, main));
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
let menu = menu.first().copied().unwrap_or("Other").to_string();
(!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool })
})
.collect()
}
@@ -340,8 +348,12 @@ mod tests {
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"));
let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|x| x.id == "tools.temporary.hand"));
// The shell's commands and the tools too: a set saved headless is the whole set.
assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++");
let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool");
assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools");
}
}
+5 -1
View File
@@ -1,4 +1,5 @@
//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers
//! `Session::precheck` accepts what `execute` accepts for the commands it covers (all but the
//! group nesting limit, which only the edit checks)
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
use photocraft_engine::{PRECHECKED, Session};
@@ -47,6 +48,9 @@ fn params() -> Vec<Value> {
json!({"name": "ok"}),
json!({"color": "chartreuse"}),
json!({"layer": 999_999}),
json!({"layer": 1}),
json!({"layer": 2}),
json!({"layer": null}),
json!({"layer": "Layer 1"}),
json!({"layer": "no such layer"}),
json!({"layer": true}),
+146
View File
@@ -0,0 +1,146 @@
//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable,
//! listed, imported and exported; tools share keys without conflicts; system keys are refused;
//! sets read and write files by path.
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")
}
fn listed<'a>(r: &'a Value, id: &str) -> &'a Value {
r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed"))
}
#[test]
fn shell_commands_and_tools_are_bindable_without_the_ui() {
let mut s = Session::new();
let r = ks(&mut s, json!({"list": true}));
assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In");
let mv = listed(&r, "tools.select.move");
assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"])));
assert!(
r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"]
.as_array()
.unwrap()
.iter()
.any(|id| !id.as_str().unwrap().starts_with("tools.select."))),
"tools sharing a letter cycle, they don't conflict: {}",
r["conflicts"]
);
// No allowUnknown needed any more.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z")));
}
#[test]
fn tools_may_share_a_key_but_a_command_takes_it() {
let mut s = Session::new();
// Pencil joins the V group: Move and Artboard keep V.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}}));
assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]);
// A command taking V removes it from every tool that held it.
let r = ks(&mut s, json!({"set": {"edit.copy": "V"}}));
for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] {
assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]);
}
}
#[test]
fn system_keys_are_refused_and_dropped_from_imports() {
let mut s = Session::new();
let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" };
let before = s.prefs().clone();
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err());
assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err());
assert_eq!(s.prefs(), &before);
let set = format!(
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F9</shortcut><shortcut>{key}</shortcut></command><command name=\"Cut\"><shortcut>{key}</shortcut></command><tool name=\"Pencil Tool\">N</tool><tool name=\"Move Tool\">V</tool></photoshop-keyboard-shortcuts>"
);
let r = ks(&mut s, json!({"importKys": set}));
assert_eq!(r["overrides"]["edit.copy"], json!("F9"));
assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]);
assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1));
assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless");
assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling");
}
#[test]
fn sets_read_and_write_files_and_export_everything() {
let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let kys = dir.join("mine.kys");
let html = dir.join("mine.html");
let mut s = Session::new();
ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"}));
let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}}));
assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}");
let text = std::fs::read_to_string(&kys).unwrap();
assert!(text.contains("pc-id=\"tools.select.pencil\">F6</tool>") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}");
assert!(std::fs::read_to_string(&html).unwrap().contains("<h2>Tools</h2>"));
// Back to the defaults, then the file restores the set, tools included.
ks(&mut s, json!({"useSet": ""}));
let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9")));
// Bad paths and shapes are errors, not panics.
for p in [
json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}),
json!({"importKys": {"path": ""}}),
json!({"importKys": 5}),
json!({"exportKys": "yes"}),
json!({"exportKys": {"path": 3}}),
json!({"summarize": {"path": ""}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
}
// A change paired with an export that fails (a folder that doesn't exist, a bad summarize
// value) changes nothing.
let before = ks(&mut s, json!({}))["overrides"].clone();
for p in [
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}),
json!({"set": {"edit.copy": "F10"}, "summarize": 7}),
json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
// A good .kys path with a bad summarize: neither file is written.
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
let r = ks(&mut s, json!({}));
assert_eq!(r["overrides"], before, "{p}");
assert!(r["sets"].get("Other").is_none(), "{p}: {r}");
assert!(!dir.join("both.kys").exists(), "{p}: no file written");
}
// A second file that passes the checks but can't be written (a NUL in its name): the user's
// existing first file is left exactly as it was, and no staged file stays behind.
let mine = dir.join("existing.kys");
std::fs::write(&mine, "the user's own file").unwrap();
let html = format!("{}/a\u{0}.html", dir.to_str().unwrap());
let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}});
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file");
let left: Vec<_> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect();
assert!(left.is_empty(), "{left:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_read_only_target_is_refused_before_anything_is_written() {
let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ro = dir.join("locked.kys");
std::fs::write(&ro, "locked").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&ro, perms.clone()).unwrap();
let mut s = Session::new();
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}");
assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked");
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
let _ = std::fs::set_permissions(&ro, perms);
let _ = std::fs::remove_dir_all(&dir);
}
+38 -1
View File
@@ -131,8 +131,9 @@ pub enum Outcome {
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
/// changed (#412).
pub const UI_SET_FIELDS: [&str; 37] = [
pub const UI_SET_FIELDS: [&str; 38] = [
"tool",
"strokeTrace",
"panels",
"dock",
"dockTabs",
@@ -492,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
None => None,
};
let gradient_classic = bool_field(p, "gradientClassic")?;
// The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV
// file is only for the user's own launch (`--stroke-trace <path>`): a path over the
// control channel would write outside the automation write root.
let stroke_trace = match p.get("strokeTrace") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(if *b { "stderr" } else { "off" }),
Some(Value::String(v)) => match v.trim() {
"stderr" | "on" | "true" => Some("stderr"),
"off" | "false" => Some("off"),
_ => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"; a CSV file is set at launch (--stroke-trace <path>)".into()),
},
Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()),
};
// The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring.
let eyedropper_size = match p.get("eyedropperSampleSize") {
Some(v) => Some(
@@ -663,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(mode) = gradient_blend {
app.ui.tool_options.gradient_blend_mode = mode;
}
if let Some(v) = &stroke_trace {
app.stroke_trace.set(v);
}
if let Some(classic) = gradient_classic {
app.ui.tool_options.gradient_classic = classic;
}
@@ -1385,6 +1402,26 @@ mod tests {
}
}
#[test]
fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
assert!(!app.stroke_trace.enabled());
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true);
assert!(app.stroke_trace.enabled());
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true);
assert!(!app.stroke_trace.enabled());
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"}));
assert_eq!(bad["ok"], false);
// A file path isn't accepted over the channel (it would write outside the automation
// write root); nothing is created.
let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id()));
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"}));
assert_eq!(bad["ok"], false);
assert!(!path.exists());
assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing");
}
#[test]
fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
+3
View File
@@ -945,6 +945,9 @@ pub struct GpuInfo {
pub canvas: String,
/// Why the GPU canvas was dropped during this session (device lost or a GPU error).
pub lost: Option<String>,
/// Present mode and frame latency ("fifo, latency 2"; launch overrides marked, see the
/// app's `presentation`). Empty in hosts that don't set it.
pub present: String,
}
impl GpuInfo {
+4
View File
@@ -3804,3 +3804,7 @@ navigator Zoom Zvětšení
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
Undo Changes Vrátit změny
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí.
The transform couldn't be applied. Transformaci nelze použít.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3198,3 +3198,7 @@ curves Smooth Glätten
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
Undo Changes Änderungen rückgängig machen
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist.
The transform couldn't be applied. Die Transformation konnte nicht angewendet werden.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3896,3 +3896,7 @@ curves Smooth Εξομάλυνση
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
Undo Changes Αναίρεση αλλαγών
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί.
The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού.
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+4
View File
@@ -4158,3 +4158,7 @@ navigator Zoom Zoom
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
Undo Changes Deshacer cambios
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine.
The transform couldn't be applied. No se pudo aplicar la transformación.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -3201,3 +3201,7 @@ curves Smooth Lisser
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
Undo Changes Annuler les modifications
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée.
The transform couldn't be applied. Impossible d'appliquer la transformation.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4115,3 +4115,7 @@ cameraRaw Yellow Sárga
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
Undo Changes Változások visszavonása
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött.
The transform couldn't be applied. Az átalakítás nem alkalmazható.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3188,3 +3188,7 @@ curves Smooth Haluskan
Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu
Undo Changes Batalkan Perubahan
{shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan.
Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai.
The transform couldn't be applied. Transformasi tidak dapat diterapkan.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -4317,3 +4317,7 @@ shape Appearance Aspetto
Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo
Undo Changes Annulla modifiche
{shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata.
Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata.
The transform couldn't be applied. Impossibile applicare la trasformazione.
Can't render this file because it contains an unexpected character in line 2654 and column 53.
+4
View File
@@ -3872,3 +3872,7 @@ curves Smooth スムーズ
Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択
Undo Changes 変更を取り消し
{shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。
Low-Latency Drawing (vsync off, applies at next launch) 低遅延描画(垂直同期オフ、次回起動時に適用)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. 自由変形を適用中です。完了してからもう一度お試しください。
The transform couldn't be applied. 変形を適用できませんでした。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3374,3 +3374,7 @@ curves Smooth 매끄럽게
Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택
Undo Changes 변경 사항 취소
{shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다.
Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요.
The transform couldn't be applied. 변형을 적용할 수 없습니다.
Can't render this file because it contains an unexpected character in line 3166 and column 53.
+4
View File
@@ -3956,3 +3956,7 @@ curves Smooth Vloeiend maken
Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren
Undo Changes Wijzigingen ongedaan maken
{shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen.
Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is.
The transform couldn't be applied. De transformatie kon niet worden toegepast.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+4
View File
@@ -3794,3 +3794,7 @@ curves Smooth Wygładź
Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je
Undo Changes Cofnij zmiany
{shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać.
Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy.
The transform couldn't be applied. Nie można zastosować przekształcenia.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3986,3 +3986,7 @@ layerLabel No Color Sem cor
Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo
Undo Changes Desfazer alterações
{shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído.
Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar.
The transform couldn't be applied. Não foi possível aplicar a transformação.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3166,3 +3166,7 @@ curves Smooth Сгладить
Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её
Undo Changes Отменить изменения
{shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена.
Low-Latency Drawing (vsync off, applies at next launch) Рисование с малой задержкой (без вертикальной синхронизации, после перезапуска)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Свободное трансформирование ещё применяется; повторите попытку, когда оно завершится.
The transform couldn't be applied. Не удалось применить трансформирование.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4269,3 +4269,7 @@ shape Appearance Izgled
Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je
Undo Changes Poništi izmene
{shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti.
Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi.
The transform couldn't be applied. Transformacija nije mogla da se primeni.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3926,3 +3926,7 @@ cameraData Lens Lens
Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç
Undo Changes Değişiklikleri Geri Al
{shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz.
Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin.
The transform couldn't be applied. Dönüştürme uygulanamadı.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+4
View File
@@ -3895,3 +3895,7 @@ curves Smooth Згладити
Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її
Undo Changes Скасувати зміни
{shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити.
Low-Latency Drawing (vsync off, applies at next launch) Малювання з малою затримкою (без вертикальної синхронізації, після перезапуску)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Вільне трансформування ще застосовується; спробуйте знову, коли воно завершиться.
The transform couldn't be applied. Не вдалося застосувати трансформування.
Can't render this file because it contains an unexpected character in line 1388 and column 18.
+4
View File
@@ -3682,3 +3682,7 @@ curves Smooth Làm mượt
Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó
Undo Changes Hoàn tác thay đổi
{shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán.
Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất.
The transform couldn't be applied. Không thể áp dụng phép biến đổi.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2812,3 +2812,7 @@ curves Smooth 平滑
Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它
Undo Changes 撤消更改
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。
Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。
The transform couldn't be applied. 无法应用变换。
Can't render this file because it contains an unexpected character in line 2600 and column 53.
+4
View File
@@ -2797,3 +2797,7 @@ curves Smooth 平滑
Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它
Undo Changes 還原變更
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。
Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。
The transform couldn't be applied. 無法套用變形。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -2
View File
@@ -35,8 +35,7 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan {
/// 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)
photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set)
}
/// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as
+29 -3
View File
@@ -800,6 +800,12 @@ pub struct PhotocraftApp {
/// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as
/// before, which tests and scripts rely on.
pub background_jobs: bool,
/// Shortest time between frames (ms), or `None` for no cap. A present mode that doesn't wait
/// for the display (Low-Latency Drawing) would otherwise redraw as fast as it can while
/// anything keeps requesting repaints (a drag hovering, autoscroll, Liquify held still).
/// After #4472 (astigmata).
pub min_frame_ms: Option<f64>,
last_frame_start_ms: f64,
/// Background job bookkeeping: opening tabs, control replies waiting on a job.
pub jobs: jobs_ui::JobsUi,
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
@@ -935,6 +941,8 @@ impl PhotocraftApp {
allow_close: false,
stylus: Default::default(),
background_jobs: false,
min_frame_ms: None,
last_frame_start_ms: 0.0,
jobs: Default::default(),
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
live_tokens: theme::live::LiveTokens::from_env(),
@@ -1053,10 +1061,10 @@ impl PhotocraftApp {
self.session.precheck(id, &params).map_err(|e| e.to_string())?;
transform_tool::commit(self);
if transform_tool::committing(self).is_some() {
return Err("the transform is still being applied; try again when it has finished".into());
return Err(tl!("Free Transform is still being applied; try again when it has finished.").into());
}
if self.ui.transform.is_some() {
return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() });
return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() });
}
}
if let Some(result) = panel_docking::command(self, id, &params) {
@@ -1774,6 +1782,20 @@ impl PhotocraftApp {
}
}
impl PhotocraftApp {
/// Wait out the rest of [`Self::min_frame_ms`] since the last frame started.
fn cap_frame_rate(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(min) = self.min_frame_ms.filter(|m| m.is_finite() && *m > 0.0) {
let wait = min.min(100.0) - (gpu_canvas::now_ms() - self.last_frame_start_ms);
if wait > 0.0 {
std::thread::sleep(std::time::Duration::from_secs_f64(wait / 1000.0));
}
}
self.last_frame_start_ms = gpu_canvas::now_ms();
}
}
impl eframe::App for PhotocraftApp {
fn on_exit(&mut self) {
if let Err(err) = tool_memory::save_on_exit(self) {
@@ -1782,6 +1804,7 @@ impl eframe::App for PhotocraftApp {
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.cap_frame_rate();
i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language));
// Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas
// cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`.
@@ -2019,7 +2042,10 @@ impl eframe::App for PhotocraftApp {
if self.stroke_trace.enabled() {
self.stroke_trace.canvas(self.perf.frame_refresh_ms);
let info = &self.perf.gpu_info;
self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || format!("{} {}", info.backend, info.adapter).trim().to_string());
self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || {
let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string();
if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) }
});
}
// Synthetic input is injected one press/release step per frame: keep frames coming until
// the queue is empty, then release control replies waiting on it.
+3 -85
View File
@@ -20,91 +20,9 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"];
/// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut.
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.symmetryTransform", "Transform Symmetry", &[], None),
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
("tool.reset", "Reset Tool", &[], None),
("tool.resetAll", "Reset All Tools", &[], None),
("window.panel.group", "Change Panel Group", &[], None),
("window.panel.layout", "Change Panel Layout", &[], None),
("window.panel.move", "Move Panel", &[], None),
("window.panel.float", "Float Panel", &[], None),
("window.panel.dock", "Dock Panel", &[], None),
("window.panel.activate", "Activate Panel", &[], None),
("window.panel.close", "Close Panel", &[], None),
("file.open", "Open…", &["File"], Some("Cmd+O")),
("file.save", "Save", &["File"], Some("Cmd+S")),
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
("view.extras", "Extras", &["View"], Some("Cmd+H")),
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
("type.editText", "Edit Type", &[], None),
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
("view.rotateView", "Rotate View", &[], None),
("view.resetView", "Reset View", &["View"], None),
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
("window.toggle.history", "History", &["Window"], None),
("window.toggle.properties", "Properties", &["Window"], None),
("window.toggle.color", "Color", &["Window"], Some("F6")),
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
("window.toggle.brushes", "Brushes", &["Window"], None),
("window.toggle.navigator", "Navigator", &["Window"], None),
("window.toggle.toolbar", "Tools", &["Window"], None),
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
("window.toggle.options", "Options", &["Window"], None),
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
("help.website", "PhotoCraft Website", &["Help"], None),
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
("help.github", "PhotoCraft on GitHub", &["Help"], None),
("help.reportIssue", "Report an Issue…", &["Help"], None),
("help.systemInfo", "System Info…", &["Help"], None),
("help.about", "About PhotoCraft", &["Help"], None),
];
/// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so the
/// commands and their keys exist without this UI (headless shortcut sets, lists, conflicts).
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = photocraft_engine::shell_commands::SHELL_COMMANDS;
/// Window › <document>: the open documents, listed at the end of the Window menu as in Photoshop.
/// The id ends in the document's index, which is its tab's position.
+8 -27
View File
@@ -816,33 +816,7 @@ fn conflict_state(overrides: &BTreeMap<String, String>, alternates: &BTreeMap<St
/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our
/// notation, where `Cmd` is Ctrl off the Mac.
pub fn reserved_shortcut(sc: &str) -> bool {
let Some(n) = prefs::normalize_shortcut(sc) else { return false };
let reserved: &[&str] = if cfg!(target_os = "macos") {
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
} else if cfg!(target_os = "windows") {
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
} else {
// Linux and the BSDs: window switching and closing, and the virtual consoles.
&[
"Alt+Tab",
"Alt+Shift+Tab",
"Alt+F4",
"Cmd+Alt+Delete",
"Cmd+Alt+F1",
"Cmd+Alt+F2",
"Cmd+Alt+F3",
"Cmd+Alt+F4",
"Cmd+Alt+F5",
"Cmd+Alt+F6",
"Cmd+Alt+F7",
"Cmd+Alt+F8",
"Cmd+Alt+F9",
"Cmd+Alt+F10",
"Cmd+Alt+F11",
"Cmd+Alt+F12",
]
};
reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n)
prefs::reserved_shortcut(sc)
}
/// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers.
@@ -1120,6 +1094,11 @@ fn humanize(key: &str) -> String {
if key == "nudgeBundlePauseMs" {
return "Nudge Bundle Pause (ms)".into();
}
// Performance: presentation without vsync (#1343); like the other graphics settings, it
// takes effect when the window next opens.
if key == "lowLatencyDrawing" {
return "Low-Latency Drawing (vsync off, applies at next launch)".into();
}
let mut s = String::new();
for (i, ch) in key.chars().enumerate() {
if i == 0 {
@@ -1556,6 +1535,8 @@ fn field_visible(section: &str, obj: &Map<String, Value>, k: &str, query: &str)
// View › Rulers (⌘R) sets it, as in Photoshop.
|| path == "unitsAndRulers.showRulers"
|| (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode"))
// The browser presents frames itself: Low-Latency Drawing does nothing there.
|| (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing")
|| (section == "export" && !export_field_visible(obj, k))
|| (section == "tools" && !tools_field_visible(obj, k));
(query.is_empty() || preference_matches(section, k, query))
+35 -3
View File
@@ -113,13 +113,32 @@ impl StrokeTrace {
/// Reads `PHOTOCRAFT_STROKE_TRACE` (off on the web, where there is no clock).
pub fn from_env() -> Self {
#[cfg(not(target_arch = "wasm32"))]
if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE").filter(|v| !v.is_empty()) {
let output = if v == "1" || v == "stderr" { Output::Stderr } else { Output::Csv(v.into()) };
return Self { output, ..Default::default() };
if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE") {
return Self::from_value(&v.to_string_lossy());
}
Self::default()
}
/// A trace set by value: `""`, `"0"` or `"off"` is off, `"1"` or `"stderr"` prints
/// summaries, anything else is a CSV path (`--stroke-trace`, `PHOTOCRAFT_STROKE_TRACE`; the
/// control channel's `ui.set {"strokeTrace"}` passes only on or off, never a path).
pub fn from_value(v: &str) -> Self {
let v = v.trim();
let output = match v {
_ if cfg!(target_arch = "wasm32") => Output::Off,
"" | "0" | "off" | "false" => Output::Off,
"1" | "stderr" | "true" => Output::Stderr,
path => Output::Csv(path.into()),
};
Self { output, ..Default::default() }
}
/// Switch the output, keeping the last summary.
pub fn set(&mut self, v: &str) {
let last = self.last.take();
*self = Self { last, ..Self::from_value(v) };
}
pub fn enabled(&self) -> bool {
self.output != Output::Off
}
@@ -265,6 +284,19 @@ mod tests {
assert!(off.last.is_none() && off.rows.is_empty());
}
#[test]
fn a_trace_is_set_by_value() {
assert_eq!(StrokeTrace::from_value("off").output, Output::Off);
assert_eq!(StrokeTrace::from_value(" 0 ").output, Output::Off);
assert_eq!(StrokeTrace::from_value("stderr").output, Output::Stderr);
assert_eq!(StrokeTrace::from_value("true").output, Output::Stderr);
assert_eq!(StrokeTrace::from_value("t.csv").output, Output::Csv("t.csv".into()));
let mut t = on();
t.last = Some(Summary::default());
t.set("off");
assert!(!t.enabled() && t.last.is_some(), "turning it off keeps the last summary");
}
#[test]
fn csv_rows_and_odd_numbers() {
let rows = [Row { t: 1.0, interval: 16.5, moves: 2, stroke: 0.25, canvas: 1.0, ui: 3.0, cpu_prev: None }];
+14
View File
@@ -94,3 +94,17 @@ mod tests {
assert_eq!(h.state().ui.status, "q");
}
}
#[cfg(test)]
mod engine_table_tests {
use super::*;
/// The engine knows the tools as data (headless shortcut sets); it must match the toolbar.
#[test]
fn the_engines_tool_table_matches_the_tools() {
let ours: Vec<(String, &str, Option<String>)> = Tool::ALL.into_iter().map(|t| (id(t), t.label(), default(t))).collect();
let engine: Vec<(String, &str, Option<String>)> =
photocraft_engine::shell_commands::TOOLS.iter().map(|(i, n, k)| (i.to_string(), *n, k.map(str::to_string))).collect();
assert_eq!(ours, engine, "update photocraft_engine::shell_commands::TOOLS");
}
}
+2 -1
View File
@@ -53,6 +53,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
- `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`.
- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`.
- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=<file.csv>` / `--stroke-trace <file.csv>`): the control channel doesn't write files outside the automation write root
- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing.
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks.
@@ -227,7 +228,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 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.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 `pc-id` or name (a tool's own letter keeps its Shift cycling); `import.tools` lists the keys given. 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 every bindable command and tool 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. A bad parameter anywhere in the call, an export path that can't be written included, changes nothing. **Headless parity:** the shell's commands (View › Zoom In, the panels; `photocraft_engine::shell_commands::SHELL_COMMANDS`) and the tools (`tools.select.<tool>`, listed with `"tool": true`) are bindable, listed, imported, exported and checked for conflicts without the UI; tools may share a key (they cycle with Shift, so a shared letter is no conflict), while a command given a tool's key takes it from the tools. Keys the system takes first (Alt+Tab, Alt+F4, Ctrl+Alt+Delete everywhere but the Mac, plus Ctrl+Esc and Ctrl+Shift+Esc on Windows and Ctrl+Alt+F1–F12 on Linux; ⌘Tab, ⌘`, ⌘⌥Esc on the Mac) are refused, and left out of an imported set (`import.reserved`). Files: `importKys: {"path": …}` reads a `.kys`, `exportKys: {"path": …}` and `summarize: {"path": …}` write the file (the reply has `kysPath` / `htmlPath`); automation (MCP) refuses these paths, as it does `actions.save`'s |
| `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 |
+4 -2
View File
@@ -119,7 +119,9 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
| `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) |
| `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). |
| `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild |
| `PHOTOCRAFT_STROKE_TRACE=1` (or `=<file.csv>`) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) |
| `PHOTOCRAFT_STROKE_TRACE=1` (or `=<file.csv>`; also `--stroke-trace <stderr|file.csv>`, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) |
| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace |
| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace |
| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |
### Live design tokens
@@ -148,7 +150,7 @@ stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval
| `cpu` | eframe's CPU time per frame: UI, tessellation and GPU submission, without the vsync wait |
| `wait` | `interval − cpu`: waiting on vsync or the swap chain, or idle |
What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`.
What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend and how frames are presented. To compare presentation settings without rebuilding, launch with `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` (see [Environment variables](#environment-variables)): with `novsync` the app no longer waits on vsync (`wait` near 0): each refresh shows the newest frame, and where the driver only offers Immediate, the screen may tear. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`.
## Driving the app programmatically
+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 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/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 166 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 167 (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 29 of 29 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 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.
**Settings that do nothing: 26 of 167** (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`.