mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 19:56:50 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b3d05636f | ||
|
|
fdaa9b8316 | ||
|
|
8376363d8c | ||
|
|
b09aeae2e2 |
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()] }]);
|
||||
|
||||
@@ -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
@@ -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, §ions, mac));
|
||||
let html = crate::shortcut_sets::summarize(set_name, §ions, 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
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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, ¶ms).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, ¶ms) {
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user